History

Add Convex OpenAPI route and method-aware functions

Rhys Sullivan committed 9 months ago

Commit 7e6277a1 parent 6da096021 files · +974 −28Browse files

Unified · Split

21 files
  1. executor/packages/database/convex/_generated/api.d.ts +18 −1
  2. executor/packages/database/convex/accounts.ts +1 −0
  3. executor/packages/database/convex/app.ts +4 −3
  4. executor/packages/database/convex/auth.ts +4 −2
  5. executor/packages/database/convex/billing.ts +7 −4
  6. executor/packages/database/convex/credentialsNode.ts +3 −2
  7. executor/packages/database/convex/executor.ts +5 −3
  8. executor/packages/database/convex/executorNode.ts +4 −2
  9. executor/packages/database/convex/http.ts +6 −0
  10. executor/packages/database/convex/http/openapi_handler.ts +339 −0
  11. executor/packages/database/convex/http/openapi_spec.test.ts +97 −0
  12. executor/packages/database/convex/http/openapi_spec.ts +282 −0
  13. executor/packages/database/convex/http/openapi_spec_registry.ts +103 −0
  14. executor/packages/database/convex/invites.ts +3 −0
  15. executor/packages/database/convex/organizationMembers.ts +4 −0
  16. executor/packages/database/convex/organizations.ts +5 −0
  17. executor/packages/database/convex/runtimeCallbacks.ts +7 −4
  18. executor/packages/database/convex/schema.ts +3 −1
  19. executor/packages/database/convex/toolRegistry.ts +63 −3
  20. executor/packages/database/convex/workspace.ts +13 −3
  21. executor/packages/database/convex/workspaces.ts +3 −0
