Unified · Split
| @@ -280,7 +280,7 @@ export declare const api: { · 279 unmodified lines | |||
| 280 | bootstrapAnonymousSession: FunctionReference< | 280 | bootstrapAnonymousSession: FunctionReference< |
| 281 | "mutation", | 281 | "mutation", |
| 282 | "public", | 282 | "public", |
| 283 | { sessionId?: string }, | 283 | { actorId?: string; sessionId?: string }, |
| 284 | any | 284 | any |
| 285 | >; | 285 | >; |
| 286 | deleteToolSource: FunctionReference< | 286 | deleteToolSource: FunctionReference< |
| @@ -1219,6 +1219,115 @@ export declare const internal: { · 932 unmodified lines | |||
| 1219 | any | 1219 | any |
| 1220 | >; | 1220 | >; |
| 1221 | }; | 1221 | }; |
| 1222 | toolRegistry: { | ||
| 1223 | beginBuild: FunctionReference< | ||
| 1224 | "mutation", | ||
| 1225 | "internal", | ||
| 1226 | { buildId: string; signature: string; workspaceId: Id<"workspaces"> }, | ||
| 1227 | any | ||
| 1228 | >; | ||
| 1229 | finishBuild: FunctionReference< | ||
| 1230 | "mutation", | ||
| 1231 | "internal", | ||
| 1232 | { buildId: string; workspaceId: Id<"workspaces"> }, | ||
| 1233 | any | ||
| 1234 | >; | ||
| 1235 | getState: FunctionReference< | ||
| 1236 | "query", | ||
| 1237 | "internal", | ||
| 1238 | { workspaceId: Id<"workspaces"> }, | ||
| 1239 | any | ||
| 1240 | >; | ||
| 1241 | getToolByPath: FunctionReference< | ||
| 1242 | "query", | ||
| 1243 | "internal", | ||
| 1244 | { buildId: string; path: string; workspaceId: Id<"workspaces"> }, | ||
| 1245 | any | ||
| 1246 | >; | ||
| 1247 | getToolsByNormalizedPath: FunctionReference< | ||
| 1248 | "query", | ||
| 1249 | "internal", | ||
| 1250 | { | ||
| 1251 | buildId: string; | ||
| 1252 | limit: number; | ||
| 1253 | normalizedPath: string; | ||
| 1254 | workspaceId: Id<"workspaces">; | ||
| 1255 | }, | ||
| 1256 | any | ||
| 1257 | >; | ||
| 1258 | listNamespaces: FunctionReference< | ||
| 1259 | "query", | ||
| 1260 | "internal", | ||
| 1261 | { buildId: string; limit: number; workspaceId: Id<"workspaces"> }, | ||
| 1262 | any | ||
| 1263 | >; | ||
| 1264 | listToolsByNamespace: FunctionReference< | ||
| 1265 | "query", | ||
| 1266 | "internal", | ||
| 1267 | { | ||
| 1268 | buildId: string; | ||
| 1269 | limit: number; | ||
| 1270 | namespace: string; | ||
| 1271 | workspaceId: Id<"workspaces">; | ||
| 1272 | }, | ||
| 1273 | any | ||
| 1274 | >; | ||
| 1275 | putNamespacesBatch: FunctionReference< | ||
| 1276 | "mutation", | ||
| 1277 | "internal", | ||
| 1278 | { | ||
| 1279 | buildId: string; | ||
| 1280 | namespaces: Array<{ | ||
| 1281 | namespace: string; | ||
| 1282 | samplePaths: Array<string>; | ||
| 1283 | toolCount: number; | ||
| 1284 | }>; | ||
| 1285 | workspaceId: Id<"workspaces">; | ||
| 1286 | }, | ||
| 1287 | any | ||
| 1288 | >; | ||
| 1289 | putToolsBatch: FunctionReference< | ||
| 1290 | "mutation", | ||
| 1291 | "internal", | ||
| 1292 | { | ||
| 1293 | buildId: string; | ||
| 1294 | tools: Array<{ | ||
| 1295 | aliases: Array<string>; | ||
| 1296 | approval: "auto" | "required"; | ||
| 1297 | description: string; | ||
| 1298 | displayInput?: string; | ||
| 1299 | displayOutput?: string; | ||
| 1300 | namespace: string; | ||
| 1301 | normalizedPath: string; | ||
| 1302 | path: string; | ||
| 1303 | preferredPath: string; | ||
| 1304 | previewInputKeys?: Array<string>; | ||
| 1305 | requiredInputKeys?: Array<string>; | ||
| 1306 | searchText: string; | ||
| 1307 | serializedToolJson: string; | ||
| 1308 | source?: string; | ||
| 1309 | typedRef?: { | ||
| 1310 | kind: "openapi_operation"; | ||
| 1311 | operationId: string; | ||
| 1312 | sourceKey: string; | ||
| 1313 | }; | ||
| 1314 | }>; | ||
| 1315 | workspaceId: Id<"workspaces">; | ||
| 1316 | }, | ||
| 1317 | any | ||
| 1318 | >; | ||
| 1319 | searchTools: FunctionReference< | ||
| 1320 | "query", | ||
| 1321 | "internal", | ||
| 1322 | { | ||
| 1323 | buildId: string; | ||
| 1324 | limit: number; | ||
| 1325 | query: string; | ||
| 1326 | workspaceId: Id<"workspaces">; | ||
| 1327 | }, | ||
| 1328 | any | ||
| 1329 | >; | ||
| 1330 | }; | ||
| 1222 | workspaceAuthInternal: { | 1331 | workspaceAuthInternal: { |
| 1223 | getWorkspaceAccessForAnonymousSubject: FunctionReference< | 1332 | getWorkspaceAccessForAnonymousSubject: FunctionReference< |
| 1224 | "query", | 1333 | "query", |
| @@ -21,6 +21,7 @@ function setup() { · 20 unmodified lines | |||
| 21 | "./app.ts": () => import("./app"), | 21 | "./app.ts": () => import("./app"), |
| 22 | "./billingInternal.ts": () => import("./billingInternal"), | 22 | "./billingInternal.ts": () => import("./billingInternal"), |
| 23 | "./billingSync.ts": () => import("./billingSync"), | 23 | "./billingSync.ts": () => import("./billingSync"), |
| 24 | "./toolRegistry.ts": () => import("./toolRegistry"), | ||
| 24 | "./_generated/api.js": () => import("./_generated/api.js"), | 25 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 25 | }); | 26 | }); |
| 26 | } | 27 | } |
| @@ -7,6 +7,7 @@ import schema from "./schema"; · 6 unmodified lines | |||
| 7 | function setup() { | 7 | function setup() { |
| 8 | return convexTest(schema, { | 8 | return convexTest(schema, { |
| 9 | "./database.ts": () => import("./database"), | 9 | "./database.ts": () => import("./database"), |
| 10 | "./toolRegistry.ts": () => import("./toolRegistry"), | ||
| 10 | "./_generated/api.js": () => import("./_generated/api.js"), | 11 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 11 | }); | 12 | }); |
| 12 | } | 13 | } |
| @@ -15,6 +15,7 @@ function setup() { · 14 unmodified lines | |||
| 15 | "./auth.ts": () => import("./auth"), | 15 | "./auth.ts": () => import("./auth"), |
| 16 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | 16 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), |
| 17 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | 17 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), |
| 18 | "./toolRegistry.ts": () => import("./toolRegistry"), | ||
| 18 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | 19 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), |
| 19 | "./_generated/api.js": () => import("./_generated/api.js"), | 20 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 20 | }); | 21 | }); |
| @@ -12,6 +12,7 @@ function setup() { · 11 unmodified lines | |||
| 12 | "./executorNode.ts": () => import("./executorNode"), | 12 | "./executorNode.ts": () => import("./executorNode"), |
| 13 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), | 13 | "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"), |
| 14 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), | 14 | "./workspaceToolCache.ts": () => import("./workspaceToolCache"), |
| 15 | "./toolRegistry.ts": () => import("./toolRegistry"), | ||
| 15 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | 16 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), |
| 16 | "./_generated/api.js": () => import("./_generated/api.js"), | 17 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 17 | }); | 18 | }); |
| @@ -16,6 +16,7 @@ function setup() { · 15 unmodified lines | |||
| 16 | "../../auth.ts": () => import("../../auth"), | 16 | "../../auth.ts": () => import("../../auth"), |
| 17 | "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"), | 17 | "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"), |
| 18 | "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"), | 18 | "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"), |
| 19 | "../../toolRegistry.ts": () => import("../../toolRegistry"), | ||
| 19 | "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"), | 20 | "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"), |
| 20 | "../../_generated/api.js": () => import("../../_generated/api.js"), | 21 | "../../_generated/api.js": () => import("../../_generated/api.js"), |
| 21 | }); | 22 | }); |
| @@ -6,6 +6,7 @@ import schema from "./schema"; · 5 unmodified lines | |||
| 6 | function setup() { | 6 | function setup() { |
| 7 | return convexTest(schema, { | 7 | return convexTest(schema, { |
| 8 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), | 8 | "./openApiSpecCache.ts": () => import("./openApiSpecCache"), |
| 9 | "./toolRegistry.ts": () => import("./toolRegistry"), | ||
| 9 | "./_generated/api.js": () => import("./_generated/api.js"), | 10 | "./_generated/api.js": () => import("./_generated/api.js"), |
| 10 | }); | 11 | }); |
| 11 | } | 12 | } |
| @@ -1,17 +1,19 @@ | |||
| 1 | "use node"; | 1 | "use node"; |
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../_generated/server"; | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | import { internal } from "../_generated/api"; | ||
| 4 | import { parseGraphqlOperationPaths } from "../../core/src/graphql/operation-paths"; | 5 | import { parseGraphqlOperationPaths } from "../../core/src/graphql/operation-paths"; |
| 5 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types"; | 6 | import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types"; |
| 7 | import { rehydrateTools, type SerializedTool } from "../../core/src/tool/source-serialization"; | ||
| 6 | import { getDecisionForContext, getToolDecision } from "./policy"; | 8 | import { getDecisionForContext, getToolDecision } from "./policy"; |
| 7 | import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths"; | 9 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 8 | import { baseTools, getWorkspaceTools } from "./workspace_tools"; | 10 | import { baseTools } from "./workspace_tools"; |
| 9 | 11 | ||
| 10 | export function getGraphqlDecision( | 12 | export function getGraphqlDecision( |
| 11 | task: TaskRecord, | 13 | task: TaskRecord, |
| 12 | tool: ToolDefinition, | 14 | tool: ToolDefinition, |
| 13 | input: unknown, | 15 | input: unknown, |
| 14 | workspaceTools: Map<string, ToolDefinition>, | 16 | workspaceTools: Map<string, ToolDefinition> | undefined, |
| 15 | policies: AccessPolicyRecord[], | 17 | policies: AccessPolicyRecord[], |
| 16 | ): { decision: PolicyDecision; effectivePaths: string[] } { | 18 | ): { decision: PolicyDecision; effectivePaths: string[] } { |
| 17 | const sourceName = tool._graphqlSource!; | 19 | const sourceName = tool._graphqlSource!; |
| @@ -30,7 +32,7 @@ export function getGraphqlDecision( · 12 unmodified lines | |||
| 30 | let worstDecision: PolicyDecision = "allow"; | 32 | let worstDecision: PolicyDecision = "allow"; |
| 31 | 33 | ||
| 32 | for (const fieldPath of fieldPaths) { | 34 | for (const fieldPath of fieldPaths) { |
| 33 | const pseudoTool = workspaceTools.get(fieldPath); | 35 | const pseudoTool = workspaceTools?.get(fieldPath); |
| 34 | const fieldDecision = pseudoTool | 36 | const fieldDecision = pseudoTool |
| 35 | ? getDecisionForContext( | 37 | ? getDecisionForContext( |
| 36 | pseudoTool, | 38 | pseudoTool, |
| @@ -63,31 +65,43 @@ export function getGraphqlDecision( · 26 unmodified lines | |||
| 63 | return { decision: worstDecision, effectivePaths: fieldPaths }; | 65 | return { decision: worstDecision, effectivePaths: fieldPaths }; |
| 64 | } | 66 | } |
| 65 | 67 | ||
| 66 | function unknownToolErrorMessage(toolPath: string, availableTools: Map<string, ToolDefinition>): string { | 68 | async function resolveRegistryBuildId( |
| 67 | const suggestions = suggestToolPaths(toolPath, availableTools); | 69 | ctx: ActionCtx, |
| 68 | const queryHint = toolPath | 70 | workspaceId: TaskRecord["workspaceId"], |
| 69 | .split(".") | 71 | ): Promise<string> { |
| 70 | .filter(Boolean) | 72 | const state = await ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as |
| 71 | .join(" "); | 73 | | null |
| 72 | const suggestionText = suggestions.length > 0 | 74 | | { readyBuildId?: string }; |
| 73 | ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}` | 75 | const buildId = state?.readyBuildId; |
| 74 | : ""; | 76 | if (!buildId) { |
| 75 | const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: false, depth: 2, limit: 12 });`; | 77 | throw new Error("Tool registry is not ready yet. Open Tools in the UI to build the registry."); |
| 76 | return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`; | 78 | } |
| 79 | return buildId; | ||
| 77 | } | 80 | } |
| 78 | 81 | ||
| 79 | export async function ensureWorkspaceTools( | 82 | async function suggestFromRegistry( |
| 80 | ctx: ActionCtx, | 83 | ctx: ActionCtx, |
| 81 | task: TaskRecord, | 84 | workspaceId: TaskRecord["workspaceId"], |
| 82 | workspaceTools?: Map<string, ToolDefinition>, | 85 | buildId: string, |
| 83 | ): Promise<Map<string, ToolDefinition>> { | 86 | toolPath: string, |
| 84 | if (workspaceTools) { | 87 | ): Promise<string[]> { |
| 85 | return workspaceTools; | 88 | const term = toolPath.split(".").filter(Boolean).join(" "); |
| 86 | } | 89 | const hits = await ctx.runQuery(internal.toolRegistry.searchTools, { |
| 87 | const result = await getWorkspaceTools(ctx, task.workspaceId, { | 90 | workspaceId, |
| 88 | actorId: task.actorId, | 91 | buildId, |
| 89 | }); | 92 | query: term, |
| 90 | return result.tools; | 93 | limit: 3, |
| 94 | }) as Array<{ preferredPath: string }>; | ||
| 95 | return hits.map((hit) => hit.preferredPath); | ||
| 96 | } | ||
| 97 | |||
| 98 | function unknownToolErrorMessage(toolPath: string, suggestions: string[]): string { | ||
| 99 | const suggestionText = suggestions.length > 0 | ||
| 100 | ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}` | ||
| 101 | : ""; | ||
| 102 | const queryHint = toolPath.split(".").filter(Boolean).join(" "); | ||
| 103 | const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: true, depth: 1, limit: 12 });`; | ||
| 104 | return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`; | ||
| 91 | } | 105 | } |
| 92 | 106 | ||
| 93 | export async function resolveToolForCall( | 107 | export async function resolveToolForCall( |
| @@ -97,41 +111,48 @@ export async function resolveToolForCall( · 3 unmodified lines | |||
| 97 | ): Promise<{ | 111 | ): Promise<{ |
| 98 | tool: ToolDefinition; | 112 | tool: ToolDefinition; |
| 99 | resolvedToolPath: string; | 113 | resolvedToolPath: string; |
| 100 | workspaceTools?: Map<string, ToolDefinition>; | ||
| 101 | }> { | 114 | }> { |
| 102 | let workspaceTools: Map<string, ToolDefinition> | undefined; | 115 | const builtin = baseTools.get(toolPath); |
| 116 | if (builtin) { | ||
| 117 | return { tool: builtin, resolvedToolPath: toolPath }; | ||
| 118 | } | ||
| 119 | |||
| 120 | const buildId = await resolveRegistryBuildId(ctx, task.workspaceId); | ||
| 121 | |||
| 103 | let resolvedToolPath = toolPath; | 122 | let resolvedToolPath = toolPath; |
| 104 | let tool = baseTools.get(toolPath); | 123 | let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, { |
| 105 | if (!tool) { | 124 | workspaceId: task.workspaceId, |
| 106 | workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools); | 125 | buildId, |
| 107 | tool = workspaceTools.get(toolPath); | 126 | path: toolPath, |
| 108 | 127 | }) as null | { path: string; serializedToolJson: string }; | |
| 109 | if (!tool) { | 128 | |
| 110 | const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools); | 129 | if (!entry) { |
| 111 | if (aliasedPath) { | 130 | const normalized = normalizeToolPathForLookup(toolPath); |
| 112 | resolvedToolPath = aliasedPath; | 131 | const hits = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, { |
| 113 | tool = workspaceTools.get(aliasedPath); | 132 | workspaceId: task.workspaceId, |
| 114 | } | 133 | buildId, |
| 134 | normalizedPath: normalized, | ||
| 135 | limit: 5, | ||
| 136 | }) as Array<{ path: string; serializedToolJson: string }>; | ||
| 137 | |||
| 138 | if (hits.length > 0) { | ||
| 139 | // Prefer exact match on preferred path formatting, otherwise shortest canonical path. | ||
| 140 | const exact = hits.find((hit) => toPreferredToolPath(hit.path) === toPreferredToolPath(toolPath)); | ||
| 141 | entry = exact ?? hits.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path))[0]!; | ||
| 142 | resolvedToolPath = entry.path; | ||
| 115 | } | 143 | } |
| 116 | } | 144 | } |
| 117 | 145 | ||
| 118 | if (!tool) { | 146 | if (!entry) { |
| 119 | const availableTools = workspaceTools ?? baseTools; | 147 | const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath); |
| 120 | const healedPath = resolveClosestToolPath(toolPath, availableTools); | 148 | throw new Error(unknownToolErrorMessage(toolPath, suggestions)); |
| 121 | if (healedPath) { | ||
| 122 | resolvedToolPath = healedPath; | ||
| 123 | tool = availableTools.get(healedPath); | ||
| 124 | } | ||
| 125 | } | 149 | } |
| 126 | 150 | ||
| 151 | const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool; | ||
| 152 | const [tool] = rehydrateTools([serialized], baseTools); | ||
| 127 | if (!tool) { | 153 | if (!tool) { |
| 128 | const availableTools = workspaceTools ?? baseTools; | 154 | throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`); |
| 129 | throw new Error(unknownToolErrorMessage(toolPath, availableTools)); | ||
| 130 | } | 155 | } |
| 131 | 156 | ||
| 132 | return { | 157 | return { tool, resolvedToolPath }; |
| 133 | tool, | ||
| 134 | resolvedToolPath, | ||
| 135 | workspaceTools, | ||
| 136 | }; | ||
| 137 | } | 158 | } |
| @@ -8,18 +8,19 @@ import type { · 7 unmodified lines | |||
| 8 | PolicyDecision, | 8 | PolicyDecision, |
| 9 | ResolvedToolCredential, | 9 | ResolvedToolCredential, |
| 10 | TaskRecord, | 10 | TaskRecord, |
| 11 | ToolDefinition, | ||
| 11 | ToolCallRecord, | 12 | ToolCallRecord, |
| 12 | ToolCallRequest, | 13 | ToolCallRequest, |
| 13 | ToolRunContext, | 14 | ToolRunContext, |
| 14 | } from "../../core/src/types"; | 15 | } from "../../core/src/types"; |
| 15 | import { describeError } from "../../core/src/utils"; | 16 | import { describeError } from "../../core/src/utils"; |
| 16 | import { asPayload } from "../lib/object"; | 17 | import { asPayload } from "../lib/object"; |
| 17 | import { getToolDecision, isToolAllowedForTask } from "./policy"; | 18 | import { getToolDecision, getDecisionForContext } from "./policy"; |
| 18 | import { baseTools } from "./workspace_tools"; | 19 | import { baseTools } from "./workspace_tools"; |
| 19 | import { publishTaskEvent } from "./events"; | 20 | import { publishTaskEvent } from "./events"; |
| 20 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; | 21 | import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle"; |
| 21 | import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials"; | 22 | import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials"; |
| 22 | import { ensureWorkspaceTools, getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; | 23 | import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution"; |
| 23 | 24 | ||
| 24 | function createApprovalId(): string { | 25 | function createApprovalId(): string { |
| 25 | return `approval_${crypto.randomUUID()}`; | 26 | return `approval_${crypto.randomUUID()}`; |
| @@ -57,13 +58,151 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 31 unmodified lines | |||
| 57 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); | 58 | const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId }); |
| 58 | const typedPolicies = policies as AccessPolicyRecord[]; | 59 | const typedPolicies = policies as AccessPolicyRecord[]; |
| 59 | 60 | ||
| 60 | let { tool, resolvedToolPath, workspaceTools } = await resolveToolForCall(ctx, task, toolPath); | 61 | // Fast system tools are handled server-side from the registry. |
| 62 | if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") { | ||
| 63 | const toolRegistry = internal.toolRegistry; | ||
| 64 | const state = await ctx.runQuery(toolRegistry.getState, { workspaceId: task.workspaceId }) as | ||
| 65 | | null | ||
| 66 | | { readyBuildId?: string }; | ||
| 67 | const buildId = state?.readyBuildId; | ||
| 68 | if (!buildId) { | ||
| 69 | throw new Error("Tool registry is not ready yet. Open Tools in the UI to build the registry."); | ||
| 70 | } | ||
| 71 | |||
| 72 | const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {}; | ||
| 73 | const isAllowed = (path: string, approval: ToolDefinition["approval"]) => | ||
| 74 | getDecisionForContext( | ||
| 75 | { path, approval, description: "", run: async () => null } as ToolDefinition, | ||
| 76 | { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId }, | ||
| 77 | typedPolicies, | ||
| 78 | ) !== "deny"; | ||
| 79 | |||
| 80 | const formatSignature = (inputHint: string | undefined, outputHint: string | undefined) => { | ||
| 81 | const args = (inputHint && inputHint.trim().length > 0) ? inputHint.trim() : "{}"; | ||
| 82 | const out = (outputHint && outputHint.trim().length > 0) ? outputHint.trim() : "unknown"; | ||
| 83 | return `(input: ${args}): Promise<${out}>`; | ||
| 84 | }; | ||
| 85 | |||
| 86 | const buildExampleCall = (path: string, previewKeys?: string[]) => { | ||
| 87 | const keys = Array.isArray(previewKeys) ? previewKeys : []; | ||
| 88 | if (keys.length === 0) { | ||
| 89 | return `await tools.${path}({});`; | ||
| 90 | } | ||
| 91 | const snippet = keys | ||
| 92 | .slice(0, 5) | ||
| 93 | .map((key) => `${key}: ...`) | ||
| 94 | .join(", "); | ||
| 95 | return `await tools.${path}({ ${snippet} });`; | ||
| 96 | }; | ||
| 97 | |||
| 98 | if (toolPath === "catalog.namespaces") { | ||
| 99 | const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200))); | ||
| 100 | const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, { | ||
| 101 | workspaceId: task.workspaceId, | ||
| 102 | buildId, | ||
| 103 | limit, | ||
| 104 | }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; | ||
| 105 | return { namespaces, total: namespaces.length }; | ||
| 106 | } | ||
| 107 | |||
| 108 | if (toolPath === "catalog.tools") { | ||
| 109 | const namespace = String(payload.namespace ?? "").trim().toLowerCase(); | ||
| 110 | const query = String(payload.query ?? "").trim(); | ||
| 111 | const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50))); | ||
| 112 | |||
| 113 | const raw = query | ||
| 114 | ? await ctx.runQuery(toolRegistry.searchTools, { | ||
| 115 | workspaceId: task.workspaceId, | ||
| 116 | buildId, | ||
| 117 | query, | ||
| 118 | limit, | ||
| 119 | }) | ||
| 120 | : namespace | ||
| 121 | ? await ctx.runQuery(toolRegistry.listToolsByNamespace, { | ||
| 122 | workspaceId: task.workspaceId, | ||
| 123 | buildId, | ||
| 124 | namespace, | ||
| 125 | limit, | ||
| 126 | }) | ||
| 127 | : []; | ||
| 128 | |||
| 129 | const results = (raw as Array<any>) | ||
| 130 | .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`)) | ||
| 131 | .filter((entry) => isAllowed(entry.path, entry.approval)) | ||
| 132 | .slice(0, limit) | ||
| 133 | .map((entry) => { | ||
| 134 | const preferredPath = entry.preferredPath ?? entry.path; | ||
| 135 | return { | ||
| 136 | path: preferredPath, | ||
| 137 | aliases: entry.aliases ?? [], | ||
| 138 | source: entry.source, | ||
| 139 | approval: entry.approval, | ||
| 140 | description: entry.description, | ||
| 141 | signatureInfo: { | ||
| 142 | input: entry.displayInput, | ||
| 143 | output: entry.displayOutput, | ||
| 144 | requiredKeys: entry.requiredInputKeys ?? [], | ||
| 145 | previewKeys: entry.previewInputKeys ?? [], | ||
| 146 | }, | ||
| 147 | signatureText: formatSignature(entry.displayInput, entry.displayOutput), | ||
| 148 | canonicalSignature: formatSignature(entry.displayInput, entry.displayOutput), | ||
| 149 | exampleCall: buildExampleCall(preferredPath, entry.previewInputKeys), | ||
| 150 | }; | ||
| 151 | }); | ||
| 152 | |||
| 153 | return { results, total: results.length }; | ||
| 154 | } | ||
| 155 | |||
| 156 | // discover | ||
| 157 | const query = String(payload.query ?? "").trim(); | ||
| 158 | const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8))); | ||
| 159 | const compact = payload.compact === false ? false : true; | ||
| 160 | const hits = await ctx.runQuery(toolRegistry.searchTools, { | ||
| 161 | workspaceId: task.workspaceId, | ||
| 162 | buildId, | ||
| 163 | query, | ||
| 164 | limit: Math.max(limit * 2, limit), | ||
| 165 | }) as Array<any>; | ||
| 166 | |||
| 167 | const filtered = hits | ||
| 168 | .filter((entry) => isAllowed(entry.path, entry.approval)) | ||
| 169 | .slice(0, limit); | ||
| 170 | |||
| 171 | const results = filtered.map((entry) => { | ||
| 172 | const preferredPath = entry.preferredPath ?? entry.path; | ||
| 173 | const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description; | ||
| 174 | return { | ||
| 175 | path: preferredPath, | ||
| 176 | aliases: entry.aliases ?? [], | ||
| 177 | source: entry.source, | ||
| 178 | approval: entry.approval, | ||
| 179 | description, | ||
| 180 | signature: formatSignature(entry.displayInput, entry.displayOutput), | ||
| 181 | canonicalSignature: formatSignature(entry.displayInput, entry.displayOutput), | ||
| 182 | signatureInfo: { | ||
| 183 | input: entry.displayInput, | ||
| 184 | output: entry.displayOutput, | ||
| 185 | requiredKeys: entry.requiredInputKeys ?? [], | ||
| 186 | previewKeys: entry.previewInputKeys ?? [], | ||
| 187 | }, | ||
| 188 | exampleCall: buildExampleCall(preferredPath, entry.previewInputKeys), | ||
| 189 | }; | ||
| 190 | }); | ||
| 191 | |||
| 192 | const bestPath = results[0]?.path ?? null; | ||
| 193 | return { | ||
| 194 | bestPath, | ||
| 195 | results, | ||
| 196 | total: results.length, | ||
| 197 | }; | ||
| 198 | } | ||
| 199 | |||
| 200 | let { tool, resolvedToolPath } = await resolveToolForCall(ctx, task, toolPath); | ||
| 61 | 201 | ||
| 62 | let decision: PolicyDecision; | 202 | let decision: PolicyDecision; |
| 63 | let effectiveToolPath = resolvedToolPath; | 203 | let effectiveToolPath = resolvedToolPath; |
| 64 | if (tool._graphqlSource) { | 204 | if (tool._graphqlSource) { |
| 65 | workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools); | 205 | const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies); |
| 66 | const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies); | ||
| 67 | decision = result.decision; | 206 | decision = result.decision; |
| 68 | if (result.effectivePaths.length > 0) { | 207 | if (result.effectivePaths.length > 0) { |
| 69 | effectiveToolPath = result.effectivePaths.join(", "); | 208 | effectiveToolPath = result.effectivePaths.join(", "); |
| @@ -179,7 +318,8 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 109 unmodified lines | |||
| 179 | actorId: task.actorId, | 318 | actorId: task.actorId, |
| 180 | clientId: task.clientId, | 319 | clientId: task.clientId, |
| 181 | credential, | 320 | credential, |
| 182 | isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies), | 321 | // Tool visibility is enforced server-side; runtime tool implementations don't use this. |
| 322 | isToolAllowed: (_path) => true, | ||
| 183 | }; | 323 | }; |
| 184 | const value = await tool.run(input, context); | 324 | const value = await tool.run(input, context); |
| 185 | await completeToolCall(ctx, { | 325 | await completeToolCall(ctx, { |
| @@ -46,7 +46,7 @@ function normalizeToolPathSegment(segment: string, isNamespace = false): string · 45 unmodified lines | |||
| 46 | return collapsed.join(""); | 46 | return collapsed.join(""); |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | function normalizeToolPath(path: string): string { | 49 | export function normalizeToolPathForLookup(path: string): string { |
| 50 | const segments = path | 50 | const segments = path |
| 51 | .split(".") | 51 | .split(".") |
| 52 | .filter(Boolean); | 52 | .filter(Boolean); |
| @@ -110,12 +110,12 @@ export function resolveAliasedToolPath( · 57 unmodified lines | |||
| 110 | ): string | null { | 110 | ): string | null { |
| 111 | if (toolMap.has(requestedPath)) return requestedPath; | 111 | if (toolMap.has(requestedPath)) return requestedPath; |
| 112 | 112 | ||
| 113 | const normalizedRequested = normalizeToolPath(requestedPath); | 113 | const normalizedRequested = normalizeToolPathForLookup(requestedPath); |
| 114 | if (!normalizedRequested) return null; | 114 | if (!normalizedRequested) return null; |
| 115 | 115 | ||
| 116 | const matches: string[] = []; | 116 | const matches: string[] = []; |
| 117 | for (const path of toolMap.keys()) { | 117 | for (const path of toolMap.keys()) { |
| 118 | if (normalizeToolPath(path) === normalizedRequested) { | 118 | if (normalizeToolPathForLookup(path) === normalizedRequested) { |
| 119 | matches.push(path); | 119 | matches.push(path); |
| 120 | } | 120 | } |
| 121 | } | 121 | } |
| @@ -134,13 +134,13 @@ export function suggestToolPaths( · 12 unmodified lines | |||
| 134 | toolMap: Map<string, ToolDefinition>, | 134 | toolMap: Map<string, ToolDefinition>, |
| 135 | limit = 3, | 135 | limit = 3, |
| 136 | ): string[] { | 136 | ): string[] { |
| 137 | const normalizedRequested = normalizeToolPath(requestedPath); | 137 | const normalizedRequested = normalizeToolPathForLookup(requestedPath); |
| 138 | const requestedSegments = normalizedRequested.split(".").filter(Boolean); | 138 | const requestedSegments = normalizedRequested.split(".").filter(Boolean); |
| 139 | const requestedNamespace = requestedSegments[0] ?? ""; | 139 | const requestedNamespace = requestedSegments[0] ?? ""; |
| 140 | 140 | ||
| 141 | return [...toolMap.keys()] | 141 | return [...toolMap.keys()] |
| 142 | .map((path) => { | 142 | .map((path) => { |
| 143 | const normalizedCandidate = normalizeToolPath(path); | 143 | const normalizedCandidate = normalizeToolPathForLookup(path); |
| 144 | const candidateSegments = normalizedCandidate.split(".").filter(Boolean); | 144 | const candidateSegments = normalizedCandidate.split(".").filter(Boolean); |
| 145 | const candidateNamespace = candidateSegments[0] ?? ""; | 145 | const candidateNamespace = candidateSegments[0] ?? ""; |
| 146 | 146 | ||
| @@ -173,7 +173,7 @@ export function resolveClosestToolPath( · 26 unmodified lines | |||
| 173 | requestedPath: string, | 173 | requestedPath: string, |
| 174 | toolMap: Map<string, ToolDefinition>, | 174 | toolMap: Map<string, ToolDefinition>, |
| 175 | ): string | null { | 175 | ): string | null { |
| 176 | const normalizedRequested = normalizeToolPath(requestedPath); | 176 | const normalizedRequested = normalizeToolPathForLookup(requestedPath); |
| 177 | if (!normalizedRequested) return null; | 177 | if (!normalizedRequested) return null; |
| 178 | 178 | ||
| 179 | const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? ""; | 179 | const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? ""; |
| @@ -182,7 +182,7 @@ export function resolveClosestToolPath( · 2 unmodified lines | |||
| 182 | 182 | ||
| 183 | const ranked = [...toolMap.keys()] | 183 | const ranked = [...toolMap.keys()] |
| 184 | .map((path) => { | 184 | .map((path) => { |
| 185 | const normalizedCandidate = normalizeToolPath(path); | 185 | const normalizedCandidate = normalizeToolPathForLookup(path); |
| 186 | const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? ""; | 186 | const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? ""; |
| 187 | const distance = levenshteinDistance(normalizedRequested, normalizedCandidate); | 187 | const distance = levenshteinDistance(normalizedRequested, normalizedCandidate); |
| 188 | let score = -distance; | 188 | let score = -distance; |
| @@ -3,14 +3,16 @@ · 2 unmodified lines | |||
| 3 | import type { ActionCtx } from "../_generated/server"; | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | import { internal } from "../_generated/api"; | 4 | import { internal } from "../_generated/api"; |
| 5 | import type { Id } from "../_generated/dataModel.d.ts"; | 5 | import type { Id } from "../_generated/dataModel.d.ts"; |
| 6 | import { createCatalogTools, createDiscoverTool } from "../../core/src/tool-discovery"; | ||
| 7 | import { buildWorkspaceTypeBundle } from "../../core/src/tool-typing/typebundle"; | 6 | import { buildWorkspaceTypeBundle } from "../../core/src/tool-typing/typebundle"; |
| 7 | import { jsonSchemaTypeHintFallback } from "../../core/src/openapi/schema-hints"; | ||
| 8 | import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../core/src/tool-typing/schema-utils"; | ||
| 8 | import { | 9 | import { |
| 9 | materializeCompiledToolSource, | 10 | materializeCompiledToolSource, |
| 10 | materializeWorkspaceSnapshot, | 11 | materializeWorkspaceSnapshot, |
| 11 | type CompiledToolSourceArtifact, | 12 | type CompiledToolSourceArtifact, |
| 12 | type WorkspaceToolSnapshot, | 13 | type WorkspaceToolSnapshot, |
| 13 | } from "../../core/src/tool-sources"; | 14 | } from "../../core/src/tool-sources"; |
| 15 | import { serializeTools } from "../../core/src/tool/source-serialization"; | ||
| 14 | import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types"; | 16 | import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types"; |
| 15 | import type { | 17 | import type { |
| 16 | AccessPolicyRecord, | 18 | AccessPolicyRecord, |
| @@ -21,6 +23,7 @@ import type { · 4 unmodified lines | |||
| 21 | } from "../../core/src/types"; | 23 | } from "../../core/src/types"; |
| 22 | import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors"; | 24 | import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors"; |
| 23 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; | 25 | import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading"; |
| 26 | import { normalizeToolPathForLookup } from "./tool_paths"; | ||
| 24 | 27 | ||
| 25 | const baseTools = new Map<string, ToolDefinition>(); | 28 | const baseTools = new Map<string, ToolDefinition>(); |
| 26 | 29 | ||
| @@ -88,6 +91,91 @@ baseTools.set("admin.delete_data", { · 61 unmodified lines | |||
| 88 | }, | 91 | }, |
| 89 | }); | 92 | }); |
| 90 | 93 | ||
| 94 | // System tools (discover/catalog) are resolved server-side. | ||
| 95 | // Their execution is handled in the Convex tool invocation pipeline. | ||
| 96 | baseTools.set("discover", { | ||
| 97 | path: "discover", | ||
| 98 | source: "system", | ||
| 99 | approval: "auto", | ||
| 100 | description: | ||
| 101 | "Search available tools by keyword. Returns preferred path aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.", | ||
| 102 | typing: { | ||
| 103 | inputSchema: { | ||
| 104 | type: "object", | ||
| 105 | properties: { | ||
| 106 | query: { type: "string" }, | ||
| 107 | depth: { type: "number" }, | ||
| 108 | limit: { type: "number" }, | ||
| 109 | compact: { type: "boolean" }, | ||
| 110 | }, | ||
| 111 | required: ["query"], | ||
| 112 | }, | ||
| 113 | outputSchema: { | ||
| 114 | type: "object", | ||
| 115 | properties: { | ||
| 116 | bestPath: {}, | ||
| 117 | results: { type: "array" }, | ||
| 118 | total: { type: "number" }, | ||
| 119 | }, | ||
| 120 | required: ["bestPath", "results", "total"], | ||
| 121 | }, | ||
| 122 | }, | ||
| 123 | run: async () => { | ||
| 124 | throw new Error("discover is handled by the server tool invocation pipeline"); | ||
| 125 | }, | ||
| 126 | }); | ||
| 127 | |||
| 128 | baseTools.set("catalog.namespaces", { | ||
| 129 | path: "catalog.namespaces", | ||
| 130 | source: "system", | ||
| 131 | approval: "auto", | ||
| 132 | description: "List available tool namespaces with counts and sample callable paths.", | ||
| 133 | typing: { | ||
| 134 | inputSchema: { type: "object", properties: {} }, | ||
| 135 | outputSchema: { | ||
| 136 | type: "object", | ||
| 137 | properties: { | ||
| 138 | namespaces: { type: "array" }, | ||
| 139 | total: { type: "number" }, | ||
| 140 | }, | ||
| 141 | required: ["namespaces", "total"], | ||
| 142 | }, | ||
| 143 | }, | ||
| 144 | run: async () => { | ||
| 145 | throw new Error("catalog.namespaces is handled by the server tool invocation pipeline"); | ||
| 146 | }, | ||
| 147 | }); | ||
| 148 | |||
| 149 | baseTools.set("catalog.tools", { | ||
| 150 | path: "catalog.tools", | ||
| 151 | source: "system", | ||
| 152 | approval: "auto", | ||
| 153 | description: "List tools with typed signatures. Supports namespace and query filters in one call.", | ||
| 154 | typing: { | ||
| 155 | inputSchema: { | ||
| 156 | type: "object", | ||
| 157 | properties: { | ||
| 158 | namespace: { type: "string" }, | ||
| 159 | query: { type: "string" }, | ||
| 160 | depth: { type: "number" }, | ||
| 161 | limit: { type: "number" }, | ||
| 162 | compact: { type: "boolean" }, | ||
| 163 | }, | ||
| 164 | }, | ||
| 165 | outputSchema: { | ||
| 166 | type: "object", | ||
| 167 | properties: { | ||
| 168 | results: { type: "array" }, | ||
| 169 | total: { type: "number" }, | ||
| 170 | }, | ||
| 171 | required: ["results", "total"], | ||
| 172 | }, | ||
| 173 | }, | ||
| 174 | run: async () => { | ||
| 175 | throw new Error("catalog.tools is handled by the server tool invocation pipeline"); | ||
| 176 | }, | ||
| 177 | }); | ||
| 178 | |||
| 91 | interface WorkspaceToolsResult { | 179 | interface WorkspaceToolsResult { |
| 92 | tools: Map<string, ToolDefinition>; | 180 | tools: Map<string, ToolDefinition>; |
| 93 | warnings: string[]; | 181 | warnings: string[]; |
| @@ -176,27 +264,227 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 82 unmodified lines | |||
| 176 | return profiles; | 264 | return profiles; |
| 177 | } | 265 | } |
| 178 | 266 | ||
| 179 | function mergeToolsWithCatalog(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> { | 267 | function mergeTools(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> { |
| 180 | const merged = new Map<string, ToolDefinition>(); | 268 | const merged = new Map<string, ToolDefinition>(); |
| 181 | 269 | ||
| 182 | for (const tool of baseTools.values()) { | 270 | for (const tool of baseTools.values()) { |
| 183 | if (tool.path === "discover") continue; | ||
| 184 | merged.set(tool.path, tool); | 271 | merged.set(tool.path, tool); |
| 185 | } | 272 | } |
| 186 | 273 | ||
| 187 | for (const tool of externalTools) { | 274 | for (const tool of externalTools) { |
| 188 | if (tool.path === "discover") continue; | ||
| 189 | merged.set(tool.path, tool); | 275 | merged.set(tool.path, tool); |
| 190 | } | 276 | } |
| 277 | return merged; | ||
| 278 | } | ||
| 191 | 279 | ||
| 192 | const catalogTools = createCatalogTools([...merged.values()]); | 280 | function tokenizePathSegment(value: string): string[] { |
| 193 | for (const tool of catalogTools) { | 281 | const normalized = value |
| 194 | merged.set(tool.path, tool); | 282 | .replace(/([a-z0-9])([A-Z])/g, "$1_$2") |
| 283 | .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") | ||
| 284 | .toLowerCase(); | ||
| 285 | |||
| 286 | return normalized | ||
| 287 | .split(/[^a-z0-9]+/) | ||
| 288 | .map((token) => token.trim()) | ||
| 289 | .filter((token) => token.length > 0); | ||
| 290 | } | ||
| 291 | |||
| 292 | const GENERIC_NAMESPACE_SUFFIXES = new Set([ | ||
| 293 | "api", | ||
| 294 | "apis", | ||
| 295 | "openapi", | ||
| 296 | "sdk", | ||
| 297 | "service", | ||
| 298 | "services", | ||
| 299 | ]); | ||
| 300 | |||
| 301 | function simplifyNamespaceSegment(segment: string): string { | ||
| 302 | const tokens = tokenizePathSegment(segment); | ||
| 303 | if (tokens.length === 0) return segment; | ||
| 304 | |||
| 305 | const collapsed: string[] = []; | ||
| 306 | for (const token of tokens) { | ||
| 307 | if (collapsed[collapsed.length - 1] === token) continue; | ||
| 308 | collapsed.push(token); | ||
| 195 | } | 309 | } |
| 196 | 310 | ||
| 197 | const discover = createDiscoverTool([...merged.values()]); | 311 | while (collapsed.length > 1) { |
| 198 | merged.set(discover.path, discover); | 312 | const last = collapsed[collapsed.length - 1]; |
| 199 | return merged; | 313 | if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break; |
| 314 | collapsed.pop(); | ||
| 315 | } | ||
| 316 | |||
| 317 | return collapsed.join("_"); | ||
| 318 | } | ||
| 319 | |||
| 320 | function preferredToolPath(path: string): string { | ||
| 321 | const segments = path.split(".").filter(Boolean); | ||
| 322 | if (segments.length === 0) return path; | ||
| 323 | |||
| 324 | const simplifiedNamespace = simplifyNamespaceSegment(segments[0]!); | ||
| 325 | if (!simplifiedNamespace || simplifiedNamespace === segments[0]) { | ||
| 326 | return path; | ||
| 327 | } | ||
| 328 | |||
| 329 | return [simplifiedNamespace, ...segments.slice(1)].join("."); | ||
| 330 | } | ||
| 331 | |||
| 332 | function toCamelSegment(segment: string): string { | ||
| 333 | return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase()); | ||
| 334 | } | ||
| 335 | |||
| 336 | function getPathAliases(path: string): string[] { | ||
| 337 | const segments = path.split(".").filter(Boolean); | ||
| 338 | if (segments.length === 0) return []; | ||
| 339 | |||
| 340 | const canonicalPath = path; | ||
| 341 | const publicPath = preferredToolPath(path); | ||
| 342 | |||
| 343 | const aliases = new Set<string>(); | ||
| 344 | const publicSegments = publicPath.split(".").filter(Boolean); | ||
| 345 | const camelPath = publicSegments.map(toCamelSegment).join("."); | ||
| 346 | const compactPath = publicSegments.map((segment) => segment.replace(/[_-]/g, "")).join("."); | ||
| 347 | const lowerPath = publicPath.toLowerCase(); | ||
| 348 | |||
| 349 | if (publicPath !== canonicalPath) aliases.add(publicPath); | ||
| 350 | if (camelPath !== publicPath) aliases.add(camelPath); | ||
| 351 | if (compactPath !== publicPath) aliases.add(compactPath); | ||
| 352 | if (lowerPath !== publicPath) aliases.add(lowerPath); | ||
| 353 | |||
| 354 | return [...aliases].slice(0, 4); | ||
| 355 | } | ||
| 356 | |||
| 357 | |||
| 358 | function normalizeHint(type?: string): string { | ||
| 359 | return type && type.trim().length > 0 ? type : "unknown"; | ||
| 360 | } | ||
| 361 | |||
| 362 | function isEmptyObjectSchema(schema: Record<string, unknown>): boolean { | ||
| 363 | if (Object.keys(schema).length === 0) return true; | ||
| 364 | const props = schema.properties && typeof schema.properties === "object" ? schema.properties as Record<string, unknown> : {}; | ||
| 365 | const required = Array.isArray(schema.required) ? schema.required : []; | ||
| 366 | return Object.keys(props).length === 0 && required.length === 0; | ||
| 367 | } | ||
| 368 | |||
| 369 | async function buildWorkspaceToolRegistry( | ||
| 370 | ctx: ActionCtx, | ||
| 371 | args: { | ||
| 372 | workspaceId: Id<"workspaces">; | ||
| 373 | signature: string; | ||
| 374 | tools: ToolDefinition[]; | ||
| 375 | }, | ||
| 376 | ): Promise<{ buildId: string }> { | ||
| 377 | const buildId = `toolreg_${crypto.randomUUID()}`; | ||
| 378 | await ctx.runMutation(internal.toolRegistry.beginBuild, { | ||
| 379 | workspaceId: args.workspaceId, | ||
| 380 | signature: args.signature, | ||
| 381 | buildId, | ||
| 382 | }); | ||
| 383 | |||
| 384 | const serialized = serializeTools(args.tools); | ||
| 385 | |||
| 386 | const entries = serialized.map((st) => { | ||
| 387 | if (st.path === "discover" || st.path.startsWith("catalog.")) { | ||
| 388 | return null; | ||
| 389 | } | ||
| 390 | const preferredPath = preferredToolPath(st.path); | ||
| 391 | const aliases = getPathAliases(st.path); | ||
| 392 | const namespace = (preferredPath.split(".")[0] ?? "default").toLowerCase(); | ||
| 393 | const normalizedPath = normalizeToolPathForLookup(st.path); | ||
| 394 | const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase(); | ||
| 395 | |||
| 396 | const inputSchema = (st.typing?.inputSchema && typeof st.typing.inputSchema === "object") | ||
| 397 | ? st.typing.inputSchema | ||
| 398 | : {}; | ||
| 399 | const outputSchema = (st.typing?.outputSchema && typeof st.typing.outputSchema === "object") | ||
| 400 | ? st.typing.outputSchema | ||
| 401 | : {}; | ||
| 402 | |||
| 403 | const requiredInputKeys = Array.isArray(st.typing?.requiredInputKeys) | ||
| 404 | ? st.typing!.requiredInputKeys!.filter((v): v is string => typeof v === "string") | ||
| 405 | : extractTopLevelRequiredKeys(inputSchema as any); | ||
| 406 | const previewInputKeys = Array.isArray(st.typing?.previewInputKeys) | ||
| 407 | ? st.typing!.previewInputKeys!.filter((v): v is string => typeof v === "string") | ||
| 408 | : buildPreviewKeys(inputSchema as any); | ||
| 409 | |||
| 410 | const displayInput = normalizeHint( | ||
| 411 | isEmptyObjectSchema(inputSchema as any) ? "{}" : jsonSchemaTypeHintFallback(inputSchema), | ||
| 412 | ); | ||
| 413 | const displayOutput = normalizeHint( | ||
| 414 | Object.keys(outputSchema as any).length === 0 ? "unknown" : jsonSchemaTypeHintFallback(outputSchema), | ||
| 415 | ); | ||
| 416 | |||
| 417 | const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation" | ||
| 418 | ? { | ||
| 419 | kind: "openapi_operation" as const, | ||
| 420 | sourceKey: st.typing.typedRef.sourceKey, | ||
| 421 | operationId: st.typing.typedRef.operationId, | ||
| 422 | } | ||
| 423 | : undefined; | ||
| 424 | |||
| 425 | return { | ||
| 426 | path: st.path, | ||
| 427 | preferredPath, | ||
| 428 | namespace, | ||
| 429 | normalizedPath, | ||
| 430 | aliases, | ||
| 431 | description: st.description, | ||
| 432 | approval: st.approval, | ||
| 433 | source: st.source, | ||
| 434 | searchText, | ||
| 435 | displayInput, | ||
| 436 | displayOutput, | ||
| 437 | requiredInputKeys, | ||
| 438 | previewInputKeys, | ||
| 439 | typedRef, | ||
| 440 | serializedToolJson: JSON.stringify(st), | ||
| 441 | }; | ||
| 442 | }); | ||
| 443 | |||
| 444 | const filteredEntries = entries.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)); | ||
| 445 | |||
| 446 | const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>(); | ||
| 447 | for (const entry of filteredEntries) { | ||
| 448 | const current = namespaceMap.get(entry.namespace) ?? { toolCount: 0, samplePaths: [] }; | ||
| 449 | current.toolCount += 1; | ||
| 450 | if (current.samplePaths.length < 6) { | ||
| 451 | current.samplePaths.push(entry.preferredPath); | ||
| 452 | } | ||
| 453 | namespaceMap.set(entry.namespace, current); | ||
| 454 | } | ||
| 455 | |||
| 456 | const namespaces = [...namespaceMap.entries()] | ||
| 457 | .map(([namespace, meta]) => ({ | ||
| 458 | namespace, | ||
| 459 | toolCount: meta.toolCount, | ||
| 460 | samplePaths: [...meta.samplePaths].sort((a, b) => a.localeCompare(b)).slice(0, 3), | ||
| 461 | })) | ||
| 462 | .sort((a, b) => a.namespace.localeCompare(b.namespace)); | ||
| 463 | |||
| 464 | const TOOL_BATCH = 100; | ||
| 465 | for (let i = 0; i < filteredEntries.length; i += TOOL_BATCH) { | ||
| 466 | await ctx.runMutation(internal.toolRegistry.putToolsBatch, { | ||
| 467 | workspaceId: args.workspaceId, | ||
| 468 | buildId, | ||
| 469 | tools: filteredEntries.slice(i, i + TOOL_BATCH), | ||
| 470 | }); | ||
| 471 | } | ||
| 472 | |||
| 473 | const NS_BATCH = 100; | ||
| 474 | for (let i = 0; i < namespaces.length; i += NS_BATCH) { | ||
| 475 | await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, { | ||
| 476 | workspaceId: args.workspaceId, | ||
| 477 | buildId, | ||
| 478 | namespaces: namespaces.slice(i, i + NS_BATCH), | ||
| 479 | }); | ||
| 480 | } | ||
| 481 | |||
| 482 | await ctx.runMutation(internal.toolRegistry.finishBuild, { | ||
| 483 | workspaceId: args.workspaceId, | ||
| 484 | buildId, | ||
| 485 | }); | ||
| 486 | |||
| 487 | return { buildId }; | ||
| 200 | } | 488 | } |
| 201 | 489 | ||
| 202 | export async function getWorkspaceTools( | 490 | export async function getWorkspaceTools( |
| @@ -251,7 +539,7 @@ export async function getWorkspaceTools( · 48 unmodified lines | |||
| 251 | if (blob) { | 539 | if (blob) { |
| 252 | const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot; | 540 | const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot; |
| 253 | const restored = materializeWorkspaceSnapshot(snapshot); | 541 | const restored = materializeWorkspaceSnapshot(snapshot); |
| 254 | const merged = mergeToolsWithCatalog(restored); | 542 | const merged = mergeTools(restored); |
| 255 | traceStep("cacheHydrate", cacheHydrateStartedAt); | 543 | traceStep("cacheHydrate", cacheHydrateStartedAt); |
| 256 | 544 | ||
| 257 | const typesStorageId = cacheEntry.typesStorageId as Id<"_storage"> | undefined; | 545 | const typesStorageId = cacheEntry.typesStorageId as Id<"_storage"> | undefined; |
| @@ -362,7 +650,7 @@ export async function getWorkspaceTools( · 104 unmodified lines | |||
| 362 | const timedOutSources = loadedSources | 650 | const timedOutSources = loadedSources |
| 363 | .filter((loaded) => loaded.timedOut) | 651 | .filter((loaded) => loaded.timedOut) |
| 364 | .map((loaded) => loaded.sourceName); | 652 | .map((loaded) => loaded.sourceName); |
| 365 | const merged = mergeToolsWithCatalog(externalTools); | 653 | const merged = mergeTools(externalTools); |
| 366 | 654 | ||
| 367 | let typesStorageId: Id<"_storage"> | undefined; | 655 | let typesStorageId: Id<"_storage"> | undefined; |
| 368 | try { | 656 | try { |
| @@ -387,6 +675,15 @@ export async function getWorkspaceTools( · 18 unmodified lines | |||
| 387 | const snapshotWriteStartedAt = Date.now(); | 675 | const snapshotWriteStartedAt = Date.now(); |
| 388 | const allTools = [...merged.values()]; | 676 | const allTools = [...merged.values()]; |
| 389 | 677 | ||
| 678 | // Build a per-tool registry for fast discover + invocation. | ||
| 679 | const registryStartedAt = Date.now(); | ||
| 680 | await buildWorkspaceToolRegistry(ctx, { | ||
| 681 | workspaceId, | ||
| 682 | signature, | ||
| 683 | tools: allTools, | ||
| 684 | }); | ||
| 685 | traceStep("toolRegistryWrite", registryStartedAt); | ||
| 686 | |||
| 390 | // Build and store a workspace-wide Monaco type bundle. | 687 | // Build and store a workspace-wide Monaco type bundle. |
| 391 | const openApiDtsBySource: Record<string, string> = {}; | 688 | const openApiDtsBySource: Record<string, string> = {}; |
| 392 | for (const loaded of loadedSources) { | 689 | for (const loaded of loadedSources) { |
| @@ -444,6 +444,8 @@ export default defineSchema({ · 443 unmodified lines | |||
| 444 | buildId: v.string(), | 444 | buildId: v.string(), |
| 445 | path: v.string(), | 445 | path: v.string(), |
| 446 | preferredPath: v.string(), | 446 | preferredPath: v.string(), |
| 447 | namespace: v.string(), | ||
| 448 | normalizedPath: v.string(), | ||
| 447 | aliases: v.array(v.string()), | 449 | aliases: v.array(v.string()), |
| 448 | description: v.string(), | 450 | description: v.string(), |
| 449 | approval: toolApprovalMode, | 451 | approval: toolApprovalMode, |
| @@ -463,22 +465,25 @@ export default defineSchema({ · 13 unmodified lines | |||
| 463 | createdAt: v.number(), | 465 | createdAt: v.number(), |
| 464 | }) | 466 | }) |
| 465 | .index("by_workspace_build_path", ["workspaceId", "buildId", "path"]) | 467 | .index("by_workspace_build_path", ["workspaceId", "buildId", "path"]) |
| 468 | .index("by_workspace_build_normalized", ["workspaceId", "buildId", "normalizedPath"]) | ||
| 469 | .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]) | ||
| 466 | .index("by_workspace_build", ["workspaceId", "buildId"]) | 470 | .index("by_workspace_build", ["workspaceId", "buildId"]) |
| 467 | .searchIndex("search_text", { | 471 | .searchIndex("search_text", { |
| 468 | searchField: "searchText", | 472 | searchField: "searchText", |
| 469 | filterFields: ["workspaceId", "buildId"], | 473 | filterFields: ["workspaceId", "buildId"], |
| 470 | }), | 474 | }), |
| 471 | 475 | ||
| 472 | // Alias -> canonical path mapping for registry lookups. | 476 | // Precomputed namespace summaries for fast catalog.namespaces. |
| 473 | workspaceToolAliases: defineTable({ | 477 | workspaceToolNamespaces: defineTable({ |
| 474 | workspaceId: v.id("workspaces"), | 478 | workspaceId: v.id("workspaces"), |
| 475 | buildId: v.string(), | 479 | buildId: v.string(), |
| 476 | alias: v.string(), | 480 | namespace: v.string(), |
| 477 | path: v.string(), | 481 | toolCount: v.number(), |
| 482 | samplePaths: v.array(v.string()), | ||
| 478 | createdAt: v.number(), | 483 | createdAt: v.number(), |
| 479 | }) | 484 | }) |
| 480 | .index("by_workspace_build_alias", ["workspaceId", "buildId", "alias"]) | 485 | .index("by_workspace_build", ["workspaceId", "buildId"]) |
| 481 | .index("by_workspace_build", ["workspaceId", "buildId"]), | 486 | .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]), |
| 482 | 487 | ||
| 483 | // Anonymous session linkage. | 488 | // Anonymous session linkage. |
| 484 | // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a | 489 | // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a |
| @@ -0,0 +1,331 @@ | |||
| 1 | import { v } from "convex/values"; | ||
| 2 | import { internalMutation, internalQuery } from "./_generated/server"; | ||
| 3 | |||
| 4 | export const getState = internalQuery({ | ||
| 5 | args: { | ||
| 6 | workspaceId: v.id("workspaces"), | ||
| 7 | }, | ||
| 8 | handler: async (ctx, args) => { | ||
| 9 | const entry = await ctx.db | ||
| 10 | .query("workspaceToolRegistryState") | ||
| 11 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 12 | .unique(); | ||
| 13 | |||
| 14 | if (!entry) return null; | ||
| 15 | |||
| 16 | return { | ||
| 17 | signature: entry.signature, | ||
| 18 | readyBuildId: entry.readyBuildId, | ||
| 19 | buildingBuildId: entry.buildingBuildId, | ||
| 20 | updatedAt: entry.updatedAt, | ||
| 21 | }; | ||
| 22 | }, | ||
| 23 | }); | ||
| 24 | |||
| 25 | export const beginBuild = internalMutation({ | ||
| 26 | args: { | ||
| 27 | workspaceId: v.id("workspaces"), | ||
| 28 | signature: v.string(), | ||
| 29 | buildId: v.string(), | ||
| 30 | }, | ||
| 31 | handler: async (ctx, args) => { | ||
| 32 | const now = Date.now(); | ||
| 33 | const existing = await ctx.db | ||
| 34 | .query("workspaceToolRegistryState") | ||
| 35 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 36 | .unique(); | ||
| 37 | |||
| 38 | if (existing) { | ||
| 39 | await ctx.db.patch(existing._id, { | ||
| 40 | signature: args.signature, | ||
| 41 | buildingBuildId: args.buildId, | ||
| 42 | updatedAt: now, | ||
| 43 | }); | ||
| 44 | return; | ||
| 45 | } | ||
| 46 | |||
| 47 | await ctx.db.insert("workspaceToolRegistryState", { | ||
| 48 | workspaceId: args.workspaceId, | ||
| 49 | signature: args.signature, | ||
| 50 | readyBuildId: undefined, | ||
| 51 | buildingBuildId: args.buildId, | ||
| 52 | createdAt: now, | ||
| 53 | updatedAt: now, | ||
| 54 | }); | ||
| 55 | }, | ||
| 56 | }); | ||
| 57 | |||
| 58 | export const putToolsBatch = internalMutation({ | ||
| 59 | args: { | ||
| 60 | workspaceId: v.id("workspaces"), | ||
| 61 | buildId: v.string(), | ||
| 62 | tools: v.array( | ||
| 63 | v.object({ | ||
| 64 | path: v.string(), | ||
| 65 | preferredPath: v.string(), | ||
| 66 | namespace: v.string(), | ||
| 67 | normalizedPath: v.string(), | ||
| 68 | aliases: v.array(v.string()), | ||
| 69 | description: v.string(), | ||
| 70 | approval: v.union(v.literal("auto"), v.literal("required")), | ||
| 71 | source: v.optional(v.string()), | ||
| 72 | searchText: v.string(), | ||
| 73 | displayInput: v.optional(v.string()), | ||
| 74 | displayOutput: v.optional(v.string()), | ||
| 75 | requiredInputKeys: v.optional(v.array(v.string())), | ||
| 76 | previewInputKeys: v.optional(v.array(v.string())), | ||
| 77 | typedRef: v.optional( | ||
| 78 | v.object({ | ||
| 79 | kind: v.literal("openapi_operation"), | ||
| 80 | sourceKey: v.string(), | ||
| 81 | operationId: v.string(), | ||
| 82 | }), | ||
| 83 | ), | ||
| 84 | serializedToolJson: v.string(), | ||
| 85 | }), | ||
| 86 | ), | ||
| 87 | }, | ||
| 88 | handler: async (ctx, args) => { | ||
| 89 | const now = Date.now(); | ||
| 90 | for (const tool of args.tools) { | ||
| 91 | await ctx.db.insert("workspaceToolRegistry", { | ||
| 92 | workspaceId: args.workspaceId, | ||
| 93 | buildId: args.buildId, | ||
| 94 | path: tool.path, | ||
| 95 | preferredPath: tool.preferredPath, | ||
| 96 | namespace: tool.namespace, | ||
| 97 | normalizedPath: tool.normalizedPath, | ||
| 98 | aliases: tool.aliases, | ||
| 99 | description: tool.description, | ||
| 100 | approval: tool.approval, | ||
| 101 | source: tool.source, | ||
| 102 | searchText: tool.searchText, | ||
| 103 | displayInput: tool.displayInput, | ||
| 104 | displayOutput: tool.displayOutput, | ||
| 105 | requiredInputKeys: tool.requiredInputKeys, | ||
| 106 | previewInputKeys: tool.previewInputKeys, | ||
| 107 | typedRef: tool.typedRef, | ||
| 108 | serializedToolJson: tool.serializedToolJson, | ||
| 109 | createdAt: now, | ||
| 110 | }); | ||
| 111 | } | ||
| 112 | }, | ||
| 113 | }); | ||
| 114 | |||
| 115 | export const putNamespacesBatch = internalMutation({ | ||
| 116 | args: { | ||
| 117 | workspaceId: v.id("workspaces"), | ||
| 118 | buildId: v.string(), | ||
| 119 | namespaces: v.array( | ||
| 120 | v.object({ | ||
| 121 | namespace: v.string(), | ||
| 122 | toolCount: v.number(), | ||
| 123 | samplePaths: v.array(v.string()), | ||
| 124 | }), | ||
| 125 | ), | ||
| 126 | }, | ||
| 127 | handler: async (ctx, args) => { | ||
| 128 | const now = Date.now(); | ||
| 129 | for (const ns of args.namespaces) { | ||
| 130 | await ctx.db.insert("workspaceToolNamespaces", { | ||
| 131 | workspaceId: args.workspaceId, | ||
| 132 | buildId: args.buildId, | ||
| 133 | namespace: ns.namespace, | ||
| 134 | toolCount: ns.toolCount, | ||
| 135 | samplePaths: ns.samplePaths, | ||
| 136 | createdAt: now, | ||
| 137 | }); | ||
| 138 | } | ||
| 139 | }, | ||
| 140 | }); | ||
| 141 | |||
| 142 | export const finishBuild = internalMutation({ | ||
| 143 | args: { | ||
| 144 | workspaceId: v.id("workspaces"), | ||
| 145 | buildId: v.string(), | ||
| 146 | }, | ||
| 147 | handler: async (ctx, args) => { | ||
| 148 | const now = Date.now(); | ||
| 149 | const state = await ctx.db | ||
| 150 | .query("workspaceToolRegistryState") | ||
| 151 | .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | ||
| 152 | .unique(); | ||
| 153 | |||
| 154 | if (!state) { | ||
| 155 | await ctx.db.insert("workspaceToolRegistryState", { | ||
| 156 | workspaceId: args.workspaceId, | ||
| 157 | signature: "", | ||
| 158 | readyBuildId: args.buildId, | ||
| 159 | buildingBuildId: undefined, | ||
| 160 | createdAt: now, | ||
| 161 | updatedAt: now, | ||
| 162 | }); | ||
| 163 | return; | ||
| 164 | } | ||
| 165 | |||
| 166 | if (state.buildingBuildId !== args.buildId) { | ||
| 167 | // Another build started; ignore finishing this one. | ||
| 168 | return; | ||
| 169 | } | ||
| 170 | |||
| 171 | await ctx.db.patch(state._id, { | ||
| 172 | readyBuildId: args.buildId, | ||
| 173 | buildingBuildId: undefined, | ||
| 174 | updatedAt: now, | ||
| 175 | }); | ||
| 176 | }, | ||
| 177 | }); | ||
| 178 | |||
| 179 | export const getToolByPath = internalQuery({ | ||
| 180 | args: { | ||
| 181 | workspaceId: v.id("workspaces"), | ||
| 182 | buildId: v.string(), | ||
| 183 | path: v.string(), | ||
| 184 | }, | ||
| 185 | handler: async (ctx, args) => { | ||
| 186 | const entry = await ctx.db | ||
| 187 | .query("workspaceToolRegistry") | ||
| 188 | .withIndex("by_workspace_build_path", (q) => | ||
| 189 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", args.path), | ||
| 190 | ) | ||
| 191 | .first(); | ||
| 192 | |||
| 193 | if (!entry) return null; | ||
| 194 | |||
| 195 | return { | ||
| 196 | path: entry.path, | ||
| 197 | preferredPath: entry.preferredPath, | ||
| 198 | approval: entry.approval, | ||
| 199 | namespace: entry.namespace, | ||
| 200 | aliases: entry.aliases, | ||
| 201 | description: entry.description, | ||
| 202 | source: entry.source, | ||
| 203 | displayInput: entry.displayInput, | ||
| 204 | displayOutput: entry.displayOutput, | ||
| 205 | requiredInputKeys: entry.requiredInputKeys, | ||
| 206 | previewInputKeys: entry.previewInputKeys, | ||
| 207 | serializedToolJson: entry.serializedToolJson, | ||
| 208 | }; | ||
| 209 | }, | ||
| 210 | }); | ||
| 211 | |||
| 212 | export const listToolsByNamespace = internalQuery({ | ||
| 213 | args: { | ||
| 214 | workspaceId: v.id("workspaces"), | ||
| 215 | buildId: v.string(), | ||
| 216 | namespace: v.string(), | ||
| 217 | limit: v.number(), | ||
| 218 | }, | ||
| 219 | handler: async (ctx, args) => { | ||
| 220 | const namespace = args.namespace.trim().toLowerCase(); | ||
| 221 | const limit = Math.max(1, Math.min(200, Math.floor(args.limit))); | ||
| 222 | if (!namespace) return []; | ||
| 223 | |||
| 224 | const entries = await ctx.db | ||
| 225 | .query("workspaceToolRegistry") | ||
| 226 | .withIndex("by_workspace_build_namespace", (q) => | ||
| 227 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("namespace", namespace), | ||
| 228 | ) | ||
| 229 | .take(limit); | ||
| 230 | |||
| 231 | return entries.map((entry) => ({ | ||
| 232 | path: entry.path, | ||
| 233 | preferredPath: entry.preferredPath, | ||
| 234 | aliases: entry.aliases, | ||
| 235 | description: entry.description, | ||
| 236 | approval: entry.approval, | ||
| 237 | source: entry.source, | ||
| 238 | displayInput: entry.displayInput, | ||
| 239 | displayOutput: entry.displayOutput, | ||
| 240 | requiredInputKeys: entry.requiredInputKeys, | ||
| 241 | previewInputKeys: entry.previewInputKeys, | ||
| 242 | })); | ||
| 243 | }, | ||
| 244 | }); | ||
| 245 | |||
| 246 | export const getToolsByNormalizedPath = internalQuery({ | ||
| 247 | args: { | ||
| 248 | workspaceId: v.id("workspaces"), | ||
| 249 | buildId: v.string(), | ||
| 250 | normalizedPath: v.string(), | ||
| 251 | limit: v.number(), | ||
| 252 | }, | ||
| 253 | handler: async (ctx, args) => { | ||
| 254 | const normalized = args.normalizedPath.trim().toLowerCase(); | ||
| 255 | if (!normalized) return []; | ||
| 256 | const limit = Math.max(1, Math.min(10, Math.floor(args.limit))); | ||
| 257 | |||
| 258 | const entries = await ctx.db | ||
| 259 | .query("workspaceToolRegistry") | ||
| 260 | .withIndex("by_workspace_build_normalized", (q) => | ||
| 261 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("normalizedPath", normalized), | ||
| 262 | ) | ||
| 263 | .take(limit); | ||
| 264 | |||
| 265 | return entries.map((entry) => ({ | ||
| 266 | path: entry.path, | ||
| 267 | preferredPath: entry.preferredPath, | ||
| 268 | approval: entry.approval, | ||
| 269 | serializedToolJson: entry.serializedToolJson, | ||
| 270 | })); | ||
| 271 | }, | ||
| 272 | }); | ||
| 273 | |||
| 274 | export const searchTools = internalQuery({ | ||
| 275 | args: { | ||
| 276 | workspaceId: v.id("workspaces"), | ||
| 277 | buildId: v.string(), | ||
| 278 | query: v.string(), | ||
| 279 | limit: v.number(), | ||
| 280 | }, | ||
| 281 | handler: async (ctx, args) => { | ||
| 282 | const term = args.query.trim(); | ||
| 283 | if (!term) return []; | ||
| 284 | |||
| 285 | const limit = Math.max(1, Math.min(50, Math.floor(args.limit))); | ||
| 286 | const hits = await ctx.db | ||
| 287 | .query("workspaceToolRegistry") | ||
| 288 | .withSearchIndex("search_text", (q) => | ||
| 289 | q.search("searchText", term) | ||
| 290 | .eq("workspaceId", args.workspaceId) | ||
| 291 | .eq("buildId", args.buildId), | ||
| 292 | ) | ||
| 293 | .take(limit); | ||
| 294 | |||
| 295 | return hits.map((entry) => ({ | ||
| 296 | path: entry.path, | ||
| 297 | preferredPath: entry.preferredPath, | ||
| 298 | aliases: entry.aliases, | ||
| 299 | description: entry.description, | ||
| 300 | approval: entry.approval, | ||
| 301 | source: entry.source, | ||
| 302 | displayInput: entry.displayInput, | ||
| 303 | displayOutput: entry.displayOutput, | ||
| 304 | requiredInputKeys: entry.requiredInputKeys, | ||
| 305 | previewInputKeys: entry.previewInputKeys, | ||
| 306 | })); | ||
| 307 | }, | ||
| 308 | }); | ||
| 309 | |||
| 310 | export const listNamespaces = internalQuery({ | ||
| 311 | args: { | ||
| 312 | workspaceId: v.id("workspaces"), | ||
| 313 | buildId: v.string(), | ||
| 314 | limit: v.number(), | ||
| 315 | }, | ||
| 316 | handler: async (ctx, args) => { | ||
| 317 | const limit = Math.max(1, Math.min(200, Math.floor(args.limit))); | ||
| 318 | const entries = await ctx.db | ||
| 319 | .query("workspaceToolNamespaces") | ||
| 320 | .withIndex("by_workspace_build", (q) => | ||
| 321 | q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId), | ||
| 322 | ) | ||
| 323 | .take(limit); | ||
| 324 | |||
| 325 | return entries.map((entry) => ({ | ||
| 326 | namespace: entry.namespace, | ||
| 327 | toolCount: entry.toolCount, | ||
| 328 | samplePaths: entry.samplePaths, | ||
| 329 | })); | ||
| 330 | }, | ||
| 331 | }); | ||
| @@ -18,24 +18,38 @@ function redactCredential<T extends { secretJson: Record<string, unknown> }>(cre · 17 unmodified lines | |||
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | export const bootstrapAnonymousSession = mutation({ | 20 | export const bootstrapAnonymousSession = mutation({ |
| 21 | args: { sessionId: v.optional(v.string()) }, | 21 | args: { |
| 22 | handler: async (ctx) => { | 22 | sessionId: v.optional(v.string()), |
| 23 | actorId: v.optional(v.string()), | ||
| 24 | }, | ||
| 25 | handler: async (ctx, args) => { | ||
| 23 | const identity = await ctx.auth.getUserIdentity(); | 26 | const identity = await ctx.auth.getUserIdentity(); |
| 24 | if (!identity) { | 27 | const requestedSessionId = args.sessionId?.trim(); |
| 25 | throw new Error("Anonymous auth token is required"); | 28 | const requestedActorId = args.actorId?.trim(); |
| 26 | } | 29 | const isAnonymousIdentityRequest = Boolean(identity && isAnonymousIdentity(identity)); |
| 27 | 30 | ||
| 28 | if (!isAnonymousIdentity(identity)) { | 31 | if (!isAnonymousIdentityRequest && identity) { |
| 29 | throw new Error("Anonymous auth token is required"); | 32 | throw new Error("Anonymous auth token is required"); |
| 30 | } | 33 | } |
| 31 | 34 | ||
| 32 | const actorId = identity.subject; | 35 | const actorId = isAnonymousIdentityRequest |
| 33 | if (!actorId.startsWith("anon_")) { | 36 | ? identity!.subject |
| 37 | : requestedActorId; | ||
| 38 | |||
| 39 | if (actorId && !actorId.startsWith("anon_")) { | ||
| 34 | throw new Error("Anonymous token subject must use anon_* actor ids"); | 40 | throw new Error("Anonymous token subject must use anon_* actor ids"); |
| 35 | } | 41 | } |
| 36 | const stableSessionId = `anon_session_${actorId}`; | 42 | |
| 43 | if (!actorId && !requestedSessionId) { | ||
| 44 | throw new Error("Anonymous auth token is required"); | ||
| 45 | } | ||
| 46 | |||
| 47 | const sessionId = actorId | ||
| 48 | ? `anon_session_${actorId}` | ||
| 49 | : requestedSessionId; | ||
| 50 | |||
| 37 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { | 51 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { |
| 38 | sessionId: stableSessionId, | 52 | sessionId, |
| 39 | actorId, | 53 | actorId, |
| 40 | clientId: "web", | 54 | clientId: "web", |
| 41 | }); | 55 | }); |
| @@ -17,6 +17,9 @@ const tables = [ · 16 unmodified lines | |||
| 17 | "tasks", | 17 | "tasks", |
| 18 | "openApiSpecCache", | 18 | "openApiSpecCache", |
| 19 | "toolSources", | 19 | "toolSources", |
| 20 | "workspaceToolRegistryState", | ||
| 21 | "workspaceToolRegistry", | ||
| 22 | "workspaceToolNamespaces", | ||
| 20 | "workspaceMembers", | 23 | "workspaceMembers", |
| 21 | "workspaceToolCache", | 24 | "workspaceToolCache", |
| 22 | "workspaces", | 25 | "workspaces", |