- 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 | "name": "executor", | 6 | "name": "executor", |
| 7 | "dependencies": { | 7 | "dependencies": { |
| 8 | "@typescript/native-preview": "^7.0.0-dev.20260212.1", | ||
| 8 | "better-result": "^2.7.0", | 9 | "better-result": "^2.7.0", |
| 9 | "convex": "^1.31.7", | 10 | "convex": "^1.31.7", |
| 10 | "graphql": "^16.12.0", | 11 | "graphql": "^16.12.0", |
| @@ -68,6 +69,22 @@ · 57 unmodified lines | |||
| 68 | 69 | ||
| 69 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], | 70 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], |
| 70 | 71 | ||
| 72 | "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260212.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260212.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260212.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260212.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-VHAVbp8d2VGm90EK//brKIYvT3iPrLXMq4/LApCdkKww/Hfn33zPRVmig4rswNaJiVu8XhcdHld5yfMw6d5A9Q=="], | ||
| 73 | |||
| 74 | "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260212.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HH4bOVbNW6ITv00VSaE3aZjCuU2d+amgFZKdhbq7NpcJDxFvxyy9GT9gkKV0D1DXz5qoxZIcyBEIbwrhABb9vg=="], | ||
| 75 | |||
| 76 | "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260212.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-vnQ2xRJscbtyS/jHO5QY2xAZ3c11Yn1ZAor/XODDrxd7N7jIrm0Vtc2CIwsi51oncLS1SZtUd9cHZmJg5zUJrQ=="], | ||
| 77 | |||
| 78 | "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "arm" }, "sha512-T8sF3YtYtODhWnFNhVuL/GABCHpKJs6ZxTtSC1LtXoM/CE0Ai06k5WKOxJG5rJrBtLIW+Dempk7qKPfhNliDTA=="], | ||
| 79 | |||
| 80 | "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-suA5OryrhL/tE7AiQXiNNV88XwKEOfO0sypJQj+cfg/fpQ2trFyDZcsdMLYVZ7J0zirDai6H3TDETYYoNFE1/g=="], | ||
| 81 | |||
| 82 | "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260212.1", "", { "os": "linux", "cpu": "x64" }, "sha512-w687rpZKJM0Lev0ya0GYJlnFCITTUmN8jDpwLXn60jrNEZzL2J4F7biA6papr2sMdKRfWvRklhjB1TKHbJ6FKA=="], | ||
| 83 | |||
| 84 | "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260212.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-NhCXPQF6OTNEZl8iwRE1ef/zHiqit5p3m7hdT2vfAOi1iA2eoazX0zTSdhgjX83o9cLjen3V1R7nbSYehFHaqw=="], | ||
| 85 | |||
| 86 | "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260212.1", "", { "os": "win32", "cpu": "x64" }, "sha512-0yqSBlASRx9rqM12QvaWc227w+bIsuI2EwAiNsoB1ybRbCXoXMah1RQlfjjTpD02eWCe/029vwrNhq+FLn7Z8A=="], | ||
| 87 | |||
| 71 | "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="], | 88 | "better-result": ["better-result@2.7.0", "", { "dependencies": { "@clack/prompts": "^0.11.0" }, "bin": { "better-result": "bin/cli.mjs" } }, "sha512-7zrmXjAK8u8Z6SOe4R65XObOR5X+Y2I/VVku3t5cPOGQ8/WsBcfFmfnIPiEl5EBMDOzPHRwbiPbMtQBKYdw7RA=="], |
| 72 | 89 | ||
| 73 | "convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="], | 90 | "convex": ["convex@1.31.7", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-PtNMe1mAIOvA8Yz100QTOaIdgt2rIuWqencVXrb4McdhxBHZ8IJ1eXTnrgCC9HydyilGT1pOn+KNqT14mqn9fQ=="], |
| @@ -1,7 +1,12 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "executor", | 2 | "name": "executor", |
| 3 | "dependencies": { | ||
| 4 | "@typescript/native-preview": "^7.0.0-dev.20260212.1", | ||
| 5 | "better-result": "^2.7.0", | ||
| 6 | "convex": "^1.31.7", | ||
| 7 | "graphql": "^16.12.0" | ||
| 8 | }, | ||
| 3 | "private": true, | 9 | "private": true, |
| 4 | "type": "module", | ||
| 5 | "scripts": { | 10 | "scripts": { |
| 6 | "doctor": "bun run executor.ts doctor", | 11 | "doctor": "bun run executor.ts doctor", |
| 7 | "doctor:prod": "bun run scripts/doctor-prod.ts", | 12 | "doctor:prod": "bun run scripts/doctor-prod.ts", |
| @@ -22,9 +27,5 @@ · 14 unmodified lines | |||
| 22 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", | 27 | "build:binary": "bun build executor.ts --compile --outfile dist/executor", |
| 23 | "build:release": "bun run scripts/build-release.ts" | 28 | "build:release": "bun run scripts/build-release.ts" |
| 24 | }, | 29 | }, |
| 25 | "dependencies": { | 30 | "type": "module" |
| 26 | "better-result": "^2.7.0", | ||
| 27 | "convex": "^1.31.7", | ||
| 28 | "graphql": "^16.12.0" | ||
| 29 | } | ||
| 30 | } | 31 | } |
| @@ -332,6 +332,12 @@ export declare const api: { · 331 unmodified lines | |||
| 332 | }, | 332 | }, |
| 333 | any | 333 | any |
| 334 | >; | 334 | >; |
| 335 | getApprovalStatus: FunctionReference< | ||
| 336 | "query", | ||
| 337 | "public", | ||
| 338 | { approvalId: string; internalSecret: string; runId: string }, | ||
| 339 | any | ||
| 340 | >; | ||
| 335 | handleToolCall: FunctionReference< | 341 | handleToolCall: FunctionReference< |
| 336 | "action", | 342 | "action", |
| 337 | "public", | 343 | "public", |
| @@ -871,6 +877,7 @@ export declare const internal: { · 533 unmodified lines | |||
| 871 | code: string; | 877 | code: string; |
| 872 | metadata?: any; | 878 | metadata?: any; |
| 873 | runtimeId?: string; | 879 | runtimeId?: string; |
| 880 | scheduleAfterCreate?: boolean; | ||
| 874 | timeoutMs?: number; | 881 | timeoutMs?: number; |
| 875 | workspaceId: Id<"workspaces">; | 882 | workspaceId: Id<"workspaces">; |
| 876 | }, | 883 | }, |
| @@ -1105,6 +1112,26 @@ export declare const internal: { · 228 unmodified lines | |||
| 1105 | any | 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 | workspaceAuthInternal: { | 1135 | workspaceAuthInternal: { |
| 1109 | getWorkspaceAccessForRequest: FunctionReference< | 1136 | getWorkspaceAccessForRequest: FunctionReference< |
| 1110 | "query", | 1137 | "query", |
| @@ -72,6 +72,15 @@ async function deleteWorkspaceData( · 71 unmodified lines | |||
| 72 | await ctx.db.delete(cacheEntry._id); | 72 | await ctx.db.delete(cacheEntry._id); |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | const cachedDeclarations = await ctx.db | ||
| 76 | .query("typecheckDeclarationCache") | ||
| 77 | .withIndex("by_workspace_created", (q) => q.eq("workspaceId", workspaceId)) | ||
| 78 | .collect(); | ||
| 79 | for (const cacheEntry of cachedDeclarations) { | ||
| 80 | await ctx.storage.delete(cacheEntry.storageId).catch(() => {}); | ||
| 81 | await ctx.db.delete(cacheEntry._id); | ||
| 82 | } | ||
| 83 | |||
| 75 | const workspaceMembers = await ctx.db | 84 | const workspaceMembers = await ctx.db |
| 76 | .query("workspaceMembers") | 85 | .query("workspaceMembers") |
| 77 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) | 86 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) |
| @@ -40,6 +40,7 @@ async function createTaskRecord( · 39 unmodified lines | |||
| 40 | workspaceId: Id<"workspaces">; | 40 | workspaceId: Id<"workspaces">; |
| 41 | actorId: string; | 41 | actorId: string; |
| 42 | clientId?: string; | 42 | clientId?: string; |
| 43 | scheduleAfterCreate?: boolean; | ||
| 43 | }, | 44 | }, |
| 44 | ): Promise<{ task: TaskRecord }> { | 45 | ): Promise<{ task: TaskRecord }> { |
| 45 | if (!args.code.trim()) { | 46 | if (!args.code.trim()) { |
| @@ -89,9 +90,11 @@ async function createTaskRecord( · 43 unmodified lines | |||
| 89 | }, | 90 | }, |
| 90 | }); | 91 | }); |
| 91 | 92 | ||
| 92 | await ctx.scheduler.runAfter(1, internal.executorNode.runTask, { | 93 | if (args.scheduleAfterCreate ?? true) { |
| 93 | taskId, | 94 | await ctx.scheduler.runAfter(1, internal.executorNode.runTask, { |
| 94 | }); | 95 | taskId, |
| 96 | }); | ||
| 97 | } | ||
| 95 | 98 | ||
| 96 | return { task }; | 99 | return { task }; |
| 97 | } | 100 | } |
| @@ -182,6 +185,7 @@ export const createTaskInternal = internalMutation({ · 84 unmodified lines | |||
| 182 | workspaceId: v.id("workspaces"), | 185 | workspaceId: v.id("workspaces"), |
| 183 | actorId: v.string(), | 186 | actorId: v.string(), |
| 184 | clientId: v.optional(v.string()), | 187 | clientId: v.optional(v.string()), |
| 188 | scheduleAfterCreate: v.optional(v.boolean()), | ||
| 185 | }, | 189 | }, |
| 186 | handler: async (ctx, args): Promise<{ task: TaskRecord }> => { | 190 | handler: async (ctx, args): Promise<{ task: TaskRecord }> => { |
| 187 | return await createTaskRecord(ctx, args); | 191 | return await createTaskRecord(ctx, args); |
| @@ -98,11 +98,34 @@ export async function runQueuedTask( · 97 unmodified lines | |||
| 98 | return null; | 98 | return null; |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | await publishTaskEvent(ctx, args.taskId, "task", "task.dispatched", { | 101 | const finished = await ctx.runMutation(internal.database.markTaskFinished as any, { |
| 102 | taskId: args.taskId, | 102 | taskId: args.taskId, |
| 103 | runtimeId: running.runtimeId, | 103 | status: dispatchResult.status, |
| 104 | dispatchId: dispatchResult.dispatchId, | 104 | result: dispatchResult.result, |
| 105 | exitCode: dispatchResult.exitCode, | ||
| 106 | error: dispatchResult.error, | ||
| 107 | }); | ||
| 108 | |||
| 109 | if (!finished) { | ||
| 110 | return null; | ||
| 111 | } | ||
| 112 | |||
| 113 | const terminalEvent = | ||
| 114 | dispatchResult.status === "completed" | ||
| 115 | ? "task.completed" | ||
| 116 | : dispatchResult.status === "timed_out" | ||
| 117 | ? "task.timed_out" | ||
| 118 | : dispatchResult.status === "denied" | ||
| 119 | ? "task.denied" | ||
| 120 | : "task.failed"; | ||
| 121 | |||
| 122 | await publishTaskEvent(ctx, args.taskId, "task", terminalEvent, { | ||
| 123 | taskId: args.taskId, | ||
| 124 | status: finished.status, | ||
| 125 | exitCode: finished.exitCode, | ||
| 105 | durationMs: dispatchResult.durationMs, | 126 | durationMs: dispatchResult.durationMs, |
| 127 | error: finished.error, | ||
| 128 | completedAt: finished.completedAt, | ||
| 106 | }); | 129 | }); |
| 107 | return null; | 130 | return null; |
| 108 | } | 131 | } |
| @@ -21,8 +21,12 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 20 unmodified lines | |||
| 21 | }) => { | 21 | }) => { |
| 22 | return (await ctx.runMutation(internal.executor.createTaskInternal, { | 22 | return (await ctx.runMutation(internal.executor.createTaskInternal, { |
| 23 | ...input, | 23 | ...input, |
| 24 | scheduleAfterCreate: false, | ||
| 24 | })) as { task: TaskRecord }; | 25 | })) as { task: TaskRecord }; |
| 25 | }, | 26 | }, |
| 27 | runTaskNow: async (taskId: string) => { | ||
| 28 | return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null; | ||
| 29 | }, | ||
| 26 | getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => { | 30 | getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => { |
| 27 | if (workspaceId) { | 31 | if (workspaceId) { |
| 28 | return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null; | 32 | return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null; |
| @@ -1,11 +1,145 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import { createHash } from "node:crypto"; | ||
| 4 | import { createRequire } from "node:module"; | ||
| 3 | import { v } from "convex/values"; | 5 | import { v } from "convex/values"; |
| 4 | import { internal } from "./_generated/api"; | 6 | import { internal } from "./_generated/api"; |
| 5 | import { internalAction } from "./_generated/server"; | 7 | import { internalAction } from "./_generated/server"; |
| 6 | import { loadSourceDtsByUrl } from "../core/src/dts-loader"; | 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 | import type { ToolDescriptor } from "../core/src/types"; | 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 | export const typecheckRunCodeInternal = internalAction({ | 144 | export const typecheckRunCodeInternal = internalAction({ |
| 11 | args: { | 145 | args: { |
| @@ -21,16 +155,120 @@ export const typecheckRunCodeInternal = internalAction({ · 9 unmodified lines | |||
| 21 | clientId: args.clientId, | 155 | clientId: args.clientId, |
| 22 | }; | 156 | }; |
| 23 | 157 | ||
| 24 | const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as { | 158 | const [result, sources] = await Promise.all([ |
| 25 | tools: ToolDescriptor[]; | 159 | ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, toolContext) as Promise<{ |
| 26 | dtsUrls?: Record<string, string>; | 160 | tools: ToolDescriptor[]; |
| 27 | }; | 161 | dtsUrls?: Record<string, string>; |
| 162 | }>, | ||
| 163 | ctx.runQuery(internal.database.listToolSources, { workspaceId: args.workspaceId }) as Promise<Array<{ | ||
| 164 | id: string; | ||
| 165 | updatedAt: number; | ||
| 166 | enabled: boolean; | ||
| 167 | }>>, | ||
| 168 | ]); | ||
| 169 | |||
| 170 | const enabledSources = sources.filter((source) => source.enabled); | ||
| 171 | const workspaceSignature = sourceSignature(args.workspaceId, enabledSources); | ||
| 172 | |||
| 173 | const dtsUrls = result.dtsUrls ?? {}; | ||
| 174 | const dtsSources = new Set(Object.keys(dtsUrls)); | ||
| 175 | const analysis = analyzeToolReferences(args.code); | ||
| 176 | const selectiveMode = shouldUseSelectiveOpenApiDts(analysis); | ||
| 177 | const toolsForTypecheck = selectToolsForTypecheck(result.tools, analysis); | ||
| 178 | |||
| 179 | let dtsUrlsToLoad: Record<string, string> = {}; | ||
| 180 | let operationIdsBySource = new Map<string, Set<string>>(); | ||
| 181 | let sourcesRequiringFullDts = new Set<string>(); | ||
| 182 | |||
| 183 | if (selectiveMode) { | ||
| 184 | const mapped = mapOperationIdsBySource(result.tools, analysis.callPaths, dtsSources); | ||
| 185 | operationIdsBySource = mapped.operationIdsBySource; | ||
| 186 | sourcesRequiringFullDts = mapped.sourcesRequiringFullDts; | ||
| 187 | |||
| 188 | for (const source of mapped.sourcesRequiringFullDts) { | ||
| 189 | const url = dtsUrls[source]; | ||
| 190 | if (url) { | ||
| 191 | dtsUrlsToLoad[source] = url; | ||
| 192 | } | ||
| 193 | } | ||
| 194 | for (const [source] of operationIdsBySource) { | ||
| 195 | const url = dtsUrls[source]; | ||
| 196 | if (url) { | ||
| 197 | dtsUrlsToLoad[source] = url; | ||
| 198 | } | ||
| 199 | } | ||
| 200 | } else { | ||
| 201 | dtsUrlsToLoad = dtsUrls; | ||
| 202 | } | ||
| 28 | 203 | ||
| 29 | const sourceDtsBySource = await loadSourceDtsByUrl(result.dtsUrls ?? {}); | 204 | const cacheKey = declarationCacheKey({ |
| 30 | const declarations = generateToolDeclarations(result.tools, { | 205 | workspaceSignature, |
| 31 | sourceDtsBySource, | 206 | toolsFingerprint: toolFingerprint(toolsForTypecheck), |
| 207 | selectionFingerprint: declarationSelectionFingerprint( | ||
| 208 | selectiveMode, | ||
| 209 | operationIdsBySource, | ||
| 210 | sourcesRequiringFullDts, | ||
| 211 | ), | ||
| 212 | enginePreference: normalizeEnginePreference(), | ||
| 213 | hasDynamicToolAccess: analysis.hasDynamicToolAccess, | ||
| 214 | hasNonCallToolAccess: analysis.hasNonCallToolAccess, | ||
| 32 | }); | 215 | }); |
| 33 | const typecheck = typecheckCode(args.code, declarations); | 216 | |
| 217 | let declarations: string | null = null; | ||
| 218 | |||
| 219 | try { | ||
| 220 | const cacheEntry = await ctx.runQuery(internal.typecheckDeclarationCache.getEntry, { | ||
| 221 | workspaceId: args.workspaceId, | ||
| 222 | cacheKey, | ||
| 223 | maxAgeMs: DECLARATION_CACHE_TTL_MS, | ||
| 224 | }); | ||
| 225 | if (cacheEntry) { | ||
| 226 | const blob = await ctx.storage.get(cacheEntry.storageId); | ||
| 227 | if (blob) { | ||
| 228 | declarations = await blob.text(); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | } catch (error) { | ||
| 232 | const message = error instanceof Error ? error.message : String(error); | ||
| 233 | console.warn(`[executor] declaration cache read failed for '${args.workspaceId}': ${message}`); | ||
| 234 | } | ||
| 235 | |||
| 236 | if (!declarations) { | ||
| 237 | const sourceDtsBySource = await loadSourceDtsByUrl(dtsUrlsToLoad); | ||
| 238 | |||
| 239 | if (selectiveMode) { | ||
| 240 | for (const [source, operationIds] of operationIdsBySource.entries()) { | ||
| 241 | const fullDts = sourceDtsBySource[source]; | ||
| 242 | if (!fullDts) continue; | ||
| 243 | const sliced = sliceOpenApiOperationsDts(fullDts, operationIds); | ||
| 244 | if (sliced) { | ||
| 245 | sourceDtsBySource[source] = sliced; | ||
| 246 | } | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | declarations = generateToolDeclarations(toolsForTypecheck, { | ||
| 251 | sourceDtsBySource, | ||
| 252 | }); | ||
| 253 | |||
| 254 | try { | ||
| 255 | const blob = new Blob([declarations], { type: "text/plain" }); | ||
| 256 | const storageId = await ctx.storage.store(blob); | ||
| 257 | await ctx.runMutation(internal.typecheckDeclarationCache.putEntry, { | ||
| 258 | workspaceId: args.workspaceId, | ||
| 259 | cacheKey, | ||
| 260 | storageId, | ||
| 261 | sizeBytes: declarations.length, | ||
| 262 | maxEntriesPerWorkspace: DECLARATION_CACHE_MAX_ENTRIES_PER_WORKSPACE, | ||
| 263 | }); | ||
| 264 | } catch (error) { | ||
| 265 | const message = error instanceof Error ? error.message : String(error); | ||
| 266 | console.warn(`[executor] declaration cache write failed for '${args.workspaceId}': ${message}`); | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | const declarationText = declarations ?? "declare const tools: {};"; | ||
| 271 | const typecheck = typecheckCode(args.code, declarationText); | ||
| 34 | 272 | ||
| 35 | return { | 273 | return { |
| 36 | ok: typecheck.ok, | 274 | ok: typecheck.ok, |
| @@ -313,6 +313,16 @@ export default defineSchema({ · 312 unmodified lines | |||
| 313 | }) | 313 | }) |
| 314 | .index("by_workspace", ["workspaceId"]), | 314 | .index("by_workspace", ["workspaceId"]), |
| 315 | 315 | ||
| 316 | typecheckDeclarationCache: defineTable({ | ||
| 317 | workspaceId: v.id("workspaces"), | ||
| 318 | cacheKey: v.string(), | ||
| 319 | storageId: v.id("_storage"), | ||
| 320 | sizeBytes: v.number(), | ||
| 321 | createdAt: v.number(), | ||
| 322 | }) | ||
| 323 | .index("by_workspace_cache_key", ["workspaceId", "cacheKey"]) | ||
| 324 | .index("by_workspace_created", ["workspaceId", "createdAt"]), | ||
| 325 | |||
| 316 | anonymousSessions: defineTable({ | 326 | anonymousSessions: defineTable({ |
| 317 | sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid> | 327 | sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid> |
| 318 | workspaceId: v.id("workspaces"), | 328 | workspaceId: v.id("workspaces"), |
| @@ -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 | return context; | 93 | return context; |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void { | 96 | subscribe(taskId: string, _workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void { |
| 97 | const set = this.listeners.get(taskId) ?? new Set(); | 97 | const set = this.listeners.get(taskId) ?? new Set(); |
| 98 | set.add(listener); | 98 | set.add(listener); |
| 99 | this.listeners.set(taskId, set); | 99 | this.listeners.set(taskId, set); |
| @@ -87,7 +87,7 @@ class FakeMcpService { · 86 unmodified lines | |||
| 87 | return context; | 87 | return context; |
| 88 | } | 88 | } |
| 89 | 89 | ||
| 90 | subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void { | 90 | subscribe(taskId: string, _workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void { |
| 91 | const set = this.listeners.get(taskId) ?? new Set(); | 91 | const set = this.listeners.get(taskId) ?? new Set(); |
| 92 | set.add(listener); | 92 | set.add(listener); |
| 93 | this.listeners.set(taskId, set); | 93 | this.listeners.set(taskId, set); |
| @@ -24,8 +24,9 @@ function getTaskTerminalState(status: string): boolean { · 23 unmodified lines | |||
| 24 | 24 | ||
| 25 | interface McpExecutorService { | 25 | interface McpExecutorService { |
| 26 | createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>; | 26 | createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>; |
| 27 | runTaskNow?(taskId: string): Promise<null>; | ||
| 27 | getTask(taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null>; | 28 | getTask(taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null>; |
| 28 | subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void; | 29 | subscribe(taskId: string, workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void; |
| 29 | bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>; | 30 | bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>; |
| 30 | listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>; | 31 | listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>; |
| 31 | listToolsForTypecheck?( | 32 | listToolsForTypecheck?( |
| @@ -135,7 +136,6 @@ function waitForTerminalTask( · 103 unmodified lines | |||
| 135 | ): Promise<TaskRecord | null> { | 136 | ): Promise<TaskRecord | null> { |
| 136 | return new Promise((resolve) => { | 137 | return new Promise((resolve) => { |
| 137 | let settled = false; | 138 | let settled = false; |
| 138 | let checking = false; | ||
| 139 | let elicitationEnabled = Boolean( | 139 | let elicitationEnabled = Boolean( |
| 140 | onApprovalPrompt | 140 | onApprovalPrompt |
| 141 | && approvalContext | 141 | && approvalContext |
| @@ -145,7 +145,6 @@ function waitForTerminalTask( · 3 unmodified lines | |||
| 145 | let loggedElicitationFallback = false; | 145 | let loggedElicitationFallback = false; |
| 146 | const seenApprovalIds = new Set<string>(); | 146 | const seenApprovalIds = new Set<string>(); |
| 147 | let unsubscribe: (() => void) | undefined; | 147 | let unsubscribe: (() => void) | undefined; |
| 148 | let poll: ReturnType<typeof setInterval> | undefined; | ||
| 149 | 148 | ||
| 150 | const logElicitationFallback = (reason: string) => { | 149 | const logElicitationFallback = (reason: string) => { |
| 151 | if (loggedElicitationFallback) return; | 150 | if (loggedElicitationFallback) return; |
| @@ -156,7 +155,7 @@ function waitForTerminalTask( · 4 unmodified lines | |||
| 156 | const done = async () => { | 155 | const done = async () => { |
| 157 | if (settled) return; | 156 | if (settled) return; |
| 158 | settled = true; | 157 | settled = true; |
| 159 | if (poll) clearInterval(poll); | 158 | clearTimeout(timeout); |
| 160 | unsubscribe?.(); | 159 | unsubscribe?.(); |
| 161 | resolve(await service.getTask(taskId, workspaceId)); | 160 | resolve(await service.getTask(taskId, workspaceId)); |
| 162 | }; | 161 | }; |
| @@ -202,44 +201,34 @@ function waitForTerminalTask( · 39 unmodified lines | |||
| 202 | } | 201 | } |
| 203 | }; | 202 | }; |
| 204 | 203 | ||
| 205 | const checkTask = async () => { | 204 | unsubscribe = service.subscribe(taskId, workspaceId, (event) => { |
| 206 | if (settled || checking) return; | 205 | const payload = typeof event.payload === "object" && event.payload |
| 207 | checking = true; | 206 | ? event.payload as Record<string, unknown> |
| 208 | try { | 207 | : {}; |
| 209 | const task = await service.getTask(taskId, workspaceId); | 208 | const type = typeof payload.status === "string" ? payload.status : undefined; |
| 210 | if (task && getTaskTerminalState(task.status)) { | 209 | const pendingApprovalCount = typeof payload.pendingApprovalCount === "number" |
| 211 | clearTimeout(timeout); | 210 | ? payload.pendingApprovalCount |
| 212 | await done(); | 211 | : 0; |
| 213 | return; | 212 | |
| 214 | } | 213 | if (typeof type === "string" && getTaskTerminalState(type)) { |
| 214 | void done(); | ||
| 215 | return; | ||
| 216 | } | ||
| 215 | 217 | ||
| 216 | await maybeHandleApprovals(); | 218 | if (pendingApprovalCount > 0) { |
| 217 | } finally { | 219 | void maybeHandleApprovals().catch(() => {}); |
| 218 | checking = false; | ||
| 219 | } | 220 | } |
| 220 | }; | 221 | }); |
| 221 | 222 | ||
| 222 | poll = setInterval(() => { | 223 | // Prompt immediately if there are already pending approvals. |
| 223 | void checkTask().catch(() => {}); | 224 | void maybeHandleApprovals().catch(() => {}); |
| 224 | }, 400); | 225 | |
| 225 | 226 | // Handle tasks that were already terminal before subscribe connected. | |
| 226 | // Check if already terminal before subscribing (race condition guard) | 227 | void service.getTask(taskId, workspaceId).then((task) => { |
| 227 | void checkTask().catch(() => {}); | 228 | if (task && getTaskTerminalState(task.status)) { |
| 228 | 229 | void done(); | |
| 229 | // Subscribe for live events when available; polling remains as fallback. | 230 | } |
| 230 | try { | 231 | }).catch(() => {}); |
| 231 | unsubscribe = service.subscribe(taskId, (event) => { | ||
| 232 | const type = typeof event.payload === "object" && event.payload | ||
| 233 | ? (event.payload as Record<string, unknown>).status | ||
| 234 | : undefined; | ||
| 235 | if (typeof type === "string" && getTaskTerminalState(type)) { | ||
| 236 | clearTimeout(timeout); | ||
| 237 | void done(); | ||
| 238 | } | ||
| 239 | }); | ||
| 240 | } catch { | ||
| 241 | // Ignore subscription errors and rely on polling. | ||
| 242 | } | ||
| 243 | }); | 232 | }); |
| 244 | } | 233 | } |
| 245 | 234 | ||
| @@ -493,6 +482,34 @@ function createRunCodeTool( · 247 unmodified lines | |||
| 493 | }; | 482 | }; |
| 494 | } | 483 | } |
| 495 | 484 | ||
| 485 | if (service.runTaskNow) { | ||
| 486 | await service.runTaskNow(created.task.id); | ||
| 487 | const task = await service.getTask(created.task.id, context.workspaceId); | ||
| 488 | if (!task) { | ||
| 489 | return { | ||
| 490 | content: [textContent(`Task ${created.task.id} not found after execution`)], | ||
| 491 | isError: true, | ||
| 492 | }; | ||
| 493 | } | ||
| 494 | |||
| 495 | const isError = task.status !== "completed"; | ||
| 496 | return { | ||
| 497 | content: [textContent(summarizeTask(task))], | ||
| 498 | structuredContent: { | ||
| 499 | taskId: task.id, | ||
| 500 | status: task.status, | ||
| 501 | runtimeId: task.runtimeId, | ||
| 502 | exitCode: task.exitCode, | ||
| 503 | error: task.error, | ||
| 504 | result: task.result, | ||
| 505 | workspaceId: context.workspaceId, | ||
| 506 | actorId: context.actorId, | ||
| 507 | sessionId: context.sessionId, | ||
| 508 | }, | ||
| 509 | ...(isError ? { isError: true } : {}), | ||
| 510 | }; | ||
| 511 | } | ||
| 512 | |||
| 496 | const waitTimeoutMs = input.resultTimeoutMs ?? Math.max(requestedTimeoutMs + 30_000, 120_000); | 513 | const waitTimeoutMs = input.resultTimeoutMs ?? Math.max(requestedTimeoutMs + 30_000, 120_000); |
| 497 | const task = await waitForTerminalTask( | 514 | const task = await waitForTerminalTask( |
| 498 | service, | 515 | service, |
| @@ -7,8 +7,8 @@ let fakeCallbackServer: ReturnType<typeof Bun.serve>; · 6 unmodified lines | |||
| 7 | const AUTH_TOKEN = "test-sandbox-token"; | 7 | const AUTH_TOKEN = "test-sandbox-token"; |
| 8 | const CALLBACK_TOKEN = "test-callback-token"; | 8 | const CALLBACK_TOKEN = "test-callback-token"; |
| 9 | 9 | ||
| 10 | let hostResponseStatus = 202; | 10 | let hostResponseStatus = 200; |
| 11 | let hostResponseBody: Record<string, unknown> = { accepted: true, dispatchId: "dispatch_test" }; | 11 | let hostResponseBody: Record<string, unknown> = { status: "completed", result: { ok: true }, exitCode: 0 }; |
| 12 | type HostRequestBody = { | 12 | type HostRequestBody = { |
| 13 | taskId: string; | 13 | taskId: string; |
| 14 | code: string; | 14 | code: string; |
| @@ -60,9 +60,9 @@ afterAll(() => { · 45 unmodified lines | |||
| 60 | }); | 60 | }); |
| 61 | 61 | ||
| 62 | describe("cloudflare worker loader dispatch", () => { | 62 | describe("cloudflare worker loader dispatch", () => { |
| 63 | test("dispatches run request and returns accepted response", async () => { | 63 | test("dispatches run request and returns terminal response", async () => { |
| 64 | hostResponseStatus = 202; | 64 | hostResponseStatus = 200; |
| 65 | hostResponseBody = { accepted: true, dispatchId: "dispatch_abc" }; | 65 | hostResponseBody = { status: "completed", result: { ok: true }, exitCode: 0 }; |
| 66 | lastHostRequestBody = null; | 66 | lastHostRequestBody = null; |
| 67 | 67 | ||
| 68 | const result = await dispatchCodeWithCloudflareWorkerLoader({ | 68 | const result = await dispatchCodeWithCloudflareWorkerLoader({ |
| @@ -73,8 +73,8 @@ describe("cloudflare worker loader dispatch", () => { · 4 unmodified lines | |||
| 73 | 73 | ||
| 74 | expect(result.ok).toBe(true); | 74 | expect(result.ok).toBe(true); |
| 75 | if (result.ok) { | 75 | if (result.ok) { |
| 76 | expect(result.accepted).toBe(true); | 76 | expect(result.status).toBe("completed"); |
| 77 | expect(result.dispatchId).toBe("dispatch_abc"); | 77 | expect(result.exitCode).toBe(0); |
| 78 | } | 78 | } |
| 79 | 79 | ||
| 80 | expect(lastHostRequestBody).not.toBeNull(); | 80 | expect(lastHostRequestBody).not.toBeNull(); |
| @@ -84,8 +84,8 @@ describe("cloudflare worker loader dispatch", () => { · 3 unmodified lines | |||
| 84 | }); | 84 | }); |
| 85 | 85 | ||
| 86 | test("transpiles TypeScript before dispatching", async () => { | 86 | test("transpiles TypeScript before dispatching", async () => { |
| 87 | hostResponseStatus = 202; | 87 | hostResponseStatus = 200; |
| 88 | hostResponseBody = { accepted: true, dispatchId: "dispatch_ts" }; | 88 | hostResponseBody = { status: "completed", result: { ok: true }, exitCode: 0 }; |
| 89 | lastHostRequestBody = null; | 89 | lastHostRequestBody = null; |
| 90 | 90 | ||
| 91 | const result = await dispatchCodeWithCloudflareWorkerLoader({ | 91 | const result = await dispatchCodeWithCloudflareWorkerLoader({ |
| @@ -24,17 +24,20 @@ import { transpileForRuntime } from "./transpile"; · 23 unmodified lines | |||
| 24 | * 4. Only explicit `return` values from the isolate are included in callback | 24 | * 4. Only explicit `return` values from the isolate are included in callback |
| 25 | * completion payloads. | 25 | * completion payloads. |
| 26 | * | 26 | * |
| 27 | * 5. The host Worker accepts the run immediately and finishes execution | 27 | * 5. The host Worker executes the run inline and returns the terminal result |
| 28 | * asynchronously, reporting terminal results back through callback RPC. | 28 | * directly in the HTTP response. |
| 29 | * | 29 | * |
| 30 | * ## Callback authentication | 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 | export interface CloudflareDispatchResult { | 35 | export interface CloudflareDispatchResult { |
| 35 | ok: true; | 36 | ok: true; |
| 36 | accepted: true; | 37 | status: "completed" | "failed" | "timed_out" | "denied"; |
| 37 | dispatchId: string; | 38 | result?: unknown; |
| 39 | error?: string; | ||
| 40 | exitCode?: number; | ||
| 38 | durationMs: number; | 41 | durationMs: number; |
| 39 | } | 42 | } |
| 40 | 43 | ||
| @@ -64,7 +67,8 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 23 unmodified lines | |||
| 64 | 67 | ||
| 65 | // ── POST to CF host worker ──────────────────────────────────────────── | 68 | // ── POST to CF host worker ──────────────────────────────────────────── |
| 66 | const controller = new AbortController(); | 69 | const controller = new AbortController(); |
| 67 | const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); | 70 | const waitTimeoutMs = Math.max(config.requestTimeoutMs, request.timeoutMs + 30_000); |
| 71 | const timeout = setTimeout(() => controller.abort(), waitTimeoutMs); | ||
| 68 | 72 | ||
| 69 | const response = await Result.tryPromise(() => | 73 | const response = await Result.tryPromise(() => |
| 70 | fetch(config.runUrl, { | 74 | fetch(config.runUrl, { |
| @@ -92,39 +96,43 @@ export async function dispatchCodeWithCloudflareWorkerLoader( · 21 unmodified lines | |||
| 92 | const cause = response.error.cause; | 96 | const cause = response.error.cause; |
| 93 | const isAbort = cause instanceof DOMException && cause.name === "AbortError"; | 97 | const isAbort = cause instanceof DOMException && cause.name === "AbortError"; |
| 94 | if (isAbort) { | 98 | if (isAbort) { |
| 95 | return mkError(`Cloudflare sandbox dispatch timed out after ${config.requestTimeoutMs}ms`); | 99 | return mkError(`Cloudflare sandbox execution timed out after ${waitTimeoutMs}ms`); |
| 96 | } | 100 | } |
| 97 | const message = cause instanceof Error ? cause.message : String(cause); | 101 | const message = cause instanceof Error ? cause.message : String(cause); |
| 98 | return mkError(`Cloudflare sandbox dispatch failed: ${message}`); | 102 | return mkError(`Cloudflare sandbox dispatch failed: ${message}`); |
| 99 | } | 103 | } |
| 100 | 104 | ||
| 101 | // ── Handle non-accepted HTTP status ─────────────────────────────────── | 105 | // ── Handle non-success HTTP status ──────────────────────────────────── |
| 102 | if (response.value.status !== 202) { | 106 | if (response.value.status !== 200) { |
| 103 | const text = await Result.tryPromise(() => response.value.text()); | 107 | const text = await Result.tryPromise(() => response.value.text()); |
| 104 | const body = text.unwrapOr(response.value.statusText); | 108 | const body = text.unwrapOr(response.value.statusText); |
| 105 | return mkError(`Cloudflare sandbox dispatch returned ${response.value.status}: ${body}`); | 109 | return mkError(`Cloudflare sandbox execution returned ${response.value.status}: ${body}`); |
| 106 | } | 110 | } |
| 107 | 111 | ||
| 108 | // ── Parse accepted response JSON ────────────────────────────────────── | 112 | // ── Parse terminal response JSON ────────────────────────────────────── |
| 109 | const body = await Result.tryPromise(() => | 113 | const body = await Result.tryPromise(() => |
| 110 | response.value.json() as Promise<{ | 114 | response.value.json() as Promise<{ |
| 111 | accepted?: boolean; | 115 | status?: "completed" | "failed" | "timed_out" | "denied"; |
| 112 | dispatchId?: string; | 116 | result?: unknown; |
| 117 | error?: string; | ||
| 118 | exitCode?: number; | ||
| 113 | }>, | 119 | }>, |
| 114 | ); | 120 | ); |
| 115 | 121 | ||
| 116 | if (body.isErr()) { | 122 | if (body.isErr()) { |
| 117 | return mkError("Cloudflare sandbox dispatch returned invalid JSON"); | 123 | return mkError("Cloudflare sandbox execution returned invalid JSON"); |
| 118 | } | 124 | } |
| 119 | 125 | ||
| 120 | if (!body.value.accepted || !body.value.dispatchId) { | 126 | if (!body.value.status) { |
| 121 | return mkError("Cloudflare sandbox dispatch response missing accepted/dispatchId"); | 127 | return mkError("Cloudflare sandbox execution response missing status"); |
| 122 | } | 128 | } |
| 123 | 129 | ||
| 124 | return { | 130 | return { |
| 125 | ok: true, | 131 | ok: true, |
| 126 | accepted: true, | 132 | status: body.value.status, |
| 127 | dispatchId: body.value.dispatchId, | 133 | result: body.value.result, |
| 134 | error: body.value.error, | ||
| 135 | exitCode: body.value.exitCode, | ||
| 128 | durationMs: Date.now() - startedAt, | 136 | durationMs: Date.now() - startedAt, |
| 129 | }; | 137 | }; |
| 130 | } | 138 | } |
| @@ -19,7 +19,7 @@ export interface CloudflareWorkerLoaderConfig { · 18 unmodified lines | |||
| 19 | runUrl: string; | 19 | runUrl: string; |
| 20 | /** Shared-secret bearer token for authenticating with the host worker. */ | 20 | /** Shared-secret bearer token for authenticating with the host worker. */ |
| 21 | authToken: string; | 21 | authToken: string; |
| 22 | /** Dispatch request timeout in ms (how long we wait for /v1/runs accepted response). */ | 22 | /** Execution request timeout in ms (how long we wait for /v1/runs terminal response). */ |
| 23 | requestTimeoutMs: number; | 23 | requestTimeoutMs: number; |
| 24 | /** Convex deployment URL used for runtime callback RPC invocations. */ | 24 | /** Convex deployment URL used for runtime callback RPC invocations. */ |
| 25 | callbackConvexUrl: string; | 25 | callbackConvexUrl: string; |
| @@ -18,6 +18,21 @@ async function sleep(ms: number): Promise<void> { · 17 unmodified lines | |||
| 18 | await new Promise((resolve) => setTimeout(resolve, ms)); | 18 | await new Promise((resolve) => setTimeout(resolve, ms)); |
| 19 | } | 19 | } |
| 20 | 20 | ||
| 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 | function sanitizeExecutionResult(value: unknown): unknown { | 36 | function sanitizeExecutionResult(value: unknown): unknown { |
| 22 | if (value === undefined) { | 37 | if (value === undefined) { |
| 23 | return undefined; | 38 | return undefined; |
| @@ -55,6 +70,7 @@ function createToolsProxy( · 31 unmodified lines | |||
| 55 | 70 | ||
| 56 | const input = args.length > 0 ? args[0] : {}; | 71 | const input = args.length > 0 ? args[0] : {}; |
| 57 | const callId = `call_${crypto.randomUUID()}`; | 72 | const callId = `call_${crypto.randomUUID()}`; |
| 73 | let retryDelayMs = MIN_RETRY_DELAY_MS; | ||
| 58 | 74 | ||
| 59 | while (true) { | 75 | while (true) { |
| 60 | const result = await adapter.invokeTool({ | 76 | const result = await adapter.invokeTool({ |
| @@ -70,7 +86,9 @@ function createToolsProxy( · 9 unmodified lines | |||
| 70 | 86 | ||
| 71 | switch (result.kind) { | 87 | switch (result.kind) { |
| 72 | case "pending": | 88 | case "pending": |
| 73 | await sleep(Math.max(50, result.retryAfterMs ?? 500)); | 89 | retryDelayMs = clampRetryDelayMs(Math.max(result.retryAfterMs ?? 0, retryDelayMs)); |
| 90 | await sleep(withJitter(retryDelayMs)); | ||
| 91 | retryDelayMs = clampRetryDelayMs(retryDelayMs * RETRY_BACKOFF_MULTIPLIER); | ||
| 74 | continue; | 92 | continue; |
| 75 | case "denied": | 93 | case "denied": |
| 76 | throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); | 94 | throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`); |
| @@ -1,5 +1,12 @@ | |||
| 1 | import { test, expect, describe } from "bun:test"; | 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 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources"; | 10 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources"; |
| 4 | import type { ToolDescriptor } from "./types"; | 11 | import type { ToolDescriptor } from "./types"; |
| 5 | 12 | ||
| @@ -33,6 +40,93 @@ const FLAT_TOOL: ToolDescriptor = { · 27 unmodified lines | |||
| 33 | 40 | ||
| 34 | const ALL_TOOLS = [MATH_TOOL, ADMIN_TOOL, FLAT_TOOL]; | 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 | // generateToolDeclarations | 131 | // generateToolDeclarations |
| 38 | // --------------------------------------------------------------------------- | 132 | // --------------------------------------------------------------------------- |
| @@ -53,6 +53,228 @@ function safeTypeExpression(raw: string | undefined, fallback: string): string { · 52 unmodified lines | |||
| 53 | return isValidTypeExpression(typeExpression) ? typeExpression : fallback; | 53 | return isValidTypeExpression(typeExpression) ? typeExpression : fallback; |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 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 | const OPENAPI_HELPER_TYPES = ` | 278 | const OPENAPI_HELPER_TYPES = ` |
| 57 | type _Normalize<T> = Exclude<T, undefined | null>; | 279 | type _Normalize<T> = Exclude<T, undefined | null>; |
| 58 | type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; | 280 | type _OrEmpty<T> = [_Normalize<T>] extends [never] ? {} : _Normalize<T>; |
| @@ -175,7 +397,12 @@ export function generateToolDeclarations( · 116 unmodified lines | |||
| 175 | const inputType = strictArgsType !== "{}" | 397 | const inputType = strictArgsType !== "{}" |
| 176 | ? `(${openApiInputType}) & (${strictArgsType})` | 398 | ? `(${openApiInputType}) & (${strictArgsType})` |
| 177 | : openApiInputType; | 399 | : openApiInputType; |
| 178 | lines.push(`${pad}${key}(input: ${inputType}): Promise<ToolOutput<operations[${opKey}]>>;`); | 400 | const openApiOutputType = `ToolOutput<operations[${opKey}]>`; |
| 401 | const strictReturnsType = safeTypeExpression(tool.strictReturnsType, "unknown"); | ||
| 402 | const outputType = strictReturnsType !== "unknown" | ||
| 403 | ? `(${openApiOutputType}) & (${strictReturnsType})` | ||
| 404 | : openApiOutputType; | ||
| 405 | lines.push(`${pad}${key}(input: ${inputType}): Promise<${outputType}>;`); | ||
| 179 | } else { | 406 | } else { |
| 180 | const strictArgsType = tool.strictArgsType?.trim(); | 407 | const strictArgsType = tool.strictArgsType?.trim(); |
| 181 | const strictReturnsType = tool.strictReturnsType?.trim(); | 408 | const strictReturnsType = tool.strictReturnsType?.trim(); |
| @@ -275,6 +502,221 @@ const TYPECHECK_OK: TypecheckResult = { ok: true, errors: [] }; · 93 unmodified lines | |||
| 275 | 502 | ||
| 276 | let warnedMissingCompilerHostSupport = false; | 503 | let warnedMissingCompilerHostSupport = false; |
| 277 | let warnedSemanticFallback = false; | 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 | function runSyntaxOnlyTypecheck( | 721 | function runSyntaxOnlyTypecheck( |
| 280 | ts: typeof import("typescript"), | 722 | ts: typeof import("typescript"), |
| @@ -374,9 +816,6 @@ export function typecheckCode( · 93 unmodified lines | |||
| 374 | code: string, | 816 | code: string, |
| 375 | toolDeclarations: string, | 817 | toolDeclarations: string, |
| 376 | ): TypecheckResult { | 818 | ): TypecheckResult { |
| 377 | const ts = getTypeScriptModule(); | ||
| 378 | if (!ts) return TYPECHECK_OK; | ||
| 379 | |||
| 380 | // Wrap the code in an async function with the tools declaration. | 819 | // Wrap the code in an async function with the tools declaration. |
| 381 | // We declare sandbox globals (console, setTimeout, etc.) ourselves since | 820 | // We declare sandbox globals (console, setTimeout, etc.) ourselves since |
| 382 | // `types: []` prevents @types/node from loading. | 821 | // `types: []` prevents @types/node from loading. |
| @@ -390,6 +829,20 @@ export function typecheckCode( · 7 unmodified lines | |||
| 390 | "}", | 829 | "}", |
| 391 | ].join("\n"); | 830 | ].join("\n"); |
| 392 | 831 | ||
| 832 | // Count header lines so we can adjust line numbers | ||
| 833 | const headerLineCount = | ||
| 834 | toolDeclarations.split("\n").length + 4; // +4 for console, setTimeout, clearTimeout, function header | ||
| 835 | |||
| 836 | if (wantsTsgoTypecheckEngine()) { | ||
| 837 | const tsgoResult = runTsgoTypecheck(wrappedCode, headerLineCount); | ||
| 838 | if (tsgoResult) { | ||
| 839 | return tsgoResult; | ||
| 840 | } | ||
| 841 | } | ||
| 842 | |||
| 843 | const ts = getTypeScriptModule(); | ||
| 844 | if (!ts) return TYPECHECK_OK; | ||
| 845 | |||
| 393 | const formatError = ( | 846 | const formatError = ( |
| 394 | diagnostic: import("typescript").Diagnostic, | 847 | diagnostic: import("typescript").Diagnostic, |
| 395 | hdrLineCount: number, | 848 | hdrLineCount: number, |
| @@ -405,10 +858,6 @@ export function typecheckCode( · 9 unmodified lines | |||
| 405 | return message; | 858 | return message; |
| 406 | }; | 859 | }; |
| 407 | 860 | ||
| 408 | // Count header lines so we can adjust line numbers | ||
| 409 | const headerLineCount = | ||
| 410 | toolDeclarations.split("\n").length + 4; // +4 for console, setTimeout, clearTimeout, function header | ||
| 411 | |||
| 412 | if (!ts.sys || typeof ts.sys.useCaseSensitiveFileNames !== "boolean") { | 861 | if (!ts.sys || typeof ts.sys.useCaseSensitiveFileNames !== "boolean") { |
| 413 | if (!warnedMissingCompilerHostSupport) { | 862 | if (!warnedMissingCompilerHostSupport) { |
| 414 | warnedMissingCompilerHostSupport = true; | 863 | warnedMissingCompilerHostSupport = true; |
| @@ -20,8 +20,8 @@ · 19 unmodified lines | |||
| 20 | * 4. Console output is intentionally discarded. Only explicit `return` values | 20 | * 4. Console output is intentionally discarded. Only explicit `return` values |
| 21 | * are included in terminal run results. | 21 | * are included in terminal run results. |
| 22 | * | 22 | * |
| 23 | * 5. `/v1/runs` returns an accepted dispatch response immediately. Terminal | 23 | * 5. `/v1/runs` waits for execution to finish and returns the terminal result |
| 24 | * result status is reported back to Convex through callback RPC. | 24 | * directly to the caller. |
| 25 | * | 25 | * |
| 26 | * ## Code isolation | 26 | * ## Code isolation |
| 27 | * | 27 | * |
| @@ -93,11 +93,6 @@ interface RunResult { · 65 unmodified lines | |||
| 93 | exitCode?: number; | 93 | exitCode?: number; |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | interface RunDispatchResponse { | ||
| 97 | accepted: true; | ||
| 98 | dispatchId: string; | ||
| 99 | } | ||
| 100 | |||
| 101 | interface ToolCallResult { | 96 | interface ToolCallResult { |
| 102 | ok: true | false; | 97 | ok: true | false; |
| 103 | value?: unknown; | 98 | value?: unknown; |
| @@ -146,10 +141,6 @@ const failedResult = (error: string): RunResult => ({ · 42 unmodified lines | |||
| 146 | error, | 141 | error, |
| 147 | }); | 142 | }); |
| 148 | 143 | ||
| 149 | async function sleep(ms: number): Promise<void> { | ||
| 150 | await new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 151 | } | ||
| 152 | |||
| 153 | // ── Tool Bridge Entrypoint ─────────────────────────────────────────────────── | 144 | // ── Tool Bridge Entrypoint ─────────────────────────────────────────────────── |
| 154 | // | 145 | // |
| 155 | // This class is exposed as a named entrypoint on the host Worker. A loopback | 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 | return body.value; | 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 | // ── Main Handler ───────────────────────────────────────────────────────────── | 345 | // ── Main Handler ───────────────────────────────────────────────────────────── |
| 390 | 346 | ||
| 391 | export default { | 347 | export default { |
| @@ -424,27 +380,15 @@ export default { · 32 unmodified lines | |||
| 424 | ); | 380 | ); |
| 425 | } | 381 | } |
| 426 | 382 | ||
| 427 | const startedAt = Date.now(); | 383 | const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env)); |
| 428 | const dispatchId = `dispatch_${body.taskId}_${startedAt}`; | 384 | const finalResult = runResult.isOk() |
| 429 | 385 | ? runResult.value | |
| 430 | ctx.waitUntil((async () => { | 386 | : failedResult( |
| 431 | const runResult = await Result.tryPromise(() => executeSandboxRun(body, ctx, env)); | 387 | `Sandbox host error: ${runResult.error.cause instanceof Error |
| 432 | const finalResult = runResult.isOk() | 388 | ? runResult.error.cause.message |
| 433 | ? runResult.value | 389 | : String(runResult.error.cause)}`, |
| 434 | : failedResult( | 390 | ); |
| 435 | `Sandbox host error: ${runResult.error.cause instanceof Error | ||
| 436 | ? runResult.error.cause.message | ||
| 437 | : String(runResult.error.cause)}`, | ||
| 438 | ); | ||
| 439 | |||
| 440 | await reportRunCompletion(body, finalResult, Date.now() - startedAt); | ||
| 441 | })()); | ||
| 442 | |||
| 443 | const response: RunDispatchResponse = { | ||
| 444 | accepted: true, | ||
| 445 | dispatchId, | ||
| 446 | }; | ||
| 447 | 391 | ||
| 448 | return Response.json(response, { status: 202 }); | 392 | return Response.json(finalResult, { status: 200 }); |
| 449 | }, | 393 | }, |
| 450 | }; | 394 | }; |