- Integrated health checks for managed backend and web services, ensuring proper status reporting. - Updated the `runDoctor` function to provide detailed feedback on backend readiness and function bootstrap status. - Added a new script function to wait for executor services to be fully ready during installation. - Refactored type hinting logic to improve clarity and maintainability in OpenAPI tool generation. - Removed obsolete end-to-end test file for anonymous MCP endpoint.
Unified · Split
| @@ -20,8 +20,8 @@ export function ToolDetail({ · 19 unmodified lines | |||
| 20 | loading?: boolean; | 20 | loading?: boolean; |
| 21 | }) { | 21 | }) { |
| 22 | const insetLeft = depth * 20 + 8 + 16 + 8; | 22 | const insetLeft = depth * 20 + 8 + 16 + 8; |
| 23 | const argsType = tool.strictArgsType?.trim() || tool.argsType; | 23 | const argsType = tool.argsType?.trim() || tool.strictArgsType; |
| 24 | const returnsType = tool.strictReturnsType?.trim() || tool.returnsType; | 24 | const returnsType = tool.returnsType?.trim() || tool.strictReturnsType; |
| 25 | const hasDetails = Boolean(tool.description || argsType || returnsType); | 25 | const hasDetails = Boolean(tool.description || argsType || returnsType); |
| 26 | 26 | ||
| 27 | return ( | 27 | return ( |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | import fs from "node:fs/promises"; | 3 | import fs from "node:fs/promises"; |
| 4 | import os from "node:os"; | 4 | import os from "node:os"; |
| 5 | import path from "node:path"; | 5 | import path from "node:path"; |
| 6 | import { checkBootstrapHealth } from "./packages/core/src/managed-runtime-bootstrap"; | ||
| 6 | import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./packages/core/src/managed-runtime"; | 7 | import { managedRuntimeDiagnostics, runManagedBackend, runManagedWeb } from "./packages/core/src/managed-runtime"; |
| 7 | 8 | ||
| 8 | interface InstallPaths { | 9 | interface InstallPaths { |
| @@ -297,6 +298,18 @@ async function stopManagedServices(): Promise<{ backendStopped: boolean; webStop · 288 unmodified lines | |||
| 297 | webStopped = (await terminatePidTree(pid)) && webStopped; | 298 | webStopped = (await terminatePidTree(pid)) && webStopped; |
| 298 | } | 299 | } |
| 299 | 300 | ||
| 301 | const [remainingBackendPidsRaw, remainingWebPidsRaw] = await Promise.all([ | ||
| 302 | listListeningPids(Number(Bun.env.EXECUTOR_BACKEND_PORT ?? 5410)), | ||
| 303 | listListeningPids(Number(Bun.env.EXECUTOR_WEB_PORT ?? 5312)), | ||
| 304 | ]); | ||
| 305 | const [remainingBackendPids, remainingWebPids] = await Promise.all([ | ||
| 306 | filterManagedPids(remainingBackendPidsRaw, "backend"), | ||
| 307 | filterManagedPids(remainingWebPidsRaw, "web"), | ||
| 308 | ]); | ||
| 309 | |||
| 310 | backendStopped = remainingBackendPids.length === 0; | ||
| 311 | webStopped = remainingWebPids.length === 0; | ||
| 312 | |||
| 300 | await fs.rm(paths.backendPidFile, { force: true }); | 313 | await fs.rm(paths.backendPidFile, { force: true }); |
| 301 | await fs.rm(paths.webPidFile, { force: true }); | 314 | await fs.rm(paths.webPidFile, { force: true }); |
| 302 | 315 | ||
| @@ -341,11 +354,29 @@ async function runDoctor(args: string[]): Promise<number> { · 38 unmodified lines | |||
| 341 | const nodeInstalled = await pathExists(info.nodeBin); | 354 | const nodeInstalled = await pathExists(info.nodeBin); |
| 342 | const backendRunning = await checkHttp(`${info.convexUrl}/version`); | 355 | const backendRunning = await checkHttp(`${info.convexUrl}/version`); |
| 343 | const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`); | 356 | const webRunning = await checkHttp(`http://127.0.0.1:${webPort}/`); |
| 344 | const healthy = nodeInstalled && webInstalled && backendRunning && webRunning; | 357 | |
| 358 | let bootstrapState: Awaited<ReturnType<typeof checkBootstrapHealth>> | null = null; | ||
| 359 | if (backendRunning) { | ||
| 360 | bootstrapState = await checkBootstrapHealth(info); | ||
| 361 | } | ||
| 362 | |||
| 363 | const functionsReady = bootstrapState?.state === "ready"; | ||
| 364 | const healthy = nodeInstalled && webInstalled && backendRunning && webRunning && functionsReady; | ||
| 345 | 365 | ||
| 346 | console.log(`Executor status: ${healthy ? "ready" : "needs attention"}`); | 366 | console.log(`Executor status: ${healthy ? "ready" : "needs attention"}`); |
| 347 | console.log(`Dashboard: http://127.0.0.1:${webPort} (${webRunning ? "running" : "not running"})`); | 367 | console.log(`Dashboard: http://127.0.0.1:${webPort} (${webRunning ? "running" : "not running"})`); |
| 348 | console.log(`Backend: ${backendRunning ? "running" : "not running"} (${info.convexUrl})`); | 368 | console.log(`Backend: ${backendRunning ? "running" : "not running"} (${info.convexUrl})`); |
| 369 | if (!backendRunning) { | ||
| 370 | console.log("Functions: unavailable (backend is not running)"); | ||
| 371 | } else if (bootstrapState?.state === "ready") { | ||
| 372 | console.log("Functions: bootstrapped"); | ||
| 373 | } else if (bootstrapState?.state === "no_project") { | ||
| 374 | console.log("Functions: not bootstrapped (no local Convex project found)"); | ||
| 375 | } else if (bootstrapState?.state === "missing_functions") { | ||
| 376 | console.log("Functions: not bootstrapped (missing deployed functions)"); | ||
| 377 | } else { | ||
| 378 | console.log("Functions: check failed"); | ||
| 379 | } | ||
| 349 | console.log(`MCP endpoint: ${info.convexSiteUrl}/mcp`); | 380 | console.log(`MCP endpoint: ${info.convexSiteUrl}/mcp`); |
| 350 | 381 | ||
| 351 | if (!backendRunning) { | 382 | if (!backendRunning) { |
| @@ -354,6 +385,15 @@ async function runDoctor(args: string[]): Promise<number> { · 2 unmodified lines | |||
| 354 | if (!webRunning) { | 385 | if (!webRunning) { |
| 355 | console.log("Next step: run `executor web`"); | 386 | console.log("Next step: run `executor web`"); |
| 356 | } | 387 | } |
| 388 | if (backendRunning && bootstrapState?.state === "no_project") { | ||
| 389 | console.log("Next step: run from your repo root or set `EXECUTOR_PROJECT_DIR` to a Convex project."); | ||
| 390 | } | ||
| 391 | if (backendRunning && bootstrapState?.state === "missing_functions") { | ||
| 392 | console.log("Next step: run `executor up` from your project root to bootstrap functions."); | ||
| 393 | } | ||
| 394 | if (backendRunning && bootstrapState?.state === "check_failed") { | ||
| 395 | console.log("Next step: run `executor up` and inspect ~/.executor/runtime/logs/backend.log"); | ||
| 396 | } | ||
| 357 | if (!webInstalled || !nodeInstalled) { | 397 | if (!webInstalled || !nodeInstalled) { |
| 358 | console.log("Missing runtime artifacts detected. Re-run install if needed."); | 398 | console.log("Missing runtime artifacts detected. Re-run install if needed."); |
| 359 | } | 399 | } |
| @@ -367,6 +407,10 @@ async function runDoctor(args: string[]): Promise<number> { · 7 unmodified lines | |||
| 367 | console.log(` convex site: ${info.convexSiteUrl}`); | 407 | console.log(` convex site: ${info.convexSiteUrl}`); |
| 368 | console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "missing"}`); | 408 | console.log(` node runtime: ${nodeInstalled ? info.nodeBin : "missing"}`); |
| 369 | console.log(` web bundle: ${webInstalled ? info.webServerEntry : "missing"}`); | 409 | console.log(` web bundle: ${webInstalled ? info.webServerEntry : "missing"}`); |
| 410 | console.log(` functions: ${bootstrapState?.state ?? "unavailable"}`); | ||
| 411 | if (bootstrapState?.detail) { | ||
| 412 | console.log(` functions detail: ${bootstrapState.detail}`); | ||
| 413 | } | ||
| 370 | console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`); | 414 | console.log(` running: backend=${backendRunning ? "yes" : "no"} web=${webRunning ? "yes" : "no"}`); |
| 371 | console.log(` config: ${info.configPath}`); | 415 | console.log(` config: ${info.configPath}`); |
| 372 | } | 416 | } |
| @@ -293,8 +293,10 @@ start_background_services() { · 292 unmodified lines | |||
| 293 | 293 | ||
| 294 | if wait_for_http "http://127.0.0.1:${BACKEND_PORT}/version" 2; then | 294 | if wait_for_http "http://127.0.0.1:${BACKEND_PORT}/version" 2; then |
| 295 | echo -e "${MUTED}Managed backend already running${NC} http://127.0.0.1:${BACKEND_PORT}" | 295 | echo -e "${MUTED}Managed backend already running${NC} http://127.0.0.1:${BACKEND_PORT}" |
| 296 | "$INSTALL_DIR/$APP" up >>"$LOG_DIR/backend.log" 2>&1 || true | ||
| 296 | elif [[ -f "$BACKEND_PID_FILE" ]]; then | 297 | elif [[ -f "$BACKEND_PID_FILE" ]]; then |
| 297 | echo -e "${MUTED}Managed backend already tracked${NC} (pid $(cat "$BACKEND_PID_FILE"))" | 298 | echo -e "${MUTED}Managed backend already tracked${NC} (pid $(cat "$BACKEND_PID_FILE"))" |
| 299 | "$INSTALL_DIR/$APP" up >>"$LOG_DIR/backend.log" 2>&1 || true | ||
| 298 | else | 300 | else |
| 299 | nohup "$INSTALL_DIR/$APP" up >"$LOG_DIR/backend.log" 2>&1 & | 301 | nohup "$INSTALL_DIR/$APP" up >"$LOG_DIR/backend.log" 2>&1 & |
| 300 | echo "$!" > "$BACKEND_PID_FILE" | 302 | echo "$!" > "$BACKEND_PID_FILE" |
| @@ -322,6 +324,37 @@ start_background_services() { · 21 unmodified lines | |||
| 322 | fi | 324 | fi |
| 323 | } | 325 | } |
| 324 | 326 | ||
| 327 | wait_for_executor_ready() { | ||
| 328 | local max_seconds="${1:-120}" | ||
| 329 | local started | ||
| 330 | started="$(date +%s)" | ||
| 331 | |||
| 332 | while true; do | ||
| 333 | if "$INSTALL_DIR/$APP" doctor >/dev/null 2>&1; then | ||
| 334 | echo -e "${MUTED}Executor services healthy${NC}" | ||
| 335 | return 0 | ||
| 336 | fi | ||
| 337 | |||
| 338 | local now | ||
| 339 | now="$(date +%s)" | ||
| 340 | if (( now - started >= max_seconds )); then | ||
| 341 | break | ||
| 342 | fi | ||
| 343 | |||
| 344 | sleep 0.5 | ||
| 345 | done | ||
| 346 | |||
| 347 | echo -e "${RED}Executor services are running but not fully ready yet.${NC}" | ||
| 348 | echo " Check: $INSTALL_DIR/$APP doctor --verbose" | ||
| 349 | if [[ -f "$LOG_DIR/backend.log" ]]; then | ||
| 350 | echo " Backend log: $LOG_DIR/backend.log" | ||
| 351 | fi | ||
| 352 | if [[ -f "$LOG_DIR/web.log" ]]; then | ||
| 353 | echo " Web log: $LOG_DIR/web.log" | ||
| 354 | fi | ||
| 355 | return 1 | ||
| 356 | } | ||
| 357 | |||
| 325 | install_web_bundle | 358 | install_web_bundle |
| 326 | 359 | ||
| 327 | if [[ "$NO_MODIFY_PATH" != "1" ]]; then | 360 | if [[ "$NO_MODIFY_PATH" != "1" ]]; then |
| @@ -341,6 +374,7 @@ if [[ "$NO_MODIFY_PATH" != "1" ]]; then · 13 unmodified lines | |||
| 341 | fi | 374 | fi |
| 342 | 375 | ||
| 343 | start_background_services | 376 | start_background_services |
| 377 | wait_for_executor_ready || true | ||
| 344 | 378 | ||
| 345 | echo "" | 379 | echo "" |
| 346 | echo "Installed to: $INSTALL_DIR/$APP" | 380 | echo "Installed to: $INSTALL_DIR/$APP" |
| @@ -1,7 +1,5 @@ | |||
| 1 | const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env; | 1 | const rawClientId = process.env.WORKOS_CLIENT_ID?.trim(); |
| 2 | const workosClientIdKey = ["WORKOS", "CLIENT", "ID"].join("_"); | 2 | const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined; |
| 3 | const rawClientId = env?.[workosClientIdKey]?.trim(); | ||
| 4 | const clientId = rawClientId && rawClientId.length > 0 ? rawClientId : undefined; | ||
| 5 | 3 | ||
| 6 | const authConfig = clientId | 4 | const authConfig = clientId |
| 7 | ? { | 5 | ? { |
| @@ -1,90 +0,0 @@ | |||
| 1 | import { 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 | "./database.ts": () => import("./database"), | ||
| 9 | "./executor.ts": () => import("./executor"), | ||
| 10 | "./executorNode.ts": () => import("./executorNode"), | ||
| 11 | "./http.ts": () => import("./http"), | ||
| 12 | "./auth.ts": () => import("./auth"), | ||
| 13 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | ||
| 14 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | ||
| 15 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | ||
| 16 | "./_generated/api.js": () => import("./_generated/api.js"), | ||
| 17 | }); | ||
| 18 | } | ||
| 19 | |||
| 20 | test("anonymous MCP endpoint allows direct access without OAuth token", async () => { | ||
| 21 | const t = setup(); | ||
| 22 | const session = await t.mutation(internal.database.bootstrapAnonymousSession, {}); | ||
| 23 | const query = new URLSearchParams({ | ||
| 24 | workspaceId: session.workspaceId, | ||
| 25 | actorId: session.actorId, | ||
| 26 | }).toString(); | ||
| 27 | |||
| 28 | const anonymousRes = await t.fetch(`/mcp/anonymous?${query}`, { | ||
| 29 | method: "POST", | ||
| 30 | headers: { | ||
| 31 | "content-type": "application/json", | ||
| 32 | }, | ||
| 33 | body: JSON.stringify({}), | ||
| 34 | }); | ||
| 35 | expect(anonymousRes.status).not.toBe(401); | ||
| 36 | expect(anonymousRes.status).not.toBe(403); | ||
| 37 | |||
| 38 | const otherSession = await t.mutation(internal.database.bootstrapAnonymousSession, {}); | ||
| 39 | const mismatchedQuery = new URLSearchParams({ | ||
| 40 | workspaceId: otherSession.workspaceId, | ||
| 41 | actorId: session.actorId, | ||
| 42 | }).toString(); | ||
| 43 | const mismatchedRes = await t.fetch(`/mcp/anonymous?${mismatchedQuery}`, { | ||
| 44 | method: "POST", | ||
| 45 | headers: { | ||
| 46 | "content-type": "application/json", | ||
| 47 | }, | ||
| 48 | body: JSON.stringify({}), | ||
| 49 | }); | ||
| 50 | expect(mismatchedRes.status).toBe(403); | ||
| 51 | |||
| 52 | const wrongEndpointRes = await t.fetch(`/mcp?${query}`, { | ||
| 53 | method: "POST", | ||
| 54 | headers: { | ||
| 55 | "content-type": "application/json", | ||
| 56 | }, | ||
| 57 | body: JSON.stringify({}), | ||
| 58 | }); | ||
| 59 | expect(wrongEndpointRes.status).toBe(400); | ||
| 60 | }); | ||
| 61 | |||
| 62 | test("OAuth discovery only applies to /mcp and not /mcp/anonymous", async () => { | ||
| 63 | const previousAuthorizationServer = process.env.MCP_AUTHORIZATION_SERVER; | ||
| 64 | process.env.MCP_AUTHORIZATION_SERVER = "https://victorious-point-35-staging.authkit.app"; | ||
| 65 | |||
| 66 | const t = setup(); | ||
| 67 | try { | ||
| 68 | const protectedResourceRes = await t.fetch("/.well-known/oauth-protected-resource?workspaceId=ms7ewkx14rnc73wwnqsj80cngd812kqt"); | ||
| 69 | expect(protectedResourceRes.status).toBe(200); | ||
| 70 | const protectedResourceBody = await protectedResourceRes.json() as { | ||
| 71 | authorization_servers: string[]; | ||
| 72 | resource: string; | ||
| 73 | }; | ||
| 74 | expect(protectedResourceBody.authorization_servers[0]).toBe(process.env.MCP_AUTHORIZATION_SERVER); | ||
| 75 | const protectedResourceUrl = new URL(protectedResourceBody.resource); | ||
| 76 | expect(protectedResourceUrl.pathname).toBe("/mcp"); | ||
| 77 | |||
| 78 | const anonymousResource = encodeURIComponent( | ||
| 79 | `${protectedResourceUrl.origin}/mcp/anonymous?workspaceId=ms7ewkx14rnc73wwnqsj80cngd812kqt&actorId=anon_123`, | ||
| 80 | ); | ||
| 81 | const anonymousRes = await t.fetch(`/.well-known/oauth-protected-resource?resource=${anonymousResource}`); | ||
| 82 | expect(anonymousRes.status).toBe(404); | ||
| 83 | } finally { | ||
| 84 | if (previousAuthorizationServer === undefined) { | ||
| 85 | delete process.env.MCP_AUTHORIZATION_SERVER; | ||
| 86 | } else { | ||
| 87 | process.env.MCP_AUTHORIZATION_SERVER = previousAuthorizationServer; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | }); | ||
| @@ -20,7 +20,7 @@ import type { ToolSourceRecord } from "../../core/src/types"; · 19 unmodified lines | |||
| 20 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; | 20 | const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; |
| 21 | 21 | ||
| 22 | /** Cache version - bump when PreparedOpenApiSpec shape changes. */ | 22 | /** Cache version - bump when PreparedOpenApiSpec shape changes. */ |
| 23 | const TOOL_SOURCE_CACHE_VERSION = "v16"; | 23 | const TOOL_SOURCE_CACHE_VERSION = "v17"; |
| 24 | 24 | ||
| 25 | export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { | 25 | export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string { |
| 26 | const parts = sources | 26 | const parts = sources |
| @@ -122,6 +122,7 @@ async function writeBootstrapEnvFile(info: ManagedRuntimeInfo, adminKey: string) · 121 unmodified lines | |||
| 122 | const contents = [ | 122 | const contents = [ |
| 123 | `CONVEX_SELF_HOSTED_URL=http://${info.config.hostInterface}:${info.config.backendPort}`, | 123 | `CONVEX_SELF_HOSTED_URL=http://${info.config.hostInterface}:${info.config.backendPort}`, |
| 124 | `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`, | 124 | `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`, |
| 125 | "WORKOS_CLIENT_ID=disabled", | ||
| 125 | ].join("\n"); | 126 | ].join("\n"); |
| 126 | await fs.writeFile(filePath, `${contents}\n`, "utf8"); | 127 | await fs.writeFile(filePath, `${contents}\n`, "utf8"); |
| 127 | return filePath; | 128 | return filePath; |
| @@ -173,15 +174,16 @@ export async function waitForBackendReady(info: ManagedRuntimeInfo, timeoutMs = · 45 unmodified lines | |||
| 173 | throw new Error("Timed out waiting for local Convex backend to become ready."); | 174 | throw new Error("Timed out waiting for local Convex backend to become ready."); |
| 174 | } | 175 | } |
| 175 | 176 | ||
| 176 | export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> { | 177 | export type BootstrapHealth = { |
| 177 | if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") { | 178 | state: "ready" | "no_project" | "missing_functions" | "check_failed"; |
| 178 | return; | 179 | projectDir?: string; |
| 179 | } | 180 | detail?: string; |
| 181 | }; | ||
| 180 | 182 | ||
| 183 | export async function checkBootstrapHealth(info: ManagedRuntimeInfo): Promise<BootstrapHealth> { | ||
| 181 | const projectDir = await findProjectDir(); | 184 | const projectDir = await findProjectDir(); |
| 182 | if (!projectDir) { | 185 | if (!projectDir) { |
| 183 | console.log("[executor] no local Convex project found; skipping function bootstrap"); | 186 | return { state: "no_project" }; |
| 184 | return; | ||
| 185 | } | 187 | } |
| 186 | 188 | ||
| 187 | await ensureConvexCliRuntime(info); | 189 | await ensureConvexCliRuntime(info); |
| @@ -189,26 +191,83 @@ export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promi · 1 unmodified line | |||
| 189 | const envFilePath = await writeBootstrapEnvFile(info, adminKey); | 191 | const envFilePath = await writeBootstrapEnvFile(info, adminKey); |
| 190 | 192 | ||
| 191 | try { | 193 | try { |
| 194 | const ensureWorkosClientId = await runManagedConvexCli( | ||
| 195 | info, | ||
| 196 | projectDir, | ||
| 197 | ["env", "set", "WORKOS_CLIENT_ID", "disabled"], | ||
| 198 | envFilePath, | ||
| 199 | { | ||
| 200 | stdout: "pipe", | ||
| 201 | stderr: "pipe", | ||
| 202 | }, | ||
| 203 | ); | ||
| 204 | if (ensureWorkosClientId.exitCode !== 0) { | ||
| 205 | const detail = ensureWorkosClientId.stderr.trim() || ensureWorkosClientId.stdout.trim() || "unknown error"; | ||
| 206 | console.warn(`[executor] could not seed WORKOS_CLIENT_ID in local backend env: ${detail}`); | ||
| 207 | } | ||
| 208 | |||
| 192 | const check = await runManagedConvexCli(info, projectDir, ["run", "app:getClientConfig"], envFilePath, { | 209 | const check = await runManagedConvexCli(info, projectDir, ["run", "app:getClientConfig"], envFilePath, { |
| 193 | stdout: "pipe", | 210 | stdout: "pipe", |
| 194 | stderr: "pipe", | 211 | stderr: "pipe", |
| 195 | }); | 212 | }); |
| 196 | 213 | ||
| 197 | if (check.exitCode === 0) { | 214 | if (check.exitCode === 0) { |
| 198 | console.log("[executor] Convex functions already bootstrapped"); | 215 | return { state: "ready", projectDir }; |
| 199 | return; | ||
| 200 | } | 216 | } |
| 201 | 217 | ||
| 218 | const detail = check.stderr.trim() || check.stdout.trim() || `exit ${check.exitCode}`; | ||
| 219 | return { state: "missing_functions", projectDir, detail }; | ||
| 220 | } catch (error) { | ||
| 221 | const detail = error instanceof Error ? error.message : String(error); | ||
| 222 | return { state: "check_failed", projectDir, detail }; | ||
| 223 | } finally { | ||
| 224 | await fs.rm(path.dirname(envFilePath), { recursive: true, force: true }); | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | export async function ensureProjectBootstrapped(info: ManagedRuntimeInfo): Promise<void> { | ||
| 229 | if (Bun.env.EXECUTOR_SKIP_BOOTSTRAP === "1") { | ||
| 230 | return; | ||
| 231 | } | ||
| 232 | |||
| 233 | const check = await checkBootstrapHealth(info); | ||
| 234 | |||
| 235 | if (check.state === "no_project") { | ||
| 236 | console.log("[executor] no local Convex project found; skipping function bootstrap"); | ||
| 237 | return; | ||
| 238 | } | ||
| 239 | |||
| 240 | if (check.state === "ready") { | ||
| 241 | console.log("[executor] Convex functions already bootstrapped"); | ||
| 242 | return; | ||
| 243 | } | ||
| 244 | |||
| 245 | if (check.state === "check_failed") { | ||
| 246 | const detail = check.detail ? `: ${check.detail}` : ""; | ||
| 247 | throw new Error(`Convex bootstrap preflight failed${detail}`); | ||
| 248 | } | ||
| 249 | |||
| 250 | const projectDir = check.projectDir; | ||
| 251 | if (!projectDir) { | ||
| 252 | throw new Error("Convex bootstrap failed to resolve project directory."); | ||
| 253 | } | ||
| 254 | |||
| 255 | const adminKey = await generateSelfHostedAdminKey(info); | ||
| 256 | const envFilePath = await writeBootstrapEnvFile(info, adminKey); | ||
| 257 | |||
| 258 | try { | ||
| 202 | console.log("[executor] bootstrapping Convex functions to local backend"); | 259 | console.log("[executor] bootstrapping Convex functions to local backend"); |
| 203 | const deploy = await runManagedConvexCli( | 260 | const deploy = await runManagedConvexCli( |
| 204 | info, | 261 | info, |
| 205 | projectDir, | 262 | projectDir, |
| 206 | ["deploy", "--yes", "--typecheck", "disable", "--codegen", "disable"], | 263 | ["deploy", "--yes", "--typecheck", "disable", "--codegen", "disable"], |
| 207 | envFilePath, | 264 | envFilePath, |
| 265 | { stdout: "pipe", stderr: "pipe" }, | ||
| 208 | ); | 266 | ); |
| 209 | 267 | ||
| 210 | if (deploy.exitCode !== 0) { | 268 | if (deploy.exitCode !== 0) { |
| 211 | throw new Error("Convex bootstrap failed while deploying local functions."); | 269 | const detail = deploy.stderr.trim() || deploy.stdout.trim() || "unknown deploy error"; |
| 270 | throw new Error(`Convex bootstrap failed while deploying local functions: ${detail}`); | ||
| 212 | } | 271 | } |
| 213 | } finally { | 272 | } finally { |
| 214 | await fs.rm(path.dirname(envFilePath), { recursive: true, force: true }); | 273 | await fs.rm(path.dirname(envFilePath), { recursive: true, force: true }); |
| @@ -55,7 +55,7 @@ export function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 54 unmodified lines | |||
| 55 | ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}` | 55 | ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}` |
| 56 | : ""; | 56 | : ""; |
| 57 | const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql")); | 57 | const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql")); |
| 58 | const discoverNote = "\n\nTooling tip: avoid repeated tiny discovery calls. Start with a single broad inventory pass via `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: false, depth: 2, limit: 20 })` to get typed signatures, then do at most one focused `tools.discover({ query, compact: false, depth: 2, limit: 12 })`. `discover` returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise copy `results[i].exampleCall`. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`)."; | 58 | const discoverNote = "\n\nTooling tip: avoid repeated tiny discovery calls. Start with a single broad inventory pass via `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: true, depth: 1, limit: 20 })`, then do at most one focused `tools.discover({ query, compact: true, depth: 1, limit: 12 })`. If you still need full signatures for a final pick, rerun only that shortlist with `compact: false`. `discover` returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise copy `results[i].exampleCall`. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`)."; |
| 59 | const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects."; | 59 | const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects."; |
| 60 | const graphqlNote = hasGraphqlTools | 60 | const graphqlNote = hasGraphqlTools |
| 61 | ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`." | 61 | ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`." |
| @@ -456,12 +456,58 @@ describe("buildOpenApiToolsFromPrepared", () => { · 455 unmodified lines | |||
| 456 | const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord"); | 456 | const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord"); |
| 457 | expect(tool).toBeDefined(); | 457 | expect(tool).toBeDefined(); |
| 458 | expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]); | 458 | expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]); |
| 459 | expect(tool?.metadata?.displayArgsType).toContain("name: ..."); | 459 | expect(tool?.metadata?.displayArgsType).toContain("name: string"); |
| 460 | expect(tool?.metadata?.displayArgsType).toContain("value: ..."); | 460 | expect(tool?.metadata?.displayArgsType).toContain("value: string"); |
| 461 | expect(tool?.metadata?.argsType).toContain("name"); | 461 | expect(tool?.metadata?.argsType).toContain("name"); |
| 462 | expect(tool?.metadata?.argsType).toContain("value"); | 462 | expect(tool?.metadata?.argsType).toContain("value"); |
| 463 | }); | 463 | }); |
| 464 | 464 | ||
| 465 | test("keeps concrete primitive type for compact single-arg hints", async () => { | ||
| 466 | const spec: Record<string, unknown> = { | ||
| 467 | openapi: "3.0.3", | ||
| 468 | info: { title: "Threads", version: "1.0.0" }, | ||
| 469 | servers: [{ url: "https://api.example.com" }], | ||
| 470 | paths: { | ||
| 471 | "/threads/{thread_id}/subscription": { | ||
| 472 | delete: { | ||
| 473 | operationId: "deleteThreadSubscription", | ||
| 474 | tags: ["activity"], | ||
| 475 | summary: "Delete a thread subscription", | ||
| 476 | parameters: [ | ||
| 477 | { | ||
| 478 | name: "thread_id", | ||
| 479 | in: "path", | ||
| 480 | required: true, | ||
| 481 | schema: { type: "string" }, | ||
| 482 | }, | ||
| 483 | ], | ||
| 484 | responses: { | ||
| 485 | "204": { | ||
| 486 | description: "deleted", | ||
| 487 | }, | ||
| 488 | }, | ||
| 489 | }, | ||
| 490 | }, | ||
| 491 | }, | ||
| 492 | }; | ||
| 493 | |||
| 494 | const prepared = await prepareOpenApiSpec(spec, "threads"); | ||
| 495 | const tools = buildOpenApiToolsFromPrepared( | ||
| 496 | { | ||
| 497 | type: "openapi", | ||
| 498 | name: "github", | ||
| 499 | spec, | ||
| 500 | baseUrl: "https://api.example.com", | ||
| 501 | }, | ||
| 502 | prepared, | ||
| 503 | ); | ||
| 504 | |||
| 505 | const tool = tools.find((candidate) => candidate.metadata?.operationId === "deleteThreadSubscription"); | ||
| 506 | expect(tool).toBeDefined(); | ||
| 507 | expect(tool?.metadata?.displayArgsType).toBe("{ thread_id: string }"); | ||
| 508 | expect(tool?.metadata?.returnsType).toBe("void"); | ||
| 509 | }); | ||
| 510 | |||
| 465 | test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => { | 511 | test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => { |
| 466 | const spec: Record<string, unknown> = { | 512 | const spec: Record<string, unknown> = { |
| 467 | openapi: "3.0.3", | 513 | openapi: "3.0.3", |
| @@ -8,6 +8,53 @@ · 7 unmodified lines | |||
| 8 | */ | 8 | */ |
| 9 | import { test, expect, describe } from "bun:test"; | 9 | import { test, expect, describe } from "bun:test"; |
| 10 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources"; | 10 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "./tool-sources"; |
| 11 | import { generateToolDeclarations, typecheckCode } from "./typechecker"; | ||
| 12 | import type { ToolDefinition, ToolDescriptor } from "./types"; | ||
| 13 | |||
| 14 | function compareStrictHintsToOpenApiTypes( | ||
| 15 | tool: ToolDefinition, | ||
| 16 | sourceName: string, | ||
| 17 | sourceDts: string, | ||
| 18 | ) { | ||
| 19 | const operationId = tool.metadata?.operationId; | ||
| 20 | if (!operationId) { | ||
| 21 | throw new Error(`Tool ${tool.path} is missing operationId`); | ||
| 22 | } | ||
| 23 | |||
| 24 | const descriptor: ToolDescriptor = { | ||
| 25 | path: tool.path, | ||
| 26 | description: tool.description, | ||
| 27 | approval: tool.approval, | ||
| 28 | source: tool.source, | ||
| 29 | operationId, | ||
| 30 | argsType: tool.metadata?.displayArgsType, | ||
| 31 | returnsType: tool.metadata?.displayReturnsType, | ||
| 32 | strictArgsType: tool.metadata?.argsType, | ||
| 33 | strictReturnsType: tool.metadata?.returnsType, | ||
| 34 | }; | ||
| 35 | |||
| 36 | const declarations = generateToolDeclarations([descriptor], { | ||
| 37 | sourceDtsBySource: { | ||
| 38 | [`openapi:${sourceName}`]: sourceDts, | ||
| 39 | }, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const strictArgs = tool.metadata?.argsType ?? "{}"; | ||
| 43 | const strictReturns = tool.metadata?.returnsType ?? "unknown"; | ||
| 44 | const code = [ | ||
| 45 | `type GeneratedInput = ToolInput<operations[${JSON.stringify(operationId)}]>`, | ||
| 46 | `type StrictInput = ${strictArgs}`, | ||
| 47 | "const _inputA: GeneratedInput = {} as StrictInput", | ||
| 48 | "const _inputB: StrictInput = {} as GeneratedInput", | ||
| 49 | `type GeneratedOutput = ToolOutput<operations[${JSON.stringify(operationId)}]>`, | ||
| 50 | `type StrictOutput = ${strictReturns}`, | ||
| 51 | "const _outputA: GeneratedOutput = {} as StrictOutput", | ||
| 52 | "const _outputB: StrictOutput = {} as GeneratedOutput", | ||
| 53 | "return true;", | ||
| 54 | ].join("\n"); | ||
| 55 | |||
| 56 | return typecheckCode(code, declarations); | ||
| 57 | } | ||
| 11 | 58 | ||
| 12 | interface SpecFixture { | 59 | interface SpecFixture { |
| 13 | name: string; | 60 | name: string; |
| @@ -206,6 +253,38 @@ describe("real-world OpenAPI specs", () => { · 192 unmodified lines | |||
| 206 | 300_000, | 253 | 300_000, |
| 207 | ); | 254 | ); |
| 208 | 255 | ||
| 256 | test( | ||
| 257 | "github: add custom labels to repo runner keeps concrete compact arg hints", | ||
| 258 | async () => { | ||
| 259 | const githubUrl = | ||
| 260 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml"; | ||
| 261 | |||
| 262 | const prepared = await prepareOpenApiSpec(githubUrl, "github", { includeDts: false }); | ||
| 263 | const tools = buildOpenApiToolsFromPrepared( | ||
| 264 | { | ||
| 265 | type: "openapi", | ||
| 266 | name: "github", | ||
| 267 | spec: githubUrl, | ||
| 268 | baseUrl: prepared.servers[0] || "https://api.github.com", | ||
| 269 | }, | ||
| 270 | prepared, | ||
| 271 | ); | ||
| 272 | |||
| 273 | const tool = tools.find( | ||
| 274 | (t) => t.metadata?.operationId === "actions/add-custom-labels-to-self-hosted-runner-for-repo", | ||
| 275 | ); | ||
| 276 | |||
| 277 | expect(tool).toBeDefined(); | ||
| 278 | expect(tool!.path).toBe("github.actions.add_custom_labels_to_self_hosted_runner_for_repo"); | ||
| 279 | expect(tool!.metadata!.displayArgsType).toContain("owner: string"); | ||
| 280 | expect(tool!.metadata!.displayArgsType).toContain("repo: string"); | ||
| 281 | expect(tool!.metadata!.displayArgsType).toContain("runner_id: number"); | ||
| 282 | expect(tool!.metadata!.displayArgsType).toContain("labels: string[]"); | ||
| 283 | expect(tool!.metadata!.displayArgsType).not.toContain("owner: ..."); | ||
| 284 | }, | ||
| 285 | 300_000, | ||
| 286 | ); | ||
| 287 | |||
| 209 | test( | 288 | test( |
| 210 | "github: create hosted runner for org has non-unknown return hint", | 289 | "github: create hosted runner for org has non-unknown return hint", |
| 211 | async () => { | 290 | async () => { |
| @@ -378,4 +457,104 @@ describe("real-world OpenAPI specs", () => { · 166 unmodified lines | |||
| 378 | }, | 457 | }, |
| 379 | 300_000, | 458 | 300_000, |
| 380 | ); | 459 | ); |
| 460 | |||
| 461 | test( | ||
| 462 | "cloudflare: access authentication logs keeps non-ellipsis compact hints", | ||
| 463 | async () => { | ||
| 464 | const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml"; | ||
| 465 | |||
| 466 | const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false }); | ||
| 467 | const tools = buildOpenApiToolsFromPrepared( | ||
| 468 | { | ||
| 469 | type: "openapi", | ||
| 470 | name: "cloudflare", | ||
| 471 | spec: cloudflareUrl, | ||
| 472 | baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4", | ||
| 473 | }, | ||
| 474 | prepared, | ||
| 475 | ); | ||
| 476 | |||
| 477 | const tool = tools.find( | ||
| 478 | (t) => t.metadata?.operationId === "access-authentication-logs-get-access-authentication-logs", | ||
| 479 | ); | ||
| 480 | |||
| 481 | expect(tool).toBeDefined(); | ||
| 482 | expect(tool!.path).toBe("cloudflare.access_authentication_logs.get_access_authentication_logs"); | ||
| 483 | expect(tool!.metadata!.displayArgsType).toContain("account_id: string"); | ||
| 484 | expect(tool!.metadata!.displayArgsType).toContain("direction?: \"desc\" | \"asc\""); | ||
| 485 | expect(tool!.metadata!.displayArgsType).not.toContain("..."); | ||
| 486 | expect(tool!.metadata!.displayReturnsType).toContain("messages: { code: number"); | ||
| 487 | expect(tool!.metadata!.displayReturnsType).toContain("result?: {"); | ||
| 488 | expect(tool!.metadata!.displayReturnsType).not.toContain("..."); | ||
| 489 | }, | ||
| 490 | 300_000, | ||
| 491 | ); | ||
| 492 | |||
| 493 | test( | ||
| 494 | "cloudflare: delete access application keeps concrete compact args/returns", | ||
| 495 | async () => { | ||
| 496 | const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml"; | ||
| 497 | |||
| 498 | const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false }); | ||
| 499 | const tools = buildOpenApiToolsFromPrepared( | ||
| 500 | { | ||
| 501 | type: "openapi", | ||
| 502 | name: "cloudflare", | ||
| 503 | spec: cloudflareUrl, | ||
| 504 | baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4", | ||
| 505 | }, | ||
| 506 | prepared, | ||
| 507 | ); | ||
| 508 | |||
| 509 | const tool = tools.find( | ||
| 510 | (t) => t.metadata?.operationId === "access-applications-delete-an-access-application", | ||
| 511 | ); | ||
| 512 | |||
| 513 | expect(tool).toBeDefined(); | ||
| 514 | expect(tool!.metadata!.displayArgsType).toBe("{ app_id: string; account_id: string }"); | ||
| 515 | expect(tool!.metadata!.displayArgsType).not.toContain("..."); | ||
| 516 | expect(tool!.metadata!.displayReturnsType).toContain("result?: { id?: string }"); | ||
| 517 | expect(tool!.metadata!.displayReturnsType).not.toContain("num..."); | ||
| 518 | }, | ||
| 519 | 300_000, | ||
| 520 | ); | ||
| 521 | |||
| 522 | test( | ||
| 523 | "cloudflare: add access application type hints diverge from openapi-typescript canonical types", | ||
| 524 | async () => { | ||
| 525 | const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml"; | ||
| 526 | |||
| 527 | const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare"); | ||
| 528 | const tools = buildOpenApiToolsFromPrepared( | ||
| 529 | { | ||
| 530 | type: "openapi", | ||
| 531 | name: "cloudflare", | ||
| 532 | spec: cloudflareUrl, | ||
| 533 | baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4", | ||
| 534 | }, | ||
| 535 | prepared, | ||
| 536 | ); | ||
| 537 | |||
| 538 | const tool = tools.find( | ||
| 539 | (t) => t.metadata?.operationId === "access-applications-add-an-application", | ||
| 540 | ); | ||
| 541 | |||
| 542 | expect(tool).toBeDefined(); | ||
| 543 | expect(prepared.dts).toBeDefined(); | ||
| 544 | |||
| 545 | const comparison = compareStrictHintsToOpenApiTypes(tool!, "cloudflare", prepared.dts!); | ||
| 546 | |||
| 547 | // Reproduces the current mismatch between fallback strict hints and generated operation types. | ||
| 548 | expect(comparison.ok).toBe(false); | ||
| 549 | expect( | ||
| 550 | comparison.errors.some((error) => error.includes("allowed_methods") || error.includes("target_criteria")), | ||
| 551 | ).toBe(true); | ||
| 552 | |||
| 553 | // Ensure UI/discovery hints remain compact even when strict hints are very large. | ||
| 554 | expect(tool!.metadata!.displayArgsType).toContain("account_id"); | ||
| 555 | expect(tool!.metadata!.displayArgsType!.length).toBeLessThan(240); | ||
| 556 | expect(tool!.metadata!.argsType!.length).toBeGreaterThan(8_000); | ||
| 557 | }, | ||
| 558 | 300_000, | ||
| 559 | ); | ||
| 381 | }); | 560 | }); |
| @@ -9,7 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | import { buildOpenApiToolPath } from "./openapi-tool-path"; | 9 | import { buildOpenApiToolPath } from "./openapi-tool-path"; |
| 10 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "./tool-source-auth"; | 10 | import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "./tool-source-auth"; |
| 11 | import { executeOpenApiRequest } from "./tool-source-execution"; | 11 | import { executeOpenApiRequest } from "./tool-source-execution"; |
| 12 | import { compactArgKeysHint, compactArgTypeHint, compactReturnTypeHint } from "./type-hints"; | 12 | import { compactArgDisplayHint, compactReturnTypeHint } from "./type-hints"; |
| 13 | import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "./tool-source-types"; | 13 | import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "./tool-source-types"; |
| 14 | import type { ToolDefinition } from "./types"; | 14 | import type { ToolDefinition } from "./types"; |
| 15 | import { asRecord } from "./utils"; | 15 | import { asRecord } from "./utils"; |
| @@ -135,9 +135,7 @@ export function buildOpenApiToolsFromPrepared( · 119 unmodified lines | |||
| 135 | 135 | ||
| 136 | const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters); | 136 | const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters); |
| 137 | 137 | ||
| 138 | const displayArgsType = argPreviewKeys.length > 0 | 138 | const displayArgsType = compactArgDisplayHint(argsType, argPreviewKeys); |
| 139 | ? compactArgKeysHint(argPreviewKeys) | ||
| 140 | : compactArgTypeHint(argsType); | ||
| 141 | const displayReturnsType = compactReturnTypeHint(returnsType); | 139 | const displayReturnsType = compactReturnTypeHint(returnsType); |
| 142 | 140 | ||
| 143 | const approval = config.overrides?.[operationIdRaw]?.approval | 141 | const approval = config.overrides?.[operationIdRaw]?.approval |
| @@ -1,7 +1,6 @@ | |||
| 1 | import type { ToolDefinition } from "./types"; | 1 | import type { ToolDefinition } from "./types"; |
| 2 | import { | 2 | import { |
| 3 | compactArgKeysHint, | 3 | compactArgDisplayHint, |
| 4 | compactArgTypeHint, | ||
| 5 | compactDescriptionLine, | 4 | compactDescriptionLine, |
| 6 | compactReturnTypeHint, | 5 | compactReturnTypeHint, |
| 7 | } from "./type-hints"; | 6 | } from "./type-hints"; |
| @@ -95,7 +94,7 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 87 unmodified lines | |||
| 95 | approval: entry.approval, | 94 | approval: entry.approval, |
| 96 | description: compact ? compactDescriptionLine(entry.description) : entry.description, | 95 | description: compact ? compactDescriptionLine(entry.description) : entry.description, |
| 97 | argsType: compact | 96 | argsType: compact |
| 98 | ? (entry.argPreviewKeys.length > 0 ? compactArgKeysHint(entry.argPreviewKeys) : compactArgTypeHint(entry.argsType)) | 97 | ? compactArgDisplayHint(entry.argsType, entry.argPreviewKeys) |
| 99 | : entry.argsType, | 98 | : entry.argsType, |
| 100 | returnsType: compact ? compactReturnTypeHint(entry.returnsType) : entry.returnsType, | 99 | returnsType: compact ? compactReturnTypeHint(entry.returnsType) : entry.returnsType, |
| 101 | signature: formatSignature(entry, depth, compact), | 100 | signature: formatSignature(entry, depth, compact), |
| @@ -1,6 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 2 | compactArgKeysHint, | 2 | compactArgDisplayHint, |
| 3 | compactArgTypeHint, | ||
| 4 | compactReturnTypeHint, | 3 | compactReturnTypeHint, |
| 5 | extractTopLevelTypeKeys, | 4 | extractTopLevelTypeKeys, |
| 6 | } from "../type-hints"; | 5 | } from "../type-hints"; |
| @@ -35,9 +34,7 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 28 unmodified lines | |||
| 35 | return "(input: ...): Promise<...>"; | 34 | return "(input: ...): Promise<...>"; |
| 36 | } | 35 | } |
| 37 | 36 | ||
| 38 | const args = entry.argPreviewKeys.length > 0 | 37 | const args = compactArgDisplayHint(entry.argsType, entry.argPreviewKeys); |
| 39 | ? compactArgKeysHint(entry.argPreviewKeys) | ||
| 40 | : compactArgTypeHint(entry.argsType); | ||
| 41 | const returns = compactReturnTypeHint(entry.returnsType); | 38 | const returns = compactReturnTypeHint(entry.returnsType); |
| 42 | 39 | ||
| 43 | if (depth === 1) { | 40 | if (depth === 1) { |
| @@ -19,6 +19,18 @@ test("compactArgTypeHint keeps only top-level previews", async () => { · 18 unmodified lines | |||
| 19 | expect(compact).toBe("{ parent: ...; title: ...; icon: ...; properties: ... }"); | 19 | expect(compact).toBe("{ parent: ...; title: ...; icon: ...; properties: ... }"); |
| 20 | }); | 20 | }); |
| 21 | 21 | ||
| 22 | test("compactArgTypeHint flattens simple object intersections", async () => { | ||
| 23 | const compact = compactArgTypeHint( | ||
| 24 | "{ owner: string; repo: string; runner_id: number } & { labels: string[] }", | ||
| 25 | ); | ||
| 26 | expect(compact).toBe("{ owner: string; repo: string; runner_id: number; labels: string[] }"); | ||
| 27 | }); | ||
| 28 | |||
| 29 | test("compactArgTypeHint dedupes trivial unions while preserving concrete types", async () => { | ||
| 30 | const compact = compactArgTypeHint("{ app_id: string | string; account_id: string }"); | ||
| 31 | expect(compact).toBe("{ app_id: string; account_id: string }"); | ||
| 32 | }); | ||
| 33 | |||
| 22 | test("compactArgKeysHint caps key list with ellipsis", async () => { | 34 | test("compactArgKeysHint caps key list with ellipsis", async () => { |
| 23 | const compact = compactArgKeysHint(["a", "b", "c", "d", "e", "f", "g", "h"]); | 35 | const compact = compactArgKeysHint(["a", "b", "c", "d", "e", "f", "g", "h"]); |
| 24 | expect(compact).toBe("{ a: ...; b: ...; c: ...; d: ...; e: ...; f: ...; ... }"); | 36 | expect(compact).toBe("{ a: ...; b: ...; c: ...; d: ...; e: ...; f: ...; ... }"); |
| @@ -29,6 +41,15 @@ test("compactReturnTypeHint collapses graphql envelopes", async () => { · 4 unmodified lines | |||
| 29 | expect(compact).toBe("{ data: ...; errors: unknown[] }"); | 41 | expect(compact).toBe("{ data: ...; errors: unknown[] }"); |
| 30 | }); | 42 | }); |
| 31 | 43 | ||
| 44 | test("compactReturnTypeHint flattens simple object intersections", async () => { | ||
| 45 | const compact = compactReturnTypeHint( | ||
| 46 | "{ errors: { code: number }[]; messages: { code: number }[]; success: true } & { result?: { id?: string } }", | ||
| 47 | ); | ||
| 48 | expect(compact).toBe( | ||
| 49 | "{ errors: { code: number }[]; messages: { code: number }[]; success: true; result?: { id?: string } }", | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | |||
| 32 | test("compactDescriptionLine trims to a single concise line", async () => { | 53 | test("compactDescriptionLine trims to a single concise line", async () => { |
| 33 | const compact = compactDescriptionLine("Create a database in Notion.\nThis endpoint supports parent/page context."); | 54 | const compact = compactDescriptionLine("Create a database in Notion.\nThis endpoint supports parent/page context."); |
| 34 | expect(compact).toBe("Create a database in Notion."); | 55 | expect(compact).toBe("Create a database in Notion."); |
| @@ -4,6 +4,134 @@ function truncateInline(value: string, maxLength: number): string { · 3 unmodified lines | |||
| 4 | return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`; | 4 | return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`; |
| 5 | } | 5 | } |
| 6 | 6 | ||
| 7 | function splitTopLevelBy(value: string, separator: string): string[] { | ||
| 8 | const parts: string[] = []; | ||
| 9 | let segment = ""; | ||
| 10 | let depthCurly = 0; | ||
| 11 | let depthSquare = 0; | ||
| 12 | let depthParen = 0; | ||
| 13 | let depthAngle = 0; | ||
| 14 | |||
| 15 | for (const char of value) { | ||
| 16 | if (char === "{") depthCurly += 1; | ||
| 17 | else if (char === "}") depthCurly = Math.max(0, depthCurly - 1); | ||
| 18 | else if (char === "[") depthSquare += 1; | ||
| 19 | else if (char === "]") depthSquare = Math.max(0, depthSquare - 1); | ||
| 20 | else if (char === "(") depthParen += 1; | ||
| 21 | else if (char === ")") depthParen = Math.max(0, depthParen - 1); | ||
| 22 | else if (char === "<") depthAngle += 1; | ||
| 23 | else if (char === ">") depthAngle = Math.max(0, depthAngle - 1); | ||
| 24 | |||
| 25 | if ( | ||
| 26 | char === separator | ||
| 27 | && depthCurly === 0 | ||
| 28 | && depthSquare === 0 | ||
| 29 | && depthParen === 0 | ||
| 30 | && depthAngle === 0 | ||
| 31 | ) { | ||
| 32 | const trimmed = segment.trim(); | ||
| 33 | if (trimmed.length > 0) parts.push(trimmed); | ||
| 34 | segment = ""; | ||
| 35 | continue; | ||
| 36 | } | ||
| 37 | |||
| 38 | segment += char; | ||
| 39 | } | ||
| 40 | |||
| 41 | const trimmed = segment.trim(); | ||
| 42 | if (trimmed.length > 0) parts.push(trimmed); | ||
| 43 | return parts; | ||
| 44 | } | ||
| 45 | |||
| 46 | function normalizeSimpleUnion(typeExpression: string): string { | ||
| 47 | const parts = splitTopLevelBy(typeExpression, "|") | ||
| 48 | .map((part) => part.trim()) | ||
| 49 | .filter((part) => part.length > 0); | ||
| 50 | if (parts.length < 2) return typeExpression; | ||
| 51 | if (parts.some((part) => /[{};]/.test(part))) return typeExpression; | ||
| 52 | |||
| 53 | const unique: string[] = []; | ||
| 54 | for (const part of parts) { | ||
| 55 | if (!unique.includes(part)) unique.push(part); | ||
| 56 | } | ||
| 57 | |||
| 58 | if (unique.length === parts.length) return typeExpression; | ||
| 59 | return unique.join(" | "); | ||
| 60 | } | ||
| 61 | |||
| 62 | function normalizeFlatObjectHint(typeHint: string): string | null { | ||
| 63 | const trimmed = typeHint.trim(); | ||
| 64 | if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; | ||
| 65 | |||
| 66 | const inner = trimmed.slice(1, -1); | ||
| 67 | const segments = splitTopLevelBy(inner, ";"); | ||
| 68 | if (segments.length === 0) return null; | ||
| 69 | |||
| 70 | const normalizedSegments: string[] = []; | ||
| 71 | for (const rawSegment of segments) { | ||
| 72 | const segment = rawSegment.trim(); | ||
| 73 | if (!segment) continue; | ||
| 74 | |||
| 75 | const colonIndex = segment.indexOf(":"); | ||
| 76 | if (colonIndex <= 0) { | ||
| 77 | normalizedSegments.push(segment); | ||
| 78 | continue; | ||
| 79 | } | ||
| 80 | |||
| 81 | const key = segment.slice(0, colonIndex).trim(); | ||
| 82 | const value = normalizeSimpleUnion(segment.slice(colonIndex + 1).trim()); | ||
| 83 | normalizedSegments.push(`${key}: ${value}`); | ||
| 84 | } | ||
| 85 | |||
| 86 | if (normalizedSegments.length === 0) return null; | ||
| 87 | return `{ ${normalizedSegments.join("; ")} }`; | ||
| 88 | } | ||
| 89 | |||
| 90 | function normalizeIntersectionObjectHint( | ||
| 91 | typeHint: string, | ||
| 92 | options: { maxParts?: number; maxKeys?: number; maxLength?: number } = {}, | ||
| 93 | ): string | null { | ||
| 94 | const maxParts = options.maxParts ?? 6; | ||
| 95 | const maxKeys = options.maxKeys ?? 8; | ||
| 96 | const maxLength = options.maxLength ?? 140; | ||
| 97 | const parts = splitTopLevelBy(typeHint, "&"); | ||
| 98 | if (parts.length < 2 || parts.length > maxParts) return null; | ||
| 99 | |||
| 100 | const orderedKeys: string[] = []; | ||
| 101 | const keyToType = new Map<string, string>(); | ||
| 102 | |||
| 103 | for (const part of parts) { | ||
| 104 | const trimmed = part.trim(); | ||
| 105 | if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; | ||
| 106 | |||
| 107 | const inner = trimmed.slice(1, -1); | ||
| 108 | const segments = splitTopLevelBy(inner, ";"); | ||
| 109 | for (const rawSegment of segments) { | ||
| 110 | const segment = rawSegment.trim(); | ||
| 111 | if (!segment || segment.startsWith("[")) continue; | ||
| 112 | const colonIndex = segment.indexOf(":"); | ||
| 113 | if (colonIndex <= 0) continue; | ||
| 114 | |||
| 115 | const key = segment.slice(0, colonIndex).trim().replace(/["']/g, ""); | ||
| 116 | if (!key) continue; | ||
| 117 | const value = normalizeSimpleUnion(segment.slice(colonIndex + 1).trim()); | ||
| 118 | if (!value) continue; | ||
| 119 | |||
| 120 | if (!keyToType.has(key)) { | ||
| 121 | orderedKeys.push(key); | ||
| 122 | keyToType.set(key, value); | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | |||
| 127 | if (orderedKeys.length === 0 || orderedKeys.length > maxKeys) return null; | ||
| 128 | |||
| 129 | const entries = orderedKeys.map((key) => `${key}: ${keyToType.get(key)}`); | ||
| 130 | const compact = `{ ${entries.join("; ")} }`; | ||
| 131 | if (compact.length > maxLength) return null; | ||
| 132 | return compact; | ||
| 133 | } | ||
| 134 | |||
| 7 | export function extractTopLevelTypeKeys(typeHint: string): string[] { | 135 | export function extractTopLevelTypeKeys(typeHint: string): string[] { |
| 8 | const text = typeHint.trim(); | 136 | const text = typeHint.trim(); |
| 9 | if (!text.startsWith("{") || !text.endsWith("}")) return []; | 137 | if (!text.startsWith("{") || !text.endsWith("}")) return []; |
| @@ -70,6 +198,30 @@ export function compactArgKeysHint(keys: string[]): string { · 60 unmodified lines | |||
| 70 | 198 | ||
| 71 | export function compactArgTypeHint(argsType: string): string { | 199 | export function compactArgTypeHint(argsType: string): string { |
| 72 | if (argsType === "{}") return "{}"; | 200 | if (argsType === "{}") return "{}"; |
| 201 | const normalized = argsType.replace(/\s+/g, " ").trim(); | ||
| 202 | |||
| 203 | const flattenedIntersection = normalizeIntersectionObjectHint(normalized, { maxLength: 160 }); | ||
| 204 | if (flattenedIntersection) { | ||
| 205 | return flattenedIntersection; | ||
| 206 | } | ||
| 207 | |||
| 208 | const inlineFriendlyObject = normalized.startsWith("{") | ||
| 209 | && normalized.endsWith("}") | ||
| 210 | && normalized.length <= 100 | ||
| 211 | && !normalized.includes("&"); | ||
| 212 | if (inlineFriendlyObject) { | ||
| 213 | return normalizeFlatObjectHint(normalized) ?? normalized; | ||
| 214 | } | ||
| 215 | |||
| 216 | const inlineObjectWithSimpleUnions = normalized.startsWith("{") | ||
| 217 | && normalized.endsWith("}") | ||
| 218 | && normalized.length <= 220 | ||
| 219 | && !normalized.includes("&") | ||
| 220 | && !normalized.slice(1, -1).includes("{") | ||
| 221 | && !normalized.slice(1, -1).includes("}"); | ||
| 222 | if (inlineObjectWithSimpleUnions) { | ||
| 223 | return normalizeFlatObjectHint(normalized) ?? normalized; | ||
| 224 | } | ||
| 73 | const keys = extractTopLevelTypeKeys(argsType); | 225 | const keys = extractTopLevelTypeKeys(argsType); |
| 74 | if (keys.length > 0) { | 226 | if (keys.length > 0) { |
| 75 | return compactArgKeysHint(keys); | 227 | return compactArgKeysHint(keys); |
| @@ -77,11 +229,43 @@ export function compactArgTypeHint(argsType: string): string { · 1 unmodified line | |||
| 77 | return truncateInline(argsType, 120); | 229 | return truncateInline(argsType, 120); |
| 78 | } | 230 | } |
| 79 | 231 | ||
| 232 | export function compactArgDisplayHint(argsType: string, argPreviewKeys: string[] = []): string { | ||
| 233 | const compactFromType = compactArgTypeHint(argsType); | ||
| 234 | if (compactFromType !== "{}" && compactFromType.includes(":")) { | ||
| 235 | const normalized = compactFromType.replace(/\s+/g, " ").trim(); | ||
| 236 | if (!normalized.includes("...")) { | ||
| 237 | return compactFromType; | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | if (argPreviewKeys.length > 0) { | ||
| 242 | return compactArgKeysHint(argPreviewKeys); | ||
| 243 | } | ||
| 244 | |||
| 245 | return compactFromType; | ||
| 246 | } | ||
| 247 | |||
| 80 | export function compactReturnTypeHint(returnsType: string): string { | 248 | export function compactReturnTypeHint(returnsType: string): string { |
| 81 | const normalized = returnsType.replace(/\s+/g, " ").trim(); | 249 | const normalized = returnsType.replace(/\s+/g, " ").trim(); |
| 82 | if (normalized.startsWith("{ data:") && normalized.includes("errors:")) { | 250 | if (normalized.startsWith("{ data:") && normalized.includes("errors:")) { |
| 83 | return "{ data: ...; errors: unknown[] }"; | 251 | return "{ data: ...; errors: unknown[] }"; |
| 84 | } | 252 | } |
| 253 | const flattenedIntersection = normalizeIntersectionObjectHint(normalized, { | ||
| 254 | maxParts: 6, | ||
| 255 | maxKeys: 16, | ||
| 256 | maxLength: 520, | ||
| 257 | }); | ||
| 258 | if (flattenedIntersection) { | ||
| 259 | return flattenedIntersection; | ||
| 260 | } | ||
| 261 | const inlineFriendlyObject = normalized.startsWith("{") | ||
| 262 | && normalized.endsWith("}") | ||
| 263 | && normalized.length <= 520 | ||
| 264 | && !normalized.includes("|") | ||
| 265 | && !normalized.includes("&"); | ||
| 266 | if (inlineFriendlyObject) { | ||
| 267 | return normalized; | ||
| 268 | } | ||
| 85 | if (normalized.endsWith("[]") && normalized.length > 90) { | 269 | if (normalized.endsWith("[]") && normalized.length > 90) { |
| 86 | return "Array<...>"; | 270 | return "Array<...>"; |
| 87 | } | 271 | } |