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
55 "": {
66 "name": "executor",
77 "dependencies": {
8+ "@typescript/native-preview": "^7.0.0-dev.20260212.1",
89 "better-result": "^2.7.0",
910 "convex": "^1.31.7",
1011 "graphql": "^16.12.0",
@@ -68,6 +69,22 @@ · 57 unmodified lines
6869
6970 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
7071
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+
7188 "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="],
7289
7390 "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 @@
11 {
22 "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+ },
39 "private": true,
4− "type": "module",
510 "scripts": {
611 "doctor": "bun run executor.ts doctor",
712 "doctor:prod": "bun run scripts/doctor-prod.ts",
@@ -22,9 +27,5 @@ · 14 unmodified lines
2227 "build:binary": "bun build executor.ts --compile --outfile dist/executor",
2328 "build:release": "bun run scripts/build-release.ts"
2429 },
25− "dependencies": {
26− "better-result": "^2.7.0",
27− "convex": "^1.31.7",
28− "graphql": "^16.12.0"
29− }
30+ "type": "module"
3031 }
executor/packages/convex/_generated/api.d.ts+27−0
@@ -332,6 +332,12 @@ export declare const api: { · 331 unmodified lines
332332 },
333333 any
334334 >;
335+ getApprovalStatus: FunctionReference<
336+ "query",
337+ "public",
338+ { approvalId: string; internalSecret: string; runId: string },
339+ any
340+ >;
335341 handleToolCall: FunctionReference<
336342 "action",
337343 "public",
@@ -871,6 +877,7 @@ export declare const internal: { · 533 unmodified lines
871877 code: string;
872878 metadata?: any;
873879 runtimeId?: string;
880+ scheduleAfterCreate?: boolean;
874881 timeoutMs?: number;
875882 workspaceId: Id<"workspaces">;
876883 },
@@ -1105,6 +1112,26 @@ export declare const internal: { · 228 unmodified lines
11051112 any
11061113 >;
11071114 };
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+ };
11081135 workspaceAuthInternal: {
11091136 getWorkspaceAccessForRequest: FunctionReference<
11101137 "query",
executor/packages/convex/accounts.ts+9−0
@@ -72,6 +72,15 @@ async function deleteWorkspaceData( · 71 unmodified lines
7272 await ctx.db.delete(cacheEntry._id);
7373 }
7474
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+
7584 const workspaceMembers = await ctx.db
7685 .query("workspaceMembers")
7786 .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
4040 workspaceId: Id<"workspaces">;
4141 actorId: string;
4242 clientId?: string;
43+ scheduleAfterCreate?: boolean;
4344 },
4445 ): Promise<{ task: TaskRecord }> {
4546 if (!args.code.trim()) {
@@ -89,9 +90,11 @@ async function createTaskRecord( · 43 unmodified lines
8990 },
9091 });
9192
92− await ctx.scheduler.runAfter(1, internal.executorNode.runTask, {
93− taskId,
94− });
93+ if (args.scheduleAfterCreate ?? true) {
94+ await ctx.scheduler.runAfter(1, internal.executorNode.runTask, {
95+ taskId,
96+ });
97+ }
9598
9699 return { task };
97100 }
@@ -182,6 +185,7 @@ export const createTaskInternal = internalMutation({ · 84 unmodified lines
182185 workspaceId: v.id("workspaces"),
183186 actorId: v.string(),
184187 clientId: v.optional(v.string()),
188+ scheduleAfterCreate: v.optional(v.boolean()),
185189 },
186190 handler: async (ctx, args): Promise<{ task: TaskRecord }> => {
187191 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
9898 return null;
9999 }
100100
101− await publishTaskEvent(ctx, args.taskId, "task", "task.dispatched", {
101+ const finished = await ctx.runMutation(internal.database.markTaskFinished as any, {
102102 taskId: args.taskId,
103− runtimeId: running.runtimeId,
104− dispatchId: dispatchResult.dispatchId,
103+ status: dispatchResult.status,
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,
105126 durationMs: dispatchResult.durationMs,
127+ error: finished.error,
128+ completedAt: finished.completedAt,
106129 });
107130 return null;
108131 }
executor/packages/convex/http/mcp_service.ts+4−0
@@ -21,8 +21,12 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 20 unmodified lines
2121 }) => {
2222 return (await ctx.runMutation(internal.executor.createTaskInternal, {
2323 ...input,
24+ scheduleAfterCreate: false,
2425 })) as { task: TaskRecord };
2526 },
27+ runTaskNow: async (taskId: string) => {
28+ return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null;
29+ },
2630 getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {
2731 if (workspaceId) {
2832 return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null;
executor/packages/convex/mcpNode.ts+247−9
@@ -1,11 +1,145 @@
11 "use node";
22
3+import { createHash } from "node:crypto";
4+import { createRequire } from "node:module";
35 import { v } from "convex/values";
46 import { internal } from "./_generated/api";
57 import { internalAction } from "./_generated/server";
68 import { loadSourceDtsByUrl } from "../core/src/dts-loader";
7−import { generateToolDeclarations, typecheckCode } from "../core/src/typechecker";
9+import {
10+ analyzeToolReferences,
11+ generateToolDeclarations,
12+ sliceOpenApiOperationsDts,
13+ typecheckCode,
14+} from "../core/src/typechecker";
815 import type { ToolDescriptor } from "../core/src/types";
16+import { sourceSignature } from "./executor_node/tool_source_loading";
17+
18+if (typeof (globalThis as { require?: unknown }).require !== "function") {
19+ (globalThis as { require?: unknown }).require = createRequire(import.meta.url);
20+}
21+
22+const DECLARATION_CACHE_VERSION = "v1";
23+const DECLARATION_CACHE_TTL_MS = 24 * 60 * 60_000;
24+const DECLARATION_CACHE_MAX_ENTRIES_PER_WORKSPACE = 96;
25+
26+function hashString(input: string): string {
27+ return createHash("sha256").update(input).digest("hex");
28+}
29+
30+function 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+
38+function 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+
55+function 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+
75+function 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+
95+function shouldUseSelectiveOpenApiDts(analysis: ReturnType<typeof analyzeToolReferences>): boolean {
96+ return !analysis.hasDynamicToolAccess && !analysis.hasNonCallToolAccess;
97+}
98+
99+function 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+
128+function 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+}
9143
10144 export const typecheckRunCodeInternal = internalAction({
11145 args: {
@@ -21,16 +155,120 @@ export const typecheckRunCodeInternal = internalAction({ · 9 unmodified lines
21155 clientId: args.clientId,
22156 };
23157
24− const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as {
25− tools: ToolDescriptor[];
26− dtsUrls?: Record<string, string>;
27− };
158+ const [result, sources] = await Promise.all([
159+ ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as Promise<{
160+ tools: ToolDescriptor[];
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+ }
28203
29− const sourceDtsBySource = await loadSourceDtsByUrl(result.dtsUrls ?? {});
30− const declarations = generateToolDeclarations(result.tools, {
31− sourceDtsBySource,
204+ const cacheKey = declarationCacheKey({
205+ workspaceSignature,
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,
32215 });
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);
34272
35273 return {
36274 ok: typecheck.ok,
executor/packages/convex/schema.ts+10−0
@@ -313,6 +313,16 @@ export default defineSchema({ · 312 unmodified lines
313313 })
314314 .index("by_workspace", ["workspaceId"]),
315315
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+
316326 anonymousSessions: defineTable({
317327 sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid>
318328 workspaceId: v.id("workspaces"),
executor/packages/convex/typecheckDeclarationCache.test.ts+188−0
@@ -0,0 +1,188 @@
1+import { describe, expect, test } from "bun:test";
2+import { convexTest } from "convex-test";
3+import { internal } from "./_generated/api";
4+import schema from "./schema";
5+
6+function setup() {
7+ return convexTest(schema, {
8+ "./typecheckDeclarationCache.ts": () => import("./typecheckDeclarationCache"),
9+ "./_generated/api.js": () => import("./_generated/api.js"),
10+ });
11+}
12+
13+describe("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 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+
4+export 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+
31+export 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
9393 return context;
9494 }
9595
96− subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void {
96+ subscribe(taskId: string, _workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void {
9797 const set = this.listeners.get(taskId) ?? new Set();
9898 set.add(listener);
9999 this.listeners.set(taskId, set);
executor/packages/core/src/mcp-server.test.ts+1−1
@@ -87,7 +87,7 @@ class FakeMcpService { · 86 unmodified lines
8787 return context;
8888 }
8989
90− subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void {
90+ subscribe(taskId: string, _workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void {
9191 const set = this.listeners.get(taskId) ?? new Set();
9292 set.add(listener);
9393 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
2424
2525 interface McpExecutorService {
2626 createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>;
27+ runTaskNow?(taskId: string): Promise<null>;
2728 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;
2930 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;
3031 listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>;
3132 listToolsForTypecheck?(
@@ -135,7 +136,6 @@ function waitForTerminalTask( · 103 unmodified lines
135136 ): Promise<TaskRecord | null> {
136137 return new Promise((resolve) => {
137138 let settled = false;
138− let checking = false;
139139 let elicitationEnabled = Boolean(
140140 onApprovalPrompt
141141 && approvalContext
@@ -145,7 +145,6 @@ function waitForTerminalTask( · 3 unmodified lines
145145 let loggedElicitationFallback = false;
146146 const seenApprovalIds = new Set<string>();
147147 let unsubscribe: (() => void) | undefined;
148− let poll: ReturnType<typeof setInterval> | undefined;
149148
150149 const logElicitationFallback = (reason: string) => {
151150 if (loggedElicitationFallback) return;
@@ -156,7 +155,7 @@ function waitForTerminalTask( · 4 unmodified lines
156155 const done = async () => {
157156 if (settled) return;
158157 settled = true;
159− if (poll) clearInterval(poll);
158+ clearTimeout(timeout);
160159 unsubscribe?.();
161160 resolve(await service.getTask(taskId, workspaceId));
162161 };
@@ -202,44 +201,34 @@ function waitForTerminalTask( · 39 unmodified lines
202201 }
203202 };
204203
205− const checkTask = async () => {
206− if (settled || checking) return;
207− checking = true;
208− try {
209− const task = await service.getTask(taskId, workspaceId);
210− if (task && getTaskTerminalState(task.status)) {
211− clearTimeout(timeout);
212− await done();
213− return;
214− }
204+ unsubscribe = service.subscribe(taskId, workspaceId, (event) => {
205+ const payload = typeof event.payload === "object" && event.payload
206+ ? event.payload as Record<string, unknown>
207+ : {};
208+ const type = typeof payload.status === "string" ? payload.status : undefined;
209+ const pendingApprovalCount = typeof payload.pendingApprovalCount === "number"
210+ ? payload.pendingApprovalCount
211+ : 0;
212+
213+ if (typeof type === "string" && getTaskTerminalState(type)) {
214+ void done();
215+ return;
216+ }
215217
216− await maybeHandleApprovals();
217− } finally {
218− checking = false;
218+ if (pendingApprovalCount > 0) {
219+ void maybeHandleApprovals().catch(() => {});
219220 }
220− };
221+ });
221222
222− poll = setInterval(() => {
223− void checkTask().catch(() => {});
224− }, 400);
225−
226− // Check if already terminal before subscribing (race condition guard)
227− void checkTask().catch(() => {});
228−
229− // Subscribe for live events when available; polling remains as fallback.
230− try {
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− }
223+ // Prompt immediately if there are already pending approvals.
224+ void maybeHandleApprovals().catch(() => {});
225+
226+ // Handle tasks that were already terminal before subscribe connected.
227+ void service.getTask(taskId, workspaceId).then((task) => {
228+ if (task && getTaskTerminalState(task.status)) {
229+ void done();
230+ }
231+ }).catch(() => {});
243232 });
244233 }
245234
@@ -493,6 +482,34 @@ function createRunCodeTool( · 247 unmodified lines
493482 };
494483 }
495484
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+
496513 const waitTimeoutMs = input.resultTimeoutMs ?? Math.max(requestedTimeoutMs + 30_000, 120_000);
497514 const task = await waitForTerminalTask(
498515 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
77 const AUTH_TOKEN = "test-sandbox-token";
88 const CALLBACK_TOKEN = "test-callback-token";
99
10−let hostResponseStatus = 202;
11−let hostResponseBody: Record<string, unknown> = { accepted: true, dispatchId: "dispatch_test" };
10+let hostResponseStatus = 200;
11+let hostResponseBody: Record<string, unknown> = { status: "completed", result: { ok: true }, exitCode: 0 };
1212 type HostRequestBody = {
1313 taskId: string;
1414 code: string;
@@ -60,9 +60,9 @@ afterAll(() => { · 45 unmodified lines
6060 });
6161
6262 describe("cloudflare worker loader dispatch", () => {
63− test("dispatches run request and returns accepted response", async () => {
64− hostResponseStatus = 202;
65− hostResponseBody = { accepted: true, dispatchId: "dispatch_abc" };
63+ test("dispatches run request and returns terminal response", async () => {
64+ hostResponseStatus = 200;
65+ hostResponseBody = { status: "completed", result: { ok: true }, exitCode: 0 };
6666 lastHostRequestBody = null;
6767
6868 const result = await dispatchCodeWithCloudflareWorkerLoader({
@@ -73,8 +73,8 @@ describe("cloudflare worker loader dispatch", () => { · 4 unmodified lines
7373
7474 expect(result.ok).toBe(true);
7575 if (result.ok) {
76− expect(result.accepted).toBe(true);
77− expect(result.dispatchId).toBe("dispatch_abc");
76+ expect(result.status).toBe("completed");
77+ expect(result.exitCode).toBe(0);
7878 }
7979
8080 expect(lastHostRequestBody).not.toBeNull();
@@ -84,8 +84,8 @@ describe("cloudflare worker loader dispatch", () => { · 3 unmodified lines
8484 });
8585
8686 test("transpiles TypeScript before dispatching", async () => {
87− hostResponseStatus = 202;
88− hostResponseBody = { accepted: true, dispatchId: "dispatch_ts" };
87+ hostResponseStatus = 200;
88+ hostResponseBody = { status: "completed", result: { ok: true }, exitCode: 0 };
8989 lastHostRequestBody = null;
9090
9191 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
2424 * 4. Only explicit `return` values from the isolate are included in callback
2525 * completion payloads.
2626 *
27− * 5. The host Worker accepts the run immediately and finishes execution
28− * asynchronously, reporting terminal results back through callback RPC.
27+ * 5. The host Worker executes the run inline and returns the terminal result
28+ * directly in the HTTP response.
2929 *
3030 * ## Callback authentication
3131 *
32− * The host Worker authenticates callback RPCs using `EXECUTOR_INTERNAL_TOKEN`.
32+ * The host Worker authenticates tool-callback RPCs using
33+ * `EXECUTOR_INTERNAL_TOKEN`.
3334 */
3435 export interface CloudflareDispatchResult {
3536 ok: true;
36− accepted: true;
37− dispatchId: string;
37+ status: "completed" | "failed" | "timed_out" | "denied";
38+ result?: unknown;
39+ error?: string;
40+ exitCode?: number;
3841 durationMs: number;
3942 }
4043
@@ -64,7 +67,8 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 23 unmodified lines
6467
6568 // ── POST to CF host worker ────────────────────────────────────────────
6669 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);
6872
6973 const response = await Result.tryPromise(() =>
7074 fetch(config.runUrl, {
@@ -92,39 +96,43 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 21 unmodified lines
9296 const cause = response.error.cause;
9397 const isAbort = cause instanceof DOMException && cause.name === "AbortError";
9498 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`);
96100 }
97101 const message = cause instanceof Error ? cause.message : String(cause);
98102 return mkError(`Cloudflare sandbox dispatch failed: ${message}`);
99103 }
100104
101− // ── Handle non-accepted HTTP status ───────────────────────────────────
102− if (response.value.status !== 202) {
105+ // ── Handle non-success HTTP status ────────────────────────────────────
106+ if (response.value.status !== 200) {
103107 const text = await Result.tryPromise(() => response.value.text());
104108 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}`);
106110 }
107111
108− // ── Parse accepted response JSON ──────────────────────────────────────
112+ // ── Parse terminal response JSON ──────────────────────────────────────
109113 const body = await Result.tryPromise(() =>
110114 response.value.json() as Promise<{
111− accepted?: boolean;
112− dispatchId?: string;
115+ status?: "completed" | "failed" | "timed_out" | "denied";
116+ result?: unknown;
117+ error?: string;
118+ exitCode?: number;
113119 }>,
114120 );
115121
116122 if (body.isErr()) {
117− return mkError("Cloudflare sandbox dispatch returned invalid JSON");
123+ return mkError("Cloudflare sandbox execution returned invalid JSON");
118124 }
119125
120− if (!body.value.accepted || !body.value.dispatchId) {
121− return mkError("Cloudflare sandbox dispatch response missing accepted/dispatchId");
126+ if (!body.value.status) {
127+ return mkError("Cloudflare sandbox execution response missing status");
122128 }
123129
124130 return {
125131 ok: true,
126− accepted: true,
127− dispatchId: body.value.dispatchId,
132+ status: body.value.status,
133+ result: body.value.result,
134+ error: body.value.error,
135+ exitCode: body.value.exitCode,
128136 durationMs: Date.now() - startedAt,
129137 };
130138 }
executor/packages/core/src/runtimes/runtime-catalog.ts+1−1
@@ -19,7 +19,7 @@ export interface CloudflareWorkerLoaderConfig { · 18 unmodified lines
1919 runUrl: string;
2020 /** Shared-secret bearer token for authenticating with the host worker. */
2121 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). */
2323 requestTimeoutMs: number;
2424 /** Convex deployment URL used for runtime callback RPC invocations. */
2525 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
1818 await new Promise((resolve) => setTimeout(resolve, ms));
1919 }
2020
21+const MIN_RETRY_DELAY_MS = 200;
22+const MAX_RETRY_DELAY_MS = 5_000;
23+const RETRY_BACKOFF_MULTIPLIER = 1.6;
24+
25+function clampRetryDelayMs(ms: number): number {
26+ return Math.min(MAX_RETRY_DELAY_MS, Math.max(MIN_RETRY_DELAY_MS, Math.round(ms)));
27+}
28+
29+function 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+
2136 function sanitizeExecutionResult(value: unknown): unknown {
2237 if (value === undefined) {
2338 return undefined;
@@ -55,6 +70,7 @@ function createToolsProxy( · 31 unmodified lines
5570
5671 const input = args.length > 0 ? args[0] : {};
5772 const callId = `call_${crypto.randomUUID()}`;
73+ let retryDelayMs = MIN_RETRY_DELAY_MS;
5874
5975 while (true) {
6076 const result = await adapter.invokeTool({
@@ -70,7 +86,9 @@ function createToolsProxy( · 9 unmodified lines
7086
7187 switch (result.kind) {
7288 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);
7492 continue;
7593 case "denied":
7694 throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`);
executor/packages/core/src/typechecker.test.ts+95−1
@@ -1,5 +1,12 @@
11 import { test, expect, describe } from "bun:test";
2−import { generateToolDeclarations, generateToolInventory, typecheckCode, type TypecheckResult } from "./typechecker";
2+import {
3+ analyzeToolReferences,
4+ generateToolDeclarations,
5+ generateToolInventory,
6+ parseTsgoDiagnostics,
7+ sliceOpenApiOperationsDts,
8+ typecheckCode,
9+} from "./typechecker";
310 import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources";
411 import type { ToolDescriptor } from "./types";
512
@@ -33,6 +40,93 @@ const FLAT_TOOL: ToolDescriptor = { · 27 unmodified lines
3340
3441 const ALL_TOOLS = [MATH_TOOL, ADMIN_TOOL, FLAT_TOOL];
3542
43+describe("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+
71+describe("sliceOpenApiOperationsDts", () => {
72+ test("keeps only requested operations", () => {
73+ const dts = `
74+export 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+
83+export 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+
104+describe("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+
36130 // ---------------------------------------------------------------------------
37131 // generateToolDeclarations
38132 // ---------------------------------------------------------------------------
executor/packages/core/src/typechecker.ts+457−8
@@ -53,6 +53,228 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 52 unmodified lines
5353 return isValidTypeExpression(typeExpression) ? typeExpression : fallback;
5454 }
5555
56+export interface ToolReferenceAnalysis {
57+ callPaths: string[];
58+ hasDynamicToolAccess: boolean;
59+ hasNonCallToolAccess: boolean;
60+}
61+
62+type StaticToolPathResult = {
63+ segments: string[] | null;
64+ dynamic: boolean;
65+};
66+
67+function 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+
85+function 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+ */
135+export 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+
203+function 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+
219+function 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+ */
230+export 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+
56278 const OPENAPI_HELPER_TYPES = `
57279 type _Normalize<T> = Exclude<T, undefined | null>;
58280 type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>;
@@ -175,7 +397,12 @@ export function generateToolDeclarations( · 116 unmodified lines
175397 const inputType = strictArgsType !== "{}"
176398 ? `(${openApiInputType}) & (${strictArgsType})`
177399 : 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}>;`);
179406 } else {
180407 const strictArgsType = tool.strictArgsType?.trim();
181408 const strictReturnsType = tool.strictReturnsType?.trim();
@@ -275,6 +502,221 @@ const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; · 93 unmodified lines
275502
276503 let warnedMissingCompilerHostSupport = false;
277504 let warnedSemanticFallback = false;
505+let warnedTsgoUnavailable = false;
506+let warnedTsgoFallback = false;
507+let resolvedTsgoExecutablePath: string | null | undefined;
508+
509+function 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+
516+function 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+
523+function 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+
530+function 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+
578+function 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+
609+export function parseTsgoDiagnostics(output: string, headerLineCount: number): string[] {
610+ return parseTsgoDiagnosticsInternal(output, headerLineCount).userErrors;
611+}
612+
613+function 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+}
278720
279721 function runSyntaxOnlyTypecheck(
280722 ts: typeof import("typescript"),
@@ -374,9 +816,6 @@ export function typecheckCode( · 93 unmodified lines
374816 code: string,
375817 toolDeclarations: string,
376818 ): TypecheckResult {
377− const ts = getTypeScriptModule();
378− if (!ts) return TYPECHECK_OK;
379−
380819 // Wrap the code in an async function with the tools declaration.
381820 // We declare sandbox globals (console, setTimeout, etc.) ourselves since
382821 // `types: []` prevents @types/node from loading.
@@ -390,6 +829,20 @@ export function typecheckCode( · 7 unmodified lines
390829 "}",
391830 ].join("\n");
392831
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+
393846 const formatError = (
394847 diagnostic: import("typescript").Diagnostic,
395848 hdrLineCount: number,
@@ -405,10 +858,6 @@ export function typecheckCode( · 9 unmodified lines
405858 return message;
406859 };
407860
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−
412861 if (!ts.sys || typeof ts.sys.useCaseSensitiveFileNames !== "boolean") {
413862 if (!warnedMissingCompilerHostSupport) {
414863 warnedMissingCompilerHostSupport = true;
executor/packages/runner-sandbox-host/src/index.ts+11−67
@@ -20,8 +20,8 @@ · 19 unmodified lines
2020 * 4. Console output is intentionally discarded. Only explicit `return` values
2121 * are included in terminal run results.
2222 *
23− * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal
24− * result status is reported back to Convex through callback RPC.
23+ * 5. `/v1/runs` waits for execution to finish and returns the terminal result
24+ * directly to the caller.
2525 *
2626 * ## Code isolation
2727 *
@@ -93,11 +93,6 @@ interface RunResult { · 65 unmodified lines
9393 exitCode?: number;
9494 }
9595
96−interface RunDispatchResponse {
97− accepted: true;
98− dispatchId: string;
99−}
100−
10196 interface ToolCallResult {
10297 ok: true | false;
10398 value?: unknown;
@@ -146,10 +141,6 @@ const failedResult = (error: string): RunResult => ({ · 42 unmodified lines
146141 error,
147142 });
148143
149−async function sleep(ms: number): Promise<void> {
150− await new Promise((resolve) => setTimeout(resolve, ms));
151−}
152−
153144 // ── Tool Bridge Entrypoint ───────────────────────────────────────────────────
154145 //
155146 // 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
351342 return body.value;
352343 }
353344
354−async 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−
389345 // ── Main Handler ─────────────────────────────────────────────────────────────
390346
391347 export default {
@@ -424,27 +380,15 @@ export default { · 32 unmodified lines
424380 );
425381 }
426382
427− const startedAt = Date.now();
428− const dispatchId = `dispatch_${body.taskId}_${startedAt}`;
429−
430− ctx.waitUntil((async () => {
431− const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env));
432− const finalResult = runResult.isOk()
433− ? runResult.value
434− : failedResult(
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− };
383+ const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env));
384+ const finalResult = runResult.isOk()
385+ ? runResult.value
386+ : failedResult(
387+ `Sandbox host error: ${runResult.error.cause instanceof Error
388+ ? runResult.error.cause.message
389+ : String(runResult.error.cause)}`,
390+ );
447391
448− return Response.json(response, { status: 202 });
392+ return Response.json(finalResult, { status: 200 });
449393 },
450394 };