History

Drop unused indexes, remove constant fields, finish workspaceId typing, fix empty-string sentinels, eliminate ctx:any and unsafe casts

- Drop 10 unused schema indexes (keep billing indexes for future Stripe use) - Remove constant fields: workspaces.plan, invites.provider - Complete workspaceId boundary typing: Id<"workspaces"> throughout lib/types, executorNode, mcp_server, session-context, and all tests - Make tasks.actorId and accessPolicies.actorId/clientId optional instead of empty-string sentinels - Replace ctx:any with ActionCtx in all executorNode.ts helper functions - Rewrite normalizeExternalToolSource with explicit typed branches (no double-casts) - Centralize actorIdForAccount in lib/identity.ts - Fix DtsStorageEntry.storageId typing to Id<"_storage">, remove as-any casts - Delete dead code: openApiSpecCache.pruneExpired, workspaceToolCache.getDtsStorageIds, deleteInvitesMissingProvider migration

Rhys Sullivan committed 9 months ago

Commit bb25be51 parent 4cda16733 files · +232 −255Browse files

Unified · Split

33 files
  1. assistant/packages/bot/src/commands/ask.tsx +3 −2
  2. assistant/packages/bot/src/views/task-message.tsx +2 −1
  3. assistant/packages/server/src/index.ts +3 −2
  4. assistant/packages/server/src/routes.ts +2 −1
  5. executor/apps/web/src/app/sign-in/route.ts +1 −1
  6. executor/apps/web/src/app/sign-up/route.ts +1 −1
  7. executor/apps/web/src/lib/session-context.tsx +2 −2
  8. executor/convex/_generated/api.d.ts +15 −15
  9. executor/convex/access-controls.test.ts +0 −1
  10. executor/convex/auth.ts +0 −2
  11. executor/convex/database.test.ts +0 −1
  12. executor/convex/database.ts +14 −30
  13. executor/convex/executor.ts +1 −4
  14. executor/convex/executorNode.ts +63 −35
  15. executor/convex/http.ts +25 −17
  16. executor/convex/invites.ts +0 −4
  17. executor/convex/migrations.ts +20 −11
  18. executor/convex/openApiSpecCache.ts +0 −32
  19. executor/convex/organizations.ts +0 −1
  20. executor/convex/schema.ts +7 −20
  21. executor/convex/workspace.ts +1 −4
  22. executor/convex/workspaceAuthInternal.ts +1 −4
  23. executor/convex/workspaceToolCache.ts +0 −18
  24. executor/convex/workspaces.ts +0 −1
  25. executor/lib/identity.ts +4 −0
  26. executor/lib/mcp-server.test.ts +2 −1
  27. executor/lib/mcp_server.ts +10 −9
  28. executor/lib/runtimes/runtime-core.test.ts +2 −1
  29. executor/lib/tool-discovery.test.ts +10 −7
  30. executor/lib/tool-sources.test.ts +4 −1
  31. executor/lib/types.ts +9 −7
  32. executor/lib/workspace-tool-cache.test.ts +6 −5
  33. executor/mcp-gateway.ts +24 −14
