- Changed the default port for the Assistant server from 3000 to 3002 in various configuration files. - Updated the `.env.example` to reflect the new `ASSISTANT_PORT` variable. - Adjusted the `dev.ts` script to utilize the new port and ensure proper environment variable handling. - Modified the README to document the updated Assistant server URL. - Removed deprecated typecheck declaration cache functionality and related tests to streamline the codebase.
Unified · Split
| @@ -25,6 +25,7 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 24 unmodified lines | |||
| 25 | # EXECUTOR_BACKEND_PORT=5410 | 25 | # EXECUTOR_BACKEND_PORT=5410 |
| 26 | # EXECUTOR_BACKEND_SITE_PORT=5411 | 26 | # EXECUTOR_BACKEND_SITE_PORT=5411 |
| 27 | # EXECUTOR_WEB_PORT=5312 | 27 | # EXECUTOR_WEB_PORT=5312 |
| 28 | # ASSISTANT_PORT=3002 | ||
| 28 | 29 | ||
| 29 | # ── MCP OAuth (optional) ── | 30 | # ── MCP OAuth (optional) ── |
| 30 | # Set to 1 to enable self-issued anonymous OAuth even without WorkOS auth. | 31 | # Set to 1 to enable self-issued anonymous OAuth even without WorkOS auth. |
| @@ -35,7 +36,7 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 4 unmodified lines | |||
| 35 | 36 | ||
| 36 | # ── Assistant (optional) ── | 37 | # ── Assistant (optional) ── |
| 37 | # OPENASSISTANT_EXECUTOR_URL=http://localhost:3001 | 38 | # OPENASSISTANT_EXECUTOR_URL=http://localhost:3001 |
| 38 | # OPENASSISTANT_CALLBACK_BASE_URL=http://localhost:3000 | 39 | # OPENASSISTANT_CALLBACK_BASE_URL=http://localhost:3002 |
| 39 | 40 | ||
| 40 | # ── Tool source API keys (optional) ── | 41 | # ── Tool source API keys (optional) ── |
| 41 | # POSTHOG_PERSONAL_API_KEY=... | 42 | # POSTHOG_PERSONAL_API_KEY=... |
| @@ -64,7 +64,7 @@ bun run dev · 63 unmodified lines | |||
| 64 | - Convex dev watcher for `executor/` | 64 | - Convex dev watcher for `executor/` |
| 65 | - Executor web app (`http://localhost:4312`) | 65 | - Executor web app (`http://localhost:4312`) |
| 66 | - Executor MCP endpoint (`<CONVEX_SITE_URL>/mcp`) | 66 | - Executor MCP endpoint (`<CONVEX_SITE_URL>/mcp`) |
| 67 | - Assistant server (`http://localhost:3000`) | 67 | - Assistant server (`http://localhost:3002`) |
| 68 | - Discord bot (only if `DISCORD_BOT_TOKEN` is set) | 68 | - Discord bot (only if `DISCORD_BOT_TOKEN` is set) |
| 69 | 69 | ||
| 70 | PIDs are tracked in `.dev.pids`. To stop all processes: | 70 | PIDs are tracked in `.dev.pids`. To stop all processes: |
| @@ -26,7 +26,7 @@ if (!DISCORD_TOKEN) { · 25 unmodified lines | |||
| 26 | throw new Error("DISCORD_BOT_TOKEN is required"); | 26 | throw new Error("DISCORD_BOT_TOKEN is required"); |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? "http://localhost:3000"; | 29 | const SERVER_URL = Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${Bun.env.ASSISTANT_PORT ?? "3002"}`; |
| 30 | const CONVEX_URL = Bun.env.CONVEX_URL; | 30 | const CONVEX_URL = Bun.env.CONVEX_URL; |
| 31 | if (!CONVEX_URL) { | 31 | if (!CONVEX_URL) { |
| 32 | throw new Error("CONVEX_URL is required. Set it in your environment."); | 32 | throw new Error("CONVEX_URL is required. Set it in your environment."); |
| @@ -7,7 +7,7 @@ import { ConvexHttpClient } from "convex/browser"; · 6 unmodified lines | |||
| 7 | import { api } from "@executor/convex/_generated/api"; | 7 | import { api } from "@executor/convex/_generated/api"; |
| 8 | import type { Id } from "@executor/convex/_generated/dataModel"; | 8 | import type { Id } from "@executor/convex/_generated/dataModel"; |
| 9 | 9 | ||
| 10 | const PORT = Number(Bun.env.PORT ?? 3000); | 10 | const PORT = Number(Bun.env.ASSISTANT_PORT ?? Bun.env.PORT ?? 3002); |
| 11 | const CONVEX_URL = Bun.env.CONVEX_URL; | 11 | const CONVEX_URL = Bun.env.CONVEX_URL; |
| 12 | if (!CONVEX_URL) { | 12 | if (!CONVEX_URL) { |
| 13 | throw new Error("CONVEX_URL is required. Set it in your environment."); | 13 | throw new Error("CONVEX_URL is required. Set it in your environment."); |
| @@ -10,7 +10,7 @@ · 9 unmodified lines | |||
| 10 | * 2. Convex cloud dev function watcher │ | 10 | * 2. Convex cloud dev function watcher │ |
| 11 | * 3. CF sandbox host worker (port 8787) ├─ all started concurrently | 11 | * 3. CF sandbox host worker (port 8787) ├─ all started concurrently |
| 12 | * 4. Executor web UI (port 4312) │ | 12 | * 4. Executor web UI (port 4312) │ |
| 13 | * 5. Assistant server (port 3000) │ | 13 | * 5. Assistant server (port 3002) │ |
| 14 | * 6. Discord bot ─┘ | 14 | * 6. Discord bot ─┘ |
| 15 | * | 15 | * |
| 16 | * All processes are killed when this script exits (Ctrl+C). | 16 | * All processes are killed when this script exits (Ctrl+C). |
| @@ -122,7 +122,8 @@ process.on("SIGTERM", shutdown); · 105 unmodified lines | |||
| 122 | 122 | ||
| 123 | const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312); | 123 | const EXECUTOR_WEB_PORT = Number(Bun.env.EXECUTOR_WEB_PORT ?? 4312); |
| 124 | const SANDBOX_PORT = Number(Bun.env.SANDBOX_PORT ?? 8787); | 124 | const SANDBOX_PORT = Number(Bun.env.SANDBOX_PORT ?? 8787); |
| 125 | const DEV_PORTS = [3000, EXECUTOR_WEB_PORT, SANDBOX_PORT]; | 125 | const ASSISTANT_PORT = Number(Bun.env.ASSISTANT_PORT ?? 3002); |
| 126 | const DEV_PORTS = [ASSISTANT_PORT, EXECUTOR_WEB_PORT, SANDBOX_PORT]; | ||
| 126 | 127 | ||
| 127 | async function killStaleProcesses() { | 128 | async function killStaleProcesses() { |
| 128 | let killed = 0; | 129 | let killed = 0; |
| @@ -198,6 +199,7 @@ spawnService("web", ["bun", "run", "dev"], { · 69 unmodified lines | |||
| 198 | spawnService("assistant", ["bun", "run", "dev"], { | 199 | spawnService("assistant", ["bun", "run", "dev"], { |
| 199 | cwd: "./assistant/packages/server", | 200 | cwd: "./assistant/packages/server", |
| 200 | env: { | 201 | env: { |
| 202 | ASSISTANT_PORT: String(ASSISTANT_PORT), | ||
| 201 | EXECUTOR_URL: urls.executorUrl, | 203 | EXECUTOR_URL: urls.executorUrl, |
| 202 | CONVEX_URL: urls.convexUrl, | 204 | CONVEX_URL: urls.convexUrl, |
| 203 | EXECUTOR_ANON_SESSION_ID: Bun.env.EXECUTOR_ANON_SESSION_ID ?? "assistant-discord-dev", | 205 | EXECUTOR_ANON_SESSION_ID: Bun.env.EXECUTOR_ANON_SESSION_ID ?? "assistant-discord-dev", |
| @@ -209,6 +211,7 @@ if (Bun.env.DISCORD_BOT_TOKEN) { · 5 unmodified lines | |||
| 209 | spawnService("bot", ["bun", "run", "dev"], { | 211 | spawnService("bot", ["bun", "run", "dev"], { |
| 210 | cwd: "./assistant/packages/bot", | 212 | cwd: "./assistant/packages/bot", |
| 211 | env: { | 213 | env: { |
| 214 | ASSISTANT_SERVER_URL: Bun.env.ASSISTANT_SERVER_URL ?? `http://localhost:${ASSISTANT_PORT}`, | ||
| 212 | CONVEX_URL: urls.convexUrl, | 215 | CONVEX_URL: urls.convexUrl, |
| 213 | }, | 216 | }, |
| 214 | }); | 217 | }); |
| @@ -970,19 +970,6 @@ export declare const internal: { · 969 unmodified lines | |||
| 970 | any | 970 | any |
| 971 | >; | 971 | >; |
| 972 | }; | 972 | }; |
| 973 | mcpNode: { | ||
| 974 | typecheckRunCodeInternal: FunctionReference< | ||
| 975 | "action", | ||
| 976 | "internal", | ||
| 977 | { | ||
| 978 | actorId?: string; | ||
| 979 | clientId?: string; | ||
| 980 | code: string; | ||
| 981 | workspaceId: Id<"workspaces">; | ||
| 982 | }, | ||
| 983 | any | ||
| 984 | >; | ||
| 985 | }; | ||
| 986 | migrations: { | 973 | migrations: { |
| 987 | backfillDtsStorageIds: FunctionReference< | 974 | backfillDtsStorageIds: FunctionReference< |
| 988 | "mutation", | 975 | "mutation", |
| @@ -1112,26 +1099,6 @@ export declare const internal: { · 123 unmodified lines | |||
| 1112 | any | 1099 | any |
| 1113 | >; | 1100 | >; |
| 1114 | }; | 1101 | }; |
| 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 | }; | ||
| 1135 | workspaceAuthInternal: { | 1102 | workspaceAuthInternal: { |
| 1136 | getWorkspaceAccessForRequest: FunctionReference< | 1103 | getWorkspaceAccessForRequest: FunctionReference< |
| 1137 | "query", | 1104 | "query", |
| @@ -72,15 +72,6 @@ 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 | |||
| 84 | const workspaceMembers = await ctx.db | 75 | const workspaceMembers = await ctx.db |
| 85 | .query("workspaceMembers") | 76 | .query("workspaceMembers") |
| 86 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) | 77 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) |
| @@ -17,7 +17,6 @@ function setup() { · 16 unmodified lines | |||
| 17 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | 17 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), |
| 18 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | 18 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), |
| 19 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | 19 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), |
| 20 | "./mcpNode.ts": () => import("./mcpNode"), | ||
| 21 | "./_generated/api.js": () => import("./_generated/api.js"), | 20 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 22 | }); | 21 | }); |
| 23 | } | 22 | } |
| @@ -14,7 +14,6 @@ function setup() { · 13 unmodified lines | |||
| 14 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | 14 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), |
| 15 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | 15 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), |
| 16 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | 16 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), |
| 17 | "./mcpNode.ts": () => import("./mcpNode"), | ||
| 18 | "./anonymousOauth.ts": () => import("./anonymousOauth"), | 17 | "./anonymousOauth.ts": () => import("./anonymousOauth"), |
| 19 | "./_generated/api.js": () => import("./_generated/api.js"), | 18 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 20 | }); | 19 | }); |
| @@ -19,10 +19,11 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 18 unmodified lines | |||
| 19 | actorId: string; | 19 | actorId: string; |
| 20 | clientId?: string; | 20 | clientId?: string; |
| 21 | }) => { | 21 | }) => { |
| 22 | return (await ctx.runMutation(internal.executor.createTaskInternal, { | 22 | const taskInput = { |
| 23 | ...input, | 23 | ...input, |
| 24 | scheduleAfterCreate: false, | 24 | scheduleAfterCreate: false, |
| 25 | })) as { task: TaskRecord }; | 25 | } as Parameters<typeof ctx.runMutation<typeof internal.executor.createTaskInternal>>[1]; |
| 26 | return (await ctx.runMutation(internal.executor.createTaskInternal, taskInput)) as { task: TaskRecord }; | ||
| 26 | }, | 27 | }, |
| 27 | runTaskNow: async (taskId: string) => { | 28 | runTaskNow: async (taskId: string) => { |
| 28 | return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null; | 29 | return (await ctx.runAction(internal.executorNode.runTask, { taskId })) as null; |
| @@ -46,31 +47,6 @@ export function createMcpExecutorService(ctx: ActionCtx) { · 17 unmodified lines | |||
| 46 | 47 | ||
| 47 | return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[]; | 48 | return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[]; |
| 48 | }, | 49 | }, |
| 49 | listToolsForTypecheck: async (toolContext: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => { | ||
| 50 | const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, { ...toolContext }) as { | ||
| 51 | tools: ToolDescriptor[]; | ||
| 52 | dtsUrls?: Record<string, string>; | ||
| 53 | }; | ||
| 54 | |||
| 55 | return { | ||
| 56 | tools: result.tools, | ||
| 57 | dtsUrls: result.dtsUrls ?? {}, | ||
| 58 | }; | ||
| 59 | }, | ||
| 60 | typecheckRunCode: async (input: { | ||
| 61 | code: string; | ||
| 62 | workspaceId: Id<"workspaces">; | ||
| 63 | actorId?: string; | ||
| 64 | clientId?: string; | ||
| 65 | }) => { | ||
| 66 | const result = await ctx.runAction(internal.mcpNode.typecheckRunCodeInternal, { | ||
| 67 | code: input.code, | ||
| 68 | workspaceId: input.workspaceId, | ||
| 69 | actorId: input.actorId, | ||
| 70 | clientId: input.clientId, | ||
| 71 | }); | ||
| 72 | return result; | ||
| 73 | }, | ||
| 74 | listPendingApprovals: async (workspaceId: Id<"workspaces">) => { | 50 | listPendingApprovals: async (workspaceId: Id<"workspaces">) => { |
| 75 | return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[]; | 51 | return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[]; |
| 76 | }, | 52 | }, |
| @@ -17,7 +17,6 @@ function setup() { · 16 unmodified lines | |||
| 17 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | 17 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), |
| 18 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | 18 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), |
| 19 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | 19 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), |
| 20 | "./mcpNode.ts": () => import("./mcpNode"), | ||
| 21 | "./_generated/api.js": () => import("./_generated/api.js"), | 20 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 22 | }); | 21 | }); |
| 23 | } | 22 | } |
| @@ -43,7 +42,7 @@ function createMcpTransport( · 19 unmodified lines | |||
| 43 | }); | 42 | }); |
| 44 | } | 43 | } |
| 45 | 44 | ||
| 46 | test("MCP run_code reproduces Convex OOM failure surface", async () => { | 45 | test("MCP run_code no longer hits typecheck OOM path", async () => { |
| 47 | const t = setup(); | 46 | const t = setup(); |
| 48 | const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); | 47 | const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); |
| 49 | 48 | ||
| @@ -61,7 +60,7 @@ test("MCP run_code reproduces Convex OOM failure surface", async () => { · 11 unmodified lines | |||
| 61 | }); | 60 | }); |
| 62 | 61 | ||
| 63 | const serialized = JSON.stringify(result); | 62 | const serialized = JSON.stringify(result); |
| 64 | expect(serialized.includes(OOM_MESSAGE)).toBe(true); | 63 | expect(serialized.includes(OOM_MESSAGE)).toBe(false); |
| 65 | } finally { | 64 | } finally { |
| 66 | await transport.close().catch(() => {}); | 65 | await transport.close().catch(() => {}); |
| 67 | await client.close().catch(() => {}); | 66 | await client.close().catch(() => {}); |
| @@ -1,279 +0,0 @@ | |||
| 1 | "use node"; | ||
| 2 | |||
| 3 | import { createHash } from "node:crypto"; | ||
| 4 | import { createRequire } from "node:module"; | ||
| 5 | import { v } from "convex/values"; | ||
| 6 | import { internal } from "./_generated/api"; | ||
| 7 | import { internalAction } from "./_generated/server"; | ||
| 8 | import { loadSourceDtsByUrl } from "../core/src/dts-loader"; | ||
| 9 | import { | ||
| 10 | analyzeToolReferences, | ||
| 11 | generateToolDeclarations, | ||
| 12 | sliceOpenApiOperationsDts, | ||
| 13 | typecheckCode, | ||
| 14 | } from "../core/src/typechecker"; | ||
| 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 | } | ||
| 143 | |||
| 144 | export const typecheckRunCodeInternal = internalAction({ | ||
| 145 | args: { | ||
| 146 | code: v.string(), | ||
| 147 | workspaceId: v.id("workspaces"), | ||
| 148 | actorId: v.optional(v.string()), | ||
| 149 | clientId: v.optional(v.string()), | ||
| 150 | }, | ||
| 151 | handler: async (ctx, args) => { | ||
| 152 | const toolContext = { | ||
| 153 | workspaceId: args.workspaceId, | ||
| 154 | actorId: args.actorId, | ||
| 155 | clientId: args.clientId, | ||
| 156 | }; | ||
| 157 | |||
| 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 | } | ||
| 203 | |||
| 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, | ||
| 215 | }); | ||
| 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); | ||
| 272 | |||
| 273 | return { | ||
| 274 | ok: typecheck.ok, | ||
| 275 | errors: [...typecheck.errors], | ||
| 276 | tools: result.tools, | ||
| 277 | }; | ||
| 278 | }, | ||
| 279 | }); | ||
| @@ -313,16 +313,6 @@ 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 | |||
| 326 | anonymousSessions: defineTable({ | 316 | anonymousSessions: defineTable({ |
| 327 | sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid> | 317 | sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid> |
| 328 | workspaceId: v.id("workspaces"), | 318 | workspaceId: v.id("workspaces"), |
| @@ -1,188 +0,0 @@ | |||
| 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 | }); | ||
| @@ -1,76 +0,0 @@ | |||
| 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 | }); | ||
| @@ -10,11 +10,6 @@ class FakeMcpService { · 9 unmodified lines | |||
| 10 | private readonly tasks = new Map<string, TaskRecord>(); | 10 | private readonly tasks = new Map<string, TaskRecord>(); |
| 11 | private readonly sessions = new Map<string, AnonymousContext>(); | 11 | private readonly sessions = new Map<string, AnonymousContext>(); |
| 12 | private readonly listeners = new Map<string, Set<(event: LiveTaskEvent) => void>>(); | 12 | private readonly listeners = new Map<string, Set<(event: LiveTaskEvent) => void>>(); |
| 13 | private typecheckPayload?: { tools: ToolDescriptor[]; dtsUrls: Record<string, string> }; | ||
| 14 | |||
| 15 | setTypecheckPayload(payload: { tools: ToolDescriptor[]; dtsUrls: Record<string, string> }) { | ||
| 16 | this.typecheckPayload = payload; | ||
| 17 | } | ||
| 18 | 13 | ||
| 19 | async createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }> { | 14 | async createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }> { |
| 20 | const id = `task_${crypto.randomUUID()}`; | 15 | const id = `task_${crypto.randomUUID()}`; |
| @@ -99,18 +94,6 @@ class FakeMcpService { · 78 unmodified lines | |||
| 99 | { path: "utils.get_time", description: "Get the current time", approval: "auto" }, | 94 | { path: "utils.get_time", description: "Get the current time", approval: "auto" }, |
| 100 | ]; | 95 | ]; |
| 101 | } | 96 | } |
| 102 | |||
| 103 | async listToolsForTypecheck( | ||
| 104 | _context: { workspaceId: string; actorId?: string; clientId?: string }, | ||
| 105 | ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }> { | ||
| 106 | if (this.typecheckPayload) { | ||
| 107 | return this.typecheckPayload; | ||
| 108 | } | ||
| 109 | return { | ||
| 110 | tools: await this.listTools(_context), | ||
| 111 | dtsUrls: {}, | ||
| 112 | }; | ||
| 113 | } | ||
| 114 | } | 97 | } |
| 115 | 98 | ||
| 116 | async function withMcpClient<T>( | 99 | async function withMcpClient<T>( |
| @@ -219,37 +202,14 @@ test("run_code MCP tool bootstraps anonymous context when workspace is omitted", · 102 unmodified lines | |||
| 219 | }); | 202 | }); |
| 220 | }); | 203 | }); |
| 221 | 204 | ||
| 222 | test("run_code typecheck uses OpenAPI .d.ts from typecheck context", async () => { | 205 | test("run_code MCP tool does not gate on TypeScript errors", async () => { |
| 223 | const service = new FakeMcpService(); | 206 | const service = new FakeMcpService(); |
| 224 | service.setTypecheckPayload({ | ||
| 225 | tools: [ | ||
| 226 | { | ||
| 227 | path: "github.issues.list_for_repo", | ||
| 228 | description: "List issues for a repository", | ||
| 229 | approval: "auto", | ||
| 230 | source: "openapi:github", | ||
| 231 | operationId: "issues/list-for-repo", | ||
| 232 | }, | ||
| 233 | ], | ||
| 234 | dtsUrls: { | ||
| 235 | "openapi:github": | ||
| 236 | "data:text/plain," | ||
| 237 | + encodeURIComponent( | ||
| 238 | `export interface operations { | ||
| 239 | "issues/list-for-repo": { | ||
| 240 | parameters: { path: { owner: string; repo: string } }; | ||
| 241 | responses: { 200: { content: { "application/json": { id: number }[] } } }; | ||
| 242 | }; | ||
| 243 | }`, | ||
| 244 | ), | ||
| 245 | }, | ||
| 246 | }); | ||
| 247 | 207 | ||
| 248 | await withMcpClient(service, async (client) => { | 208 | await withMcpClient(service, async (client) => { |
| 249 | const result = (await client.callTool({ | 209 | const result = (await client.callTool({ |
| 250 | name: "run_code", | 210 | name: "run_code", |
| 251 | arguments: { | 211 | arguments: { |
| 252 | code: "await tools.github.issues.list_for_repo({ owner: 123, repo: 'answeroverflow' });", | 212 | code: "const value: string = 123; return value;", |
| 253 | }, | 213 | }, |
| 254 | })) as { | 214 | })) as { |
| 255 | isError?: boolean; | 215 | isError?: boolean; |
| @@ -257,11 +217,9 @@ test("run_code typecheck uses OpenAPI .d.ts from typecheck context", async () => · 1 unmodified line | |||
| 257 | structuredContent?: Record<string, unknown>; | 217 | structuredContent?: Record<string, unknown>; |
| 258 | }; | 218 | }; |
| 259 | 219 | ||
| 260 | expect(result.isError).toBe(true); | 220 | expect(result.isError).toBeUndefined(); |
| 261 | const typecheckErrors = Array.isArray(result.structuredContent?.typecheckErrors) | 221 | const text = result.content.find((part) => part.type === "text"); |
| 262 | ? (result.structuredContent!.typecheckErrors as string[]) | 222 | expect(text?.text).toContain("status: completed"); |
| 263 | : []; | 223 | expect(result.structuredContent?.typecheckErrors).toBeUndefined(); |
| 264 | expect(typecheckErrors.length).toBeGreaterThan(0); | ||
| 265 | expect(typecheckErrors.some((error) => error.includes("owner") || error.includes("number"))).toBe(true); | ||
| 266 | }); | 224 | }); |
| 267 | }); | 225 | }); |
| @@ -2,8 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 1 unmodified line | |||
| 2 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | 2 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 3 | import { Result } from "better-result"; | 3 | import { Result } from "better-result"; |
| 4 | import { z } from "zod"; | 4 | import { z } from "zod"; |
| 5 | import { loadSourceDtsByUrl } from "./dts-loader"; | 5 | import { generateToolInventory } from "./typechecker"; |
| 6 | import { generateToolDeclarations, generateToolInventory, typecheckCode } from "./typechecker"; | ||
| 7 | import type { LiveTaskEvent } from "./events"; | 6 | import type { LiveTaskEvent } from "./events"; |
| 8 | import type { | 7 | import type { |
| 9 | AnonymousContext, | 8 | AnonymousContext, |
| @@ -29,15 +28,6 @@ interface McpExecutorService { · 19 unmodified lines | |||
| 29 | subscribe(taskId: string, workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void; | 28 | subscribe(taskId: string, workspaceId: Id<"workspaces">, listener: (event: LiveTaskEvent) => void): () => void; |
| 30 | bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>; | 29 | bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>; |
| 31 | listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>; | 30 | listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>; |
| 32 | listToolsForTypecheck?( | ||
| 33 | context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }, | ||
| 34 | ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }>; | ||
| 35 | typecheckRunCode?(input: { | ||
| 36 | code: string; | ||
| 37 | workspaceId: Id<"workspaces">; | ||
| 38 | actorId?: string; | ||
| 39 | clientId?: string; | ||
| 40 | }): Promise<{ ok: boolean; errors: string[]; tools: ToolDescriptor[] }>; | ||
| 41 | listPendingApprovals?(workspaceId: Id<"workspaces">): Promise<PendingApprovalRecord[]>; | 31 | listPendingApprovals?(workspaceId: Id<"workspaces">): Promise<PendingApprovalRecord[]>; |
| 42 | resolveApproval?(input: { | 32 | resolveApproval?(input: { |
| 43 | workspaceId: Id<"workspaces">; | 33 | workspaceId: Id<"workspaces">; |
| @@ -238,7 +228,7 @@ function waitForTerminalTask( · 194 unmodified lines | |||
| 238 | 228 | ||
| 239 | function buildRunCodeDescription(tools?: ToolDescriptor[]): string { | 229 | function buildRunCodeDescription(tools?: ToolDescriptor[]): string { |
| 240 | const base = | 230 | const base = |
| 241 | "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns only explicit return values (console output is not returned). Code is typechecked before execution — type errors are returned without running. Runtime has no filesystem/process/import access; use `tools.*` for external calls."; | 231 | "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns only explicit return values (console output is not returned). Runtime has no filesystem/process/import access; use `tools.*` for external calls."; |
| 242 | const toolList = tools ?? []; | 232 | const toolList = tools ?? []; |
| 243 | const topLevelKeys = listTopLevelToolKeys(toolList); | 233 | const topLevelKeys = listTopLevelToolKeys(toolList); |
| 244 | const rootKeysNote = topLevelKeys.length > 0 | 234 | const rootKeysNote = topLevelKeys.length > 0 |
| @@ -381,83 +371,6 @@ function createRunCodeTool( · 136 unmodified lines | |||
| 381 | }; | 371 | }; |
| 382 | } | 372 | } |
| 383 | 373 | ||
| 384 | // Typecheck code before execution — align with Monaco by loading source .d.ts | ||
| 385 | // for OpenAPI tools when available. | ||
| 386 | let toolsForContext: ToolDescriptor[]; | ||
| 387 | let typecheck: { ok: boolean; errors: readonly string[] }; | ||
| 388 | if (service.typecheckRunCode) { | ||
| 389 | const result = await service.typecheckRunCode({ | ||
| 390 | code: input.code, | ||
| 391 | workspaceId: context.workspaceId, | ||
| 392 | actorId: context.actorId, | ||
| 393 | clientId: context.clientId, | ||
| 394 | }); | ||
| 395 | toolsForContext = result.tools; | ||
| 396 | typecheck = { | ||
| 397 | ok: result.ok, | ||
| 398 | errors: result.errors, | ||
| 399 | }; | ||
| 400 | } else { | ||
| 401 | let sourceDtsBySource: Record<string, string> = {}; | ||
| 402 | if (service.listToolsForTypecheck) { | ||
| 403 | const { tools, dtsUrls } = await service.listToolsForTypecheck({ | ||
| 404 | workspaceId: context.workspaceId, | ||
| 405 | actorId: context.actorId, | ||
| 406 | clientId: context.clientId, | ||
| 407 | }); | ||
| 408 | toolsForContext = tools; | ||
| 409 | sourceDtsBySource = await loadSourceDtsByUrl(dtsUrls ?? {}); | ||
| 410 | } else { | ||
| 411 | toolsForContext = await service.listTools({ | ||
| 412 | workspaceId: context.workspaceId, | ||
| 413 | actorId: context.actorId, | ||
| 414 | clientId: context.clientId, | ||
| 415 | }); | ||
| 416 | } | ||
| 417 | |||
| 418 | const declarations = generateToolDeclarations(toolsForContext, { | ||
| 419 | sourceDtsBySource, | ||
| 420 | }); | ||
| 421 | typecheck = typecheckCode(input.code, declarations); | ||
| 422 | } | ||
| 423 | |||
| 424 | if (!typecheck.ok) { | ||
| 425 | const topLevelKeys = listTopLevelToolKeys(toolsForContext); | ||
| 426 | const toolsShadowingError = typecheck.errors.some((error) => | ||
| 427 | error.includes("Block-scoped variable 'tools' used before its declaration") | ||
| 428 | || error.includes("'tools' implicitly has type 'any'"), | ||
| 429 | ); | ||
| 430 | |||
| 431 | const hintLines: string[] = []; | ||
| 432 | if (toolsShadowingError) { | ||
| 433 | hintLines.push("Hint: avoid declaring a local variable named `tools`."); | ||
| 434 | hintLines.push("Example: `const discovered = await tools.discover({ query: \"github issues\" });`"); | ||
| 435 | } | ||
| 436 | if (topLevelKeys.length > 0) { | ||
| 437 | hintLines.push(`Available top-level tool keys: ${topLevelKeys.join(", ")}`); | ||
| 438 | } | ||
| 439 | |||
| 440 | const errorText = [ | ||
| 441 | "TypeScript type errors in generated code:", | ||
| 442 | "", | ||
| 443 | ...typecheck.errors.map((e) => ` ${e}`), | ||
| 444 | ...(hintLines.length > 0 ? ["", ...hintLines] : []), | ||
| 445 | "", | ||
| 446 | "Fix the type errors and try again.", | ||
| 447 | ].join("\n"); | ||
| 448 | |||
| 449 | return { | ||
| 450 | content: [textContent(errorText)], | ||
| 451 | isError: true, | ||
| 452 | structuredContent: { | ||
| 453 | typecheckErrors: typecheck.errors, | ||
| 454 | workspaceId: context.workspaceId, | ||
| 455 | actorId: context.actorId, | ||
| 456 | sessionId: context.sessionId, | ||
| 457 | }, | ||
| 458 | }; | ||
| 459 | } | ||
| 460 | |||
| 461 | const created = await service.createTask({ | 374 | const created = await service.createTask({ |
| 462 | code: input.code, | 375 | code: input.code, |
| 463 | timeoutMs: requestedTimeoutMs, | 376 | timeoutMs: requestedTimeoutMs, |
| @@ -589,20 +502,10 @@ async function createMcpServer( · 125 unmodified lines | |||
| 589 | ); | 502 | ); |
| 590 | const onApprovalPrompt = createMcpApprovalPrompt(mcp); | 503 | const onApprovalPrompt = createMcpApprovalPrompt(mcp); |
| 591 | 504 | ||
| 592 | // If workspace context is provided, fetch tool inventory for richer description | ||
| 593 | let tools: ToolDescriptor[] | undefined; | ||
| 594 | if (context) { | ||
| 595 | tools = await service.listTools({ | ||
| 596 | workspaceId: context.workspaceId, | ||
| 597 | actorId: context.actorId, | ||
| 598 | clientId: context.clientId, | ||
| 599 | }); | ||
| 600 | } | ||
| 601 | |||
| 602 | mcp.registerTool( | 505 | mcp.registerTool( |
| 603 | "run_code", | 506 | "run_code", |
| 604 | { | 507 | { |
| 605 | description: buildRunCodeDescription(tools), | 508 | description: buildRunCodeDescription(), |
| 606 | inputSchema: context ? BOUND_INPUT : FULL_INPUT, | 509 | inputSchema: context ? BOUND_INPUT : FULL_INPUT, |
| 607 | }, | 510 | }, |
| 608 | createRunCodeTool(service, context, onApprovalPrompt), | 511 | createRunCodeTool(service, context, onApprovalPrompt), |
| @@ -2,7 +2,7 @@ · 1 unmodified line | |||
| 2 | * Kills all processes started by `bun dev`. | 2 | * Kills all processes started by `bun dev`. |
| 3 | * | 3 | * |
| 4 | * 1. Reads PIDs from .dev.pids and kills process trees | 4 | * 1. Reads PIDs from .dev.pids and kills process trees |
| 5 | * 2. Falls back to killing anything on known dev ports (3000, 3002) | 5 | * 2. Falls back to killing anything on known dev ports (assistant port) |
| 6 | * | 6 | * |
| 7 | * Usage: bun run kill:all | 7 | * Usage: bun run kill:all |
| 8 | */ | 8 | */ |
| @@ -11,7 +11,8 @@ import { join } from "node:path"; · 2 unmodified lines | |||
| 11 | import { unlinkSync } from "node:fs"; | 11 | import { unlinkSync } from "node:fs"; |
| 12 | 12 | ||
| 13 | const PID_FILE = join(import.meta.dir, ".dev.pids"); | 13 | const PID_FILE = join(import.meta.dir, ".dev.pids"); |
| 14 | const DEV_PORTS = [3000, 3002]; | 14 | const ASSISTANT_PORT = Number(Bun.env.ASSISTANT_PORT ?? 3002); |
| 15 | const DEV_PORTS = [ASSISTANT_PORT]; | ||
| 15 | 16 | ||
| 16 | let killed = 0; | 17 | let killed = 0; |
| 17 | let skipped = 0; | 18 | let skipped = 0; |