executor/packages/database/convex/_generated/api.d.ts+18−1
@@ -1161,12 +1161,18 @@ export declare const internal: { · 1160 unmodified lines
1161 { buildId: string; cursor?: string; workspaceId: Id<"workspaces"> },1161 { buildId: string; cursor?: string; workspaceId: Id<"workspaces"> },
1162 any1162 any
1163 >;1163 >;
1164 finishBuild: FunctionReference<1164 failBuild: FunctionReference<
1165 "mutation",1165 "mutation",
1166 "internal",1166 "internal",
1167 { buildId: string; workspaceId: Id<"workspaces"> },1167 { buildId: string; workspaceId: Id<"workspaces"> },
1168 any1168 any
1169 >;1169 >;
1170 finishBuild: FunctionReference<
1171 "mutation",
1172 "internal",
1173 { buildId: string; signature: string; workspaceId: Id<"workspaces"> },
1174 any
1175 >;
1170 getState: FunctionReference<1176 getState: FunctionReference<
1171 "query",1177 "query",
1172 "internal",1178 "internal",
@@ -1274,6 +1280,17 @@ export declare const internal: { · 101 unmodified lines
1274 },1280 },
1275 any1281 any
1276 >;1282 >;
1283 updateBuildMetadata: FunctionReference<
1284 "mutation",
1285 "internal",
1286 {
1287 buildId: string;
1288 typesStorageId?: Id<"_storage">;
1289 warnings: Array<string>;
1290 workspaceId: Id<"workspaces">;
1291 },
1292 any
1293 >;
1277 };1294 };
1278 workspaceAuthInternal: {1295 workspaceAuthInternal: {
1279 getWorkspaceAccessForAnonymousSubject: FunctionReference<1296 getWorkspaceAccessForAnonymousSubject: FunctionReference<
executor/packages/database/convex/accounts.ts+1−0
@@ -2,6 +2,7 @@ import { authedMutation } from "../../core/src/function-builders"; · 1 unmodified line
2import { deleteCurrentAccountHandler } from "../src/accounts/delete-current-account";2import { deleteCurrentAccountHandler } from "../src/accounts/delete-current-account";
3 3
4export const deleteCurrentAccount = authedMutation({4export const deleteCurrentAccount = authedMutation({
5 method: "POST",
5 args: {},6 args: {},
6 handler: deleteCurrentAccountHandler,7 handler: deleteCurrentAccountHandler,
7});8});
executor/packages/database/convex/app.ts+4−3
@@ -1,9 +1,9 @@
1import { query } from "./_generated/server";1import { customQuery, optionalAccountQuery } from "../../core/src/function-builders";
2import { optionalAccountQuery } from "../../core/src/function-builders";
3 2
4const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);3const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY);
5 4
6export const getClientConfig = query({5export const getClientConfig = customQuery({
6 method: "GET",
7 args: {},7 args: {},
8 handler: async () => {8 handler: async () => {
9 return {9 return {
@@ -19,6 +19,7 @@ export const getClientConfig = query({ · 9 unmodified lines
19});19});
20 20
21export const getCurrentAccount = optionalAccountQuery({21export const getCurrentAccount = optionalAccountQuery({
22 method: "GET",
22 args: {},23 args: {},
23 handler: async (ctx) => ctx.account,24 handler: async (ctx) => ctx.account,
24});25});
executor/packages/database/convex/auth.ts+4−2
@@ -1,7 +1,8 @@
1import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";1import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit";
2import { components, internal } from "./_generated/api";2import { components, internal } from "./_generated/api";
3import type { DataModel } from "./_generated/dataModel.d.ts";3import type { DataModel } from "./_generated/dataModel.d.ts";
4import { internalMutation, mutation } from "./_generated/server";4import { internalMutation } from "./_generated/server";
5import { customMutation } from "../../core/src/function-builders";
5import { bootstrapCurrentWorkosAccountImpl } from "../src/auth/bootstrap";6import { bootstrapCurrentWorkosAccountImpl } from "../src/auth/bootstrap";
6import { workosEventHandlers } from "../src/auth/event_handlers";7import { workosEventHandlers } from "../src/auth/event_handlers";
7 8
@@ -39,7 +40,8 @@ export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ · 31 unmodified lines
39 handler: async () => null,40 handler: async () => null,
40});41});
41 42
42export const bootstrapCurrentWorkosAccount = mutation({43export const bootstrapCurrentWorkosAccount = customMutation({
44 method: "POST",
43 args: {},45 args: {},
44 handler: bootstrapCurrentWorkosAccountImpl,46 handler: bootstrapCurrentWorkosAccountImpl,
45});47});
executor/packages/database/convex/billing.ts+7−4
@@ -1,8 +1,7 @@
1import { StripeSubscriptions } from "@convex-dev/stripe";1import { StripeSubscriptions } from "@convex-dev/stripe";
2import { v } from "convex/values";2import { v } from "convex/values";
3import { components, internal } from "./_generated/api";3import { components, internal } from "./_generated/api";
4import { action } from "./_generated/server";4import { customAction, organizationMutation, organizationQuery } from "../../core/src/function-builders";
5import { organizationMutation, organizationQuery } from "../../core/src/function-builders";
6import {5import {
7 createCustomerPortalHandler,6 createCustomerPortalHandler,
8 createSubscriptionCheckoutHandler,7 createSubscriptionCheckoutHandler,
@@ -13,13 +12,15 @@ import { · 4 unmodified lines
13const stripeClient = new StripeSubscriptions(components.stripe, {});12const stripeClient = new StripeSubscriptions(components.stripe, {});
14 13
15export const getSummary = organizationQuery({14export const getSummary = organizationQuery({
15 method: "GET",
16 args: {},16 args: {},
17 handler: async (ctx) => {17 handler: async (ctx) => {
18 return await getBillingSummaryHandler(ctx, components);18 return await getBillingSummaryHandler(ctx, components);
19 },19 },
20});20});
21 21
22export const createSubscriptionCheckout = action({22export const createSubscriptionCheckout = customAction({
23 method: "POST",
23 args: {24 args: {
24 organizationId: v.id("organizations"),25 organizationId: v.id("organizations"),
25 priceId: v.string(),26 priceId: v.string(),
@@ -32,7 +33,8 @@ export const createSubscriptionCheckout = action({ · 6 unmodified lines
32 },33 },
33});34});
34 35
35export const createCustomerPortal = action({36export const createCustomerPortal = customAction({
37 method: "POST",
36 args: {38 args: {
37 organizationId: v.id("organizations"),39 organizationId: v.id("organizations"),
38 returnUrl: v.optional(v.string()),40 returnUrl: v.optional(v.string()),
@@ -47,6 +49,7 @@ export const createCustomerPortal = action({ · 8 unmodified lines
47});49});
48 50
49export const retrySeatSync = organizationMutation({51export const retrySeatSync = organizationMutation({
52 method: "POST",
50 requireBillingAdmin: true,53 requireBillingAdmin: true,
51 args: {},54 args: {},
52 handler: async (ctx) => {55 handler: async (ctx) => {
executor/packages/database/convex/credentialsNode.ts+3−2
@@ -2,7 +2,7 @@ · 1 unmodified line
2 2
3import { v } from "convex/values";3import { v } from "convex/values";
4import { internal } from "./_generated/api";4import { internal } from "./_generated/api";
5import { action } from "./_generated/server";5import { customAction } from "../../core/src/function-builders";
6import { upsertCredentialHandler } from "../src/credentials-node/upsert-credential";6import { upsertCredentialHandler } from "../src/credentials-node/upsert-credential";
7import {7import {
8 credentialProviderValidator,8 credentialProviderValidator,
@@ -10,7 +10,8 @@ import { · 1 unmodified line
10 jsonObjectValidator,10 jsonObjectValidator,
11} from "../src/database/validators";11} from "../src/database/validators";
12 12
13export const upsertCredential = action({13export const upsertCredential = customAction({
14 method: "POST",
14 args: {15 args: {
15 id: v.optional(v.string()),16 id: v.optional(v.string()),
16 workspaceId: v.id("workspaces"),17 workspaceId: v.id("workspaces"),
executor/packages/database/convex/executor.ts+5−3
@@ -1,7 +1,7 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { action, internalMutation } from "./_generated/server";3import { internalMutation } from "./_generated/server";
4import { workspaceMutation } from "../../core/src/function-builders";4import { customAction, workspaceMutation } from "../../core/src/function-builders";
5import {5import {
6 completeRuntimeRunHandler,6 completeRuntimeRunHandler,
7 createTaskHandler,7 createTaskHandler,
@@ -11,7 +11,8 @@ import { · 3 unmodified lines
11} from "../src/executor/handlers";11} from "../src/executor/handlers";
12import { jsonObjectValidator } from "../src/database/validators";12import { jsonObjectValidator } from "../src/database/validators";
13 13
14export const createTask = action({14export const createTask = customAction({
15 method: "POST",
15 args: {16 args: {
16 code: v.string(),17 code: v.string(),
17 timeoutMs: v.optional(v.number()),18 timeoutMs: v.optional(v.number()),
@@ -45,6 +46,7 @@ export const createTaskInternal = internalMutation({ · 27 unmodified lines
45});46});
46 47
47export const resolveApproval = workspaceMutation({48export const resolveApproval = workspaceMutation({
49 method: "POST",
48 args: {50 args: {
49 approvalId: v.string(),51 approvalId: v.string(),
50 decision: v.union(v.literal("approved"), v.literal("denied")),52 decision: v.union(v.literal("approved"), v.literal("denied")),
executor/packages/database/convex/executorNode.ts+4−2
@@ -1,7 +1,7 @@
1"use node";1"use node";
2 2
3import { v } from "convex/values";3import { v } from "convex/values";
4import { action, internalAction } from "./_generated/server";4import { internalAction } from "./_generated/server";
5import type {5import type {
6 ToolCallResult,6 ToolCallResult,
7 ToolDescriptor,7 ToolDescriptor,
@@ -18,8 +18,10 @@ import { · 10 unmodified lines
18import { runQueuedTask } from "../src/runtime/task_runner";18import { runQueuedTask } from "../src/runtime/task_runner";
19import { handleExternalToolCallRequest } from "../src/runtime/external_tool_call";19import { handleExternalToolCallRequest } from "../src/runtime/external_tool_call";
20import { jsonObjectValidator } from "../src/database/validators";20import { jsonObjectValidator } from "../src/database/validators";
21import { customAction } from "../../core/src/function-builders";
21 22
22export const listToolsWithWarnings = action({23export const listToolsWithWarnings = customAction({
24 method: "POST",
23 args: {25 args: {
24 workspaceId: v.id("workspaces"),26 workspaceId: v.id("workspaces"),
25 accountId: v.optional(v.id("accounts")),27 accountId: v.optional(v.id("accounts")),
executor/packages/database/convex/http.ts+6−0
@@ -4,6 +4,7 @@ import { components } from "./_generated/api"; · 3 unmodified lines
4import { authKit } from "./auth";4import { authKit } from "./auth";
5import { anonymousJwksHandler, anonymousTokenHandler } from "./http/anonymous_auth";5import { anonymousJwksHandler, anonymousTokenHandler } from "./http/anonymous_auth";
6import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler";6import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler";
7import { openApiHandler } from "./http/openapi_handler";
7import {8import {
8 oauthAuthorizationServerHandler,9 oauthAuthorizationServerHandler,
9 oauthProtectedResourceHandler,10 oauthProtectedResourceHandler,
@@ -29,4 +30,9 @@ http.route({ path: "/.well-known/jwks.json", method: "GET", handler: anonymousJw · 19 unmodified lines
29http.route({ path: "/auth/anonymous/token", method: "POST", handler: anonymousTokenHandler });30http.route({ path: "/auth/anonymous/token", method: "POST", handler: anonymousTokenHandler });
30http.route({ path: "/auth/anonymous/token", method: "GET", handler: anonymousTokenHandler });31http.route({ path: "/auth/anonymous/token", method: "GET", handler: anonymousTokenHandler });
31 32
33http.route({ path: "/api", method: "POST", handler: openApiHandler });
34http.route({ path: "/api", method: "GET", handler: openApiHandler });
35http.route({ pathPrefix: "/api/", method: "POST", handler: openApiHandler });
36http.route({ pathPrefix: "/api/", method: "GET", handler: openApiHandler });
37
32export default http;38export default http;
executor/packages/database/convex/http/openapi_handler.ts+339−0
@@ -0,0 +1,339 @@
1import { makeFunctionReference } from "convex/server";
2import { httpAction } from "../_generated/server";
3import { buildOpenApiDocument } from "./openapi_spec";
4import { collectPublicFunctionSpecs } from "./openapi_spec_registry";
5
6type FunctionType = "query" | "mutation" | "action";
7
8type OpenApiPayload = {
9 path?: string;
10 function?: string;
11 args?: unknown;
12 format?: string;
13 type?: string;
14 [key: string]: unknown;
15};
16
17type HttpActionCtx = Parameters<Parameters<typeof httpAction>[0]>[0];
18
19type ActionError = Error & {
20 data?: unknown;
21};
22
23const JSON_FORMAT = "json";
24const OPENAPI_SPEC_PATH = "/api/openapi.json";
25const API_RUN_PREFIX = "/api/run/";
26const API_PREFIX = "/api/";
27const CONTROL_QUERY_PARAMS = new Set(["format", "path", "function", "type", "args"]);
28
29function parseRunPathToFunctionIdentifier(rawPath: string): string {
30 const normalizedPath = rawPath.replace(/^\/+|\/+$/g, "");
31 if (!normalizedPath) {
32 throw new Error("Function path is required");
33 }
34
35 if (normalizedPath.includes(":")) {
36 const [modulePathRaw, exportNameRaw] = decodeURIComponent(normalizedPath).split(":");
37 if (!modulePathRaw) {
38 throw new Error("Function path is required");
39 }
40
41 const modulePath = modulePathRaw.endsWith(".ts")
42 ? modulePathRaw.slice(0, -3)
43 : modulePathRaw;
44
45 if (!exportNameRaw || exportNameRaw === "default") {
46 return modulePath;
47 }
48
49 return `${modulePath}:${exportNameRaw}`;
50 }
51
52 if (normalizedPath.endsWith(".ts")) {
53 return decodeURIComponent(normalizedPath.slice(0, -3));
54 }
55
56 const segments = normalizedPath.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
57 if (segments.length === 0) {
58 throw new Error("Function path is required");
59 }
60
61 if (segments.length === 1) {
62 return segments[0]!;
63 }
64
65 const exportName = segments[segments.length - 1]!;
66 const modulePath = segments.slice(0, -1).join("/");
67 return exportName === "default" ? modulePath : `${modulePath}:${exportName}`;
68}
69
70function functionIdentifierFromUrlPath(pathname: string): string | undefined {
71 if (pathname.startsWith(API_RUN_PREFIX)) {
72 return parseRunPathToFunctionIdentifier(pathname.slice(API_RUN_PREFIX.length));
73 }
74
75 if (pathname === "/api" || pathname === "/api/") {
76 return undefined;
77 }
78
79 if (pathname.startsWith(API_PREFIX)) {
80 return parseRunPathToFunctionIdentifier(pathname.slice(API_PREFIX.length));
81 }
82
83 return undefined;
84}
85
86function toActionError(error: unknown): ActionError {
87 if (error instanceof Error) {
88 return error as ActionError;
89 }
90 return new Error("Unknown function error");
91}
92
93function isTypeLookupError(error: unknown, type: FunctionType): boolean {
94 const message = toActionError(error).message;
95 return message.startsWith(`No ${type} named`)
96 || message.startsWith("Could not find public function")
97 || message.startsWith("Could not find function")
98 || message.includes(`is not a ${type}`)
99 || message.includes("Function not found");
100}
101
102function isLookupError(error: unknown): boolean {
103 const message = toActionError(error).message;
104 return message.startsWith("No query named")
105 || message.startsWith("No mutation named")
106 || message.startsWith("No action named")
107 || message.startsWith("Could not find public function")
108 || message.startsWith("Could not find function")
109 || message.includes("is not a query")
110 || message.includes("is not a mutation")
111 || message.includes("is not an action")
112 || message.includes("Function not found");
113}
114
115function errorResponse(errorMessage: string, status = 400): Response {
116 return Response.json(
117 {
118 errorMessage,
119 },
120 { status },
121 );
122}
123
124function functionErrorResponse(error: unknown, status?: number): Response {
125 const actionError = toActionError(error);
126 const resolvedStatus = status ?? (isLookupError(actionError) ? 404 : 500);
127
128 return Response.json(
129 {
130 errorMessage: actionError.message,
131 ...(actionError.data === undefined ? {} : { errorData: actionError.data }),
132 },
133 { status: resolvedStatus },
134 );
135}
136
137function successResponse(value: unknown): Response {
138 return Response.json(value === undefined ? null : value, { status: 200 });
139}
140
141function isRecord(value: unknown): value is Record<string, unknown> {
142 return typeof value === "object" && value !== null && !Array.isArray(value);
143}
144
145function isFunctionType(value: unknown): value is FunctionType {
146 return value === "query" || value === "mutation" || value === "action";
147}
148
149function parseQueryValue(rawValue: string): unknown {
150 const value = rawValue.trim();
151 const looksJson = value.startsWith("{")
152 || value.startsWith("[")
153 || value === "true"
154 || value === "false"
155 || value === "null"
156 || /^-?\d+(?:\.\d+)?$/.test(value)
157 || /^".*"$/.test(value);
158
159 if (looksJson) {
160 try {
161 return JSON.parse(value);
162 } catch {
163 return rawValue;
164 }
165 }
166
167 return rawValue;
168}
169
170function mergeQueryParam(args: Record<string, unknown>, key: string, value: unknown): void {
171 const existing = args[key];
172 if (existing === undefined) {
173 args[key] = value;
174 return;
175 }
176
177 if (Array.isArray(existing)) {
178 existing.push(value);
179 return;
180 }
181
182 args[key] = [existing, value];
183}
184
185async function parsePostPayload(request: Request): Promise<OpenApiPayload> {
186 const rawBody = await request.text();
187 if (rawBody.trim().length === 0) {
188 return {};
189 }
190
191 const parsed = JSON.parse(rawBody) as unknown;
192 if (!isRecord(parsed)) {
193 throw new Error("Request body must be a JSON object");
194 }
195
196 return parsed as OpenApiPayload;
197}
198
199function parseGetPayload(url: URL): OpenApiPayload {
200 const format = url.searchParams.get("format") ?? undefined;
201 const path = url.searchParams.get("path") ?? undefined;
202 const type = url.searchParams.get("type") ?? undefined;
203 const inlineArgs: Record<string, unknown> = {};
204
205 for (const [key, rawValue] of url.searchParams.entries()) {
206 if (CONTROL_QUERY_PARAMS.has(key)) {
207 continue;
208 }
209 mergeQueryParam(inlineArgs, key, parseQueryValue(rawValue));
210 }
211
212 const argsParam = url.searchParams.get("args");
213 let args: Record<string, unknown> | undefined;
214 if (argsParam) {
215 const parsedArgs = JSON.parse(argsParam) as unknown;
216 if (!isRecord(parsedArgs)) {
217 throw new Error("args query parameter must be a JSON object");
218 }
219 args = {
220 ...parsedArgs,
221 ...inlineArgs,
222 };
223 } else if (Object.keys(inlineArgs).length > 0) {
224 args = inlineArgs;
225 }
226
227 return {
228 ...(type ? { type } : {}),
229 ...(format ? { format } : {}),
230 ...(path ? { path } : {}),
231 ...(args ? { args } : {}),
232 };
233}
234
235function extractArgs(payload: OpenApiPayload): Record<string, unknown> {
236 if (payload.args !== undefined) {
237 if (!isRecord(payload.args)) {
238 throw new Error("args must be an object");
239 }
240 return payload.args;
241 }
242
243 const directArgs: Record<string, unknown> = {};
244 for (const [key, value] of Object.entries(payload)) {
245 if (CONTROL_QUERY_PARAMS.has(key)) {
246 continue;
247 }
248 directArgs[key] = value;
249 }
250 return directArgs;
251}
252
253async function runAsType(
254 ctx: HttpActionCtx,
255 type: FunctionType,
256 functionIdentifier: string,
257 args: Record<string, unknown>,
258): Promise<unknown> {
259 switch (type) {
260 case "query":
261 return await ctx.runQuery(makeFunctionReference<"query">(functionIdentifier), args);
262 case "mutation":
263 return await ctx.runMutation(makeFunctionReference<"mutation">(functionIdentifier), args);
264 case "action":
265 return await ctx.runAction(makeFunctionReference<"action">(functionIdentifier), args);
266 }
267}
268
269export const openApiHandler = httpAction(async (ctx, request) => {
270 try {
271 const url = new URL(request.url);
272
273 if (request.method === "GET" && url.pathname === OPENAPI_SPEC_PATH) {
274 const functions = await collectPublicFunctionSpecs();
275 return Response.json(buildOpenApiDocument(functions, url.origin), {
276 status: 200,
277 headers: {
278 "content-type": "application/json; charset=utf-8",
279 },
280 });
281 }
282
283 const urlIdentifier = functionIdentifierFromUrlPath(url.pathname);
284
285 const payload = request.method === "GET"
286 ? parseGetPayload(url)
287 : await parsePostPayload(request);
288
289 const payloadIdentifier = payload.path ?? payload.function;
290 const functionIdentifier = urlIdentifier
291 ?? (typeof payloadIdentifier === "string" ? parseRunPathToFunctionIdentifier(payloadIdentifier) : undefined);
292
293 if (!functionIdentifier) {
294 return errorResponse("Function path is required. Provide /api/run/<function> or a body path field.");
295 }
296
297 const format = payload.format ?? JSON_FORMAT;
298 if (format !== JSON_FORMAT) {
299 return errorResponse(`Only format=\"${JSON_FORMAT}\" is supported`);
300 }
301
302 const args = extractArgs(payload);
303
304 if (payload.type && !isFunctionType(payload.type)) {
305 return errorResponse("type must be one of: query, mutation, action");
306 }
307
308 if (payload.type && isFunctionType(payload.type)) {
309 try {
310 const value = await runAsType(ctx, payload.type, functionIdentifier, args);
311 return successResponse(value);
312 } catch (error) {
313 return functionErrorResponse(error, isTypeLookupError(error, payload.type) ? 404 : 500);
314 }
315 }
316
317 const attemptedTypes: FunctionType[] = request.method === "GET"
318 ? ["query"]
319 : ["query", "mutation", "action"];
320
321 let lookupError: unknown;
322 for (const type of attemptedTypes) {
323 try {
324 const value = await runAsType(ctx, type, functionIdentifier, args);
325 return successResponse(value);
326 } catch (error) {
327 if (isTypeLookupError(error, type)) {
328 lookupError = error;
329 continue;
330 }
331 return functionErrorResponse(error, 500);
332 }
333 }
334
335 return functionErrorResponse(lookupError ?? new Error(`Function not found: ${functionIdentifier}`), 404);
336 } catch (error) {
337 return errorResponse(error instanceof Error ? error.message : "Invalid OpenAPI request payload");
338 }
339});
executor/packages/database/convex/http/openapi_spec.test.ts+97−0
@@ -0,0 +1,97 @@
1import { describe, expect, test } from "bun:test";
2import { buildOpenApiDocument, schemaFromValidator, type OpenApiFunctionSpec } from "./openapi_spec";
3import { collectPublicFunctionSpecs } from "./openapi_spec_registry";
4
5describe("openapi_spec", () => {
6 test("builds object schemas with required fields", () => {
7 const schema = schemaFromValidator({
8 type: "object",
9 value: {
10 workspaceId: {
11 fieldType: { type: "id", tableName: "workspaces" },
12 optional: false,
13 },
14 sessionId: {
15 fieldType: { type: "string" },
16 optional: true,
17 },
18 },
19 });
20
21 expect(schema).toEqual({
22 type: "object",
23 required: ["workspaceId"],
24 properties: {
25 workspaceId: {
26 type: "string",
27 description: 'ID from table "workspaces"',
28 },
29 sessionId: {
30 type: "string",
31 },
32 },
33 });
34 });
35
36 test("parses stringified validator json", () => {
37 const schema = schemaFromValidator(
38 '{"type":"object","value":{"name":{"fieldType":{"type":"string"},"optional":false}}}',
39 );
40
41 expect(schema).toEqual({
42 type: "object",
43 required: ["name"],
44 properties: {
45 name: {
46 type: "string",
47 },
48 },
49 });
50 });
51
52 test("generates GET and POST operations from function specs", () => {
53 const functions: OpenApiFunctionSpec[] = [
54 {
55 identifier: "app:getClientConfig",
56 functionType: "query",
57 method: "GET",
58 args: { type: "object", value: {} },
59 returns: { type: "object", value: {} },
60 },
61 {
62 identifier: "workspace:upsertCredential",
63 functionType: "mutation",
64 method: "POST",
65 args: { type: "object", value: {} },
66 returns: { type: "any" },
67 },
68 ];
69
70 const document = buildOpenApiDocument(functions, "https://example.convex.cloud");
71 const paths = document.paths as Record<string, Record<string, unknown>>;
72
73 expect(paths["/api/run/app/getClientConfig"]?.get).toBeDefined();
74 expect(paths["/api/run/workspace/upsertCredential"]?.post).toBeDefined();
75 });
76
77 test("collects public function specs from convex modules", async () => {
78 const specs = await collectPublicFunctionSpecs();
79 const identifiers = new Set(specs.map((spec) => spec.identifier));
80
81 expect(identifiers.has("app:getClientConfig")).toBe(true);
82 expect(identifiers.has("workspace:listTasks")).toBe(true);
83 expect(identifiers.has("executor:createTask")).toBe(true);
84
85 const updateRole = specs.find((spec) => spec.identifier === "organizationMembers:updateRole");
86 expect(typeof updateRole?.args).toBe("string");
87
88 const updateRoleDoc = buildOpenApiDocument([updateRole!], "https://example.convex.cloud");
89 const schemas = (updateRoleDoc.components as { schemas: Record<string, unknown> }).schemas;
90 const argsSchema = schemas.Request_organizationMembers_updateRole as {
91 properties?: Record<string, unknown>;
92 };
93
94 expect(argsSchema.properties?.accountId).toBeDefined();
95 expect(argsSchema.properties?.role).toBeDefined();
96 });
97});
executor/packages/database/convex/http/openapi_spec.ts+282−0
@@ -0,0 +1,282 @@
1type FunctionType = "query" | "mutation" | "action";
2
3const CONTROL_QUERY_PARAMS = new Set(["format", "args", "path", "function", "type"]);
4
5export type ValidatorJson = {
6 type: string;
7 [key: string]: unknown;
8};
9
10export type OpenApiFunctionSpec = {
11 identifier: string;
12 functionType: FunctionType;
13 method: "GET" | "POST";
14 args?: ValidatorJson | string | null;
15 returns?: ValidatorJson | string | null;
16};
17
18function toRecord(value: unknown): Record<string, unknown> {
19 return typeof value === "object" && value !== null && !Array.isArray(value)
20 ? (value as Record<string, unknown>)
21 : {};
22}
23
24function schemaId(identifier: string): string {
25 return identifier.replace(/[^a-zA-Z0-9_]/g, "_");
26}
27
28function endpointPath(identifier: string): string {
29 const [modulePath, exportName] = identifier.split(":");
30 if (!modulePath || !exportName) {
31 return `/api/run/${identifier.replace(/:/g, "/")}`;
32 }
33 return `/api/run/${modulePath}/${exportName}`;
34}
35
36function buildGetParametersFromArgsSchema(argsSchema: Record<string, unknown>): Array<Record<string, unknown>> {
37 const schemaType = typeof argsSchema.type === "string" ? argsSchema.type : undefined;
38 const properties = toRecord(argsSchema.properties);
39 const required = Array.isArray(argsSchema.required)
40 ? new Set(argsSchema.required.filter((entry): entry is string => typeof entry === "string"))
41 : new Set<string>();
42
43 if (schemaType !== "object" || Object.keys(properties).length === 0) {
44 return [{
45 in: "query",
46 name: "args",
47 required: false,
48 schema: {
49 type: "string",
50 description: "JSON-encoded args object",
51 },
52 }];
53 }
54
55 const parameters: Array<Record<string, unknown>> = [];
56 for (const [name, value] of Object.entries(properties)) {
57 if (CONTROL_QUERY_PARAMS.has(name)) {
58 continue;
59 }
60
61 parameters.push({
62 in: "query",
63 name,
64 required: required.has(name),
65 schema: toRecord(value),
66 });
67 }
68
69 return parameters;
70}
71
72export function schemaFromValidator(validator?: ValidatorJson | string | null): Record<string, unknown> {
73 if (typeof validator === "string") {
74 try {
75 const parsed = JSON.parse(validator) as ValidatorJson;
76 return schemaFromValidator(parsed);
77 } catch {
78 return {};
79 }
80 }
81
82 if (!validator || typeof validator !== "object") {
83 return {};
84 }
85
86 switch (validator.type) {
87 case "null":
88 return { type: "string", nullable: true };
89 case "number":
90 return { type: "number" };
91 case "bigint":
92 return { type: "integer", format: "int64" };
93 case "boolean":
94 return { type: "boolean" };
95 case "string":
96 return { type: "string" };
97 case "bytes":
98 return { type: "string", format: "byte" };
99 case "any":
100 return {};
101 case "literal": {
102 const literalValue = validator.value;
103 if (typeof literalValue === "string") {
104 return { type: "string", enum: [literalValue] };
105 }
106 if (typeof literalValue === "boolean") {
107 return { type: "boolean", enum: [literalValue] };
108 }
109 return { type: "number", enum: [literalValue] };
110 }
111 case "id":
112 return {
113 type: "string",
114 description: `ID from table "${String(validator.tableName ?? "unknown")}"`,
115 };
116 case "array":
117 return {
118 type: "array",
119 items: schemaFromValidator((validator.value as ValidatorJson | undefined) ?? { type: "any" }),
120 };
121 case "record": {
122 const values = validator.values as { fieldType?: ValidatorJson } | undefined;
123 return {
124 type: "object",
125 additionalProperties: schemaFromValidator(values?.fieldType ?? { type: "any" }),
126 };
127 }
128 case "object": {
129 const rawMembers = validator.value as Record<string, { fieldType: ValidatorJson; optional?: boolean }> | undefined;
130 const members = rawMembers ?? {};
131 const properties: Record<string, unknown> = {};
132 const required: string[] = [];
133
134 for (const [key, value] of Object.entries(members)) {
135 properties[key] = schemaFromValidator(value.fieldType);
136 if (!value.optional) {
137 required.push(key);
138 }
139 }
140
141 return {
142 type: "object",
143 ...(required.length > 0 ? { required } : {}),
144 ...(Object.keys(properties).length > 0 ? { properties } : {}),
145 };
146 }
147 case "union": {
148 const members = Array.isArray(validator.value) ? (validator.value as ValidatorJson[]) : [];
149 const hasNull = members.some((member) => member.type === "null");
150 const nonNullMembers = members.filter((member) => member.type !== "null");
151
152 if (nonNullMembers.length === 1) {
153 return {
154 ...schemaFromValidator(nonNullMembers[0]),
155 ...(hasNull ? { nullable: true } : {}),
156 };
157 }
158
159 return {
160 ...(hasNull ? { nullable: true } : {}),
161 oneOf: nonNullMembers.map((member) => schemaFromValidator(member)),
162 };
163 }
164 default:
165 return {};
166 }
167}
168
169export function buildOpenApiDocument(functions: OpenApiFunctionSpec[], serverUrl: string): Record<string, unknown> {
170 const paths: Record<string, Record<string, unknown>> = {};
171 const schemas: Record<string, unknown> = {
172 FailedResponse: {
173 type: "object",
174 properties: {
175 errorMessage: { type: "string" },
176 errorData: {},
177 },
178 },
179 };
180
181 for (const fn of functions) {
182 const id = schemaId(fn.identifier);
183 const requestSchemaName = `Request_${id}`;
184 const responseSchemaName = `Response_${id}`;
185
186 const argsSchema = schemaFromValidator(fn.args);
187 schemas[requestSchemaName] = argsSchema;
188 schemas[responseSchemaName] = schemaFromValidator(fn.returns);
189
190 const path = endpointPath(fn.identifier);
191 const method = fn.method.toLowerCase();
192
193 const baseOperation: Record<string, unknown> = {
194 summary: `Calls ${fn.functionType} ${fn.identifier}`,
195 tags: [fn.functionType],
196 operationId: `${fn.functionType}_${id}`,
197 responses: {
198 "200": {
199 description: "Convex executed the request",
200 content: {
201 "application/json": {
202 schema: { $ref: `#/components/schemas/${responseSchemaName}` },
203 },
204 },
205 },
206 "400": {
207 description: "Invalid request payload",
208 content: {
209 "application/json": {
210 schema: { $ref: "#/components/schemas/FailedResponse" },
211 },
212 },
213 },
214 "404": {
215 description: "Function not found",
216 content: {
217 "application/json": {
218 schema: { $ref: "#/components/schemas/FailedResponse" },
219 },
220 },
221 },
222 "500": {
223 description: "Function execution failed",
224 content: {
225 "application/json": {
226 schema: { $ref: "#/components/schemas/FailedResponse" },
227 },
228 },
229 },
230 },
231 };
232
233 const operation = fn.method === "POST"
234 ? {
235 ...baseOperation,
236 requestBody: {
237 required: true,
238 content: {
239 "application/json": {
240 schema: { $ref: `#/components/schemas/${requestSchemaName}` },
241 },
242 },
243 },
244 }
245 : {
246 ...baseOperation,
247 parameters: buildGetParametersFromArgsSchema(argsSchema),
248 };
249
250 const existingPath = paths[path] ?? {};
251 paths[path] = {
252 ...existingPath,
253 [method]: operation,
254 };
255 }
256
257 return {
258 openapi: "3.0.3",
259 info: {
260 title: "Convex API",
261 version: "0.0.0",
262 },
263 servers: [{ url: serverUrl }],
264 security: [{ bearerAuth: [] }],
265 tags: [
266 { name: "query", description: "Functions that read data" },
267 { name: "mutation", description: "Functions that write data" },
268 { name: "action", description: "Functions that can call external APIs" },
269 ],
270 paths,
271 components: {
272 securitySchemes: {
273 bearerAuth: {
274 type: "http",
275 scheme: "bearer",
276 description: "Use Authorization: Bearer <token>",
277 },
278 },
279 schemas,
280 },
281 };
282}
executor/packages/database/convex/http/openapi_spec_registry.ts+103−0
@@ -0,0 +1,103 @@
1import type { OpenApiFunctionSpec, ValidatorJson } from "./openapi_spec";
2
3type ConvexPublicRegistration = {
4 isPublic?: boolean;
5 isQuery?: boolean;
6 isMutation?: boolean;
7 isAction?: boolean;
8 exportArgs?: () => ValidatorJson | null;
9 exportReturns?: () => ValidatorJson | null;
10};
11
12function readValidator(
13 fn: ConvexPublicRegistration,
14 key: "exportArgs" | "exportReturns",
15): ValidatorJson | string | null {
16 try {
17 return fn[key]?.() ?? null;
18 } catch {
19 return null;
20 }
21}
22
23function collectFromModule(modulePath: string, exportsObject: Record<string, unknown>): OpenApiFunctionSpec[] {
24 const functions: OpenApiFunctionSpec[] = [];
25
26 for (const [exportName, value] of Object.entries(exportsObject)) {
27 if (typeof value !== "function") {
28 continue;
29 }
30
31 const fn = value as ConvexPublicRegistration;
32 if (!fn.isPublic) {
33 continue;
34 }
35
36 const functionType = fn.isQuery ? "query" : fn.isMutation ? "mutation" : fn.isAction ? "action" : null;
37 if (!functionType) {
38 continue;
39 }
40
41 functions.push({
42 identifier: `${modulePath}:${exportName}`,
43 functionType,
44 method: functionType === "query" ? "GET" : "POST",
45 args: readValidator(fn, "exportArgs"),
46 returns: readValidator(fn, "exportReturns"),
47 });
48 }
49
50 return functions;
51}
52
53const moduleLoaders: Array<{
54 modulePath: string;
55 load: () => Promise<Record<string, unknown>>;
56}> = [
57 { modulePath: "accounts", load: () => import("../accounts") },
58 { modulePath: "app", load: () => import("../app") },
59 { modulePath: "auth", load: () => import("../auth") },
60 { modulePath: "billing", load: () => import("../billing") },
61 { modulePath: "executor", load: () => import("../executor") },
62 { modulePath: "invites", load: () => import("../invites") },
63 { modulePath: "organizationMembers", load: () => import("../organizationMembers") },
64 { modulePath: "organizations", load: () => import("../organizations") },
65 { modulePath: "runtimeCallbacks", load: () => import("../runtimeCallbacks") },
66 { modulePath: "workspace", load: () => import("../workspace") },
67 { modulePath: "workspaces", load: () => import("../workspaces") },
68];
69
70export async function collectPublicFunctionSpecs(): Promise<OpenApiFunctionSpec[]> {
71 const moduleFunctions: OpenApiFunctionSpec[] = [];
72
73 for (const loader of moduleLoaders) {
74 try {
75 const exportsObject = await loader.load();
76 moduleFunctions.push(...collectFromModule(loader.modulePath, exportsObject));
77 } catch {
78 // Skip modules that are unavailable in the current runtime.
79 }
80 }
81
82 // Node runtime public actions are declared in "use node" files and are added manually.
83 const nodeRuntimeFunctions: OpenApiFunctionSpec[] = [
84 {
85 identifier: "credentialsNode:upsertCredential",
86 functionType: "action",
87 method: "POST",
88 args: { type: "any" },
89 returns: { type: "any" },
90 },
91 {
92 identifier: "executorNode:listToolsWithWarnings",
93 functionType: "action",
94 method: "POST",
95 args: { type: "any" },
96 returns: { type: "any" },
97 },
98 ];
99
100 const merged = [...moduleFunctions, ...nodeRuntimeFunctions];
101 merged.sort((a, b) => a.identifier.localeCompare(b.identifier));
102 return merged;
103}
executor/packages/database/convex/invites.ts+3−0
@@ -23,6 +23,7 @@ const organizationRoleValidator = v.union( · 22 unmodified lines
23);23);
24 24
25export const list = organizationQuery({25export const list = organizationQuery({
26 method: "GET",
26 requireAdmin: true,27 requireAdmin: true,
27 args: {},28 args: {},
28 handler: async (ctx) => {29 handler: async (ctx) => {
@@ -47,6 +48,7 @@ export const list = organizationQuery({ · 18 unmodified lines
47});48});
48 49
49export const create = organizationMutation({50export const create = organizationMutation({
51 method: "POST",
50 requireAdmin: true,52 requireAdmin: true,
51 args: {53 args: {
52 email: v.string(),54 email: v.string(),
@@ -197,6 +199,7 @@ export const deliverWorkosInvite = internalAction({ · 144 unmodified lines
197});199});
198 200
199export const revoke = organizationMutation({201export const revoke = organizationMutation({
202 method: "POST",
200 requireAdmin: true,203 requireAdmin: true,
201 args: {204 args: {
202 inviteId: v.id("invites"),205 inviteId: v.id("invites"),
executor/packages/database/convex/organizationMembers.ts+4−0
@@ -13,6 +13,7 @@ const organizationRoleValidator = v.union( · 12 unmodified lines
13);13);
14 14
15export const list = organizationQuery({15export const list = organizationQuery({
16 method: "GET",
16 args: {},17 args: {},
17 handler: async (ctx) => {18 handler: async (ctx) => {
18 const members = await ctx.db19 const members = await ctx.db
@@ -43,6 +44,7 @@ export const list = organizationQuery({ · 24 unmodified lines
43});44});
44 45
45export const updateRole = organizationMutation({46export const updateRole = organizationMutation({
47 method: "POST",
46 args: {48 args: {
47 accountId: v.id("accounts"),49 accountId: v.id("accounts"),
48 role: organizationRoleValidator,50 role: organizationRoleValidator,
@@ -70,6 +72,7 @@ export const updateRole = organizationMutation({ · 21 unmodified lines
70});72});
71 73
72export const updateBillable = organizationMutation({74export const updateBillable = organizationMutation({
75 method: "POST",
73 args: {76 args: {
74 accountId: v.id("accounts"),77 accountId: v.id("accounts"),
75 billable: v.boolean(),78 billable: v.boolean(),
@@ -99,6 +102,7 @@ export const updateBillable = organizationMutation({ · 23 unmodified lines
99});102});
100 103
101export const remove = organizationMutation({104export const remove = organizationMutation({
105 method: "POST",
102 args: {106 args: {
103 accountId: v.id("accounts"),107 accountId: v.id("accounts"),
104 },108 },
executor/packages/database/convex/organizations.ts+5−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9} from "../src/organizations/handlers";9} from "../src/organizations/handlers";
10 10
11export const create = authedMutation({11export const create = authedMutation({
12 method: "POST",
12 args: {13 args: {
13 name: v.string(),14 name: v.string(),
14 },15 },
@@ -18,6 +19,7 @@ export const create = authedMutation({ · 3 unmodified lines
18});19});
19 20
20export const listMine = optionalAccountQuery({21export const listMine = optionalAccountQuery({
22 method: "GET",
21 args: {},23 args: {},
22 handler: async (ctx) => {24 handler: async (ctx) => {
23 return await listOrganizationsMineHandler(ctx);25 return await listOrganizationsMineHandler(ctx);
@@ -25,6 +27,7 @@ export const listMine = optionalAccountQuery({ · 1 unmodified line
25});27});
26 28
27export const getNavigationState = optionalAccountQuery({29export const getNavigationState = optionalAccountQuery({
30 method: "GET",
28 args: {},31 args: {},
29 handler: async (ctx) => {32 handler: async (ctx) => {
30 return await getNavigationStateHandler(ctx);33 return await getNavigationStateHandler(ctx);
@@ -32,6 +35,7 @@ export const getNavigationState = optionalAccountQuery({ · 1 unmodified line
32});35});
33 36
34export const getOrganizationAccess = optionalAccountQuery({37export const getOrganizationAccess = optionalAccountQuery({
38 method: "GET",
35 args: {39 args: {
36 organizationId: v.id("organizations"),40 organizationId: v.id("organizations"),
37 },41 },
@@ -41,6 +45,7 @@ export const getOrganizationAccess = optionalAccountQuery({ · 3 unmodified lines
41});45});
42 46
43export const resolveWorkosOrganizationId = optionalAccountQuery({47export const resolveWorkosOrganizationId = optionalAccountQuery({
48 method: "GET",
44 args: {49 args: {
45 organizationId: v.id("organizations"),50 organizationId: v.id("organizations"),
46 },51 },
executor/packages/database/convex/runtimeCallbacks.ts+7−4
@@ -1,6 +1,6 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { action, mutation, query } from "./_generated/server";3import { customAction, customMutation, customQuery } from "../../core/src/function-builders";
4import {4import {
5 completeRunHandler,5 completeRunHandler,
6 getApprovalStatusHandler,6 getApprovalStatusHandler,
@@ -8,7 +8,8 @@ import { · 1 unmodified line
8} from "../src/runtime-callbacks/handlers";8} from "../src/runtime-callbacks/handlers";
9import { jsonObjectValidator } from "../src/database/validators";9import { jsonObjectValidator } from "../src/database/validators";
10 10
11export const handleToolCall = action({11export const handleToolCall = customAction({
12 method: "POST",
12 args: {13 args: {
13 internalSecret: v.string(),14 internalSecret: v.string(),
14 runId: v.string(),15 runId: v.string(),
@@ -21,7 +22,8 @@ export const handleToolCall = action({ · 6 unmodified lines
21 },22 },
22});23});
23 24
24export const completeRun = mutation({25export const completeRun = customMutation({
26 method: "POST",
25 args: {27 args: {
26 internalSecret: v.string(),28 internalSecret: v.string(),
27 runId: v.string(),29 runId: v.string(),
@@ -35,7 +37,8 @@ export const completeRun = mutation({ · 7 unmodified lines
35 },37 },
36});38});
37 39
38export const getApprovalStatus = query({40export const getApprovalStatus = customQuery({
41 method: "GET",
39 args: {42 args: {
40 internalSecret: v.string(),43 internalSecret: v.string(),
41 runId: v.string(),44 runId: v.string(),
executor/packages/database/convex/schema.ts+3−1
@@ -451,9 +451,11 @@ export default defineSchema({ · 450 unmodified lines
451 // Stores the currently "ready" build id for search + invocation.451 // Stores the currently "ready" build id for search + invocation.
452 workspaceToolRegistryState: defineTable({452 workspaceToolRegistryState: defineTable({
453 workspaceId: v.id("workspaces"),453 workspaceId: v.id("workspaces"),
454 signature: v.string(),454 signature: v.optional(v.string()),
455 readyBuildId: v.optional(v.string()),455 readyBuildId: v.optional(v.string()),
456 buildingBuildId: v.optional(v.string()),456 buildingBuildId: v.optional(v.string()),
457 typesStorageId: v.optional(v.id("_storage")),
458 warnings: v.optional(v.array(v.string())),
457 createdAt: v.number(),459 createdAt: v.number(),
458 updatedAt: v.number(),460 updatedAt: v.number(),
459 })461 })
executor/packages/database/convex/toolRegistry.ts+63−3
@@ -18,6 +18,8 @@ export const getState = internalQuery({ · 17 unmodified lines
18 signature: entry.signature,18 signature: entry.signature,
19 readyBuildId: entry.readyBuildId,19 readyBuildId: entry.readyBuildId,
20 buildingBuildId: entry.buildingBuildId,20 buildingBuildId: entry.buildingBuildId,
21 typesStorageId: entry.typesStorageId,
22 warnings: entry.warnings ?? [],
21 updatedAt: entry.updatedAt,23 updatedAt: entry.updatedAt,
22 };24 };
23 },25 },
@@ -38,7 +40,6 @@ export const beginBuild = internalMutation({ · 14 unmodified lines
38 40
39 if (existing) {41 if (existing) {
40 await ctx.db.patch(existing._id, {42 await ctx.db.patch(existing._id, {
41 signature: args.signature,
42 buildingBuildId: args.buildId,43 buildingBuildId: args.buildId,
43 updatedAt: now,44 updatedAt: now,
44 });45 });
@@ -50,6 +51,8 @@ export const beginBuild = internalMutation({ · 5 unmodified lines
50 signature: args.signature,51 signature: args.signature,
51 readyBuildId: undefined,52 readyBuildId: undefined,
52 buildingBuildId: args.buildId,53 buildingBuildId: args.buildId,
54 typesStorageId: undefined,
55 warnings: [],
53 createdAt: now,56 createdAt: now,
54 updatedAt: now,57 updatedAt: now,
55 });58 });
@@ -144,6 +147,7 @@ export const finishBuild = internalMutation({ · 88 unmodified lines
144 args: {147 args: {
145 workspaceId: v.id("workspaces"),148 workspaceId: v.id("workspaces"),
146 buildId: v.string(),149 buildId: v.string(),
150 signature: v.string(),
147 },151 },
148 handler: async (ctx, args) => {152 handler: async (ctx, args) => {
149 const now = Date.now();153 const now = Date.now();
@@ -155,9 +159,11 @@ export const finishBuild = internalMutation({ · 5 unmodified lines
155 if (!state) {159 if (!state) {
156 await ctx.db.insert("workspaceToolRegistryState", {160 await ctx.db.insert("workspaceToolRegistryState", {
157 workspaceId: args.workspaceId,161 workspaceId: args.workspaceId,
158 signature: "",162 signature: args.signature,
159 readyBuildId: args.buildId,163 readyBuildId: args.buildId,
160 buildingBuildId: undefined,164 buildingBuildId: undefined,
165 typesStorageId: undefined,
166 warnings: [],
161 createdAt: now,167 createdAt: now,
162 updatedAt: now,168 updatedAt: now,
163 });169 });
@@ -171,12 +177,64 @@ export const finishBuild = internalMutation({ · 7 unmodified lines
171 177
172 await ctx.db.patch(state._id, {178 await ctx.db.patch(state._id, {
173 readyBuildId: args.buildId,179 readyBuildId: args.buildId,
180 signature: args.signature,
174 buildingBuildId: undefined,181 buildingBuildId: undefined,
175 updatedAt: now,182 updatedAt: now,
176 });183 });
177 },184 },
178});185});
179 186
187export const updateBuildMetadata = internalMutation({
188 args: {
189 workspaceId: v.id("workspaces"),
190 buildId: v.string(),
191 typesStorageId: v.optional(v.id("_storage")),
192 warnings: v.array(v.string()),
193 },
194 handler: async (ctx, args) => {
195 const state = await ctx.db
196 .query("workspaceToolRegistryState")
197 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
198 .unique();
199 if (!state) {
200 return;
201 }
202 if (state.readyBuildId !== args.buildId) {
203 return;
204 }
205
206 await ctx.db.patch(state._id, {
207 typesStorageId: args.typesStorageId,
208 warnings: args.warnings,
209 updatedAt: Date.now(),
210 });
211 },
212});
213
214export const failBuild = internalMutation({
215 args: {
216 workspaceId: v.id("workspaces"),
217 buildId: v.string(),
218 },
219 handler: async (ctx, args) => {
220 const state = await ctx.db
221 .query("workspaceToolRegistryState")
222 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
223 .unique();
224 if (!state) {
225 return;
226 }
227 if (state.buildingBuildId !== args.buildId) {
228 return;
229 }
230
231 await ctx.db.patch(state._id, {
232 buildingBuildId: undefined,
233 updatedAt: Date.now(),
234 });
235 },
236});
237
180const PRUNE_DELETE_PAGE_SIZE = 10;238const PRUNE_DELETE_PAGE_SIZE = 10;
181const PRUNE_NAMESPACE_SCAN_PAGE_SIZE = 250;239const PRUNE_NAMESPACE_SCAN_PAGE_SIZE = 250;
182 240
@@ -363,6 +421,7 @@ export const getToolByPath = internalQuery({ · 180 unmodified lines
363 displayOutput: entry.displayOutput,421 displayOutput: entry.displayOutput,
364 requiredInputKeys: entry.requiredInputKeys,422 requiredInputKeys: entry.requiredInputKeys,
365 previewInputKeys: entry.previewInputKeys,423 previewInputKeys: entry.previewInputKeys,
424 typedRef: entry.typedRef,
366 serializedToolJson: entry.serializedToolJson,425 serializedToolJson: entry.serializedToolJson,
367 };426 };
368 },427 },
@@ -377,7 +436,7 @@ export const listToolsByNamespace = internalQuery({ · 8 unmodified lines
377 },436 },
378 handler: async (ctx, args) => {437 handler: async (ctx, args) => {
379 const namespace = args.namespace.trim().toLowerCase();438 const namespace = args.namespace.trim().toLowerCase();
380 const limit = Math.max(1, Math.min(200, Math.floor(args.limit)));439 const limit = Math.max(1, Math.min(20_000, Math.floor(args.limit)));
381 if (!namespace) return [];440 if (!namespace) return [];
382 441
383 const entries = await ctx.db442 const entries = await ctx.db
@@ -398,6 +457,7 @@ export const listToolsByNamespace = internalQuery({ · 14 unmodified lines
398 displayOutput: entry.displayOutput,457 displayOutput: entry.displayOutput,
399 requiredInputKeys: entry.requiredInputKeys,458 requiredInputKeys: entry.requiredInputKeys,
400 previewInputKeys: entry.previewInputKeys,459 previewInputKeys: entry.previewInputKeys,
460 typedRef: entry.typedRef,
401 }));461 }));
402 },462 },
403});463});
executor/packages/database/convex/workspace.ts+13−3
@@ -1,7 +1,6 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { mutation } from "./_generated/server";3import { customMutation, workspaceMutation, workspaceQuery } from "../../core/src/function-builders";
4import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders";
5import { getOrganizationMembership, isAdminRole } from "../../core/src/identity";4import { getOrganizationMembership, isAdminRole } from "../../core/src/identity";
6import {5import {
7 credentialProviderValidator,6 credentialProviderValidator,
@@ -15,7 +14,8 @@ import { · 7 unmodified lines
15 toolSourceTypeValidator,14 toolSourceTypeValidator,
16} from "../src/database/validators";15} from "../src/database/validators";
17 16
18export const bootstrapAnonymousSession = mutation({17export const bootstrapAnonymousSession = customMutation({
18 method: "POST",
19 args: {19 args: {
20 sessionId: v.optional(v.string()),20 sessionId: v.optional(v.string()),
21 accountId: v.optional(v.string()),21 accountId: v.optional(v.string()),
@@ -27,6 +27,7 @@ export const bootstrapAnonymousSession = mutation({ · 5 unmodified lines
27});27});
28 28
29export const listTasks = workspaceQuery({29export const listTasks = workspaceQuery({
30 method: "GET",
30 args: {},31 args: {},
31 handler: async (ctx) => {32 handler: async (ctx) => {
32 return await ctx.runQuery(internal.database.listTasks, {33 return await ctx.runQuery(internal.database.listTasks, {
@@ -36,6 +37,7 @@ export const listTasks = workspaceQuery({ · 3 unmodified lines
36});37});
37 38
38export const listPendingApprovals = workspaceQuery({39export const listPendingApprovals = workspaceQuery({
40 method: "GET",
39 args: {},41 args: {},
40 handler: async (ctx) => {42 handler: async (ctx) => {
41 return await ctx.runQuery(internal.database.listPendingApprovals, {43 return await ctx.runQuery(internal.database.listPendingApprovals, {
@@ -45,6 +47,7 @@ export const listPendingApprovals = workspaceQuery({ · 3 unmodified lines
45});47});
46 48
47export const upsertAccessPolicy = workspaceMutation({49export const upsertAccessPolicy = workspaceMutation({
50 method: "POST",
48 requireAdmin: true,51 requireAdmin: true,
49 args: {52 args: {
50 id: v.optional(v.string()),53 id: v.optional(v.string()),
@@ -73,6 +76,7 @@ export const upsertAccessPolicy = workspaceMutation({ · 22 unmodified lines
73});76});
74 77
75export const listAccessPolicies = workspaceQuery({78export const listAccessPolicies = workspaceQuery({
79 method: "GET",
76 args: {},80 args: {},
77 handler: async (ctx) => {81 handler: async (ctx) => {
78 return await ctx.runQuery(internal.database.listAccessPolicies, {82 return await ctx.runQuery(internal.database.listAccessPolicies, {
@@ -83,6 +87,7 @@ export const listAccessPolicies = workspaceQuery({ · 4 unmodified lines
83});87});
84 88
85export const upsertCredential = workspaceMutation({89export const upsertCredential = workspaceMutation({
90 method: "POST",
86 requireAdmin: true,91 requireAdmin: true,
87 args: {92 args: {
88 id: v.optional(v.string()),93 id: v.optional(v.string()),
@@ -109,6 +114,7 @@ export const upsertCredential = workspaceMutation({ · 20 unmodified lines
109});114});
110 115
111export const listCredentials = workspaceQuery({116export const listCredentials = workspaceQuery({
117 method: "GET",
112 requireAdmin: true,118 requireAdmin: true,
113 args: {},119 args: {},
114 handler: async (ctx) => {120 handler: async (ctx) => {
@@ -128,6 +134,7 @@ export const listCredentials = workspaceQuery({ · 13 unmodified lines
128});134});
129 135
130export const resolveCredential = workspaceQuery({136export const resolveCredential = workspaceQuery({
137 method: "GET",
131 requireAdmin: true,138 requireAdmin: true,
132 args: {139 args: {
133 sourceKey: v.string(),140 sourceKey: v.string(),
@@ -143,6 +150,7 @@ export const resolveCredential = workspaceQuery({ · 9 unmodified lines
143});150});
144 151
145export const upsertToolSource = workspaceMutation({152export const upsertToolSource = workspaceMutation({
153 method: "POST",
146 requireAdmin: true,154 requireAdmin: true,
147 args: {155 args: {
148 id: v.optional(v.string()),156 id: v.optional(v.string()),
@@ -168,6 +176,7 @@ export const upsertToolSource = workspaceMutation({ · 19 unmodified lines
168});176});
169 177
170export const listToolSources = workspaceQuery({178export const listToolSources = workspaceQuery({
179 method: "GET",
171 args: {},180 args: {},
172 handler: async (ctx) => {181 handler: async (ctx) => {
173 return await ctx.runQuery(internal.database.listToolSources, {182 return await ctx.runQuery(internal.database.listToolSources, {
@@ -177,6 +186,7 @@ export const listToolSources = workspaceQuery({ · 3 unmodified lines
177});186});
178 187
179export const deleteToolSource = workspaceMutation({188export const deleteToolSource = workspaceMutation({
189 method: "POST",
180 requireAdmin: true,190 requireAdmin: true,
181 args: {191 args: {
182 sourceId: v.string(),192 sourceId: v.string(),
executor/packages/database/convex/workspaces.ts+3−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
7} from "../src/workspaces/handlers";7} from "../src/workspaces/handlers";
8 8
9export const create = authedMutation({9export const create = authedMutation({
10 method: "POST",
10 args: {11 args: {
11 name: v.string(),12 name: v.string(),
12 organizationId: v.optional(v.id("organizations")),13 organizationId: v.optional(v.id("organizations")),
@@ -18,6 +19,7 @@ export const create = authedMutation({ · 5 unmodified lines
18});19});
19 20
20export const list = optionalAccountQuery({21export const list = optionalAccountQuery({
22 method: "GET",
21 args: {23 args: {
22 organizationId: v.optional(v.id("organizations")),24 organizationId: v.optional(v.id("organizations")),
23 },25 },
@@ -27,6 +29,7 @@ export const list = optionalAccountQuery({ · 3 unmodified lines
27});29});
28 30
29export const generateWorkspaceIconUploadUrl = authedMutation({31export const generateWorkspaceIconUploadUrl = authedMutation({
32 method: "POST",
30 args: {},33 args: {},
31 handler: async (ctx) => {34 handler: async (ctx) => {
32 return await generateWorkspaceIconUploadUrlHandler(ctx);35 return await generateWorkspaceIconUploadUrlHandler(ctx);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t