assistant/packages/bot/src/commands/ask.tsx+3−2
@@ -9,6 +9,7 @@ import type { ConvexReactClient } from "convex/react"; · 8 unmodified lines
9import { ConvexProvider } from "convex/react";9import { ConvexProvider } from "convex/react";
10import type { ReacordInstance } from "@openassistant/reacord";10import type { ReacordInstance } from "@openassistant/reacord";
11import { Effect, Runtime } from "effect";11import { Effect, Runtime } from "effect";
12import type { Id } from "@executor/convex/_generated/dataModel";
12import { TaskMessage } from "../views/task-message";13import { TaskMessage } from "../views/task-message";
13 14
14interface AskCommandDeps {15interface AskCommandDeps {
@@ -29,13 +30,13 @@ export async function handleAskCommand( · 14 unmodified lines
29 await interaction.deferReply();30 await interaction.deferReply();
30 31
31 let agentTaskId: string;32 let agentTaskId: string;
32 let workspaceId: string;33 let workspaceId: Id<"workspaces">;
33 try {34 try {
34 const data = await unwrap(35 const data = await unwrap(
35 deps.api.api.tasks.post({ prompt, requesterId }),36 deps.api.api.tasks.post({ prompt, requesterId }),
36 );37 );
37 agentTaskId = data.agentTaskId;38 agentTaskId = data.agentTaskId;
38 workspaceId = data.workspaceId;39 workspaceId = data.workspaceId as Id<"workspaces">;
39 } catch (error) {40 } catch (error) {
40 await interaction.editReply({41 await interaction.editReply({
41 content: `\u274c Failed to create task: ${error instanceof Error ? error.message : String(error)}`,42 content: `\u274c Failed to create task: ${error instanceof Error ? error.message : String(error)}`,
assistant/packages/bot/src/views/task-message.tsx+2−1
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18} from "@openassistant/reacord";18} from "@openassistant/reacord";
19import { useMutation, useQuery } from "convex/react";19import { useMutation, useQuery } from "convex/react";
20import { api } from "@executor/convex/_generated/api";20import { api } from "@executor/convex/_generated/api";
21import type { Id } from "@executor/convex/_generated/dataModel";
21 22
22// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
23// Component24// Component
@@ -26,7 +27,7 @@ import { api } from "@executor/convex/_generated/api"; · 2 unmodified lines
26export interface TaskMessageProps {27export interface TaskMessageProps {
27 readonly agentTaskId: string;28 readonly agentTaskId: string;
28 readonly prompt: string;29 readonly prompt: string;
29 readonly workspaceId: string;30 readonly workspaceId: Id<"workspaces">;
30}31}
31 32
32export function TaskMessage({ agentTaskId, prompt, workspaceId }: TaskMessageProps) {33export function TaskMessage({ agentTaskId, prompt, workspaceId }: TaskMessageProps) {
assistant/packages/server/src/index.ts+3−2
@@ -5,6 +5,7 @@ · 4 unmodified lines
5import { createApp } from "./routes";5import { createApp } from "./routes";
6import { ConvexHttpClient } from "convex/browser";6import { ConvexHttpClient } from "convex/browser";
7import { api } from "@executor/convex/_generated/api";7import { api } from "@executor/convex/_generated/api";
8import type { Id } from "@executor/convex/_generated/dataModel";
8 9
9const PORT = Number(Bun.env.PORT ?? 3000);10const PORT = Number(Bun.env.PORT ?? 3000);
10const CONVEX_URL = Bun.env.CONVEX_URL;11const CONVEX_URL = Bun.env.CONVEX_URL;
@@ -23,11 +24,11 @@ const ANON_SESSION_ID = Bun.env.EXECUTOR_ANON_SESSION_ID?.trim(); · 12 unmodified lines
23 24
24// Bootstrap anonymous context on executor Convex backend25// Bootstrap anonymous context on executor Convex backend
25const convex = new ConvexHttpClient(CONVEX_URL);26const convex = new ConvexHttpClient(CONVEX_URL);
26let ctx: { workspaceId: string; actorId: string; clientId?: string };27let ctx: { workspaceId: Id<"workspaces">; actorId: string; clientId?: string };
27 28
28if (PINNED_WORKSPACE_ID && PINNED_ACTOR_ID) {29if (PINNED_WORKSPACE_ID && PINNED_ACTOR_ID) {
29 ctx = {30 ctx = {
30 workspaceId: PINNED_WORKSPACE_ID,31 workspaceId: PINNED_WORKSPACE_ID as Id<"workspaces">,
31 actorId: PINNED_ACTOR_ID,32 actorId: PINNED_ACTOR_ID,
32 clientId: PINNED_CLIENT_ID,33 clientId: PINNED_CLIENT_ID,
33 };34 };
assistant/packages/server/src/routes.ts+2−1
@@ -10,6 +10,7 @@ import { createAgent } from "@assistant/core"; · 9 unmodified lines
10import type { TaskEvent } from "@assistant/core";10import type { TaskEvent } from "@assistant/core";
11import { ConvexHttpClient } from "convex/browser";11import { ConvexHttpClient } from "convex/browser";
12import { api } from "@executor/convex/_generated/api";12import { api } from "@executor/convex/_generated/api";
13import type { Id } from "@executor/convex/_generated/dataModel";
13 14
14// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
15// Config16// Config
@@ -17,7 +18,7 @@ import { api } from "@executor/convex/_generated/api"; · 1 unmodified line
17 18
18export interface ServerOptions {19export interface ServerOptions {
19 readonly executorUrl: string;20 readonly executorUrl: string;
20 readonly workspaceId: string;21 readonly workspaceId: Id<"workspaces">;
21 readonly actorId: string;22 readonly actorId: string;
22 readonly clientId?: string;23 readonly clientId?: string;
23 readonly context?: string;24 readonly context?: string;
executor/apps/web/src/app/sign-in/route.ts+1−1
@@ -93,7 +93,7 @@ async function resolveOrganizationHint(request: NextRequest): Promise<string | u · 92 unmodified lines
93 try {93 try {
94 const workosOrganizationId = await convexClient.query(94 const workosOrganizationId = await convexClient.query(
95 api.organizations.resolveWorkosOrganizationId,95 api.organizations.resolveWorkosOrganizationId,
96 { organizationId: organizationHint as unknown as Id<"organizations"> },96 { organizationId: organizationHint as Id<"organizations"> },
97 );97 );
98 return typeof workosOrganizationId === "string" ? workosOrganizationId : undefined;98 return typeof workosOrganizationId === "string" ? workosOrganizationId : undefined;
99 } catch {99 } catch {
executor/apps/web/src/app/sign-up/route.ts+1−1
@@ -93,7 +93,7 @@ async function resolveOrganizationHint(request: NextRequest): Promise<string | u · 92 unmodified lines
93 try {93 try {
94 const workosOrganizationId = await convexClient.query(94 const workosOrganizationId = await convexClient.query(
95 api.organizations.resolveWorkosOrganizationId,95 api.organizations.resolveWorkosOrganizationId,
96 { organizationId: organizationHint as unknown as Id<"organizations"> },96 { organizationId: organizationHint as Id<"organizations"> },
97 );97 );
98 return typeof workosOrganizationId === "string" ? workosOrganizationId : undefined;98 return typeof workosOrganizationId === "string" ? workosOrganizationId : undefined;
99 } catch {99 } catch {
executor/apps/web/src/lib/session-context.tsx+2−2
@@ -231,7 +231,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 230 unmodified lines
231 localStorage.setItem(SESSION_KEY, context.sessionId);231 localStorage.setItem(SESSION_KEY, context.sessionId);
232 localStorage.setItem(ACTIVE_WORKSPACE_KEY, context.workspaceId);232 localStorage.setItem(ACTIVE_WORKSPACE_KEY, context.workspaceId);
233 setStoredSessionId(context.sessionId);233 setStoredSessionId(context.sessionId);
234 setActiveWorkspaceId(context.workspaceId as Id<"workspaces">);234 setActiveWorkspaceId(context.workspaceId);
235 setManualGuestContext(context);235 setManualGuestContext(context);
236 } catch (cause) {236 } catch (cause) {
237 const message = cause instanceof Error ? cause.message : "Failed to create anonymous organization";237 const message = cause instanceof Error ? cause.message : "Failed to create anonymous organization";
@@ -401,7 +401,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 163 unmodified lines
401 if (guestContext) {401 if (guestContext) {
402 return [402 return [
403 {403 {
404 id: guestContext.workspaceId as Id<"workspaces">,404 id: guestContext.workspaceId,
405 docId: null,405 docId: null,
406 name: "Anonymous Workspace",406 name: "Anonymous Workspace",
407 organizationId: null,407 organizationId: null,
executor/convex/_generated/api.d.ts+15−15
@@ -829,7 +829,7 @@ export declare const internal: { · 828 unmodified lines
829 },829 },
830 any830 any
831 >;831 >;
832 deleteAnonymousSessionsMissingAccountId: FunctionReference<832 cleanupAccessPolicyEmptyStringSentinels: FunctionReference<
833 "mutation",833 "mutation",
834 "internal",834 "internal",
835 {835 {
@@ -841,7 +841,19 @@ export declare const internal: { · 5 unmodified lines
841 },841 },
842 any842 any
843 >;843 >;
844 deleteAnonymousSessionsMissingUserId: FunctionReference<844 cleanupTaskEmptyStringSentinels: FunctionReference<
845 "mutation",
846 "internal",
847 {
848 batchSize?: number;
849 cursor?: string | null;
850 dryRun?: boolean;
851 fn?: string;
852 next?: Array<string>;
853 },
854 any
855 >;
856 deleteAnonymousSessionsMissingAccountId: FunctionReference<
845 "mutation",857 "mutation",
846 "internal",858 "internal",
847 {859 {
@@ -853,7 +865,7 @@ export declare const internal: { · 5 unmodified lines
853 },865 },
854 any866 any
855 >;867 >;
856 deleteInvitesMissingProvider: FunctionReference<868 deleteAnonymousSessionsMissingUserId: FunctionReference<
857 "mutation",869 "mutation",
858 "internal",870 "internal",
859 {871 {
@@ -921,12 +933,6 @@ export declare const internal: { · 61 unmodified lines
921 { maxAgeMs: number; specUrl: string; version: string },933 { maxAgeMs: number; specUrl: string; version: string },
922 any934 any
923 >;935 >;
924 pruneExpired: FunctionReference<
925 "mutation",
926 "internal",
927 { batchSize?: number; maxAgeMs: number },
928 any
929 >;
930 putEntry: FunctionReference<936 putEntry: FunctionReference<
931 "mutation",937 "mutation",
932 "internal",938 "internal",
@@ -954,12 +960,6 @@ export declare const internal: { · 21 unmodified lines
954 >;960 >;
955 };961 };
956 workspaceToolCache: {962 workspaceToolCache: {
957 getDtsStorageIds: FunctionReference<
958 "query",
959 "internal",
960 { workspaceId: Id<"workspaces"> },
961 any
962 >;
963 getEntry: FunctionReference<963 getEntry: FunctionReference<
964 "query",964 "query",
965 "internal",965 "internal",
executor/convex/access-controls.test.ts+0−1
@@ -82,7 +82,6 @@ async function seedUser( · 81 unmodified lines
82 organizationId,82 organizationId,
83 slug: `ws-${subject}`,83 slug: `ws-${subject}`,
84 name: opts.workspaceName ?? `${name}'s Workspace`,84 name: opts.workspaceName ?? `${name}'s Workspace`,
85 plan: "free",
86 createdByAccountId: accountId,85 createdByAccountId: accountId,
87 createdAt: now,86 createdAt: now,
88 updatedAt: now,87 updatedAt: now,
executor/convex/auth.ts+0−2
@@ -309,7 +309,6 @@ async function ensurePersonalWorkspace( · 308 unmodified lines
309 organizationId,309 organizationId,
310 slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`,310 slug: `${baseSlug}-${opts.workosUserId.slice(-6)}`,
311 name: personalNames.workspaceName,311 name: personalNames.workspaceName,
312 plan: "free",
313 createdByAccountId: accountId,312 createdByAccountId: accountId,
314 createdAt: opts.now,313 createdAt: opts.now,
315 updatedAt: opts.now,314 updatedAt: opts.now,
@@ -480,7 +479,6 @@ const workosEventHandlers = { · 164 unmodified lines
480 organizationId: organization._id,479 organizationId: organization._id,
481 slug: "default",480 slug: "default",
482 name: "Default Workspace",481 name: "Default Workspace",
483 plan: "free",
484 createdByAccountId: organization.createdByAccountId,482 createdByAccountId: organization.createdByAccountId,
485 createdAt: now,483 createdAt: now,
486 updatedAt: now,484 updatedAt: now,
executor/convex/database.test.ts+0−1
@@ -25,7 +25,6 @@ async function seedWorkspace(t: ReturnType<typeof setup>, name = "test-ws"): Pro · 24 unmodified lines
25 name,25 name,
26 slug: name,26 slug: name,
27 organizationId: orgId,27 organizationId: orgId,
28 plan: "free",
29 createdAt: Date.now(),28 createdAt: Date.now(),
30 updatedAt: Date.now(),29 updatedAt: Date.now(),
31 });30 });
executor/convex/database.ts+14−30
@@ -27,21 +27,6 @@ const credentialProviderValidator = v.union( · 26 unmodified lines
27const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));27const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
28const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));28const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
29 29
30function normalizeOptional(value?: string): string {
31 if (typeof value !== "string") {
32 return "";
33 }
34 const trimmed = value.trim();
35 return trimmed.length > 0 ? trimmed : "";
36}
37
38function optionalFromNormalized(value?: string): string | undefined {
39 if (!value) {
40 return undefined;
41 }
42 return value;
43}
44
45function slugify(input: string): string {30function slugify(input: string): string {
46 const slug = input31 const slug = input
47 .toLowerCase()32 .toLowerCase()
@@ -118,8 +103,8 @@ function mapTask(doc: Doc<"tasks">) { · 70 unmodified lines
118 timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TIMEOUT_MS,103 timeoutMs: typeof doc.timeoutMs === "number" ? doc.timeoutMs : DEFAULT_TIMEOUT_MS,
119 metadata: asRecord(doc.metadata),104 metadata: asRecord(doc.metadata),
120 workspaceId: doc.workspaceId,105 workspaceId: doc.workspaceId,
121 actorId: optionalFromNormalized(doc.actorId),106 actorId: doc.actorId,
122 clientId: optionalFromNormalized(doc.clientId),107 clientId: doc.clientId,
123 createdAt: doc.createdAt,108 createdAt: doc.createdAt,
124 updatedAt: doc.updatedAt,109 updatedAt: doc.updatedAt,
125 startedAt: doc.startedAt,110 startedAt: doc.startedAt,
@@ -149,8 +134,8 @@ function mapPolicy(doc: Doc<"accessPolicies">) { · 23 unmodified lines
149 return {134 return {
150 id: doc.policyId,135 id: doc.policyId,
151 workspaceId: doc.workspaceId,136 workspaceId: doc.workspaceId,
152 actorId: optionalFromNormalized(doc.actorId),137 actorId: doc.actorId,
153 clientId: optionalFromNormalized(doc.clientId),138 clientId: doc.clientId,
154 toolPathPattern: doc.toolPathPattern,139 toolPathPattern: doc.toolPathPattern,
155 decision: doc.decision,140 decision: doc.decision,
156 priority: doc.priority,141 priority: doc.priority,
@@ -165,7 +150,7 @@ function mapCredential(doc: Doc<"sourceCredentials">) { · 8 unmodified lines
165 workspaceId: doc.workspaceId,150 workspaceId: doc.workspaceId,
166 sourceKey: doc.sourceKey,151 sourceKey: doc.sourceKey,
167 scope: doc.scope,152 scope: doc.scope,
168 actorId: optionalFromNormalized(doc.actorId),153 actorId: doc.actorId || undefined,
169 provider: doc.provider ?? "managed",154 provider: doc.provider ?? "managed",
170 secretJson: asRecord(doc.secretJson),155 secretJson: asRecord(doc.secretJson),
171 createdAt: doc.createdAt,156 createdAt: doc.createdAt,
@@ -242,7 +227,6 @@ async function ensureAnonymousIdentity( · 70 unmodified lines
242 organizationId,227 organizationId,
243 slug: `anonymous-${crypto.randomUUID().slice(0, 8)}`,228 slug: `anonymous-${crypto.randomUUID().slice(0, 8)}`,
244 name: anonymousWorkspaceName,229 name: anonymousWorkspaceName,
245 plan: "free",
246 createdByAccountId: account._id,230 createdByAccountId: account._id,
247 createdAt: now,231 createdAt: now,
248 updatedAt: now,232 updatedAt: now,
@@ -351,8 +335,8 @@ export const createTask = internalMutation({ · 102 unmodified lines
351 code: args.code,335 code: args.code,
352 runtimeId: args.runtimeId,336 runtimeId: args.runtimeId,
353 workspaceId: args.workspaceId,337 workspaceId: args.workspaceId,
354 actorId: normalizeOptional(args.actorId),338 actorId: args.actorId?.trim() || undefined,
355 clientId: normalizeOptional(args.clientId),339 clientId: args.clientId?.trim() || undefined,
356 status: "queued",340 status: "queued",
357 timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,341 timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
358 metadata: asRecord(args.metadata),342 metadata: asRecord(args.metadata),
@@ -720,7 +704,7 @@ export const bootstrapAnonymousSession = internalMutation({ · 361 unmodified lines
720 args: { sessionId: v.optional(v.string()) },704 args: { sessionId: v.optional(v.string()) },
721 handler: async (ctx, args) => {705 handler: async (ctx, args) => {
722 const now = Date.now();706 const now = Date.now();
723 const requestedSessionId = normalizeOptional(args.sessionId);707 const requestedSessionId = args.sessionId?.trim() || "";
724 const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false;708 const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false;
725 709
726 if (requestedSessionId) {710 if (requestedSessionId) {
@@ -814,8 +798,8 @@ export const upsertAccessPolicy = internalMutation({ · 87 unmodified lines
814 if (existing) {798 if (existing) {
815 await ctx.db.patch(existing._id, {799 await ctx.db.patch(existing._id, {
816 workspaceId: args.workspaceId,800 workspaceId: args.workspaceId,
817 actorId: normalizeOptional(args.actorId),801 actorId: args.actorId?.trim() || undefined,
818 clientId: normalizeOptional(args.clientId),802 clientId: args.clientId?.trim() || undefined,
819 toolPathPattern: args.toolPathPattern,803 toolPathPattern: args.toolPathPattern,
820 decision: args.decision,804 decision: args.decision,
821 priority: args.priority ?? 100,805 priority: args.priority ?? 100,
@@ -825,8 +809,8 @@ export const upsertAccessPolicy = internalMutation({ · 3 unmodified lines
825 await ctx.db.insert("accessPolicies", {809 await ctx.db.insert("accessPolicies", {
826 policyId,810 policyId,
827 workspaceId: args.workspaceId,811 workspaceId: args.workspaceId,
828 actorId: normalizeOptional(args.actorId),812 actorId: args.actorId?.trim() || undefined,
829 clientId: normalizeOptional(args.clientId),813 clientId: args.clientId?.trim() || undefined,
830 toolPathPattern: args.toolPathPattern,814 toolPathPattern: args.toolPathPattern,
831 decision: args.decision,815 decision: args.decision,
832 priority: args.priority ?? 100,816 priority: args.priority ?? 100,
@@ -877,7 +861,7 @@ export const upsertCredential = internalMutation({ · 44 unmodified lines
877 },861 },
878 handler: async (ctx, args) => {862 handler: async (ctx, args) => {
879 const now = Date.now();863 const now = Date.now();
880 const actorId = args.scope === "actor" ? normalizeOptional(args.actorId) : "";864 const actorId = args.scope === "actor" ? (args.actorId?.trim() || "") : "";
881 865
882 const existing = await ctx.db866 const existing = await ctx.db
883 .query("sourceCredentials")867 .query("sourceCredentials")
@@ -970,7 +954,7 @@ export const resolveCredential = internalQuery({ · 86 unmodified lines
970 },954 },
971 handler: async (ctx, args) => {955 handler: async (ctx, args) => {
972 if (args.scope === "actor") {956 if (args.scope === "actor") {
973 const actorId = normalizeOptional(args.actorId);957 const actorId = args.actorId?.trim() || "";
974 if (!actorId) {958 if (!actorId) {
975 return null;959 return null;
976 }960 }
executor/convex/executor.ts+1−4
@@ -4,6 +4,7 @@ import type { Id } from "./_generated/dataModel"; · 3 unmodified lines
4import type { MutationCtx } from "./_generated/server";4import type { MutationCtx } from "./_generated/server";
5import { internalMutation } from "./_generated/server";5import { internalMutation } from "./_generated/server";
6import { workspaceMutation } from "../lib/functionBuilders";6import { workspaceMutation } from "../lib/functionBuilders";
7import { actorIdForAccount } from "../lib/identity";
7import type { ApprovalRecord, TaskRecord } from "../lib/types";8import type { ApprovalRecord, TaskRecord } from "../lib/types";
8 9
9const DEFAULT_TIMEOUT_MS = 300_000;10const DEFAULT_TIMEOUT_MS = 300_000;
@@ -20,10 +21,6 @@ async function publishTaskEvent( · 10 unmodified lines
20 await ctx.runMutation(internal.database.createTaskEvent, input);21 await ctx.runMutation(internal.database.createTaskEvent, input);
21}22}
22 23
23function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }) {
24 return account.provider === "anonymous" ? account.providerAccountId : account._id;
25}
26
27async function createTaskRecord(24async function createTaskRecord(
28 ctx: MutationCtx,25 ctx: MutationCtx,
29 args: {26 args: {
executor/convex/executorNode.ts+63−35
@@ -2,10 +2,13 @@ · 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 type { Id } from "./_generated/dataModel";
5import { action, internalAction } from "./_generated/server";6import { action, internalAction } from "./_generated/server";
7import type { ActionCtx } from "./_generated/server";
6import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_adapter";8import { InProcessExecutionAdapter } from "../lib/adapters/in_process_execution_adapter";
7import { resolveCredentialPayload } from "../lib/credential_providers";9import { resolveCredentialPayload } from "../lib/credential_providers";
8import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants";10import { APPROVAL_DENIED_PREFIX } from "../lib/execution_constants";
11import { actorIdForAccount } from "../lib/identity";
9import { runCodeWithAdapter } from "../lib/runtimes/runtime_core";12import { runCodeWithAdapter } from "../lib/runtimes/runtime_core";
10import { createDiscoverTool } from "../lib/tool_discovery";13import { createDiscoverTool } from "../lib/tool_discovery";
11import {14import {
@@ -17,6 +20,9 @@ import { · 5 unmodified lines
17 serializeTools,20 serializeTools,
18 type PreparedOpenApiSpec,21 type PreparedOpenApiSpec,
19 type ExternalToolSourceConfig,22 type ExternalToolSourceConfig,
23 type McpToolSourceConfig,
24 type OpenApiToolSourceConfig,
25 type GraphqlToolSourceConfig,
20 type WorkspaceToolSnapshot,26 type WorkspaceToolSnapshot,
21} from "../lib/tool_sources";27} from "../lib/tool_sources";
22import { DEFAULT_TOOLS } from "../lib/tools";28import { DEFAULT_TOOLS } from "../lib/tools";
@@ -173,27 +179,23 @@ function normalizeExternalToolSource(raw: { · 150 unmodified lines
173 name: string;179 name: string;
174 config: Record<string, unknown>;180 config: Record<string, unknown>;
175}): ExternalToolSourceConfig {181}): ExternalToolSourceConfig {
176 const merged = {182 const config = raw.config;
177 type: raw.type,
178 name: raw.name,
179 ...raw.config,
180 } as Record<string, unknown>;
181 183
182 if (raw.type === "mcp") {184 if (raw.type === "mcp") {
183 if (typeof merged.url !== "string" || merged.url.trim().length === 0) {185 if (typeof config.url !== "string" || config.url.trim().length === 0) {
184 throw new Error(`MCP source '${raw.name}' missing url`);186 throw new Error(`MCP source '${raw.name}' missing url`);
185 }187 }
186 188
187 if (189 if (
188 merged.transport !== undefined190 config.transport !== undefined
189 && merged.transport !== "sse"191 && config.transport !== "sse"
190 && merged.transport !== "streamable-http"192 && config.transport !== "streamable-http"
191 ) {193 ) {
192 throw new Error(`MCP source '${raw.name}' has invalid transport`);194 throw new Error(`MCP source '${raw.name}' has invalid transport`);
193 }195 }
194 196
195 if (merged.queryParams !== undefined) {197 if (config.queryParams !== undefined) {
196 const queryParams = merged.queryParams;198 const queryParams = config.queryParams;
197 if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) {199 if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) {
198 throw new Error(`MCP source '${raw.name}' queryParams must be an object`);200 throw new Error(`MCP source '${raw.name}' queryParams must be an object`);
199 }201 }
@@ -205,22 +207,52 @@ function normalizeExternalToolSource(raw: { · 5 unmodified lines
205 }207 }
206 }208 }
207 209
208 return merged as unknown as ExternalToolSourceConfig;210 const result: McpToolSourceConfig = {
211 type: "mcp",
212 name: raw.name,
213 url: config.url,
214 transport: config.transport as McpToolSourceConfig["transport"],
215 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
216 defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],
217 overrides: config.overrides as McpToolSourceConfig["overrides"],
218 };
219 return result;
209 }220 }
210 221
211 if (raw.type === "graphql") {222 if (raw.type === "graphql") {
212 if (typeof merged.endpoint !== "string" || merged.endpoint.trim().length === 0) {223 if (typeof config.endpoint !== "string" || config.endpoint.trim().length === 0) {
213 throw new Error(`GraphQL source '${raw.name}' missing endpoint`);224 throw new Error(`GraphQL source '${raw.name}' missing endpoint`);
214 }225 }
215 return merged as unknown as ExternalToolSourceConfig;226
227 const result: GraphqlToolSourceConfig = {
228 type: "graphql",
229 name: raw.name,
230 endpoint: config.endpoint,
231 schema: config.schema as GraphqlToolSourceConfig["schema"],
232 auth: config.auth as GraphqlToolSourceConfig["auth"],
233 defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],
234 defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"],
235 overrides: config.overrides as GraphqlToolSourceConfig["overrides"],
236 };
237 return result;
216 }238 }
217 239
218 const spec = merged.spec;240 const spec = config.spec;
219 if (typeof spec !== "string" && typeof spec !== "object") {241 if (typeof spec !== "string" && (typeof spec !== "object" || spec === null)) {
220 throw new Error(`OpenAPI source '${raw.name}' missing spec`);242 throw new Error(`OpenAPI source '${raw.name}' missing spec`);
221 }243 }
222 244
223 return merged as unknown as ExternalToolSourceConfig;245 const result: OpenApiToolSourceConfig = {
246 type: "openapi",
247 name: raw.name,
248 spec: spec as OpenApiToolSourceConfig["spec"],
249 baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],
250 auth: config.auth as OpenApiToolSourceConfig["auth"],
251 defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],
252 defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"],
253 overrides: config.overrides as OpenApiToolSourceConfig["overrides"],
254 };
255 return result;
224}256}
225 257
226async function sleep(ms: number): Promise<void> {258async function sleep(ms: number): Promise<void> {
@@ -230,7 +262,7 @@ async function sleep(ms: number): Promise<void> { · 3 unmodified lines
230const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool]));262const baseTools = new Map<string, ToolDefinition>(DEFAULT_TOOLS.map((tool) => [tool.path, tool]));
231interface DtsStorageEntry {263interface DtsStorageEntry {
232 sourceKey: string;264 sourceKey: string;
233 storageId: string;265 storageId: Id<"_storage">;
234}266}
235 267
236const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;268const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
@@ -239,7 +271,7 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 2 unmodified lines
239const OPENAPI_CACHE_VERSION = "v14";271const OPENAPI_CACHE_VERSION = "v14";
240 272
241async function publish(273async function publish(
242 ctx: any,274 ctx: ActionCtx,
243 taskId: string,275 taskId: string,
244 eventName: "task" | "approval",276 eventName: "task" | "approval",
245 type: string,277 type: string,
@@ -253,7 +285,7 @@ async function publish( · 7 unmodified lines
253 });285 });
254}286}
255 287
256async function waitForApproval(ctx: any, approvalId: string): Promise<"approved" | "denied"> {288async function waitForApproval(ctx: ActionCtx, approvalId: string): Promise<"approved" | "denied"> {
257 while (true) {289 while (true) {
258 const approval = await ctx.runQuery(internal.database.getApproval, { approvalId });290 const approval = await ctx.runQuery(internal.database.getApproval, { approvalId });
259 if (!approval) {291 if (!approval) {
@@ -278,7 +310,7 @@ async function waitForApproval(ctx: any, approvalId: string): Promise<"approved" · 18 unmodified lines
278 * No size limits — Convex file storage handles multi-MB specs without issue.310 * No size limits — Convex file storage handles multi-MB specs without issue.
279 */311 */
280async function loadCachedOpenApiSpec(312async function loadCachedOpenApiSpec(
281 ctx: any,313 ctx: ActionCtx,
282 specUrl: string,314 specUrl: string,
283 sourceName: string,315 sourceName: string,
284): Promise<PreparedOpenApiSpec> {316): Promise<PreparedOpenApiSpec> {
@@ -327,7 +359,7 @@ async function loadCachedOpenApiSpec( · 42 unmodified lines
327}359}
328 360
329async function loadSourceTools(361async function loadSourceTools(
330 ctx: any,362 ctx: ActionCtx,
331 source: ExternalToolSourceConfig,363 source: ExternalToolSourceConfig,
332): Promise<{ tools: ToolDefinition[]; warnings: string[] }> {364): Promise<{ tools: ToolDefinition[]; warnings: string[] }> {
333 if (source.type === "openapi" && typeof source.spec === "string") {365 if (source.type === "openapi" && typeof source.spec === "string") {
@@ -356,7 +388,7 @@ interface WorkspaceToolsResult { · 22 unmodified lines
356 dtsStorageIds: DtsStorageEntry[];388 dtsStorageIds: DtsStorageEntry[];
357}389}
358 390
359async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<WorkspaceToolsResult> {391async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspaces">): Promise<WorkspaceToolsResult> {
360 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))392 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
361 .filter((source: { enabled: boolean }) => source.enabled);393 .filter((source: { enabled: boolean }) => source.enabled);
362 const signature = sourceSignature(workspaceId, sources);394 const signature = sourceSignature(workspaceId, sources);
@@ -440,7 +472,7 @@ async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<Workspa · 77 unmodified lines
440 dtsEntries.map(async (entry) => {472 dtsEntries.map(async (entry) => {
441 const dtsBlob = new Blob([entry.content], { type: "text/plain" });473 const dtsBlob = new Blob([entry.content], { type: "text/plain" });
442 const sid = await ctx.storage.store(dtsBlob);474 const sid = await ctx.storage.store(dtsBlob);
443 return { sourceKey: entry.sourceKey, storageId: String(sid) };475 return { sourceKey: entry.sourceKey, storageId: sid };
444 }),476 }),
445 );477 );
446 dtsStorageIds = storedDts;478 dtsStorageIds = storedDts;
@@ -463,7 +495,7 @@ async function getWorkspaceTools(ctx: any, workspaceId: string): Promise<Workspa · 16 unmodified lines
463 workspaceId,495 workspaceId,
464 signature,496 signature,
465 storageId,497 storageId,
466 dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId as any })),498 dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),
467 toolCount: allTools.length,499 toolCount: allTools.length,
468 sizeBytes: json.length,500 sizeBytes: json.length,
469 });501 });
@@ -608,8 +640,8 @@ function listVisibleToolDescriptors( · 138 unmodified lines
608}640}
609 641
610async function listToolsForContext(642async function listToolsForContext(
611 ctx: any,643 ctx: ActionCtx,
612 context: { workspaceId: string; actorId?: string; clientId?: string },644 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
613): Promise<ToolDescriptor[]> {645): Promise<ToolDescriptor[]> {
614 const [result, policies] = await Promise.all([646 const [result, policies] = await Promise.all([
615 getWorkspaceTools(ctx, context.workspaceId),647 getWorkspaceTools(ctx, context.workspaceId),
@@ -621,8 +653,8 @@ async function listToolsForContext( · 5 unmodified lines
621}653}
622 654
623async function listToolsWithWarningsForContext(655async function listToolsWithWarningsForContext(
624 ctx: any,656 ctx: ActionCtx,
625 context: { workspaceId: string; actorId?: string; clientId?: string },657 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
626): Promise<{658): Promise<{
627 tools: ToolDescriptor[];659 tools: ToolDescriptor[];
628 warnings: string[];660 warnings: string[];
@@ -656,10 +688,6 @@ async function listToolsWithWarningsForContext( · 27 unmodified lines
656 };688 };
657}689}
658 690
659function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }): string {
660 return account.provider === "anonymous" ? account.providerAccountId : account._id;
661}
662
663function isToolAllowedForTask(691function isToolAllowedForTask(
664 task: TaskRecord,692 task: TaskRecord,
665 toolPath: string,693 toolPath: string,
@@ -672,7 +700,7 @@ function isToolAllowedForTask( · 6 unmodified lines
672}700}
673 701
674async function resolveCredentialHeaders(702async function resolveCredentialHeaders(
675 ctx: any,703 ctx: ActionCtx,
676 spec: ToolCredentialSpec,704 spec: ToolCredentialSpec,
677 task: TaskRecord,705 task: TaskRecord,
678): Promise<ResolvedToolCredential | null> {706): Promise<ResolvedToolCredential | null> {
@@ -774,7 +802,7 @@ function getGraphqlDecision( · 95 unmodified lines
774 return { decision: worstDecision, effectivePaths: fieldPaths };802 return { decision: worstDecision, effectivePaths: fieldPaths };
775}803}
776 804
777async function invokeTool(ctx: any, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {805async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
778 const { toolPath, input, callId } = call;806 const { toolPath, input, callId } = call;
779 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });807 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
780 const typedPolicies = policies as AccessPolicyRecord[];808 const typedPolicies = policies as AccessPolicyRecord[];
executor/convex/http.ts+25−17
@@ -110,13 +110,23 @@ async function verifyMcpToken( · 109 unmodified lines
110 }110 }
111}111}
112 112
113/**
114 * Validated boundary for external workspace ID strings.
115 * Convex IDs are opaque strings at runtime; this cast is the single
116 * validated entry point so downstream code never needs ad-hoc casts.
117 */
118function parseWorkspaceId(raw: string): Id<"workspaces"> {
119 return raw as Id<"workspaces">;
120}
121
113function parseMcpContext(url: URL): {122function parseMcpContext(url: URL): {
114 workspaceId: string;123 workspaceId: Id<"workspaces">;
115 clientId?: string;124 clientId?: string;
116 sessionId?: string;125 sessionId?: string;
117} | undefined {126} | undefined {
118 const workspaceId = url.searchParams.get("workspaceId");127 const raw = url.searchParams.get("workspaceId");
119 if (!workspaceId) return undefined;128 if (!raw) return undefined;
129 const workspaceId = parseWorkspaceId(raw);
120 const clientId = url.searchParams.get("clientId") ?? undefined;130 const clientId = url.searchParams.get("clientId") ?? undefined;
121 const sessionId = url.searchParams.get("sessionId") ?? undefined;131 const sessionId = url.searchParams.get("sessionId") ?? undefined;
122 return { workspaceId, clientId, sessionId };132 return { workspaceId, clientId, sessionId };
@@ -162,7 +172,7 @@ const mcpHandler = httpAction(async (ctx, request) => { · 39 unmodified lines
162 try {172 try {
163 if (mcpAuthConfig.enabled && auth?.subject) {173 if (mcpAuthConfig.enabled && auth?.subject) {
164 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, {174 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForWorkosSubject, {
165 workspaceId: requestedContext.workspaceId as Id<"workspaces">,175 workspaceId: requestedContext.workspaceId,
166 subject: auth.subject,176 subject: auth.subject,
167 });177 });
168 178
@@ -177,7 +187,7 @@ const mcpHandler = httpAction(async (ctx, request) => { · 8 unmodified lines
177 }187 }
178 188
179 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {189 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, {
180 workspaceId: requestedContext.workspaceId as Id<"workspaces">,190 workspaceId: requestedContext.workspaceId,
181 sessionId: requestedContext.sessionId,191 sessionId: requestedContext.sessionId,
182 });192 });
183 193
@@ -209,18 +219,17 @@ const mcpHandler = httpAction(async (ctx, request) => { · 25 unmodified lines
209 timeoutMs?: number;219 timeoutMs?: number;
210 runtimeId?: string;220 runtimeId?: string;
211 metadata?: Record<string, unknown>;221 metadata?: Record<string, unknown>;
212 workspaceId: string;222 workspaceId: Id<"workspaces">;
213 actorId: string;223 actorId: string;
214 clientId?: string;224 clientId?: string;
215 }) => {225 }) => {
216 return (await ctx.runMutation(internal.executor.createTaskInternal, {226 return (await ctx.runMutation(internal.executor.createTaskInternal, {
217 ...input,227 ...input,
218 workspaceId: input.workspaceId as Id<"workspaces">,
219 })) as { task: TaskRecord };228 })) as { task: TaskRecord };
220 },229 },
221 getTask: async (taskId: string, workspaceId?: string) => {230 getTask: async (taskId: string, workspaceId?: Id<"workspaces">) => {
222 if (workspaceId) {231 if (workspaceId) {
223 return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId: workspaceId as Id<"workspaces"> })) as TaskRecord | null;232 return (await ctx.runQuery(internal.database.getTaskInWorkspace, { taskId, workspaceId })) as TaskRecord | null;
224 }233 }
225 return null;234 return null;
226 },235 },
@@ -230,15 +239,15 @@ const mcpHandler = httpAction(async (ctx, request) => { · 3 unmodified lines
230 bootstrapAnonymousContext: async (sessionId?: string) => {239 bootstrapAnonymousContext: async (sessionId?: string) => {
231 return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext;240 return (await ctx.runMutation(internal.database.bootstrapAnonymousSession, { sessionId })) as AnonymousContext;
232 },241 },
233 listTools: async (toolContext?: { workspaceId: string; actorId?: string; clientId?: string }) => {242 listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
234 if (!toolContext) {243 if (!toolContext) {
235 return [];244 return [];
236 }245 }
237 246
238 return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext, workspaceId: toolContext.workspaceId as Id<"workspaces"> })) as ToolDescriptor[];247 return (await ctx.runAction(internal.executorNode.listToolsInternal, { ...toolContext })) as ToolDescriptor[];
239 },248 },
240 listToolsForTypecheck: async (toolContext: { workspaceId: string; actorId?: string; clientId?: string }) => {249 listToolsForTypecheck: async (toolContext: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
241 const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, { ...toolContext, workspaceId: toolContext.workspaceId as Id<"workspaces"> }) as {250 const result = await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, { ...toolContext }) as {
242 tools: ToolDescriptor[];251 tools: ToolDescriptor[];
243 dtsUrls?: Record<string, string>;252 dtsUrls?: Record<string, string>;
244 };253 };
@@ -248,11 +257,11 @@ const mcpHandler = httpAction(async (ctx, request) => { · 3 unmodified lines
248 dtsUrls: result.dtsUrls ?? {},257 dtsUrls: result.dtsUrls ?? {},
249 };258 };
250 },259 },
251 listPendingApprovals: async (workspaceId: string) => {260 listPendingApprovals: async (workspaceId: Id<"workspaces">) => {
252 return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId: workspaceId as Id<"workspaces"> })) as PendingApprovalRecord[];261 return (await ctx.runQuery(internal.database.listPendingApprovals, { workspaceId })) as PendingApprovalRecord[];
253 },262 },
254 resolveApproval: async (input: {263 resolveApproval: async (input: {
255 workspaceId: string;264 workspaceId: Id<"workspaces">;
256 approvalId: string;265 approvalId: string;
257 decision: "approved" | "denied";266 decision: "approved" | "denied";
258 reviewerId?: string;267 reviewerId?: string;
@@ -260,7 +269,6 @@ const mcpHandler = httpAction(async (ctx, request) => { · 1 unmodified line
260 }) => {269 }) => {
261 return await ctx.runMutation(internal.executor.resolveApprovalInternal, {270 return await ctx.runMutation(internal.executor.resolveApprovalInternal, {
262 ...input,271 ...input,
263 workspaceId: input.workspaceId as Id<"workspaces">,
264 });272 });
265 },273 },
266 };274 };
executor/convex/invites.ts+0−4
@@ -185,15 +185,12 @@ export const create = organizationMutation({ · 184 unmodified lines
185 }185 }
186 const inviterWorkosUserId = ctx.account.providerAccountId;186 const inviterWorkosUserId = ctx.account.providerAccountId;
187 187
188 const provider = "workos";
189
190 const inviteId = await ctx.db.insert("invites", {188 const inviteId = await ctx.db.insert("invites", {
191 organizationId: ctx.organizationId,189 organizationId: ctx.organizationId,
192 workspaceId: args.workspaceId,190 workspaceId: args.workspaceId,
193 email: normalizedEmail,191 email: normalizedEmail,
194 role: args.role,192 role: args.role,
195 status: "pending",193 status: "pending",
196 provider,
197 invitedByAccountId: ctx.account._id,194 invitedByAccountId: ctx.account._id,
198 expiresAt,195 expiresAt,
199 createdAt: now,196 createdAt: now,
@@ -222,7 +219,6 @@ export const create = organizationMutation({ · 22 unmodified lines
222 expiresAt: invite.expiresAt,219 expiresAt: invite.expiresAt,
223 },220 },
224 delivery: {221 delivery: {
225 provider,
226 providerInviteId: invite.providerInviteId ?? null,222 providerInviteId: invite.providerInviteId ?? null,
227 state: "queued",223 state: "queued",
228 },224 },
executor/convex/migrations.ts+20−11
@@ -81,17 +81,6 @@ export const deleteSourceCredentialsMissingProvider = migrations.define({ · 80 unmodified lines
81 },81 },
82});82});
83 83
84export const deleteInvitesMissingProvider = migrations.define({
85 table: "invites",
86 migrateOne: async (ctx, invite) => {
87 if (invite.provider) {
88 return;
89 }
90
91 await ctx.db.delete(invite._id);
92 },
93});
94
95export const deleteAnonymousSessionsMissingUserId = migrations.define({84export const deleteAnonymousSessionsMissingUserId = migrations.define({
96 table: "anonymousSessions",85 table: "anonymousSessions",
97 migrateOne: async (ctx, session) => {86 migrateOne: async (ctx, session) => {
@@ -112,4 +101,24 @@ export const backfillDtsStorageIds = migrations.define({ · 14 unmodified lines
112 },101 },
113});102});
114 103
104export const cleanupTaskEmptyStringSentinels = migrations.define({
105 table: "tasks",
106 migrateOne: async (_ctx, task) => {
107 const patch: Record<string, undefined> = {};
108 if (task.actorId === "") patch.actorId = undefined;
109 if (task.clientId === "") patch.clientId = undefined;
110 if (Object.keys(patch).length > 0) return patch;
111 },
112});
113
114export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({
115 table: "accessPolicies",
116 migrateOne: async (_ctx, policy) => {
117 const patch: Record<string, undefined> = {};
118 if (policy.actorId === "") patch.actorId = undefined;
119 if (policy.clientId === "") patch.clientId = undefined;
120 if (Object.keys(patch).length > 0) return patch;
121 },
122});
123
115export const run = migrations.runner();124export const run = migrations.runner();
executor/convex/openApiSpecCache.ts+0−32
@@ -66,35 +66,3 @@ export const putEntry = internalMutation({ · 65 unmodified lines
66 });66 });
67 },67 },
68});68});
69
70/**
71 * Remove all cache entries older than the given timestamp.
72 * Returns the number of entries removed.
73 */
74export const pruneExpired = internalMutation({
75 args: {
76 maxAgeMs: v.number(),
77 batchSize: v.optional(v.number()),
78 },
79 handler: async (ctx, args) => {
80 const cutoff = Date.now() - args.maxAgeMs;
81 const limit = args.batchSize ?? 50;
82
83 // Scan all entries (the table should be small — one row per unique spec URL)
84 const entries = await ctx.db
85 .query("openApiSpecCache")
86 .collect();
87
88 let removed = 0;
89 for (const entry of entries) {
90 if (removed >= limit) break;
91 if (entry.createdAt < cutoff) {
92 await ctx.storage.delete(entry.storageId).catch(() => {});
93 await ctx.db.delete(entry._id);
94 removed++;
95 }
96 }
97
98 return removed;
99 },
100});
executor/convex/organizations.ts+0−1
@@ -75,7 +75,6 @@ export const create = authedMutation({ · 74 unmodified lines
75 organizationId,75 organizationId,
76 slug: "default",76 slug: "default",
77 name: "Default Workspace",77 name: "Default Workspace",
78 plan: "free",
79 createdByAccountId: account._id,78 createdByAccountId: account._id,
80 createdAt: now,79 createdAt: now,
81 updatedAt: now,80 updatedAt: now,
executor/convex/schema.ts+7−20
@@ -25,7 +25,6 @@ const inviteStatus = v.union( · 24 unmodified lines
25 v.literal("revoked"),25 v.literal("revoked"),
26 v.literal("failed"),26 v.literal("failed"),
27);27);
28const inviteProvider = v.literal("workos");
29const taskStatus = v.union(28const taskStatus = v.union(
30 v.literal("queued"),29 v.literal("queued"),
31 v.literal("running"),30 v.literal("running"),
@@ -58,8 +57,7 @@ export default defineSchema({ · 26 unmodified lines
58 updatedAt: v.number(),57 updatedAt: v.number(),
59 lastLoginAt: v.optional(v.number()),58 lastLoginAt: v.optional(v.number()),
60 })59 })
61 .index("by_provider", ["provider", "providerAccountId"])60 .index("by_provider", ["provider", "providerAccountId"]),
62 .index("by_email", ["email"]),
63 61
64 workspaces: defineTable({62 workspaces: defineTable({
65 workosOrgId: v.optional(v.string()),63 workosOrgId: v.optional(v.string()),
@@ -67,7 +65,6 @@ export default defineSchema({ · 1 unmodified line
67 slug: v.string(),65 slug: v.string(),
68 name: v.string(),66 name: v.string(),
69 iconStorageId: v.optional(v.id("_storage")),67 iconStorageId: v.optional(v.id("_storage")),
70 plan: v.string(),
71 createdByAccountId: v.optional(v.id("accounts")),68 createdByAccountId: v.optional(v.id("accounts")),
72 createdAt: v.number(),69 createdAt: v.number(),
73 updatedAt: v.number(),70 updatedAt: v.number(),
@@ -75,7 +72,6 @@ export default defineSchema({ · 1 unmodified line
75 .index("by_workos_org_id", ["workosOrgId"])72 .index("by_workos_org_id", ["workosOrgId"])
76 .index("by_organization_created", ["organizationId", "createdAt"])73 .index("by_organization_created", ["organizationId", "createdAt"])
77 .index("by_organization_slug", ["organizationId", "slug"])74 .index("by_organization_slug", ["organizationId", "slug"])
78 .index("by_creator_created", ["createdByAccountId", "createdAt"])
79 .index("by_slug", ["slug"]),75 .index("by_slug", ["slug"]),
80 76
81 organizations: defineTable({77 organizations: defineTable({
@@ -106,7 +102,6 @@ export default defineSchema({ · 24 unmodified lines
106 .index("by_org", ["organizationId"])102 .index("by_org", ["organizationId"])
107 .index("by_org_account", ["organizationId", "accountId"])103 .index("by_org_account", ["organizationId", "accountId"])
108 .index("by_account", ["accountId"])104 .index("by_account", ["accountId"])
109 .index("by_org_status", ["organizationId", "status"])
110 .index("by_org_billable_status", ["organizationId", "billable", "status"]),105 .index("by_org_billable_status", ["organizationId", "billable", "status"]),
111 106
112 workspaceMembers: defineTable({107 workspaceMembers: defineTable({
@@ -121,7 +116,6 @@ export default defineSchema({ · 8 unmodified lines
121 .index("by_workspace", ["workspaceId"])116 .index("by_workspace", ["workspaceId"])
122 .index("by_workspace_account", ["workspaceId", "accountId"])117 .index("by_workspace_account", ["workspaceId", "accountId"])
123 .index("by_account", ["accountId"])118 .index("by_account", ["accountId"])
124 .index("by_workspace_status", ["workspaceId", "status"])
125 .index("by_workos_membership_id", ["workosOrgMembershipId"]),119 .index("by_workos_membership_id", ["workosOrgMembershipId"]),
126 120
127 invites: defineTable({121 invites: defineTable({
@@ -130,7 +124,6 @@ export default defineSchema({ · 2 unmodified lines
130 email: v.string(),124 email: v.string(),
131 role: orgRole,125 role: orgRole,
132 status: inviteStatus,126 status: inviteStatus,
133 provider: inviteProvider,
134 providerInviteId: v.optional(v.string()),127 providerInviteId: v.optional(v.string()),
135 invitedByAccountId: v.id("accounts"),128 invitedByAccountId: v.id("accounts"),
136 expiresAt: v.number(),129 expiresAt: v.number(),
@@ -139,7 +132,6 @@ export default defineSchema({ · 2 unmodified lines
139 updatedAt: v.number(),132 updatedAt: v.number(),
140 })133 })
141 .index("by_org", ["organizationId"])134 .index("by_org", ["organizationId"])
142 .index("by_org_status_created", ["organizationId", "status", "createdAt"])
143 .index("by_org_email_status", ["organizationId", "email", "status"]),135 .index("by_org_email_status", ["organizationId", "email", "status"]),
144 136
145 billingCustomers: defineTable({137 billingCustomers: defineTable({
@@ -182,8 +174,8 @@ export default defineSchema({ · 36 unmodified lines
182 code: v.string(),174 code: v.string(),
183 runtimeId: v.string(),175 runtimeId: v.string(),
184 workspaceId: v.id("workspaces"),176 workspaceId: v.id("workspaces"),
185 actorId: v.string(),177 actorId: v.optional(v.string()),
186 clientId: v.string(),178 clientId: v.optional(v.string()),
187 status: taskStatus,179 status: taskStatus,
188 timeoutMs: v.number(),180 timeoutMs: v.number(),
189 metadata: v.any(),181 metadata: v.any(),
@@ -213,7 +205,6 @@ export default defineSchema({ · 23 unmodified lines
213 resolvedAt: v.optional(v.number()),205 resolvedAt: v.optional(v.number()),
214 })206 })
215 .index("by_approval_id", ["approvalId"])207 .index("by_approval_id", ["approvalId"])
216 .index("by_task", ["taskId"])
217 .index("by_workspace_created", ["workspaceId", "createdAt"])208 .index("by_workspace_created", ["workspaceId", "createdAt"])
218 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),209 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),
219 210
@@ -225,14 +216,13 @@ export default defineSchema({ · 5 unmodified lines
225 payload: v.any(),216 payload: v.any(),
226 createdAt: v.number(),217 createdAt: v.number(),
227 })218 })
228 .index("by_sequence", ["sequence"])
229 .index("by_task_sequence", ["taskId", "sequence"]),219 .index("by_task_sequence", ["taskId", "sequence"]),
230 220
231 accessPolicies: defineTable({221 accessPolicies: defineTable({
232 policyId: v.string(),222 policyId: v.string(),
233 workspaceId: v.id("workspaces"),223 workspaceId: v.id("workspaces"),
234 actorId: v.string(),224 actorId: v.optional(v.string()),
235 clientId: v.string(),225 clientId: v.optional(v.string()),
236 toolPathPattern: v.string(),226 toolPathPattern: v.string(),
237 decision: policyDecision,227 decision: policyDecision,
238 priority: v.number(),228 priority: v.number(),
@@ -253,7 +243,6 @@ export default defineSchema({ · 14 unmodified lines
253 createdAt: v.number(),243 createdAt: v.number(),
254 updatedAt: v.number(),244 updatedAt: v.number(),
255 })245 })
256 .index("by_credential_id", ["credentialId"])
257 .index("by_workspace_created", ["workspaceId", "createdAt"])246 .index("by_workspace_created", ["workspaceId", "createdAt"])
258 .index("by_workspace_source_scope_actor", ["workspaceId", "sourceKey", "scope", "actorId"]),247 .index("by_workspace_source_scope_actor", ["workspaceId", "sourceKey", "scope", "actorId"]),
259 248
@@ -269,8 +258,7 @@ export default defineSchema({ · 9 unmodified lines
269 })258 })
270 .index("by_source_id", ["sourceId"])259 .index("by_source_id", ["sourceId"])
271 .index("by_workspace_updated", ["workspaceId", "updatedAt"])260 .index("by_workspace_updated", ["workspaceId", "updatedAt"])
272 .index("by_workspace_name", ["workspaceId", "name"])261 .index("by_workspace_name", ["workspaceId", "name"]),
273 .index("by_workspace_enabled_updated", ["workspaceId", "enabled", "updatedAt"]),
274 262
275 agentTasks: defineTable({263 agentTasks: defineTable({
276 agentTaskId: v.string(),264 agentTaskId: v.string(),
@@ -286,8 +274,7 @@ export default defineSchema({ · 9 unmodified lines
286 updatedAt: v.number(),274 updatedAt: v.number(),
287 })275 })
288 .index("by_agent_task_id", ["agentTaskId"])276 .index("by_agent_task_id", ["agentTaskId"])
289 .index("by_workspace_created", ["workspaceId", "createdAt"])277 .index("by_workspace_created", ["workspaceId", "createdAt"]),
290 .index("by_requester_created", ["requesterId", "createdAt"]),
291 278
292 openApiSpecCache: defineTable({279 openApiSpecCache: defineTable({
293 specUrl: v.string(),280 specUrl: v.string(),
executor/convex/workspace.ts+1−4
@@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { mutation, query } from "./_generated/server";3import { mutation, query } from "./_generated/server";
4import { workspaceMutation, workspaceQuery } from "../lib/functionBuilders";4import { workspaceMutation, workspaceQuery } from "../lib/functionBuilders";
5import { actorIdForAccount } from "../lib/identity";
5 6
6const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));7const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
7const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));8const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
@@ -9,10 +10,6 @@ const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("acto · 1 unmodified line
9const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));10const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));
10const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));11const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
11 12
12function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }): string {
13 return account.provider === "anonymous" ? account.providerAccountId : account._id;
14}
15
16function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T {13function redactCredential<T extends { secretJson: Record<string, unknown> }>(credential: T): T {
17 return {14 return {
18 ...credential,15 ...credential,
executor/convex/workspaceAuthInternal.ts+1−4
@@ -1,15 +1,12 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import { internalQuery } from "./_generated/server";2import { internalQuery } from "./_generated/server";
3import {3import {
4 actorIdForAccount,
4 requireWorkspaceAccessForAccount,5 requireWorkspaceAccessForAccount,
5 resolveAccountForRequest,6 resolveAccountForRequest,
6 resolveWorkosAccountBySubject,7 resolveWorkosAccountBySubject,
7} from "../lib/identity";8} from "../lib/identity";
8 9
9function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }): string {
10 return account.provider === "anonymous" ? account.providerAccountId : account._id;
11}
12
13export const getWorkspaceAccessForRequest = internalQuery({10export const getWorkspaceAccessForRequest = internalQuery({
14 args: {11 args: {
15 workspaceId: v.id("workspaces"),12 workspaceId: v.id("workspaces"),
executor/convex/workspaceToolCache.ts+0−18
@@ -72,21 +72,3 @@ export const putEntry = internalMutation({ · 71 unmodified lines
72 });72 });
73 },73 },
74});74});
75
76/**
77 * Look up .d.ts storage IDs for a workspace (used to generate download URLs).
78 */
79export const getDtsStorageIds = internalQuery({
80 args: {
81 workspaceId: v.id("workspaces"),
82 },
83 handler: async (ctx, args) => {
84 const entry = await ctx.db
85 .query("workspaceToolCache")
86 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
87 .unique();
88
89 if (!entry) return [];
90 return entry.dtsStorageIds;
91 },
92});
executor/convex/workspaces.ts+0−1
@@ -193,7 +193,6 @@ export const create = authedMutation({ · 192 unmodified lines
193 slug,193 slug,
194 name,194 name,
195 iconStorageId: args.iconStorageId,195 iconStorageId: args.iconStorageId,
196 plan: "free",
197 createdByAccountId: account._id,196 createdByAccountId: account._id,
198 createdAt: now,197 createdAt: now,
199 updatedAt: now,198 updatedAt: now,
executor/lib/identity.ts+4−0
@@ -158,3 +158,7 @@ export function isAdminRole(role: string): boolean { · 157 unmodified lines
158export function canManageBilling(role: string): boolean {158export function canManageBilling(role: string): boolean {
159 return role === "owner" || role === "billing_admin";159 return role === "owner" || role === "billing_admin";
160}160}
161
162export function actorIdForAccount(account: { _id: string; provider: string; providerAccountId: string }): string {
163 return account.provider === "anonymous" ? account.providerAccountId : account._id;
164}
executor/lib/mcp-server.test.ts+2−1
@@ -3,6 +3,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 2 unmodified lines
3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4import { handleMcpRequest } from "./mcp_server";4import { handleMcpRequest } from "./mcp_server";
5import type { LiveTaskEvent } from "./events";5import type { LiveTaskEvent } from "./events";
6import type { Id } from "../convex/_generated/dataModel";
6import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";7import type { AnonymousContext, CreateTaskInput, TaskRecord, ToolDescriptor } from "./types";
7 8
8class FakeMcpService {9class FakeMcpService {
@@ -75,7 +76,7 @@ class FakeMcpService { · 66 unmodified lines
75 const now = Date.now();76 const now = Date.now();
76 const context: AnonymousContext = {77 const context: AnonymousContext = {
77 sessionId: sessionId ?? `anon_session_${crypto.randomUUID()}`,78 sessionId: sessionId ?? `anon_session_${crypto.randomUUID()}`,
78 workspaceId: `ws_${crypto.randomUUID()}`,79 workspaceId: `ws_${crypto.randomUUID()}` as Id<"workspaces">,
79 actorId: `anon_${crypto.randomUUID()}`,80 actorId: `anon_${crypto.randomUUID()}`,
80 clientId: "mcp",81 clientId: "mcp",
81 accountId: `account_${crypto.randomUUID()}`,82 accountId: `account_${crypto.randomUUID()}`,
executor/lib/mcp_server.ts+10−9
@@ -10,6 +10,7 @@ import type { · 9 unmodified lines
10 TaskRecord,10 TaskRecord,
11 ToolDescriptor,11 ToolDescriptor,
12} from "./types";12} from "./types";
13import type { Id } from "../convex/_generated/dataModel";
13 14
14function getTaskTerminalState(status: string): boolean {15function getTaskTerminalState(status: string): boolean {
15 return status === "completed" || status === "failed" || status === "timed_out" || status === "denied";16 return status === "completed" || status === "failed" || status === "timed_out" || status === "denied";
@@ -21,16 +22,16 @@ function getTaskTerminalState(status: string): boolean { · 5 unmodified lines
21 22
22interface McpExecutorService {23interface McpExecutorService {
23 createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>;24 createTask(input: CreateTaskInput): Promise<{ task: TaskRecord }>;
24 getTask(taskId: string, workspaceId?: string): Promise<TaskRecord | null>;25 getTask(taskId: string, workspaceId?: Id<"workspaces">): Promise<TaskRecord | null>;
25 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void;26 subscribe(taskId: string, listener: (event: LiveTaskEvent) => void): () => void;
26 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;27 bootstrapAnonymousContext(sessionId?: string): Promise<AnonymousContext>;
27 listTools(context?: { workspaceId: string; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>;28 listTools(context?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }): Promise<ToolDescriptor[]>;
28 listToolsForTypecheck?(29 listToolsForTypecheck?(
29 context: { workspaceId: string; actorId?: string; clientId?: string },30 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
30 ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }>;31 ): Promise<{ tools: ToolDescriptor[]; dtsUrls: Record<string, string> }>;
31 listPendingApprovals?(workspaceId: string): Promise<PendingApprovalRecord[]>;32 listPendingApprovals?(workspaceId: Id<"workspaces">): Promise<PendingApprovalRecord[]>;
32 resolveApproval?(input: {33 resolveApproval?(input: {
33 workspaceId: string;34 workspaceId: Id<"workspaces">;
34 approvalId: string;35 approvalId: string;
35 decision: "approved" | "denied";36 decision: "approved" | "denied";
36 reviewerId?: string;37 reviewerId?: string;
@@ -44,7 +45,7 @@ interface ApprovalPromptDecision { · 7 unmodified lines
44}45}
45 46
46interface ApprovalPromptContext {47interface ApprovalPromptContext {
47 workspaceId: string;48 workspaceId: Id<"workspaces">;
48 actorId: string;49 actorId: string;
49}50}
50 51
@@ -58,7 +59,7 @@ type ApprovalPrompt = ( · 7 unmodified lines
58// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
59 60
60export interface McpWorkspaceContext {61export interface McpWorkspaceContext {
61 workspaceId: string;62 workspaceId: Id<"workspaces">;
62 actorId: string;63 actorId: string;
63 clientId?: string;64 clientId?: string;
64 sessionId?: string;65 sessionId?: string;
@@ -173,7 +174,7 @@ function summarizeTask(task: TaskRecord): string { · 108 unmodified lines
173function waitForTerminalTask(174function waitForTerminalTask(
174 service: McpExecutorService,175 service: McpExecutorService,
175 taskId: string,176 taskId: string,
176 workspaceId: string,177 workspaceId: Id<"workspaces">,
177 waitTimeoutMs: number,178 waitTimeoutMs: number,
178 onApprovalPrompt?: ApprovalPrompt,179 onApprovalPrompt?: ApprovalPrompt,
179 approvalContext?: ApprovalPromptContext,180 approvalContext?: ApprovalPromptContext,
@@ -422,7 +423,7 @@ function createRunCodeTool( · 242 unmodified lines
422 const requestedTimeoutMs = input.timeoutMs ?? 300_000;423 const requestedTimeoutMs = input.timeoutMs ?? 300_000;
423 424
424 // Resolve context: bound context takes priority, then input, then anonymous425 // Resolve context: bound context takes priority, then input, then anonymous
425 let context: { workspaceId: string; actorId: string; clientId?: string; sessionId?: string };426 let context: { workspaceId: Id<"workspaces">; actorId: string; clientId?: string; sessionId?: string };
426 427
427 if (boundContext) {428 if (boundContext) {
428 context = { ...boundContext, sessionId: input.sessionId ?? boundContext.sessionId };429 context = { ...boundContext, sessionId: input.sessionId ?? boundContext.sessionId };
executor/lib/runtimes/runtime-core.test.ts+2−1
@@ -1,6 +1,7 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import { runCodeWithAdapter } from "./runtime_core";2import { runCodeWithAdapter } from "./runtime_core";
3import { loadExternalTools } from "../tool_sources";3import { loadExternalTools } from "../tool_sources";
4import type { Id } from "../../convex/_generated/dataModel";
4import type {5import type {
5 ExecutionAdapter,6 ExecutionAdapter,
6 RuntimeOutputEvent,7 RuntimeOutputEvent,
@@ -34,7 +35,7 @@ function createRuntimeAdapter( · 27 unmodified lines
34 try {35 try {
35 const context: ToolRunContext = {36 const context: ToolRunContext = {
36 taskId: call.runId,37 taskId: call.runId,
37 workspaceId: "ws_test",38 workspaceId: "ws_test" as Id<"workspaces">,
38 actorId: "actor_test",39 actorId: "actor_test",
39 clientId: "web",40 clientId: "web",
40 isToolAllowed: () => true,41 isToolAllowed: () => true,
executor/lib/tool-discovery.test.ts+10−7
@@ -1,7 +1,10 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import type { Id } from "../convex/_generated/dataModel";
2import { createDiscoverTool } from "./tool_discovery";3import { createDiscoverTool } from "./tool_discovery";
3import type { ToolDefinition } from "./types";4import type { ToolDefinition } from "./types";
4 5
6const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
7
5test("discover returns aliases and example calls", async () => {8test("discover returns aliases and example calls", async () => {
6 const tool = createDiscoverTool([9 const tool = createDiscoverTool([
7 {10 {
@@ -19,7 +22,7 @@ test("discover returns aliases and example calls", async () => { · 11 unmodified lines
19 22
20 const result = await tool.run(23 const result = await tool.run(
21 { query: "addnumbers", depth: 2 },24 { query: "addnumbers", depth: 2 },
22 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },25 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
23 ) as {26 ) as {
24 bestPath: string | null;27 bestPath: string | null;
25 results: Array<{28 results: Array<{
@@ -57,7 +60,7 @@ test("discover example call handles input-shaped args", async () => { · 31 unmodified lines
57 60
58 const result = await tool.run(61 const result = await tool.run(
59 { query: "issuecreate", depth: 2 },62 { query: "issuecreate", depth: 2 },
60 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },63 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
61 ) as {64 ) as {
62 bestPath: string | null;65 bestPath: string | null;
63 results: Array<{ exampleCall: string }>;66 results: Array<{ exampleCall: string }>;
@@ -86,7 +89,7 @@ test("discover uses compact signatures by default and allows full mode", async ( · 22 unmodified lines
86 89
87 const compactResult = await tool.run(90 const compactResult = await tool.run(
88 { query: "linear teams", depth: 2 },91 { query: "linear teams", depth: 2 },
89 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },92 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
90 ) as {93 ) as {
91 bestPath: string | null;94 bestPath: string | null;
92 results: Array<{ description: string; signature: string }>;95 results: Array<{ description: string; signature: string }>;
@@ -94,7 +97,7 @@ test("discover uses compact signatures by default and allows full mode", async ( · 1 unmodified line
94 97
95 const fullResult = await tool.run(98 const fullResult = await tool.run(
96 { query: "linear teams", depth: 2, compact: false },99 { query: "linear teams", depth: 2, compact: false },
97 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },100 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
98 ) as {101 ) as {
99 bestPath: string | null;102 bestPath: string | null;
100 results: Array<{ description: string; signature: string }>;103 results: Array<{ description: string; signature: string }>;
@@ -128,7 +131,7 @@ test("discover returns null bestPath when there are no matches", async () => { · 27 unmodified lines
128 131
129 const result = await tool.run(132 const result = await tool.run(
130 { query: "totally_unrelated_keyword" },133 { query: "totally_unrelated_keyword" },
131 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },134 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
132 ) as { bestPath: string | null; results: Array<unknown>; total: number };135 ) as { bestPath: string | null; results: Array<unknown>; total: number };
133 136
134 expect(result.bestPath).toBeNull();137 expect(result.bestPath).toBeNull();
@@ -164,7 +167,7 @@ test("discover bestPath prefers simpler exact intent operation", async () => { · 29 unmodified lines
164 167
165 const result = await tool.run(168 const result = await tool.run(
166 { query: "linear issue create", depth: 2 },169 { query: "linear issue create", depth: 2 },
167 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },170 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
168 ) as { bestPath: string | null; results: Array<{ path: string }> };171 ) as { bestPath: string | null; results: Array<{ path: string }> };
169 172
170 expect(result.bestPath).toBe("linear.mutation.issuecreate");173 expect(result.bestPath).toBe("linear.mutation.issuecreate");
@@ -199,7 +202,7 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 28 unmodified lines
199 202
200 const result = await tool.run(203 const result = await tool.run(
201 { query: "linear teams list", depth: 2 },204 { query: "linear teams list", depth: 2 },
202 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },205 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
203 ) as { bestPath: string | null; results: Array<{ path: string }> };206 ) as { bestPath: string | null; results: Array<{ path: string }> };
204 207
205 expect(result.bestPath).toBe("linear.query.teams");208 expect(result.bestPath).toBe("linear.query.teams");
executor/lib/tool-sources.test.ts+4−1
@@ -1,7 +1,10 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import type { Id } from "../convex/_generated/dataModel";
2import { loadExternalTools } from "./tool_sources";3import { loadExternalTools } from "./tool_sources";
3import type { ExternalToolSourceConfig } from "./tool_sources";4import type { ExternalToolSourceConfig } from "./tool_sources";
4 5
6const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
7
5function makeInlineSpec(tag: string, operationId: string): Record<string, unknown> {8function makeInlineSpec(tag: string, operationId: string): Record<string, unknown> {
6 return {9 return {
7 openapi: "3.0.3",10 openapi: "3.0.3",
@@ -461,7 +464,7 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 453 unmodified lines
461 expect(batchTool).toBeDefined();464 expect(batchTool).toBeDefined();
462 expect(rawTool).toBeDefined();465 expect(rawTool).toBeDefined();
463 466
464 const context = { taskId: "t", workspaceId: "w", isToolAllowed: () => true };467 const context = { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true };
465 468
466 const teamsResult = await teamsTool!.run({}, context);469 const teamsResult = await teamsTool!.run({}, context);
467 const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context);470 const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context);
executor/lib/types.ts+9−7
@@ -1,5 +1,7 @@
1// ── Shared types (inlined from @executor/contracts) ──────────────────────────1// ── Shared types (inlined from @executor/contracts) ──────────────────────────
2 2
3import type { Id } from "../convex/_generated/dataModel";
4
3export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied";5export type TaskStatus = "queued" | "running" | "completed" | "failed" | "timed_out" | "denied";
4export type ApprovalStatus = "pending" | "approved" | "denied";6export type ApprovalStatus = "pending" | "approved" | "denied";
5export type PolicyDecision = "allow" | "require_approval" | "deny";7export type PolicyDecision = "allow" | "require_approval" | "deny";
@@ -15,7 +17,7 @@ export interface TaskRecord { · 9 unmodified lines
15 status: TaskStatus;17 status: TaskStatus;
16 timeoutMs: number;18 timeoutMs: number;
17 metadata: Record<string, unknown>;19 metadata: Record<string, unknown>;
18 workspaceId: string;20 workspaceId: Id<"workspaces">;
19 actorId?: string;21 actorId?: string;
20 clientId?: string;22 clientId?: string;
21 createdAt: number;23 createdAt: number;
@@ -55,7 +57,7 @@ export interface TaskEventRecord { · 33 unmodified lines
55 57
56export interface AccessPolicyRecord {58export interface AccessPolicyRecord {
57 id: string;59 id: string;
58 workspaceId: string;60 workspaceId: Id<"workspaces">;
59 actorId?: string;61 actorId?: string;
60 clientId?: string;62 clientId?: string;
61 toolPathPattern: string;63 toolPathPattern: string;
@@ -67,7 +69,7 @@ export interface AccessPolicyRecord { · 5 unmodified lines
67 69
68export interface CredentialRecord {70export interface CredentialRecord {
69 id: string;71 id: string;
70 workspaceId: string;72 workspaceId: Id<"workspaces">;
71 sourceKey: string;73 sourceKey: string;
72 scope: CredentialScope;74 scope: CredentialScope;
73 actorId?: string;75 actorId?: string;
@@ -79,7 +81,7 @@ export interface CredentialRecord { · 5 unmodified lines
79 81
80export interface ToolSourceRecord {82export interface ToolSourceRecord {
81 id: string;83 id: string;
82 workspaceId: string;84 workspaceId: Id<"workspaces">;
83 name: string;85 name: string;
84 type: ToolSourceType;86 type: ToolSourceType;
85 config: Record<string, unknown>;87 config: Record<string, unknown>;
@@ -112,7 +114,7 @@ export interface OpenApiSourceQuality { · 26 unmodified lines
112 114
113export interface AnonymousContext {115export interface AnonymousContext {
114 sessionId: string;116 sessionId: string;
115 workspaceId: string;117 workspaceId: Id<"workspaces">;
116 actorId: string;118 actorId: string;
117 clientId: string;119 clientId: string;
118 accountId: string;120 accountId: string;
@@ -128,7 +130,7 @@ export interface CreateTaskInput { · 9 unmodified lines
128 timeoutMs?: number;130 timeoutMs?: number;
129 runtimeId?: string;131 runtimeId?: string;
130 metadata?: Record<string, unknown>;132 metadata?: Record<string, unknown>;
131 workspaceId: string;133 workspaceId: Id<"workspaces">;
132 actorId: string;134 actorId: string;
133 clientId?: string;135 clientId?: string;
134}136}
@@ -201,7 +203,7 @@ export interface ResolvedToolCredential { · 66 unmodified lines
201 203
202export interface ToolRunContext {204export interface ToolRunContext {
203 taskId: string;205 taskId: string;
204 workspaceId: string;206 workspaceId: Id<"workspaces">;
205 actorId?: string;207 actorId?: string;
206 clientId?: string;208 clientId?: string;
207 credential?: ResolvedToolCredential;209 credential?: ResolvedToolCredential;
executor/lib/workspace-tool-cache.test.ts+6−5
@@ -16,6 +16,8 @@ import { · 15 unmodified lines
16} from "./tool_sources";16} from "./tool_sources";
17import type { ToolDefinition } from "./types";17import type { ToolDefinition } from "./types";
18 18
19const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
20
19function makeBaseTools(): Map<string, ToolDefinition> {21function makeBaseTools(): Map<string, ToolDefinition> {
20 return new Map([22 return new Map([
21 [23 [
@@ -236,7 +238,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 214 unmodified lines
236 // The rehydrated run should work238 // The rehydrated run should work
237 const result = await rehydrated[0]!.run(239 const result = await rehydrated[0]!.run(
238 { message: "hello" },240 { message: "hello" },
239 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },241 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
240 );242 );
241 expect(result).toBe("hello");243 expect(result).toBe("hello");
242 });244 });
@@ -344,11 +346,11 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 101 unmodified lines
344 try {346 try {
345 const rawResult = await rawTool!.run(347 const rawResult = await rawTool!.run(
346 { query: "query viewer { viewer { id } }" },348 { query: "query viewer { viewer { id } }" },
347 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },349 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
348 );350 );
349 const teamsResult = await teamsTool!.run(351 const teamsResult = await teamsTool!.run(
350 {},352 {},
351 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },353 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
352 );354 );
353 355
354 expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });356 expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });
@@ -395,7 +397,7 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 40 unmodified lines
395 const echoTool = rehydrated.find((t) => t.path === "echo")!;397 const echoTool = rehydrated.find((t) => t.path === "echo")!;
396 const result = await echoTool.run(398 const result = await echoTool.run(
397 { message: "round-trip" },399 { message: "round-trip" },
398 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },400 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
399 );401 );
400 expect(result).toBe("round-trip");402 expect(result).toBe("round-trip");
401 403
@@ -487,7 +489,6 @@ describe("workspace tool cache table", () => { · 85 unmodified lines
487 name: "test-ws",489 name: "test-ws",
488 slug: "test-ws",490 slug: "test-ws",
489 organizationId: orgId,491 organizationId: orgId,
490 plan: "free",
491 createdAt: Date.now(),492 createdAt: Date.now(),
492 updatedAt: Date.now(),493 updatedAt: Date.now(),
493 });494 });
executor/mcp-gateway.ts+24−14
@@ -94,13 +94,23 @@ async function verifyOptionalMcpToken(request: Request): Promise<string | null> · 93 unmodified lines
94 return payload.sub;94 return payload.sub;
95}95}
96 96
97/**
98 * Validated boundary for external workspace ID strings.
99 * Convex IDs are opaque strings at runtime; this cast is the single
100 * validated entry point so downstream code never needs ad-hoc casts.
101 */
102function parseWorkspaceId(raw: string): Id<"workspaces"> {
103 return raw as Id<"workspaces">;
104}
105
97function parseRequestedContext(url: URL): {106function parseRequestedContext(url: URL): {
98 workspaceId?: string;107 workspaceId?: Id<"workspaces">;
99 actorId?: string;108 actorId?: string;
100 clientId?: string;109 clientId?: string;
101 sessionId?: string;110 sessionId?: string;
102} {111} {
103 const workspaceId = url.searchParams.get("workspaceId") ?? undefined;112 const rawWorkspaceId = url.searchParams.get("workspaceId");
113 const workspaceId = rawWorkspaceId ? parseWorkspaceId(rawWorkspaceId) : undefined;
104 const actorId = url.searchParams.get("actorId") ?? undefined;114 const actorId = url.searchParams.get("actorId") ?? undefined;
105 const clientId = url.searchParams.get("clientId") ?? undefined;115 const clientId = url.searchParams.get("clientId") ?? undefined;
106 const sessionId = url.searchParams.get("sessionId") ?? undefined;116 const sessionId = url.searchParams.get("sessionId") ?? undefined;
@@ -122,12 +132,12 @@ function createService(context?: McpWorkspaceContext, bearerToken?: string) { · 15 unmodified lines
122 timeoutMs?: number;132 timeoutMs?: number;
123 runtimeId?: string;133 runtimeId?: string;
124 metadata?: Record<string, unknown>;134 metadata?: Record<string, unknown>;
125 workspaceId: string;135 workspaceId: Id<"workspaces">;
126 actorId: string;136 actorId: string;
127 clientId?: string;137 clientId?: string;
128 }) => {138 }) => {
129 const created = await convex.mutation(api.executor.createTask, {139 const created = await convex.mutation(api.executor.createTask, {
130 workspaceId: input.workspaceId as Id<"workspaces">,140 workspaceId: input.workspaceId,
131 sessionId,141 sessionId,
132 code: input.code,142 code: input.code,
133 timeoutMs: input.timeoutMs,143 timeoutMs: input.timeoutMs,
@@ -139,13 +149,13 @@ function createService(context?: McpWorkspaceContext, bearerToken?: string) { · 5 unmodified lines
139 return created as { task: TaskRecord };149 return created as { task: TaskRecord };
140 },150 },
141 151
142 getTask: async (taskId: string, workspace?: string) => {152 getTask: async (taskId: string, workspace?: Id<"workspaces">) => {
143 const effectiveWorkspaceId = workspace ?? workspaceId;153 const effectiveWorkspaceId = workspace ?? workspaceId;
144 if (!effectiveWorkspaceId) {154 if (!effectiveWorkspaceId) {
145 return null;155 return null;
146 }156 }
147 const task = await convex.query(api.workspace.getTaskInWorkspace, {157 const task = await convex.query(api.workspace.getTaskInWorkspace, {
148 workspaceId: effectiveWorkspaceId as Id<"workspaces">,158 workspaceId: effectiveWorkspaceId,
149 sessionId,159 sessionId,
150 taskId,160 taskId,
151 });161 });
@@ -161,12 +171,12 @@ function createService(context?: McpWorkspaceContext, bearerToken?: string) { · 9 unmodified lines
161 return bootstrap as AnonymousContext;171 return bootstrap as AnonymousContext;
162 },172 },
163 173
164 listTools: async (toolContext?: { workspaceId: string; actorId?: string; clientId?: string }) => {174 listTools: async (toolContext?: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string }) => {
165 if (!toolContext) {175 if (!toolContext) {
166 return [];176 return [];
167 }177 }
168 const tools = await convex.action(api.executorNode.listTools, {178 const tools = await convex.action(api.executorNode.listTools, {
169 workspaceId: toolContext.workspaceId as Id<"workspaces">,179 workspaceId: toolContext.workspaceId,
170 sessionId,180 sessionId,
171 actorId: toolContext.actorId,181 actorId: toolContext.actorId,
172 clientId: toolContext.clientId,182 clientId: toolContext.clientId,
@@ -174,23 +184,23 @@ function createService(context?: McpWorkspaceContext, bearerToken?: string) { · 1 unmodified line
174 return tools as ToolDescriptor[];184 return tools as ToolDescriptor[];
175 },185 },
176 186
177 listPendingApprovals: async (approvalWorkspaceId: string) => {187 listPendingApprovals: async (approvalWorkspaceId: Id<"workspaces">) => {
178 const approvals = await convex.query(api.workspace.listPendingApprovals, {188 const approvals = await convex.query(api.workspace.listPendingApprovals, {
179 workspaceId: approvalWorkspaceId as Id<"workspaces">,189 workspaceId: approvalWorkspaceId,
180 sessionId,190 sessionId,
181 });191 });
182 return approvals as PendingApprovalRecord[];192 return approvals as PendingApprovalRecord[];
183 },193 },
184 194
185 resolveApproval: async (input: {195 resolveApproval: async (input: {
186 workspaceId: string;196 workspaceId: Id<"workspaces">;
187 approvalId: string;197 approvalId: string;
188 decision: "approved" | "denied";198 decision: "approved" | "denied";
189 reviewerId?: string;199 reviewerId?: string;
190 reason?: string;200 reason?: string;
191 }) => {201 }) => {
192 return await convex.mutation(api.executor.resolveApproval, {202 return await convex.mutation(api.executor.resolveApproval, {
193 workspaceId: input.workspaceId as Id<"workspaces">,203 workspaceId: input.workspaceId,
194 sessionId,204 sessionId,
195 approvalId: input.approvalId,205 approvalId: input.approvalId,
196 decision: input.decision,206 decision: input.decision,
@@ -203,7 +213,7 @@ function createService(context?: McpWorkspaceContext, bearerToken?: string) { · 6 unmodified lines
203 213
204async function resolveContext(214async function resolveContext(
205 requested: {215 requested: {
206 workspaceId?: string;216 workspaceId?: Id<"workspaces">;
207 actorId?: string;217 actorId?: string;
208 clientId?: string;218 clientId?: string;
209 sessionId?: string;219 sessionId?: string;
@@ -233,7 +243,7 @@ async function resolveContext( · 23 unmodified lines
233 }243 }
234 244
235 const requestContext = await convex.query(api.workspace.getRequestContext, {245 const requestContext = await convex.query(api.workspace.getRequestContext, {
236 workspaceId: requested.workspaceId as Id<"workspaces">,246 workspaceId: requested.workspaceId,
237 sessionId: requested.sessionId,247 sessionId: requested.sessionId,
238 });248 });
239 249