- 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.
Unified · Split
| @@ -5,6 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | "": { |
| 6 | 6 | "name": "executor", |
| 7 | 7 | "dependencies": { |
| 8 | + "@typescript/native-preview": "^7.0.0-dev.20260212.1", | |
| 8 | 9 | "better-result": "^2.7.0", |
| 9 | 10 | "convex": "^1.31.7", |
| 10 | 11 | "graphql": "^16.12.0", |
| @@ -68,6 +69,22 @@ · 57 unmodified lines | ||
| 68 | 69 | |
| 69 | 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 | 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 | 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=="], |
| @@ -1,7 +1,12 @@ | ||
| 1 | 1 | { |
| 2 | 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 | 9 | "private": true, |
| 4 | − "type": "module", | |
| 5 | 10 | "scripts": { |
| 6 | 11 | "doctor": "bun run executor.ts doctor", |
| 7 | 12 | "doctor:prod": "bun run scripts/doctor-prod.ts", |
| @@ -22,9 +27,5 @@ · 14 unmodified lines | ||
| 22 | 27 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", |
| 23 | 28 | "build:release": "bun run scripts/build-release.ts" |
| 24 | 29 | }, |
| 25 | − "dependencies": { | |
| 26 | − "better-result": "^2.7.0", | |
| 27 | − "convex": "^1.31.7", | |
| 28 | − "graphql": "^16.12.0" | |
| 29 | − } | |
| 30 | + "type": "module" | |
| 30 | 31 | } |
| @@ -332,6 +332,12 @@ export declare const api: { · 331 unmodified lines | ||
| 332 | 332 | }, |
| 333 | 333 | any |
| 334 | 334 | >; |
| 335 | + getApprovalStatus: FunctionReference< | |
| 336 | + "query", | |
| 337 | + "public", | |
| 338 | + { approvalId: string; internalSecret: string; runId: string }, | |
| 339 | + any | |
| 340 | + >; | |
| 335 | 341 | handleToolCall: FunctionReference< |
| 336 | 342 | "action", |
| 337 | 343 | "public", |
| @@ -871,6 +877,7 @@ export declare const internal: { · 533 unmodified lines | ||
| 871 | 877 | code: string; |
| 872 | 878 | metadata?: any; |
| 873 | 879 | runtimeId?: string; |
| 880 | + scheduleAfterCreate?: boolean; | |
| 874 | 881 | timeoutMs?: number; |
| 875 | 882 | workspaceId: Id<"workspaces">; |
| 876 | 883 | }, |
| @@ -1105,6 +1112,26 @@ export declare const internal: { · 228 unmodified lines | ||
| 1105 | 1112 | 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 | 1135 | workspaceAuthInternal: { |
| 1109 | 1136 | getWorkspaceAccessForRequest: FunctionReference< |
| 1110 | 1137 | "query", |
| @@ -72,6 +72,15 @@ async function deleteWorkspaceData( · 71 unmodified lines | ||
| 72 | 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 | 84 | const workspaceMembers = await ctx.db |
| 76 | 85 | .query("workspaceMembers") |
| 77 | 86 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) |
| @@ -40,6 +40,7 @@ async function createTaskRecord( · 39 unmodified lines | ||
| 40 | 40 | workspaceId: Id<"workspaces">; |
| 41 | 41 | actorId: string; |
| 42 | 42 | clientId?: string; |
| 43 | + scheduleAfterCreate?: boolean; | |
| 43 | 44 | }, |
| 44 | 45 | ): Promise<{ task: TaskRecord }> { |
| 45 | 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 | − taskId, | |
| 94 | − }); | |
| 93 | + if (args.scheduleAfterCreate ?? true) { | |
| 94 | + await ctx.scheduler.runAfter(1, internal.executorNode.runTask, { | |
| 95 | + taskId, | |
| 96 | + }); | |
| 97 | + } | |
| 95 | 98 | |
| 96 | 99 | return { task }; |
| 97 | 100 | } |
| @@ -182,6 +185,7 @@ export const createTaskInternal = internalMutation({ · 84 unmodified lines | ||
| 182 | 185 | workspaceId: v.id("workspaces"), |
| 183 | 186 | actorId: v.string(), |
| 184 | 187 | clientId: v.optional(v.string()), |
| 188 | + scheduleAfterCreate: v.optional(v.boolean()), | |
| 185 | 189 | }, |
| 186 | 190 | handler: async (ctx, args): Promise<{ task: TaskRecord }> => { |
| 187 | 191 | return await createTaskRecord(ctx, args); |
| @@ -98,11 +98,34 @@ export async function runQueuedTask( · 97 unmodified lines | ||
| 98 | 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 | 102 | 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, | |
| 105 | 126 | durationMs: dispatchResult.durationMs, |
| 127 | + error: finished.error, | |
| 128 | + completedAt: finished.completedAt, | |
| 106 | 129 | }); |
| 107 | 130 | return null; |
| 108 | 131 | } |
| @@ -21,8 +21,12 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 20 unmodified lines | ||
| 21 | 21 | }) => { |
| 22 | 22 | return (await ctx.runMutation(internal.executor.createTaskInternal, { |
| 23 | 23 | ...input, |
| 24 | + scheduleAfterCreate: false, | |
| 24 | 25 | })) as { task: TaskRecord }; |
| 25 | 26 | }, |
| 27 | + runTaskNow: async (taskId: string) => { | |
| 28 | + return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null; | |
| 29 | + }, | |
| 26 | 30 | getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => { |
| 27 | 31 | if (workspaceId) { |
| 28 | 32 | return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null; |
| @@ -1,11 +1,145 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | +import { createHash } from "node:crypto"; | |
| 4 | +import { createRequire } from "node:module"; | |
| 3 | 5 | import { v } from "convex/values"; |
| 4 | 6 | import { internal } from "./_generated/api"; |
| 5 | 7 | import { internalAction } from "./_generated/server"; |
| 6 | 8 | 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"; | |
| 8 | 15 | 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 | +} | |
| 9 | 143 | |
| 10 | 144 | export const typecheckRunCodeInternal = internalAction({ |
| 11 | 145 | args: { |
| @@ -21,16 +155,120 @@ export const typecheckRunCodeInternal = internalAction({ · 9 unmodified lines | ||
| 21 | 155 | clientId: args.clientId, |
| 22 | 156 | }; |
| 23 | 157 | |
| 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 | + } | |
| 28 | 203 | |
| 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, | |
| 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 | 273 | return { |
| 36 | 274 | ok: typecheck.ok, |
| @@ -313,6 +313,16 @@ export default defineSchema({ · 312 unmodified lines | ||
| 313 | 313 | }) |
| 314 | 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 | 326 | anonymousSessions: defineTable({ |
| 317 | 327 | sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid> |
| 318 | 328 | workspaceId: v.id("workspaces"), |
| @@ -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 | +}); | |
| @@ -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 | +}); | |
| @@ -93,7 +93,7 @@ class FakeMcpService { · 92 unmodified lines | ||
| 93 | 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 | 97 | const set = this.listeners.get(taskId) ?? new Set(); |
| 98 | 98 | set.add(listener); |
| 99 | 99 | this.listeners.set(taskId, set); |
| @@ -87,7 +87,7 @@ class FakeMcpService { · 86 unmodified lines | ||
| 87 | 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 | 91 | const set = this.listeners.get(taskId) ?? new Set(); |
| 92 | 92 | set.add(listener); |
| 93 | 93 | this.listeners.set(taskId, set); |
| @@ -24,8 +24,9 @@ function getTaskTerminalState(status: string): boolean { · 23 unmodified lines | ||
| 24 | 24 | |
| 25 | 25 | interface McpExecutorService { |
| 26 | 26 | createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>; |
| 27 | + runTaskNow?(taskId: string): Promise<null>; | |
| 27 | 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 | 30 | bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>; |
| 30 | 31 | listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>; |
| 31 | 32 | listToolsForTypecheck?( |
| @@ -135,7 +136,6 @@ function waitForTerminalTask( · 103 unmodified lines | ||
| 135 | 136 | ): Promise<TaskRecord | null> { |
| 136 | 137 | return new Promise((resolve) => { |
| 137 | 138 | let settled = false; |
| 138 | − let checking = false; | |
| 139 | 139 | let elicitationEnabled = Boolean( |
| 140 | 140 | onApprovalPrompt |
| 141 | 141 | && approvalContext |
| @@ -145,7 +145,6 @@ function waitForTerminalTask( · 3 unmodified lines | ||
| 145 | 145 | let loggedElicitationFallback = false; |
| 146 | 146 | const seenApprovalIds = new Set<string>(); |
| 147 | 147 | let unsubscribe: (() => void) | undefined; |
| 148 | − let poll: ReturnType<typeof setInterval> | undefined; | |
| 149 | 148 | |
| 150 | 149 | const logElicitationFallback = (reason: string) => { |
| 151 | 150 | if (loggedElicitationFallback) return; |
| @@ -156,7 +155,7 @@ function waitForTerminalTask( · 4 unmodified lines | ||
| 156 | 155 | const done = async () => { |
| 157 | 156 | if (settled) return; |
| 158 | 157 | settled = true; |
| 159 | − if (poll) clearInterval(poll); | |
| 158 | + clearTimeout(timeout); | |
| 160 | 159 | unsubscribe?.(); |
| 161 | 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 () => { | |
| 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 | + } | |
| 215 | 217 | |
| 216 | − await maybeHandleApprovals(); | |
| 217 | − } finally { | |
| 218 | − checking = false; | |
| 218 | + if (pendingApprovalCount > 0) { | |
| 219 | + void maybeHandleApprovals().catch(() => {}); | |
| 219 | 220 | } |
| 220 | − }; | |
| 221 | + }); | |
| 221 | 222 | |
| 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(() => {}); | |
| 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 | 513 | const waitTimeoutMs = input.resultTimeoutMs ?? Math.max(requestedTimeoutMs + 30_000, 120_000); |
| 497 | 514 | const task = await waitForTerminalTask( |
| 498 | 515 | service, |
| @@ -7,8 +7,8 @@ let fakeCallbackServer: ReturnType<typeof Bun.serve>; · 6 unmodified lines | ||
| 7 | 7 | const AUTH_TOKEN = "test-sandbox-token"; |
| 8 | 8 | const CALLBACK_TOKEN = "test-callback-token"; |
| 9 | 9 | |
| 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 }; | |
| 12 | 12 | type HostRequestBody = { |
| 13 | 13 | taskId: string; |
| 14 | 14 | code: string; |
| @@ -60,9 +60,9 @@ afterAll(() => { · 45 unmodified lines | ||
| 60 | 60 | }); |
| 61 | 61 | |
| 62 | 62 | 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 }; | |
| 66 | 66 | lastHostRequestBody = null; |
| 67 | 67 | |
| 68 | 68 | const result = await dispatchCodeWithCloudflareWorkerLoader({ |
| @@ -73,8 +73,8 @@ describe("cloudflare worker loader dispatch", () => { · 4 unmodified lines | ||
| 73 | 73 | |
| 74 | 74 | expect(result.ok).toBe(true); |
| 75 | 75 | 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); | |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | 80 | expect(lastHostRequestBody).not.toBeNull(); |
| @@ -84,8 +84,8 @@ describe("cloudflare worker loader dispatch", () => { · 3 unmodified lines | ||
| 84 | 84 | }); |
| 85 | 85 | |
| 86 | 86 | 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 }; | |
| 89 | 89 | lastHostRequestBody = null; |
| 90 | 90 | |
| 91 | 91 | const result = await dispatchCodeWithCloudflareWorkerLoader({ |
| @@ -24,17 +24,20 @@ import { transpileForRuntime } from "./transpile"; · 23 unmodified lines | ||
| 24 | 24 | * 4. Only explicit `return` values from the isolate are included in callback |
| 25 | 25 | * completion payloads. |
| 26 | 26 | * |
| 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. | |
| 29 | 29 | * |
| 30 | 30 | * ## 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 | */ |
| 34 | 35 | export interface CloudflareDispatchResult { |
| 35 | 36 | ok: true; |
| 36 | − accepted: true; | |
| 37 | − dispatchId: string; | |
| 37 | + status: "completed" | "failed" | "timed_out" | "denied"; | |
| 38 | + result?: unknown; | |
| 39 | + error?: string; | |
| 40 | + exitCode?: number; | |
| 38 | 41 | durationMs: number; |
| 39 | 42 | } |
| 40 | 43 | |
| @@ -64,7 +67,8 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 23 unmodified lines | ||
| 64 | 67 | |
| 65 | 68 | // ── POST to CF host worker ──────────────────────────────────────────── |
| 66 | 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 | 73 | const response = await Result.tryPromise(() => |
| 70 | 74 | fetch(config.runUrl, { |
| @@ -92,39 +96,43 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 21 unmodified lines | ||
| 92 | 96 | const cause = response.error.cause; |
| 93 | 97 | const isAbort = cause instanceof DOMException && cause.name === "AbortError"; |
| 94 | 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 | 101 | const message = cause instanceof Error ? cause.message : String(cause); |
| 98 | 102 | return mkError(`Cloudflare sandbox dispatch failed: ${message}`); |
| 99 | 103 | } |
| 100 | 104 | |
| 101 | − // ── Handle non-accepted HTTP status ─────────────────────────────────── | |
| 102 | − if (response.value.status !== 202) { | |
| 105 | + // ── Handle non-success HTTP status ──────────────────────────────────── | |
| 106 | + if (response.value.status !== 200) { | |
| 103 | 107 | const text = await Result.tryPromise(() => response.value.text()); |
| 104 | 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 | 113 | const body = await Result.tryPromise(() => |
| 110 | 114 | 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; | |
| 113 | 119 | }>, |
| 114 | 120 | ); |
| 115 | 121 | |
| 116 | 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) { | |
| 121 | − return mkError("Cloudflare sandbox dispatch response missing accepted/dispatchId"); | |
| 126 | + if (!body.value.status) { | |
| 127 | + return mkError("Cloudflare sandbox execution response missing status"); | |
| 122 | 128 | } |
| 123 | 129 | |
| 124 | 130 | return { |
| 125 | 131 | 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, | |
| 128 | 136 | durationMs: Date.now() - startedAt, |
| 129 | 137 | }; |
| 130 | 138 | } |
| @@ -19,7 +19,7 @@ export interface CloudflareWorkerLoaderConfig { · 18 unmodified lines | ||
| 19 | 19 | runUrl: string; |
| 20 | 20 | /** Shared-secret bearer token for authenticating with the host worker. */ |
| 21 | 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 | 23 | requestTimeoutMs: number; |
| 24 | 24 | /** Convex deployment URL used for runtime callback RPC invocations. */ |
| 25 | 25 | callbackConvexUrl: string; |
| @@ -18,6 +18,21 @@ async function sleep(ms: number): Promise<void> { · 17 unmodified lines | ||
| 18 | 18 | await new Promise((resolve) => setTimeout(resolve, ms)); |
| 19 | 19 | } |
| 20 | 20 | |
| 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 | + | |
| 21 | 36 | function sanitizeExecutionResult(value: unknown): unknown { |
| 22 | 37 | if (value === undefined) { |
| 23 | 38 | return undefined; |
| @@ -55,6 +70,7 @@ function createToolsProxy( · 31 unmodified lines | ||
| 55 | 70 | |
| 56 | 71 | const input = args.length > 0 ? args[0] : {}; |
| 57 | 72 | const callId = `call_${crypto.randomUUID()}`; |
| 73 | + let retryDelayMs = MIN_RETRY_DELAY_MS; | |
| 58 | 74 | |
| 59 | 75 | while (true) { |
| 60 | 76 | const result = await adapter.invokeTool({ |
| @@ -70,7 +86,9 @@ function createToolsProxy( · 9 unmodified lines | ||
| 70 | 86 | |
| 71 | 87 | switch (result.kind) { |
| 72 | 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 | 92 | continue; |
| 75 | 93 | case "denied": |
| 76 | 94 | throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); |
| @@ -1,5 +1,12 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 10 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources"; |
| 4 | 11 | import type { ToolDescriptor } from "./types"; |
| 5 | 12 | |
| @@ -33,6 +40,93 @@ const FLAT_TOOL: ToolDescriptor = { · 27 unmodified lines | ||
| 33 | 40 | |
| 34 | 41 | const ALL_TOOLS = [MATH_TOOL, ADMIN_TOOL, FLAT_TOOL]; |
| 35 | 42 | |
| 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 | + | |
| 36 | 130 | // --------------------------------------------------------------------------- |
| 37 | 131 | // generateToolDeclarations |
| 38 | 132 | // --------------------------------------------------------------------------- |
| @@ -53,6 +53,228 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 52 unmodified lines | ||
| 53 | 53 | return isValidTypeExpression(typeExpression) ? typeExpression : fallback; |
| 54 | 54 | } |
| 55 | 55 | |
| 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 | + | |
| 56 | 278 | const OPENAPI_HELPER_TYPES = ` |
| 57 | 279 | type _Normalize<T> = Exclude<T, undefined | null>; |
| 58 | 280 | type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; |
| @@ -175,7 +397,12 @@ export function generateToolDeclarations( · 116 unmodified lines | ||
| 175 | 397 | const inputType = strictArgsType !== "{}" |
| 176 | 398 | ? `(${openApiInputType}) & (${strictArgsType})` |
| 177 | 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 | 406 | } else { |
| 180 | 407 | const strictArgsType = tool.strictArgsType?.trim(); |
| 181 | 408 | const strictReturnsType = tool.strictReturnsType?.trim(); |
| @@ -275,6 +502,221 @@ const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; · 93 unmodified lines | ||
| 275 | 502 | |
| 276 | 503 | let warnedMissingCompilerHostSupport = false; |
| 277 | 504 | 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 | +} | |
| 278 | 720 | |
| 279 | 721 | function runSyntaxOnlyTypecheck( |
| 280 | 722 | ts: typeof import("typescript"), |
| @@ -374,9 +816,6 @@ export function typecheckCode( · 93 unmodified lines | ||
| 374 | 816 | code: string, |
| 375 | 817 | toolDeclarations: string, |
| 376 | 818 | ): TypecheckResult { |
| 377 | − const ts = getTypeScriptModule(); | |
| 378 | − if (!ts) return TYPECHECK_OK; | |
| 379 | − | |
| 380 | 819 | // Wrap the code in an async function with the tools declaration. |
| 381 | 820 | // We declare sandbox globals (console, setTimeout, etc.) ourselves since |
| 382 | 821 | // `types: []` prevents @types/node from loading. |
| @@ -390,6 +829,20 @@ export function typecheckCode( · 7 unmodified lines | ||
| 390 | 829 | "}", |
| 391 | 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 | 846 | const formatError = ( |
| 394 | 847 | diagnostic: import("typescript").Diagnostic, |
| 395 | 848 | hdrLineCount: number, |
| @@ -405,10 +858,6 @@ export function typecheckCode( · 9 unmodified lines | ||
| 405 | 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 | 861 | if (!ts.sys || typeof ts.sys.useCaseSensitiveFileNames !== "boolean") { |
| 413 | 862 | if (!warnedMissingCompilerHostSupport) { |
| 414 | 863 | warnedMissingCompilerHostSupport = true; |
| @@ -20,8 +20,8 @@ · 19 unmodified lines | ||
| 20 | 20 | * 4. Console output is intentionally discarded. Only explicit `return` values |
| 21 | 21 | * are included in terminal run results. |
| 22 | 22 | * |
| 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. | |
| 25 | 25 | * |
| 26 | 26 | * ## Code isolation |
| 27 | 27 | * |
| @@ -93,11 +93,6 @@ interface RunResult { · 65 unmodified lines | ||
| 93 | 93 | exitCode?: number; |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | −interface RunDispatchResponse { | |
| 97 | − accepted: true; | |
| 98 | − dispatchId: string; | |
| 99 | −} | |
| 100 | − | |
| 101 | 96 | interface ToolCallResult { |
| 102 | 97 | ok: true | false; |
| 103 | 98 | value?: unknown; |
| @@ -146,10 +141,6 @@ const failedResult = (error: string): RunResult => ({ · 42 unmodified lines | ||
| 146 | 141 | error, |
| 147 | 142 | }); |
| 148 | 143 | |
| 149 | −async function sleep(ms: number): Promise<void> { | |
| 150 | − await new Promise((resolve) => setTimeout(resolve, ms)); | |
| 151 | −} | |
| 152 | − | |
| 153 | 144 | // ── Tool Bridge Entrypoint ─────────────────────────────────────────────────── |
| 154 | 145 | // |
| 155 | 146 | // 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 | 342 | return body.value; |
| 352 | 343 | } |
| 353 | 344 | |
| 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 | − | |
| 389 | 345 | // ── Main Handler ───────────────────────────────────────────────────────────── |
| 390 | 346 | |
| 391 | 347 | export default { |
| @@ -424,27 +380,15 @@ export default { · 32 unmodified lines | ||
| 424 | 380 | ); |
| 425 | 381 | } |
| 426 | 382 | |
| 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 | + ); | |
| 447 | 391 | |
| 448 | − return Response.json(response, { status: 202 }); | |
| 392 | + return Response.json(finalResult, { status: 200 }); | |
| 449 | 393 | }, |
| 450 | 394 | }; |