History

Simplify executor data invariants and split test suites

Move membership and actor handling toward write-time consistency, centralize tool registry/config validation, and reduce schema/index complexity to cut runtime edge cases. Also split executor tests into fast/integration/e2e buckets and make scheduler behavior test-safe by default.

Rhys Sullivan committed 8 months ago

Commit 271dc0e1 parent 3b5b63e33 files · +909 −408Browse files

Unified · Split

33 files
  1. CLAUDE.md +14 −2
  2. executor/package.json +4 −0
  3. executor/packages/core/src/identity.ts +30 −36
  4. executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.test.ts +9 −0
  5. executor/packages/core/src/tool/source-serialization.ts +12 −5
  6. executor/packages/database/convex/access-controls.test.ts +48 −2
  7. executor/packages/database/convex/database/anonymous_session.ts +1 −1
  8. executor/packages/database/convex/database/tool_sources.ts +2 −2
  9. executor/packages/database/convex/executor-mcp.e2e.test.ts +25 −1
  10. executor/packages/database/convex/invites.ts +3 −2
  11. executor/packages/database/convex/organizationMembers.ts +20 −6
  12. executor/packages/database/convex/schema.ts +4 −12
  13. executor/packages/database/src/auth/actor_identity.ts +31 −0
  14. executor/packages/database/src/auth/bootstrap.ts +20 −31
  15. executor/packages/database/src/auth/event_handlers.ts +100 −101
  16. executor/packages/database/src/auth/memberships.ts +21 −49
  17. executor/packages/database/src/auth/workspace_membership_projection.ts +126 −0
  18. executor/packages/database/src/billing/public-handlers.ts +2 −1
  19. executor/packages/database/src/credentials-node/upsert-credential.ts +7 −6
  20. executor/packages/database/src/database/anonymous.ts +2 −42
  21. executor/packages/database/src/database/tool_source_config.ts +179 −0
  22. executor/packages/database/src/executor/handlers.ts +7 −12
  23. executor/packages/database/src/lib/scheduler.ts +14 −1
  24. executor/packages/database/src/organizations/handlers.ts +14 −4
  25. executor/packages/database/src/runtime/actor_auth.ts +6 −9
  26. executor/packages/database/src/runtime/tool_call_resolution.ts +7 −25
  27. executor/packages/database/src/runtime/tool_invocation.ts +7 −14
  28. executor/packages/database/src/runtime/tool_registry_state.ts +77 −0
  29. executor/packages/database/src/runtime/tool_source_loading.ts +5 −38
  30. executor/packages/database/src/runtime/workspace_tools.ts +2 −1
  31. executor/packages/database/src/workspaces/handlers.ts +14 −4
  32. executor/scripts/test/run-suite.ts +92 −0
  33. package.json +4 −1
CLAUDE.md+14−2
@@ -33,13 +33,16 @@ Run from repo root unless noted. · 32 unmodified lines
3333
3434 ### Main quality commands
3535
36−- `bun run test` - runs executor + assistant test suites.
36+- `bun run test` - runs executor fast suite + assistant tests.
3737 - `bun run typecheck` - runs executor + assistant TypeScript checks.
3838 - `bun run knip` - dead-code/unused-export scan.
3939
4040 ### Package-specific quality commands
4141
42−- `bun run test:executor` - `bun test --cwd executor`.
42+- `bun run test:executor` - executor fast/normal tests (`bun run --cwd executor test`).
43+- `bun run test:executor:integration` - executor integration tests (`bun run --cwd executor test:integration`).
44+- `bun run test:executor:e2e` - executor e2e tests (`bun run --cwd executor test:e2e`).
45+- `bun run test:executor:all` - all executor tests (`bun run --cwd executor test:all`).
4346 - `bun run test:assistant` - assistant tests (`assistant/packages/core/src`).
4447 - `bun run typecheck:executor` - `tsc --noEmit -p executor/tsconfig.json`.
4548 - `bun run typecheck:assistant` - checks server + bot tsconfig files.
@@ -135,9 +138,18 @@ Run from repo root unless noted. · 89 unmodified lines
135138
136139 - Use `bun:test` (`import { test, expect } from "bun:test"`).
137140 - Keep tests close to code with `.test.ts` / `.e2e.test.ts` naming.
141+- Keep scheduler behavior test-safe by default; do not require `DISABLE_CONVEX_SCHEDULER=1` to run the normal fast suite.
138142 - When changing Convex auth/access logic, add/adjust permission tests in `executor/packages/convex/access-controls.test.ts`.
139143 - When changing tool discovery/typing behavior, update tests in `executor/packages/core/src/*.test.ts`.
140144
145+## User Preferences (Observed)
146+
147+- Preserve local/cloud parity: prefer the same architecture and runtime behavior in both environments.
148+- Reduce complexity by enforcing write-time invariants over read-time repair (`ensure*` backfills should be minimal and clearly scoped).
149+- For greenfield iterations, prefer clearing/resetting the DB over adding one-off migrations.
150+- Keep default feedback loops fast: split tests into fast, integration, and e2e buckets and make fast the default.
151+- Favor practical simplification over theoretical abstractions when refactoring data models and auth flows.
152+
141153 ## Generated/Build Artifacts
142154
143155 - Do not manually edit generated/build outputs such as:
executor/package.json+4−0
@@ -26,6 +26,10 @@ · 25 unmodified lines
2626 "web": "bun run executor.ts web",
2727 "deploy": "bunx convex deploy",
2828 "codegen": "bunx convex codegen",
29+ "test": "bun run scripts/test/run-suite.ts fast",
30+ "test:integration": "bun run scripts/test/run-suite.ts integration",
31+ "test:e2e": "bun run scripts/test/run-suite.ts e2e",
32+ "test:all": "bun run scripts/test/run-suite.ts all",
2933 "test:e2e:install": "bun test scripts/e2e/install.e2e.test.ts",
3034 "test:e2e:install:sandbox": "bun test scripts/e2e/install.sandbox.e2e.test.ts",
3135 "sandbox:install:session": "bun run scripts/e2e/install.sandbox.session.ts",
executor/packages/core/src/identity.ts+30−36
@@ -7,18 +7,6 @@ type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">; · 6 unmodified lines
77
88 type WorkspaceAccessMembership = Pick<Doc<"workspaceMembers">, "role" | "status">;
99
10−function mapOrganizationRoleToWorkspaceRole(
11− role: Doc<"organizationMembers">["role"],
12−): Doc<"workspaceMembers">["role"] {
13− if (role === "owner") {
14− return "owner";
15− }
16− if (role === "admin") {
17− return "admin";
18− }
19− return "member";
20−}
21−
2210 export type WorkspaceAccess = {
2311 account: Doc<"accounts">;
2412 workspace: Doc<"workspaces">;
@@ -80,10 +68,22 @@ export async function getOrganizationMembership( · 55 unmodified lines
8068 organizationId: Id<"organizations">,
8169 accountId: Id<"accounts">,
8270 ) {
83− return await ctx.db
71+ const memberships = await ctx.db
8472 .query("organizationMembers")
8573 .withIndex("by_org_account", (q) => q.eq("organizationId", organizationId).eq("accountId", accountId))
86− .unique();
74+ .collect();
75+
76+ if (memberships.length === 0) {
77+ return null;
78+ }
79+
80+ memberships.sort((a, b) => {
81+ const statusRankA = a.status === "active" ? 2 : a.status === "pending" ? 1 : 0;
82+ const statusRankB = b.status === "active" ? 2 : b.status === "pending" ? 1 : 0;
83+ return statusRankB - statusRankA || b.updatedAt - a.updatedAt;
84+ });
85+
86+ return memberships[0]!;
8787 }
8888
8989 export async function getWorkspaceMembership(
@@ -91,10 +91,22 @@ export async function getWorkspaceMembership( · 1 unmodified line
9191 workspaceId: Id<"workspaces">,
9292 accountId: Id<"accounts">,
9393 ) {
94− return await ctx.db
94+ const memberships = await ctx.db
9595 .query("workspaceMembers")
9696 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
97− .unique();
97+ .collect();
98+
99+ if (memberships.length === 0) {
100+ return null;
101+ }
102+
103+ memberships.sort((a, b) => {
104+ const statusRankA = a.status === "active" ? 2 : a.status === "pending" ? 1 : 0;
105+ const statusRankB = b.status === "active" ? 2 : b.status === "pending" ? 1 : 0;
106+ return statusRankB - statusRankA || b.updatedAt - a.updatedAt;
107+ });
108+
109+ return memberships[0]!;
98110 }
99111
100112 export async function requireWorkspaceAccessForAccount(
@@ -108,32 +120,14 @@ export async function requireWorkspaceAccessForAccount( · 7 unmodified lines
108120 }
109121
110122 const workspaceMembership = await getWorkspaceMembership(ctx, workspace._id, account._id);
111− if (workspaceMembership) {
112− if (workspaceMembership.status !== "active") {
113− throw new Error("You are not a member of this workspace");
114− }
115−
116− return {
117− account,
118− workspace,
119− workspaceMembership,
120− };
121− }
122−
123− const organizationMembership = await getOrganizationMembership(ctx, workspace.organizationId, account._id);
124− if (!organizationMembership || organizationMembership.status !== "active") {
123+ if (!workspaceMembership || workspaceMembership.status !== "active") {
125124 throw new Error("You are not a member of this workspace");
126125 }
127126
128− const derivedWorkspaceMembership: WorkspaceAccessMembership = {
129− role: mapOrganizationRoleToWorkspaceRole(organizationMembership.role),
130− status: "active",
131− };
132−
133127 return {
134128 account,
135129 workspace,
136− workspaceMembership: derivedWorkspaceMembership,
130+ workspaceMembership,
137131 };
138132 }
139133
executor/packages/core/src/runtimes/cloudflare/worker/loader-runtime.test.ts+9−0
@@ -3,6 +3,7 @@ import { dispatchCodeWithCloudflareWorkerLoader } from "./loader-runtime"; · 2 unmodified lines
33
44 let fakeHostServer: ReturnType<typeof Bun.serve>;
55 let fakeCallbackServer: ReturnType<typeof Bun.serve>;
6+let previousConvexUrl: string | undefined;
67
78 const AUTH_TOKEN = "test-sandbox-token";
89 const CALLBACK_TOKEN = "test-callback-token";
@@ -19,6 +20,8 @@ type HostRequestBody = { · 10 unmodified lines
1920 let lastHostRequestBody: HostRequestBody | null = null;
2021
2122 beforeAll(() => {
23+ previousConvexUrl = process.env.CONVEX_URL;
24+
2225 fakeCallbackServer = Bun.serve({
2326 port: 0,
2427 fetch: () => Response.json({ ok: true }),
@@ -44,6 +47,7 @@ beforeAll(() => { · 19 unmodified lines
4447
4548 process.env.CLOUDFLARE_SANDBOX_RUN_URL = `http://127.0.0.1:${fakeHostServer.port}/v1/runs`;
4649 process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = AUTH_TOKEN;
50+ delete process.env.CONVEX_URL;
4751 process.env.CONVEX_SITE_URL = `http://127.0.0.1:${fakeCallbackServer.port}`;
4852 process.env.EXECUTOR_INTERNAL_TOKEN = CALLBACK_TOKEN;
4953 process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS = "10000";
@@ -54,6 +58,11 @@ afterAll(() => { · 4 unmodified lines
5458 fakeCallbackServer?.stop(true);
5559 delete process.env.CLOUDFLARE_SANDBOX_RUN_URL;
5660 delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN;
61+ if (previousConvexUrl === undefined) {
62+ delete process.env.CONVEX_URL;
63+ } else {
64+ process.env.CONVEX_URL = previousConvexUrl;
65+ }
5766 delete process.env.CONVEX_SITE_URL;
5867 delete process.env.EXECUTOR_INTERNAL_TOKEN;
5968 delete process.env.CLOUDFLARE_SANDBOX_REQUEST_TIMEOUT_MS;
executor/packages/core/src/tool/source-serialization.ts+12−5
@@ -1,4 +1,3 @@
1−import { Result } from "better-result";
21 import { connectMcp, extractMcpResult } from "../mcp-runtime";
32 import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
43 import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";
@@ -129,7 +128,11 @@ export function rehydrateTools( · 124 unmodified lines
129128 return {
130129 ...base,
131130 run: async (input: unknown, context) => {
132− return await executeOpenApiRequest(runSpec, input, context.credential?.headers);
131+ const response = await executeOpenApiRequest(runSpec, input, context.credential?.headers);
132+ if (response.isErr()) {
133+ throw new Error(response.error.message);
134+ }
135+ return response.value;
133136 },
134137 };
135138 }
@@ -193,7 +196,11 @@ export function rehydrateTools( · 57 unmodified lines
193196 throw new Error("GraphQL query string is required");
194197 }
195198 const variables = payload.variables;
196− return await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);
199+ const response = await executeGraphqlRequest(endpoint, authHeaders, query, variables, context.credential?.headers);
200+ if (response.isErr()) {
201+ throw new Error(response.error.message);
202+ }
203+ return response.value;
197204 },
198205 };
199206 }
@@ -220,10 +227,10 @@ export function rehydrateTools( · 20 unmodified lines
220227 context.credential?.headers,
221228 );
222229 if (envelopeResult.isErr()) {
223− return envelopeResult;
230+ throw new Error(envelopeResult.error.message);
224231 }
225232
226− return Result.ok(selectGraphqlFieldEnvelope(envelopeResult.value, operationName));
233+ return selectGraphqlFieldEnvelope(envelopeResult.value, operationName);
227234 },
228235 };
229236 }
executor/packages/database/convex/access-controls.test.ts+48−2
@@ -116,7 +116,7 @@ async function addOrgMember( · 115 unmodified lines
116116 ) {
117117 const now = Date.now();
118118 return await t.run(async (ctx) => {
119− return await ctx.db.insert("organizationMembers", {
119+ const membershipId = await ctx.db.insert("organizationMembers", {
120120 organizationId: opts.organizationId,
121121 accountId: opts.accountId,
122122 role: opts.role,
@@ -126,6 +126,38 @@ async function addOrgMember( · 3 unmodified lines
126126 createdAt: now,
127127 updatedAt: now,
128128 });
129+
130+ const role = opts.role === "owner" || opts.role === "admin" ? opts.role : "member";
131+ const workspaces = await ctx.db
132+ .query("workspaces")
133+ .withIndex("by_organization_created", (q) => q.eq("organizationId", opts.organizationId))
134+ .collect();
135+
136+ for (const workspace of workspaces) {
137+ const existing = await ctx.db
138+ .query("workspaceMembers")
139+ .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", opts.accountId))
140+ .first();
141+
142+ if (existing) {
143+ await ctx.db.patch(existing._id, {
144+ role,
145+ status: opts.status ?? "active",
146+ updatedAt: now,
147+ });
148+ } else {
149+ await ctx.db.insert("workspaceMembers", {
150+ workspaceId: workspace._id,
151+ accountId: opts.accountId,
152+ role,
153+ status: opts.status ?? "active",
154+ createdAt: now,
155+ updatedAt: now,
156+ });
157+ }
158+ }
159+
160+ return membershipId;
129161 });
130162 }
131163
@@ -143,6 +175,20 @@ async function addWorkspaceMember( · 11 unmodified lines
143175 ) {
144176 const now = Date.now();
145177 return await t.run(async (ctx) => {
178+ const existing = await ctx.db
179+ .query("workspaceMembers")
180+ .withIndex("by_workspace_account", (q) => q.eq("workspaceId", opts.workspaceId).eq("accountId", opts.accountId))
181+ .first();
182+
183+ if (existing) {
184+ await ctx.db.patch(existing._id, {
185+ role: opts.role,
186+ status: opts.status ?? "active",
187+ updatedAt: now,
188+ });
189+ return existing._id;
190+ }
191+
146192 return await ctx.db.insert("workspaceMembers", {
147193 workspaceId: opts.workspaceId,
148194 accountId: opts.accountId,
@@ -262,7 +308,7 @@ describe("workspace access controls", () => { · 113 unmodified lines
262308 expect(tasks).toEqual([]);
263309 });
264310
265− test("org member without explicit workspace membership can read tasks", async () => {
311+ test("org membership projection grants workspace read access", async () => {
266312 const t = setup();
267313 const owner = await seedUser(t, { subject: "ws-owner-org-fallback" });
268314 const member = await seedUser(t, { subject: "ws-member-org-fallback" });
executor/packages/database/convex/database/anonymous_session.ts+1−1
@@ -115,7 +115,7 @@ export const ensureAnonymousMcpSession = internalMutation({ · 114 unmodified lines
115115 const membership = await ctx.db
116116 .query("workspaceMembers")
117117 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", account._id))
118− .unique();
118+ .first();
119119 if (!membership || membership.status !== "active") {
120120 throw new Error("Anonymous actor does not have workspace access");
121121 }
executor/packages/database/convex/database/tool_sources.ts+2−2
@@ -5,8 +5,8 @@ import { · 4 unmodified lines
55 mapSource,
66 normalizeSourceAuthFingerprint,
77 } from "../../src/database/mappers";
8+import { normalizeToolSourceConfig } from "../../src/database/tool_source_config";
89 import { toolSourceTypeValidator } from "../../src/database/validators";
9−import { asRecord } from "../../src/lib/object";
1010
1111 export const upsertToolSource = internalMutation({
1212 args: {
@@ -20,7 +20,7 @@ export const upsertToolSource = internalMutation({ · 7 unmodified lines
2020 handler: async (ctx, args) => {
2121 const now = Date.now();
2222 const sourceId = args.id ?? `src_${crypto.randomUUID()}`;
23− const config = asRecord(args.config);
23+ const config = normalizeToolSourceConfig(args.type, args.config);
2424 const specHash = computeSourceSpecHash(args.type, config);
2525 const authFingerprint = normalizeSourceAuthFingerprint(config.auth);
2626 const [existing, conflict] = await Promise.all([
executor/packages/database/convex/executor-mcp.e2e.test.ts+25−1
@@ -1,4 +1,4 @@
1−import { expect, test } from "bun:test";
1+import { afterAll, beforeAll, expect, test } from "bun:test";
22 import { convexTest } from "convex-test";
33 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
44 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -6,6 +6,30 @@ import { internal } from "./_generated/api"; · 1 unmodified line
66 import type { Id } from "./_generated/dataModel.d.ts";
77 import schema from "./schema";
88
9+let previousAnonymousAuthPrivateKeyPem: string | undefined;
10+let previousAnonymousAuthPublicKeyPem: string | undefined;
11+
12+beforeAll(() => {
13+ previousAnonymousAuthPrivateKeyPem = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
14+ previousAnonymousAuthPublicKeyPem = process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM;
15+ delete process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
16+ delete process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM;
17+});
18+
19+afterAll(() => {
20+ if (previousAnonymousAuthPrivateKeyPem === undefined) {
21+ delete process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
22+ } else {
23+ process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = previousAnonymousAuthPrivateKeyPem;
24+ }
25+
26+ if (previousAnonymousAuthPublicKeyPem === undefined) {
27+ delete process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM;
28+ } else {
29+ process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM = previousAnonymousAuthPublicKeyPem;
30+ }
31+});
32+
933 function setup() {
1034 return convexTest(schema, {
1135 "./database.ts": () => import("./database"),
executor/packages/database/convex/invites.ts+3−2
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 workosEnabled,
1414 } from "../src/invites/workos";
1515 import { normalizePersonalOrganizationName } from "../src/invites/normalize";
16+import { safeRunAfter } from "../src/lib/scheduler";
1617
1718 const organizationRoleValidator = v.union(
1819 v.literal("owner"),
@@ -99,7 +100,7 @@ export const create = organizationMutation({ · 80 unmodified lines
99100 updatedAt: now,
100101 });
101102
102− await ctx.scheduler.runAfter(0, internal.invites.deliverWorkosInvite, {
103+ await safeRunAfter(ctx.scheduler, 0, internal.invites.deliverWorkosInvite, {
103104 inviteId,
104105 inviterWorkosUserId,
105106 expiresInDays: args.expiresInDays,
@@ -216,7 +217,7 @@ export const revoke = organizationMutation({ · 110 unmodified lines
216217 });
217218
218219 if (invite.providerInviteId) {
219− await ctx.scheduler.runAfter(0, internal.invites.revokeWorkosInvite, {
220+ await safeRunAfter(ctx.scheduler, 0, internal.invites.revokeWorkosInvite, {
220221 inviteId: invite._id as Id<"invites">,
221222 providerInviteId: String(invite.providerInviteId),
222223 });
executor/packages/database/convex/organizationMembers.ts+20−6
@@ -2,6 +2,8 @@ import { v } from "convex/values"; · 1 unmodified line
22 import { internal } from "./_generated/api";
33 import { getOrganizationMembership } from "../../core/src/identity";
44 import { organizationMutation, organizationQuery } from "../../core/src/function-builders";
5+import { upsertOrganizationMembership } from "../src/auth/memberships";
6+import { safeRunAfter } from "../src/lib/scheduler";
57
68 const organizationRoleValidator = v.union(
79 v.literal("owner"),
@@ -52,9 +54,15 @@ export const updateRole = organizationMutation({ · 44 unmodified lines
5254 throw new Error("Organization member not found");
5355 }
5456
55− await ctx.db.patch(membership._id, {
57+ await upsertOrganizationMembership(ctx, {
58+ organizationId: ctx.organizationId,
59+ accountId: args.accountId,
5660 role: args.role,
57− updatedAt: Date.now(),
61+ status: membership.status,
62+ billable: membership.billable,
63+ invitedByAccountId: membership.invitedByAccountId,
64+ workosOrgMembershipId: membership.workosOrgMembershipId,
65+ now: Date.now(),
5866 });
5967
6068 return { ok: true };
@@ -81,7 +89,7 @@ export const updateBillable = organizationMutation({ · 20 unmodified lines
8189 const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, {
8290 organizationId: ctx.organizationId,
8391 });
84− await ctx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, {
92+ await safeRunAfter(ctx.scheduler, 0, internal.billingSync.syncSeatQuantity, {
8593 organizationId: ctx.organizationId,
8694 expectedVersion: nextVersion,
8795 });
@@ -101,15 +109,21 @@ export const remove = organizationMutation({ · 13 unmodified lines
101109 throw new Error("Organization member not found");
102110 }
103111
104− await ctx.db.patch(membership._id, {
112+ await upsertOrganizationMembership(ctx, {
113+ organizationId: ctx.organizationId,
114+ accountId: args.accountId,
115+ role: membership.role,
105116 status: "removed",
106− updatedAt: Date.now(),
117+ billable: false,
118+ invitedByAccountId: membership.invitedByAccountId,
119+ workosOrgMembershipId: membership.workosOrgMembershipId,
120+ now: Date.now(),
107121 });
108122
109123 const nextVersion = await ctx.runMutation(internal.billingInternal.bumpSeatSyncVersion, {
110124 organizationId: ctx.organizationId,
111125 });
112− await ctx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, {
126+ await safeRunAfter(ctx.scheduler, 0, internal.billingSync.syncSeatQuantity, {
113127 organizationId: ctx.organizationId,
114128 expectedVersion: nextVersion,
115129 });
executor/packages/database/convex/schema.ts+4−12
@@ -194,21 +194,18 @@ export default defineSchema({ · 193 unmodified lines
194194 //
195195 // Primary access patterns:
196196 // - Resolve by organization.
197− // - (Potential/expected) resolve by Stripe customer id in webhook reconciliation.
198197 billingCustomers: defineTable({
199198 organizationId: v.id("organizations"),
200199 stripeCustomerId: v.string(), // external Stripe customer ID
201200 createdAt: v.number(),
202201 updatedAt: v.number(),
203202 })
204− .index("by_org", ["organizationId"])
205− .index("by_stripe_customer_id", ["stripeCustomerId"]),
203+ .index("by_org", ["organizationId"]),
206204
207205 // Stripe subscription state for an organization.
208206 //
209207 // Primary access patterns:
210208 // - List subscriptions for an org.
211− // - (Potential/expected) resolve by subscription id and filter by status.
212209 billingSubscriptions: defineTable({
213210 organizationId: v.id("organizations"),
214211 stripeSubscriptionId: v.string(), // external Stripe subscription ID
@@ -221,9 +218,7 @@ export default defineSchema({ · 6 unmodified lines
221218 createdAt: v.number(),
222219 updatedAt: v.number(),
223220 })
224− .index("by_org", ["organizationId"])
225− .index("by_org_status", ["organizationId", "status"])
226− .index("by_stripe_subscription_id", ["stripeSubscriptionId"]),
221+ .index("by_org", ["organizationId"]),
227222
228223 // Seat syncing bookkeeping (eg Stripe per-seat quantity).
229224 // Stored separately from subscription records so sync logic can be retried/idempotent.
@@ -294,7 +289,6 @@ export default defineSchema({ · 64 unmodified lines
294289 // Primary access patterns:
295290 // - Get a specific call by (taskId, callId).
296291 // - List calls for a task ordered by creation time.
297− // - (Potential/expected) resolve via approval id to tie approvals to tool call rows.
298292 toolCalls: defineTable({
299293 taskId: v.string(),
300294 callId: v.string(),
@@ -309,8 +303,7 @@ export default defineSchema({ · 8 unmodified lines
309303 })
310304 .index("by_task_call", ["taskId", "callId"])
311305 .index("by_task_created", ["taskId", "createdAt"])
312− .index("by_workspace_created", ["workspaceId", "createdAt"])
313− .index("by_approval_id", ["approvalId"]),
306+ .index("by_workspace_created", ["workspaceId", "createdAt"]),
314307
315308 // Append-only event log for a task.
316309 // `sequence` is monotonically increasing per task (used for ordered replay).
@@ -482,8 +475,7 @@ export default defineSchema({ · 165 unmodified lines
482475 samplePaths: v.array(v.string()),
483476 createdAt: v.number(),
484477 })
485− .index("by_workspace_build", ["workspaceId", "buildId"])
486− .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]),
478+ .index("by_workspace_build", ["workspaceId", "buildId"]),
487479
488480 // Anonymous session linkage.
489481 // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a
executor/packages/database/src/auth/actor_identity.ts+31−0
@@ -0,0 +1,31 @@
1+import { actorIdForAccount } from "../../../core/src/identity";
2+
3+type WorkspaceAccessIdentity = {
4+ actorId?: string;
5+ accountId: string;
6+ provider: string;
7+ providerAccountId: string;
8+};
9+
10+export function canonicalActorIdForWorkspaceAccess(access: WorkspaceAccessIdentity): string {
11+ const existingActorId = typeof access.actorId === "string" ? access.actorId.trim() : "";
12+ if (existingActorId.length > 0) {
13+ return existingActorId;
14+ }
15+
16+ return actorIdForAccount({
17+ _id: access.accountId,
18+ provider: access.provider,
19+ providerAccountId: access.providerAccountId,
20+ });
21+}
22+
23+export function assertMatchesCanonicalActorId(
24+ providedActorId: string | undefined,
25+ canonicalActorId: string,
26+ fieldName = "actorId",
27+): void {
28+ if (providedActorId && providedActorId !== canonicalActorId) {
29+ throw new Error(`${fieldName} must match the authenticated workspace actor`);
30+ }
31+}
executor/packages/database/src/auth/bootstrap.ts+20−31
@@ -3,12 +3,12 @@ import { upsertWorkosAccount } from "./accounts"; · 2 unmodified lines
33 import { getOrganizationByWorkosOrgId } from "./db_queries";
44 import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity";
55 import {
6− ensureWorkspaceMembership,
76 mapOrganizationRoleToWorkspaceRole,
87 markPendingInvitesAcceptedByEmail,
98 upsertOrganizationMembership,
109 } from "./memberships";
1110 import { ensurePersonalWorkspace, refreshGeneratedPersonalWorkspaceNames } from "./personal_workspace";
11+import { projectAccountOrganizationMembershipsToWorkspaceMembers } from "./workspace_membership_projection";
1212 import type { AccountId } from "./types";
1313
1414 async function seedHintedOrganizationMembership(
@@ -51,31 +51,6 @@ async function seedHintedOrganizationMembership( · 36 unmodified lines
5151 });
5252 }
5353
54−async function syncWorkspaceMembershipsForOrganizations(ctx: MutationCtx, args: { accountId: AccountId; now: number }) {
55− const activeOrganizationMemberships = await ctx.db
56− .query("organizationMembers")
57− .withIndex("by_account", (q) => q.eq("accountId", args.accountId))
58− .filter((q) => q.eq(q.field("status"), "active"))
59− .collect();
60−
61− for (const membership of activeOrganizationMemberships) {
62− const orgWorkspaces = await ctx.db
63− .query("workspaces")
64− .withIndex("by_organization_created", (q) => q.eq("organizationId", membership.organizationId))
65− .collect();
66−
67− const workspaceRole = mapOrganizationRoleToWorkspaceRole(membership.role);
68− for (const workspace of orgWorkspaces) {
69− await ensureWorkspaceMembership(ctx, {
70− workspaceId: workspace._id,
71− accountId: args.accountId,
72− role: workspaceRole,
73− now: args.now,
74− });
75− }
76− }
77−}
78−
7954 async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { accountId: AccountId }) {
8055 const activeWorkspaceMembership = await ctx.db
8156 .query("workspaceMembers")
@@ -86,6 +61,18 @@ async function ensureAtLeastOneWorkspaceMembership(ctx: MutationCtx, args: { acc · 4 unmodified lines
8661 return Boolean(activeWorkspaceMembership);
8762 }
8863
64+async function backfillWorkspaceMembershipsIfMissing(ctx: MutationCtx, args: { accountId: AccountId; now: number }) {
65+ await projectAccountOrganizationMembershipsToWorkspaceMembers(ctx, {
66+ accountId: args.accountId,
67+ now: args.now,
68+ mapRole: mapOrganizationRoleToWorkspaceRole,
69+ });
70+
71+ return await ensureAtLeastOneWorkspaceMembership(ctx, {
72+ accountId: args.accountId,
73+ });
74+}
75+
8976 export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) {
9077 const identity = await ctx.auth.getUserIdentity();
9178 if (!identity) return null;
@@ -125,14 +112,16 @@ export async function bootstrapCurrentWorkosAccountImpl(ctx: MutationCtx) { · 33 unmodified lines
125112 now,
126113 });
127114
128− await syncWorkspaceMembershipsForOrganizations(ctx, {
115+ let hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, {
129116 accountId: account._id,
130− now,
131117 });
132118
133− const hasActiveWorkspaceMembership = await ensureAtLeastOneWorkspaceMembership(ctx, {
134− accountId: account._id,
135− });
119+ if (!hasActiveWorkspaceMembership) {
120+ hasActiveWorkspaceMembership = await backfillWorkspaceMembershipsIfMissing(ctx, {
121+ accountId: account._id,
122+ now,
123+ });
124+ }
136125
137126 if (!hasActiveWorkspaceMembership) {
138127 await ensurePersonalWorkspace(ctx, account._id, {
executor/packages/database/src/auth/event_handlers.ts+100−101
@@ -10,11 +10,12 @@ import { · 9 unmodified lines
1010 } from "./db_queries";
1111 import {
1212 deriveOrganizationMembershipState,
13− deriveWorkspaceMembershipRole,
13+ mapOrganizationRoleToWorkspaceRole,
1414 markPendingInvitesAcceptedByEmail,
1515 upsertOrganizationMembership,
1616 } from "./memberships";
1717 import { ensureUniqueOrganizationSlug } from "./naming";
18+import { seedWorkspaceMembersFromOrganization } from "./workspace_membership_projection";
1819
1920 type WorkosMembershipEventData = {
2021 id: string;
@@ -26,53 +27,63 @@ type WorkosMembershipEventData = { · 5 unmodified lines
2627 status?: string;
2728 };
2829
29−async function resolveMembershipRecord(
30+function deriveOrganizationRoleFromWorkosSlug(workosRoleSlug?: string): Doc<"organizationMembers">["role"] {
31+ return workosRoleSlug === "admin" ? "admin" : "member";
32+}
33+
34+async function resolveMembershipAccountAndOrganization(
3035 ctx: Pick<MutationCtx, "db">,
3136 data: WorkosMembershipEventData,
3237 ) {
33− let membership = await ctx.db
34− .query("workspaceMembers")
35− .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))
36− .unique();
37−
38− let account: Doc<"accounts"> | null = null;
39− let workspace: Doc<"workspaces"> | null = null;
38+ const workosUserId = data.user_id ?? data.userId;
39+ const workosOrgId = data.organization_id ?? data.organizationId;
4040
41− if (!membership) {
42− const workosUserId = data.user_id ?? data.userId;
43− const workosOrgId = data.organization_id ?? data.organizationId;
44− if (!workosUserId || !workosOrgId) {
45− return { membership: null, account: null, workspace: null };
46− }
47−
48− [account, workspace] = await Promise.all([
41+ if (workosUserId && workosOrgId) {
42+ const [account, organization] = await Promise.all([
4943 getAccountByWorkosId(ctx, workosUserId),
50− getWorkspaceByWorkosOrgId(ctx, workosOrgId),
44+ getOrganizationByWorkosOrgId(ctx, workosOrgId),
5145 ]);
52− if (!account || !workspace) {
53− return { membership: null, account: null, workspace: null };
54− }
55−
56− const workspaceId = workspace._id;
57− const accountId = account._id;
5846
59− membership = await ctx.db
60− .query("workspaceMembers")
61− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId))
62− .unique();
63− if (!membership) {
64− return { membership: null, account: null, workspace: null };
47+ if (account && organization) {
48+ return {
49+ account,
50+ organization,
51+ fallbackRole: undefined as Doc<"workspaceMembers">["role"] | undefined,
52+ };
6553 }
66− } else {
67− account = await ctx.db.get(membership.accountId);
68− workspace = await ctx.db.get(membership.workspaceId);
6954 }
7055
56+ const linkedMemberships = await ctx.db
57+ .query("workspaceMembers")
58+ .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id))
59+ .collect();
60+ if (linkedMemberships.length === 0) {
61+ return {
62+ account: null,
63+ organization: null,
64+ fallbackRole: undefined as Doc<"workspaceMembers">["role"] | undefined,
65+ };
66+ }
67+
68+ const linkedMembership = linkedMemberships[0]!;
69+ const [account, workspace] = await Promise.all([
70+ ctx.db.get(linkedMembership.accountId),
71+ ctx.db.get(linkedMembership.workspaceId),
72+ ]);
7173 if (!account || !workspace) {
72− return { membership: null, account: null, workspace: null };
74+ return {
75+ account: null,
76+ organization: null,
77+ fallbackRole: linkedMembership.role,
78+ };
7379 }
7480
75− return { membership, account, workspace };
81+ const organization = await ctx.db.get(workspace.organizationId);
82+ return {
83+ account,
84+ organization,
85+ fallbackRole: linkedMembership.role,
86+ };
7687 }
7788
7889 export const workosEventHandlers = {
@@ -158,6 +169,13 @@ export const workosEventHandlers = { · 79 unmodified lines
158169 workosOrgId: event.data.id,
159170 updatedAt: now,
160171 });
172+
173+ await seedWorkspaceMembersFromOrganization(ctx, {
174+ organizationId: organization._id,
175+ workspaceId: existingWorkspace._id,
176+ now,
177+ mapRole: mapOrganizationRoleToWorkspaceRole,
178+ });
161179 return;
162180 }
163181
@@ -167,6 +185,13 @@ export const workosEventHandlers = { · 3 unmodified lines
167185 workosOrgId: event.data.id,
168186 updatedAt: now,
169187 });
188+
189+ await seedWorkspaceMembersFromOrganization(ctx, {
190+ organizationId: organization._id,
191+ workspaceId: organizationWorkspace._id,
192+ now,
193+ mapRole: mapOrganizationRoleToWorkspaceRole,
194+ });
170195 return;
171196 }
172197
@@ -179,6 +204,16 @@ export const workosEventHandlers = { · 6 unmodified lines
179204 createdAt: now,
180205 updatedAt: now,
181206 });
207+
208+ const createdWorkspace = await getWorkspaceByWorkosOrgId(ctx, event.data.id);
209+ if (createdWorkspace) {
210+ await seedWorkspaceMembersFromOrganization(ctx, {
211+ organizationId: organization._id,
212+ workspaceId: createdWorkspace._id,
213+ now,
214+ mapRole: mapOrganizationRoleToWorkspaceRole,
215+ });
216+ }
182217 },
183218
184219 "organization.updated": async (ctx, event) => {
@@ -217,119 +252,83 @@ export const workosEventHandlers = { · 32 unmodified lines
217252 "organization_membership.created": async (ctx, event) => {
218253 const now = Date.now();
219254 const data = event.data as WorkosMembershipEventData;
220− const workosUserId = data.user_id ?? data.userId;
221− const workosOrgId = data.organization_id ?? data.organizationId;
222− if (!workosUserId || !workosOrgId) return;
223−
224− const [account, workspace] = await Promise.all([
225− getAccountByWorkosId(ctx, workosUserId),
226− getWorkspaceByWorkosOrgId(ctx, workosOrgId),
227− ]);
228− if (!account || !workspace) return;
255+ const { account, organization } = await resolveMembershipAccountAndOrganization(ctx, data);
256+ if (!account || !organization) return;
229257
230− const existing = await ctx.db
231− .query("workspaceMembers")
232− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
233− .unique();
234−
235− const role = deriveWorkspaceMembershipRole(data.role?.slug);
258+ const role = deriveOrganizationRoleFromWorkosSlug(data.role?.slug);
236259 const status = deriveOrganizationMembershipState(data.status);
237260
238261 await upsertOrganizationMembership(ctx, {
239− organizationId: workspace.organizationId,
262+ organizationId: organization._id,
240263 accountId: account._id,
241264 role,
242265 status,
243266 billable: status === "active",
267+ workosOrgMembershipId: data.id,
244268 now,
245269 });
246270
247271 if (status === "active") {
248272 await markPendingInvitesAcceptedByEmail(ctx, {
249− organizationId: workspace.organizationId,
273+ organizationId: organization._id,
250274 email: account.email,
251275 acceptedAt: now,
252276 });
253277 }
254−
255− if (existing) {
256− await ctx.db.patch(existing._id, {
257− workosOrgMembershipId: event.data.id,
258− role,
259− status,
260− updatedAt: now,
261− });
262− return;
263− }
264−
265− await ctx.db.insert("workspaceMembers", {
266− workspaceId: workspace._id,
267− accountId: account._id,
268− workosOrgMembershipId: event.data.id,
269− role,
270− status,
271− createdAt: now,
272− updatedAt: now,
273− });
274278 },
275279
276280 "organization_membership.updated": async (ctx, event) => {
277281 const now = Date.now();
278282 const data = event.data as WorkosMembershipEventData;
279− const { membership, account, workspace } = await resolveMembershipRecord(ctx, data);
283+ const { account, organization } = await resolveMembershipAccountAndOrganization(ctx, data);
280284
281− if (!membership || !account || !workspace) {
285+ if (!account || !organization) {
282286 return;
283287 }
284288
285− const role = deriveWorkspaceMembershipRole(data.role?.slug);
289+ const role = deriveOrganizationRoleFromWorkosSlug(data.role?.slug);
286290 const status = deriveOrganizationMembershipState(data.status);
287291
288292 await upsertOrganizationMembership(ctx, {
289− organizationId: workspace.organizationId,
293+ organizationId: organization._id,
290294 accountId: account._id,
291295 role,
292296 status,
293297 billable: status === "active",
298+ workosOrgMembershipId: data.id,
294299 now,
295300 });
296301
297302 if (status === "active") {
298303 await markPendingInvitesAcceptedByEmail(ctx, {
299− organizationId: workspace.organizationId,
304+ organizationId: organization._id,
300305 email: account.email,
301306 acceptedAt: now,
302307 });
303308 }
304−
305− await ctx.db.patch(membership._id, {
306− workosOrgMembershipId: data.id,
307− role,
308− status,
309− updatedAt: now,
310− });
311309 },
312310
313311 "organization_membership.deleted": async (ctx, event) => {
314312 const now = Date.now();
315− const membership = await ctx.db
316− .query("workspaceMembers")
317− .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id))
318− .unique();
319− if (!membership) return;
320−
321− const workspace = await ctx.db.get(membership.workspaceId);
322− if (workspace?.organizationId) {
323− await upsertOrganizationMembership(ctx, {
324− organizationId: workspace.organizationId,
325− accountId: membership.accountId,
326− role: membership.role,
327− status: "removed",
328− billable: false,
329− now,
330− });
313+ const data = event.data as WorkosMembershipEventData;
314+ const { account, organization, fallbackRole } = await resolveMembershipAccountAndOrganization(ctx, data);
315+ if (!account || !organization) {
316+ return;
331317 }
332318
333− await ctx.db.delete(membership._id);
319+ const existingOrgMembership = await ctx.db
320+ .query("organizationMembers")
321+ .withIndex("by_org_account", (q) => q.eq("organizationId", organization._id).eq("accountId", account._id))
322+ .first();
323+
324+ await upsertOrganizationMembership(ctx, {
325+ organizationId: organization._id,
326+ accountId: account._id,
327+ role: existingOrgMembership?.role ?? (fallbackRole === "admin" ? "admin" : "member"),
328+ status: "removed",
329+ billable: false,
330+ workosOrgMembershipId: data.id,
331+ now,
332+ });
334333 },
335334 } satisfies Partial<Parameters<AuthKit<DataModel>["events"]>[0]>;
executor/packages/database/src/auth/memberships.ts+21−49
@@ -1,13 +1,12 @@
1−import type { Doc } from "../../convex/_generated/dataModel.d.ts";
21 import type {
32 AccountId,
43 DbCtx,
54 OrganizationId,
65 OrganizationMemberStatus,
76 OrganizationRole,
8− WorkspaceId,
97 WorkspaceMemberRole,
108 } from "./types";
9+import { projectOrganizationMembershipToWorkspaceMembers } from "./workspace_membership_projection";
1110
1211 export function mapOrganizationRoleToWorkspaceRole(role: OrganizationRole): WorkspaceMemberRole {
1312 if (role === "owner") {
@@ -28,13 +27,14 @@ export async function upsertOrganizationMembership( · 14 unmodified lines
2827 status: OrganizationMemberStatus;
2928 billable: boolean;
3029 invitedByAccountId?: AccountId;
30+ workosOrgMembershipId?: string;
3131 now: number;
3232 },
3333 ) {
3434 const existing = await ctx.db
3535 .query("organizationMembers")
3636 .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
37− .unique();
37+ .first();
3838
3939 if (existing) {
4040 await ctx.db.patch(existing._id, {
@@ -42,56 +42,32 @@ export async function upsertOrganizationMembership( · 1 unmodified line
4242 status: args.status,
4343 billable: args.billable,
4444 invitedByAccountId: args.invitedByAccountId,
45+ workosOrgMembershipId: args.workosOrgMembershipId,
4546 joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
4647 updatedAt: args.now,
4748 });
48− return;
49+ } else {
50+ await ctx.db.insert("organizationMembers", {
51+ organizationId: args.organizationId,
52+ accountId: args.accountId,
53+ role: args.role,
54+ status: args.status,
55+ billable: args.billable,
56+ invitedByAccountId: args.invitedByAccountId,
57+ workosOrgMembershipId: args.workosOrgMembershipId,
58+ joinedAt: args.status === "active" ? args.now : undefined,
59+ createdAt: args.now,
60+ updatedAt: args.now,
61+ });
4962 }
5063
51− await ctx.db.insert("organizationMembers", {
64+ await projectOrganizationMembershipToWorkspaceMembers(ctx, {
5265 organizationId: args.organizationId,
5366 accountId: args.accountId,
54− role: args.role,
67+ role: mapOrganizationRoleToWorkspaceRole(args.role),
5568 status: args.status,
56− billable: args.billable,
57− invitedByAccountId: args.invitedByAccountId,
58− joinedAt: args.status === "active" ? args.now : undefined,
59− createdAt: args.now,
60− updatedAt: args.now,
61− });
62−}
63−
64−export async function ensureWorkspaceMembership(
65− ctx: DbCtx,
66− args: {
67− workspaceId: WorkspaceId;
68− accountId: AccountId;
69− role: WorkspaceMemberRole;
70− now: number;
71− },
72−) {
73− const existing = await ctx.db
74− .query("workspaceMembers")
75− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId))
76− .unique();
77−
78− if (existing) {
79− if (existing.status === "active") {
80− await ctx.db.patch(existing._id, {
81− role: args.role,
82− updatedAt: args.now,
83− });
84− }
85− return;
86− }
87−
88− await ctx.db.insert("workspaceMembers", {
89− workspaceId: args.workspaceId,
90− accountId: args.accountId,
91− role: args.role,
92− status: "active",
93− createdAt: args.now,
94− updatedAt: args.now,
69+ now: args.now,
70+ workosOrgMembershipId: args.workosOrgMembershipId,
9571 });
9672 }
9773
@@ -124,10 +100,6 @@ export async function markPendingInvitesAcceptedByEmail( · 26 unmodified lines
124100 }
125101 }
126102
127−export function deriveWorkspaceMembershipRole(workosRoleSlug?: string): Doc<"workspaceMembers">["role"] {
128− return workosRoleSlug === "admin" ? "admin" : "member";
129−}
130−
131103 export function deriveOrganizationMembershipState(status?: string): OrganizationMemberStatus {
132104 return status === "active" ? "active" : "pending";
133105 }
executor/packages/database/src/auth/workspace_membership_projection.ts+126−0
@@ -0,0 +1,126 @@
1+import type { Doc } from "../../convex/_generated/dataModel.d.ts";
2+import type { AccountId, DbCtx, OrganizationId, WorkspaceMemberRole } from "./types";
3+
4+type WorkspaceMemberStatus = Doc<"workspaceMembers">["status"];
5+
6+type ProjectOrganizationMembershipArgs = {
7+ organizationId: OrganizationId;
8+ accountId: AccountId;
9+ role: WorkspaceMemberRole;
10+ status: WorkspaceMemberStatus;
11+ now: number;
12+ workosOrgMembershipId?: string;
13+};
14+
15+async function upsertWorkspaceMembership(
16+ ctx: DbCtx,
17+ args: {
18+ workspaceId: Doc<"workspaces">["_id"];
19+ accountId: AccountId;
20+ role: WorkspaceMemberRole;
21+ status: WorkspaceMemberStatus;
22+ now: number;
23+ workosOrgMembershipId?: string;
24+ },
25+): Promise<void> {
26+ const existing = await ctx.db
27+ .query("workspaceMembers")
28+ .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", args.accountId))
29+ .first();
30+
31+ if (existing) {
32+ await ctx.db.patch(existing._id, {
33+ role: args.role,
34+ status: args.status,
35+ workosOrgMembershipId: args.workosOrgMembershipId,
36+ updatedAt: args.now,
37+ });
38+ return;
39+ }
40+
41+ await ctx.db.insert("workspaceMembers", {
42+ workspaceId: args.workspaceId,
43+ accountId: args.accountId,
44+ role: args.role,
45+ status: args.status,
46+ workosOrgMembershipId: args.workosOrgMembershipId,
47+ createdAt: args.now,
48+ updatedAt: args.now,
49+ });
50+}
51+
52+export async function projectOrganizationMembershipToWorkspaceMembers(
53+ ctx: DbCtx,
54+ args: ProjectOrganizationMembershipArgs,
55+): Promise<void> {
56+ const workspaces = await ctx.db
57+ .query("workspaces")
58+ .withIndex("by_organization_created", (q) => q.eq("organizationId", args.organizationId))
59+ .collect();
60+
61+ for (const workspace of workspaces) {
62+ await upsertWorkspaceMembership(ctx, {
63+ workspaceId: workspace._id,
64+ accountId: args.accountId,
65+ role: args.role,
66+ status: args.status,
67+ now: args.now,
68+ workosOrgMembershipId: args.workosOrgMembershipId,
69+ });
70+ }
71+}
72+
73+export async function projectAccountOrganizationMembershipsToWorkspaceMembers(
74+ ctx: DbCtx,
75+ args: { accountId: AccountId; now: number; mapRole: (role: Doc<"organizationMembers">["role"]) => WorkspaceMemberRole },
76+): Promise<void> {
77+ const memberships = await ctx.db
78+ .query("organizationMembers")
79+ .withIndex("by_account", (q) => q.eq("accountId", args.accountId))
80+ .collect();
81+
82+ for (const membership of memberships) {
83+ if (membership.status === "removed") {
84+ continue;
85+ }
86+
87+ await projectOrganizationMembershipToWorkspaceMembers(ctx, {
88+ organizationId: membership.organizationId,
89+ accountId: membership.accountId,
90+ role: args.mapRole(membership.role),
91+ status: membership.status,
92+ now: args.now,
93+ workosOrgMembershipId: membership.workosOrgMembershipId,
94+ });
95+ }
96+}
97+
98+export async function seedWorkspaceMembersFromOrganization(
99+ ctx: DbCtx,
100+ args: {
101+ organizationId: OrganizationId;
102+ workspaceId: Doc<"workspaces">["_id"];
103+ now: number;
104+ mapRole: (role: Doc<"organizationMembers">["role"]) => WorkspaceMemberRole;
105+ },
106+): Promise<void> {
107+ const orgMemberships = await ctx.db
108+ .query("organizationMembers")
109+ .withIndex("by_org", (q) => q.eq("organizationId", args.organizationId))
110+ .collect();
111+
112+ for (const membership of orgMemberships) {
113+ if (membership.status === "removed") {
114+ continue;
115+ }
116+
117+ await upsertWorkspaceMembership(ctx, {
118+ workspaceId: args.workspaceId,
119+ accountId: membership.accountId,
120+ role: args.mapRole(membership.role),
121+ status: membership.status,
122+ now: args.now,
123+ workosOrgMembershipId: membership.workosOrgMembershipId,
124+ });
125+ }
126+}
executor/packages/database/src/billing/public-handlers.ts+2−1
@@ -2,6 +2,7 @@ import type { StripeSubscriptions } from "@convex-dev/stripe"; · 1 unmodified line
22 import type { Id } from "../../convex/_generated/dataModel.d.ts";
33 import type { ActionCtx, MutationCtx, QueryCtx } from "../../convex/_generated/server";
44 import { canManageBilling, isAdminRole } from "../../../core/src/identity";
5+import { safeRunAfter } from "../lib/scheduler";
56
67 type Internal = typeof import("../../convex/_generated/api").internal;
78 type Components = typeof import("../../convex/_generated/api").components;
@@ -209,7 +210,7 @@ export async function retrySeatSyncHandler( · 201 unmodified lines
209210 organizationId: typedCtx.organizationId,
210211 });
211212
212− await typedCtx.scheduler.runAfter(0, internal.billingSync.syncSeatQuantity, {
213+ await safeRunAfter(typedCtx.scheduler, 0, internal.billingSync.syncSeatQuantity, {
213214 organizationId: typedCtx.organizationId,
214215 expectedVersion: nextVersion,
215216 });
executor/packages/database/src/credentials-node/upsert-credential.ts+7−6
@@ -1,6 +1,10 @@
11 import { WorkOS } from "@workos-inc/node";
22 import type { Id } from "../../convex/_generated/dataModel.d.ts";
33 import type { ActionCtx } from "../../convex/_generated/server";
4+import {
5+ assertMatchesCanonicalActorId,
6+ canonicalActorIdForWorkspaceAccess,
7+} from "../auth/actor_identity";
48 import { asRecord } from "../lib/object";
59
610 type Internal = typeof import("../../convex/_generated/api").internal;
@@ -170,12 +174,9 @@ export async function upsertCredentialHandler( · 163 unmodified lines
170174 workspaceId: args.workspaceId,
171175 sessionId: args.sessionId,
172176 });
173− const canonicalActorId = access.provider === "anonymous"
174− ? access.providerAccountId
175− : access.accountId;
176−
177− if (args.scope === "actor" && args.actorId && args.actorId !== canonicalActorId) {
178− throw new Error("actorId must match the authenticated workspace actor");
177+ const canonicalActorId = canonicalActorIdForWorkspaceAccess(access);
178+ if (args.scope === "actor") {
179+ assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
179180 }
180181
181182 const actorId = normalizedActorId(args.scope, args.actorId ?? canonicalActorId);
executor/packages/database/src/database/anonymous.ts+2−42
@@ -2,9 +2,7 @@ import type { Doc } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line
22 import type { MutationCtx } from "../../convex/_generated/server";
33 import { slugify } from "../../../core/src/identity";
44 import { ensureUniqueSlug } from "../../../core/src/slug";
5−
6−type OrganizationRole = "owner" | "admin" | "member" | "billing_admin";
7−type OrganizationMemberStatus = "active" | "pending" | "removed";
5+import { upsertOrganizationMembership } from "../auth/memberships";
86
97 async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> {
108 const baseSlug = slugify(baseName, "workspace");
@@ -17,44 +15,6 @@ async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseNa · 6 unmodified lines
1715 });
1816 }
1917
20−async function upsertOrganizationMembership(
21− ctx: Pick<MutationCtx, "db">,
22− args: {
23− organizationId: Doc<"organizations">["_id"];
24− accountId: Doc<"accounts">["_id"];
25− role: OrganizationRole;
26− status: OrganizationMemberStatus;
27− billable: boolean;
28− now: number;
29− },
30−) {
31− const existing = await ctx.db
32− .query("organizationMembers")
33− .withIndex("by_org_account", (q) => q.eq("organizationId", args.organizationId).eq("accountId", args.accountId))
34− .unique();
35−
36− if (existing) {
37− await ctx.db.patch(existing._id, {
38− role: args.role,
39− status: args.status,
40− billable: args.billable,
41− joinedAt: args.status === "active" ? (existing.joinedAt ?? args.now) : existing.joinedAt,
42− updatedAt: args.now,
43− });
44− return;
45− }
46−
47− await ctx.db.insert("organizationMembers", {
48− organizationId: args.organizationId,
49− accountId: args.accountId,
50− role: args.role,
51− status: args.status,
52− billable: args.billable,
53− joinedAt: args.status === "active" ? args.now : undefined,
54− createdAt: args.now,
55− updatedAt: args.now,
56− });
57−}
5818
5919 export async function ensureAnonymousIdentity(
6020 ctx: MutationCtx,
@@ -136,7 +96,7 @@ export async function ensureAnonymousIdentity( · 75 unmodified lines
13696 let user = await ctx.db
13797 .query("workspaceMembers")
13898 .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
139− .unique();
99+ .first();
140100
141101 if (!user) {
142102 const userId = await ctx.db.insert("workspaceMembers", {
executor/packages/database/src/database/tool_source_config.ts+179−0
@@ -0,0 +1,179 @@
1+import type { OpenApiAuth } from "../../../core/src/tool/source-types";
2+import type { ToolApprovalMode } from "../../../core/src/types";
3+import { asRecord } from "../lib/object";
4+
5+type ToolSourceType = "mcp" | "openapi" | "graphql";
6+
7+function optionalTrimmedString(value: unknown): string | undefined {
8+ if (typeof value !== "string") return undefined;
9+ const trimmed = value.trim();
10+ return trimmed.length > 0 ? trimmed : undefined;
11+}
12+
13+function requiredTrimmedString(value: unknown, fieldName: string): string {
14+ const trimmed = optionalTrimmedString(value);
15+ if (!trimmed) {
16+ throw new Error(`Tool source ${fieldName} is required`);
17+ }
18+ return trimmed;
19+}
20+
21+function normalizeStringMap(value: unknown, fieldName: string): Record<string, string> | undefined {
22+ if (value === undefined) return undefined;
23+ const record = asRecord(value);
24+ if (Object.keys(record).length === 0) {
25+ return undefined;
26+ }
27+
28+ const normalized: Record<string, string> = {};
29+ for (const [key, rawValue] of Object.entries(record)) {
30+ const normalizedKey = key.trim();
31+ if (!normalizedKey) continue;
32+ if (typeof rawValue !== "string") {
33+ throw new Error(`Tool source ${fieldName}.${normalizedKey} must be a string`);
34+ }
35+ normalized[normalizedKey] = rawValue;
36+ }
37+
38+ return Object.keys(normalized).length > 0 ? normalized : undefined;
39+}
40+
41+function normalizeApprovalMode(value: unknown, fieldName: string): ToolApprovalMode | undefined {
42+ if (value === undefined) return undefined;
43+ if (value === "auto" || value === "required") {
44+ return value;
45+ }
46+ throw new Error(`Tool source ${fieldName} must be 'auto' or 'required'`);
47+}
48+
49+function normalizeOverrides(value: unknown, fieldName: string): Record<string, { approval?: ToolApprovalMode }> | undefined {
50+ if (value === undefined) return undefined;
51+ const raw = asRecord(value);
52+ if (Object.keys(raw).length === 0) {
53+ return undefined;
54+ }
55+
56+ const normalized: Record<string, { approval?: ToolApprovalMode }> = {};
57+ for (const [rawKey, rawValue] of Object.entries(raw)) {
58+ const key = rawKey.trim();
59+ if (!key) continue;
60+ const entry = asRecord(rawValue);
61+ const approval = normalizeApprovalMode(entry.approval, `${fieldName}.${key}.approval`);
62+ normalized[key] = approval ? { approval } : {};
63+ }
64+
65+ return Object.keys(normalized).length > 0 ? normalized : undefined;
66+}
67+
68+function normalizeAuthMode(value: unknown): "static" | "workspace" | "actor" | undefined {
69+ if (value === undefined) return undefined;
70+ if (value === "static" || value === "workspace" || value === "actor") {
71+ return value;
72+ }
73+ throw new Error("Tool source auth.mode must be 'static', 'workspace', or 'actor'");
74+}
75+
76+function normalizeAuth(value: unknown): OpenApiAuth | undefined {
77+ if (value === undefined) return undefined;
78+
79+ const auth = asRecord(value);
80+ const authType = optionalTrimmedString(auth.type);
81+ if (!authType) {
82+ throw new Error("Tool source auth.type is required when auth is provided");
83+ }
84+
85+ if (authType === "none") {
86+ return { type: "none" };
87+ }
88+
89+ if (authType === "basic") {
90+ return {
91+ type: "basic",
92+ mode: normalizeAuthMode(auth.mode),
93+ username: optionalTrimmedString(auth.username),
94+ password: optionalTrimmedString(auth.password),
95+ };
96+ }
97+
98+ if (authType === "bearer") {
99+ return {
100+ type: "bearer",
101+ mode: normalizeAuthMode(auth.mode),
102+ token: optionalTrimmedString(auth.token),
103+ };
104+ }
105+
106+ if (authType === "apiKey") {
107+ const header = requiredTrimmedString(auth.header, "auth.header");
108+ return {
109+ type: "apiKey",
110+ mode: normalizeAuthMode(auth.mode),
111+ header,
112+ value: optionalTrimmedString(auth.value),
113+ };
114+ }
115+
116+ throw new Error(`Unsupported tool source auth.type '${authType}'`);
117+}
118+
119+function normalizeSpec(value: unknown): string | Record<string, unknown> {
120+ if (typeof value === "string") {
121+ const trimmed = value.trim();
122+ if (!trimmed) {
123+ throw new Error("Tool source spec is required");
124+ }
125+ return trimmed;
126+ }
127+
128+ const specObject = asRecord(value);
129+ if (Object.keys(specObject).length === 0) {
130+ throw new Error("Tool source spec must be a non-empty string or object");
131+ }
132+ return specObject;
133+}
134+
135+export function normalizeToolSourceConfig(type: ToolSourceType, rawConfig: unknown): Record<string, unknown> {
136+ const config = asRecord(rawConfig);
137+
138+ if (type === "mcp") {
139+ const url = requiredTrimmedString(config.url, "url");
140+ const transport = config.transport;
141+ if (transport !== undefined && transport !== "sse" && transport !== "streamable-http") {
142+ throw new Error("Tool source transport must be 'sse' or 'streamable-http'");
143+ }
144+
145+ return {
146+ url,
147+ transport,
148+ auth: normalizeAuth(config.auth),
149+ queryParams: normalizeStringMap(config.queryParams, "queryParams"),
150+ discoveryHeaders: normalizeStringMap(config.discoveryHeaders, "discoveryHeaders"),
151+ defaultApproval: normalizeApprovalMode(config.defaultApproval, "defaultApproval"),
152+ overrides: normalizeOverrides(config.overrides, "overrides"),
153+ };
154+ }
155+
156+ if (type === "graphql") {
157+ const endpoint = requiredTrimmedString(config.endpoint, "endpoint");
158+
159+ return {
160+ endpoint,
161+ schema: Object.keys(asRecord(config.schema)).length > 0 ? asRecord(config.schema) : undefined,
162+ auth: normalizeAuth(config.auth),
163+ defaultQueryApproval: normalizeApprovalMode(config.defaultQueryApproval, "defaultQueryApproval"),
164+ defaultMutationApproval: normalizeApprovalMode(config.defaultMutationApproval, "defaultMutationApproval"),
165+ overrides: normalizeOverrides(config.overrides, "overrides"),
166+ };
167+ }
168+
169+ return {
170+ spec: normalizeSpec(config.spec),
171+ collectionUrl: optionalTrimmedString(config.collectionUrl),
172+ postmanProxyUrl: optionalTrimmedString(config.postmanProxyUrl),
173+ baseUrl: optionalTrimmedString(config.baseUrl),
174+ auth: normalizeAuth(config.auth),
175+ defaultReadApproval: normalizeApprovalMode(config.defaultReadApproval, "defaultReadApproval"),
176+ defaultWriteApproval: normalizeApprovalMode(config.defaultWriteApproval, "defaultWriteApproval"),
177+ overrides: normalizeOverrides(config.overrides, "overrides"),
178+ };
179+}
executor/packages/database/src/executor/handlers.ts+7−12
@@ -3,6 +3,10 @@ import type { ActionCtx, MutationCtx } from "../../convex/_generated/server"; · 2 unmodified lines
33 import { actorIdForAccount } from "../../../core/src/identity";
44 import { defaultRuntimeId, isKnownRuntimeId, isRuntimeEnabled } from "../../../core/src/runtimes/runtime-catalog";
55 import type { ApprovalRecord, TaskExecutionOutcome, TaskRecord } from "../../../core/src/types";
6+import {
7+ assertMatchesCanonicalActorId,
8+ canonicalActorIdForWorkspaceAccess,
9+} from "../auth/actor_identity";
610 import { DEFAULT_TASK_TIMEOUT_MS } from "../task/constants";
711 import { createTaskEvent } from "../task/events";
812 import { markTaskFinished } from "../task/finish";
@@ -164,15 +168,8 @@ export async function createTaskHandler( · 155 unmodified lines
164168 sessionId: args.sessionId,
165169 });
166170
167− const canonicalActorId = actorIdForAccount({
168− _id: access.accountId,
169− provider: access.provider,
170− providerAccountId: access.providerAccountId,
171− });
172−
173− if (args.actorId && args.actorId !== canonicalActorId) {
174− throw new Error("actorId must match the authenticated workspace actor");
175− }
171+ const canonicalActorId = canonicalActorIdForWorkspaceAccess(access);
172+ assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
176173
177174 const waitForResult = args.waitForResult ?? false;
178175 const created = await ctx.runMutation(internal.executor.createTaskInternal, {
@@ -243,9 +240,7 @@ export async function resolveApprovalHandler( · 64 unmodified lines
243240 };
244241
245242 const canonicalActorId = actorIdForAccount(typedCtx.account);
246− if (args.reviewerId && args.reviewerId !== canonicalActorId) {
247− throw new Error("reviewerId must match the authenticated workspace actor");
248− }
243+ assertMatchesCanonicalActorId(args.reviewerId, canonicalActorId, "reviewerId");
249244
250245 return await resolveApprovalRecord(typedCtx, internal, {
251246 ...args,
executor/packages/database/src/lib/scheduler.ts+14−1
@@ -2,8 +2,21 @@ type SchedulerLike = { · 1 unmodified line
22 runAfter: (delayMs: number, functionReference: any, args: any) => Promise<any>;
33 };
44
5+function isTestProcess(): boolean {
6+ if (process.env.NODE_ENV === "test") {
7+ return true;
8+ }
9+
10+ if (process.env.BUN_TEST === "1") {
11+ return true;
12+ }
13+
14+ const argv = process.argv.join(" ");
15+ return argv.includes(".test.ts");
16+}
17+
518 export function isSchedulerDisabled(): boolean {
6− return process.env.DISABLE_CONVEX_SCHEDULER === "1";
19+ return process.env.DISABLE_CONVEX_SCHEDULER === "1" || isTestProcess();
720 }
821
922 export async function safeRunAfter(
executor/packages/database/src/organizations/handlers.ts+14−4
@@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line
22 import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";
33 import { getOrganizationMembership, slugify } from "../../../core/src/identity";
44 import { ensureUniqueSlug } from "../../../core/src/slug";
5+import {
6+ mapOrganizationRoleToWorkspaceRole,
7+ upsertOrganizationMembership,
8+} from "../auth/memberships";
9+import { seedWorkspaceMembersFromOrganization } from "../auth/workspace_membership_projection";
510
611 type WorkspaceSummary = {
712 id: Id<"workspaces">;
@@ -64,15 +69,13 @@ export async function createOrganizationHandler(ctx: unknown, args: { name: stri · 56 unmodified lines
6469 updatedAt: now,
6570 });
6671
67− await typedCtx.db.insert("organizationMembers", {
72+ await upsertOrganizationMembership(typedCtx, {
6873 organizationId,
6974 accountId: account._id,
7075 role: "owner",
7176 status: "active",
7277 billable: true,
73− joinedAt: now,
74− createdAt: now,
75− updatedAt: now,
78+ now,
7679 });
7780
7881 const workspaceId = await typedCtx.db.insert("workspaces", {
@@ -84,6 +87,13 @@ export async function createOrganizationHandler(ctx: unknown, args: { name: stri · 5 unmodified lines
8487 updatedAt: now,
8588 });
8689
90+ await seedWorkspaceMembersFromOrganization(typedCtx, {
91+ organizationId,
92+ workspaceId,
93+ now,
94+ mapRole: mapOrganizationRoleToWorkspaceRole,
95+ });
96+
8797 const organization = await typedCtx.db.get(organizationId);
8898 const workspace = await typedCtx.db.get(workspaceId);
8999 if (!organization || !workspace) {
executor/packages/database/src/runtime/actor_auth.ts+6−9
@@ -3,7 +3,10 @@ · 2 unmodified lines
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import type { Id } from "../../convex/_generated/dataModel.d.ts";
55 import { internal } from "../../convex/_generated/api";
6−import { actorIdForAccount } from "../../../core/src/identity";
6+import {
7+ assertMatchesCanonicalActorId,
8+ canonicalActorIdForWorkspaceAccess,
9+} from "../auth/actor_identity";
710
811 export async function requireCanonicalActor(
912 ctx: ActionCtx,
@@ -17,13 +20,7 @@ export async function requireCanonicalActor( · 7 unmodified lines
1720 workspaceId: args.workspaceId,
1821 sessionId: args.sessionId,
1922 });
20− const canonicalActorId = actorIdForAccount({
21− _id: access.accountId,
22− provider: access.provider,
23− providerAccountId: access.providerAccountId,
24− });
25− if (args.actorId && args.actorId !== canonicalActorId) {
26− throw new Error("actorId must match the authenticated workspace actor");
27− }
23+ const canonicalActorId = canonicalActorIdForWorkspaceAccess(access);
24+ assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
2825 return canonicalActorId;
2926 }
executor/packages/database/src/runtime/tool_call_resolution.ts+7−25
@@ -6,9 +6,9 @@ import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation- · 5 unmodified lines
66 import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
77 import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";
88 import { getDecisionForContext, getToolDecision } from "./policy";
9+import { getReadyRegistryBuildId } from "./tool_registry_state";
910 import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
1011 import { baseTools } from "./workspace_tools";
11−import { sourceSignature } from "./tool_source_loading";
1212
1313 export function getGraphqlDecision(
1414 task: TaskRecord,
@@ -66,29 +66,6 @@ export function getGraphqlDecision( · 51 unmodified lines
6666 return { decision: worstDecision, effectivePaths: fieldPaths };
6767 }
6868
69−async function resolveRegistryBuildId(
70− ctx: ActionCtx,
71− workspaceId: TaskRecord["workspaceId"],
72−): Promise<string> {
73− const [state, sources] = await Promise.all([
74− ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>,
75− ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>,
76− ]);
77−
78− const enabledSources = sources.filter((source) => source.enabled);
79− const signature = sourceSignature(workspaceId, enabledSources);
80− const expectedSignature = `toolreg_v2|${signature}`;
81− const buildId = state?.readyBuildId;
82−
83− if (!buildId || state.signature !== expectedSignature) {
84− throw new Error(
85− "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
86− );
87− }
88−
89− return buildId;
90−}
91−
9269 async function suggestFromRegistry(
9370 ctx: ActionCtx,
9471 workspaceId: TaskRecord["workspaceId"],
@@ -127,7 +104,12 @@ export async function resolveToolForCall( · 32 unmodified lines
127104 return { tool: builtin, resolvedToolPath: toolPath };
128105 }
129106
130− const buildId = await resolveRegistryBuildId(ctx, task.workspaceId);
107+ const buildId = await getReadyRegistryBuildId(ctx, {
108+ workspaceId: task.workspaceId,
109+ actorId: task.actorId,
110+ clientId: task.clientId,
111+ refreshOnStale: true,
112+ });
131113
132114 let resolvedToolPath = toolPath;
133115 let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
executor/packages/database/src/runtime/tool_invocation.ts+7−14
@@ -21,7 +21,7 @@ import { publishTaskEvent } from "./events"; · 20 unmodified lines
2121 import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
2222 import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";
2323 import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
24−import { sourceSignature } from "./tool_source_loading";
24+import { getReadyRegistryBuildId } from "./tool_registry_state";
2525
2626 function createApprovalId(): string {
2727 return `approval_${crypto.randomUUID()}`;
@@ -62,19 +62,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 34 unmodified lines
6262 // Fast system tools are handled server-side from the registry.
6363 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
6464 const toolRegistry = internal.toolRegistry;
65− const [state, sources] = await Promise.all([
66− ctx.runQuery(toolRegistry.getState, { workspaceId: task.workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>,
67− ctx.runQuery(internal.database.listToolSources, { workspaceId: task.workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>,
68− ]);
69− const enabledSources = sources.filter((source) => source.enabled);
70− const signature = sourceSignature(task.workspaceId, enabledSources);
71− const expectedSignature = `toolreg_v2|${signature}`;
72− const buildId = state?.readyBuildId;
73− if (!buildId || state.signature !== expectedSignature) {
74− throw new Error(
75− "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
76− );
77− }
65+ const buildId = await getReadyRegistryBuildId(ctx, {
66+ workspaceId: task.workspaceId,
67+ actorId: task.actorId,
68+ clientId: task.clientId,
69+ refreshOnStale: true,
70+ });
7871
7972 const payload = typeof input === "string"
8073 ? { query: input }
executor/packages/database/src/runtime/tool_registry_state.ts+77−0
@@ -0,0 +1,77 @@
1+"use node";
2+
3+import type { ActionCtx } from "../../convex/_generated/server";
4+import type { Id } from "../../convex/_generated/dataModel.d.ts";
5+import { internal } from "../../convex/_generated/api";
6+import { sourceSignature } from "./tool_source_loading";
7+
8+export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";
9+
10+export function registrySignatureForWorkspace(
11+ workspaceId: Id<"workspaces">,
12+ sources: Array<{ id: string; updatedAt: number; enabled: boolean }>,
13+): string {
14+ const enabledSources = sources.filter((source) => source.enabled);
15+ return `${TOOL_REGISTRY_SIGNATURE_PREFIX}${sourceSignature(workspaceId, enabledSources)}`;
16+}
17+
18+type RegistryState = {
19+ signature: string;
20+ readyBuildId?: string;
21+} | null;
22+
23+type ToolSourceState = {
24+ id: string;
25+ updatedAt: number;
26+ enabled: boolean;
27+};
28+
29+async function readRegistryState(
30+ ctx: Pick<ActionCtx, "runQuery">,
31+ workspaceId: Id<"workspaces">,
32+): Promise<{ buildId?: string; isReady: boolean }> {
33+ const [state, sources] = await Promise.all([
34+ ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<RegistryState>,
35+ ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<ToolSourceState[]>,
36+ ]);
37+
38+ const expectedSignature = registrySignatureForWorkspace(workspaceId, sources);
39+ const buildId = state?.readyBuildId;
40+
41+ return {
42+ buildId,
43+ isReady: Boolean(buildId && state?.signature === expectedSignature),
44+ };
45+}
46+
47+export async function getReadyRegistryBuildId(
48+ ctx: Pick<ActionCtx, "runQuery" | "runAction">,
49+ args: {
50+ workspaceId: Id<"workspaces">;
51+ actorId?: string;
52+ clientId?: string;
53+ refreshOnStale?: boolean;
54+ },
55+): Promise<string> {
56+ const initial = await readRegistryState(ctx, args.workspaceId);
57+ if (initial.isReady && initial.buildId) {
58+ return initial.buildId;
59+ }
60+
61+ if (args.refreshOnStale) {
62+ await ctx.runAction(internal.executorNode.listToolsWithWarningsInternal, {
63+ workspaceId: args.workspaceId,
64+ actorId: args.actorId,
65+ clientId: args.clientId,
66+ });
67+
68+ const refreshed = await readRegistryState(ctx, args.workspaceId);
69+ if (refreshed.isReady && refreshed.buildId) {
70+ return refreshed.buildId;
71+ }
72+ }
73+
74+ throw new Error(
75+ "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
76+ );
77+}
executor/packages/database/src/runtime/tool_source_loading.ts+5−38
@@ -20,6 +20,7 @@ import type { · 19 unmodified lines
2020 PreparedOpenApiSpec,
2121 } from "../../../core/src/tool/source-types";
2222 import type { ToolSourceRecord } from "../../../core/src/types";
23+import { normalizeToolSourceConfig } from "../database/tool_source_config";
2324 import { asPayload } from "../lib/object";
2425
2526 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
@@ -40,40 +41,15 @@ export function normalizeExternalToolSource(raw: { · 14 unmodified lines
4041 name: string;
4142 config: Record<string, unknown>;
4243 }): ExternalToolSourceConfig {
43− const config = raw.config;
44+ const config = normalizeToolSourceConfig(raw.type, raw.config);
4445
4546 if (raw.type === "mcp") {
46− if (typeof config.url !== "string" || config.url.trim().length === 0) {
47− throw new Error(`MCP source '${raw.name}' missing url`);
48− }
49−
50− if (
51− config.transport !== undefined
52− && config.transport !== "sse"
53− && config.transport !== "streamable-http"
54− ) {
55− throw new Error(`MCP source '${raw.name}' has invalid transport`);
56− }
57−
58− if (config.queryParams !== undefined) {
59− const queryParams = config.queryParams;
60− if (!queryParams || typeof queryParams !== "object" || Array.isArray(queryParams)) {
61− throw new Error(`MCP source '${raw.name}' queryParams must be an object`);
62− }
63−
64− for (const value of Object.values(queryParams as Record<string, unknown>)) {
65− if (typeof value !== "string") {
66− throw new Error(`MCP source '${raw.name}' queryParams values must be strings`);
67− }
68− }
69− }
70−
7147 const result: McpToolSourceConfig = {
7248 type: "mcp",
7349 name: raw.name,
7450 sourceId: raw.id,
7551 sourceKey: `source:${raw.id}`,
76− url: config.url,
52+ url: String(config.url),
7753 auth: config.auth as McpToolSourceConfig["auth"],
7854 transport: config.transport as McpToolSourceConfig["transport"],
7955 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
@@ -84,16 +60,12 @@ export function normalizeExternalToolSource(raw: { · 4 unmodified lines
8460 }
8561
8662 if (raw.type === "graphql") {
87− if (typeof config.endpoint !== "string" || config.endpoint.trim().length === 0) {
88− throw new Error(`GraphQL source '${raw.name}' missing endpoint`);
89− }
90−
9163 const result: GraphqlToolSourceConfig = {
9264 type: "graphql",
9365 name: raw.name,
9466 sourceId: raw.id,
9567 sourceKey: `source:${raw.id}`,
96− endpoint: config.endpoint,
68+ endpoint: String(config.endpoint),
9769 schema: config.schema as GraphqlToolSourceConfig["schema"],
9870 auth: config.auth as GraphqlToolSourceConfig["auth"],
9971 defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],
@@ -103,17 +75,12 @@ export function normalizeExternalToolSource(raw: { · 3 unmodified lines
10375 return result;
10476 }
10577
106− const spec = config.spec;
107− if (typeof spec !== "string" && (typeof spec !== "object" || spec === null)) {
108− throw new Error(`OpenAPI source '${raw.name}' missing spec`);
109− }
110−
11178 const result: OpenApiToolSourceConfig = {
11279 type: "openapi",
11380 name: raw.name,
11481 sourceId: raw.id,
11582 sourceKey: `source:${raw.id}`,
116− spec: spec as OpenApiToolSourceConfig["spec"],
83+ spec: config.spec as OpenApiToolSourceConfig["spec"],
11784 baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],
11885 auth: config.auth as OpenApiToolSourceConfig["auth"],
11986 defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],
executor/packages/database/src/runtime/workspace_tools.ts+2−1
@@ -23,6 +23,7 @@ import type { · 22 unmodified lines
2323 } from "../../../core/src/types";
2424 import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
2525 import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
26+import { registrySignatureForWorkspace } from "./tool_registry_state";
2627 import { normalizeToolPathForLookup } from "./tool_paths";
2728
2829 const baseTools = new Map<string, ToolDefinition>();
@@ -516,7 +517,7 @@ export async function getWorkspaceTools( · 487 unmodified lines
516517 traceStep("listToolSources", listSourcesStartedAt);
517518 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
518519 const signature = sourceSignature(workspaceId, sources);
519− const registrySignature = `toolreg_v2|${signature}`;
520+ const registrySignature = registrySignatureForWorkspace(workspaceId, sources);
520521 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
521522 includeDts,
522523 sourceTimeoutMs: sourceTimeoutMs ?? null,
executor/packages/database/src/workspaces/handlers.ts+14−4
@@ -2,6 +2,11 @@ import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; · 1 unmodified line
22 import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";
33 import { getOrganizationMembership, slugify } from "../../../core/src/identity";
44 import { ensureUniqueSlug } from "../../../core/src/slug";
5+import {
6+ mapOrganizationRoleToWorkspaceRole,
7+ upsertOrganizationMembership,
8+} from "../auth/memberships";
9+import { seedWorkspaceMembersFromOrganization } from "../auth/workspace_membership_projection";
510
611 type WorkspaceResult = {
712 id: Id<"workspaces">;
@@ -175,15 +180,13 @@ export async function createWorkspaceHandler( · 167 unmodified lines
175180 updatedAt: now,
176181 });
177182
178− await typedCtx.db.insert("organizationMembers", {
183+ await upsertOrganizationMembership(typedCtx, {
179184 organizationId,
180185 accountId: account._id,
181186 role: "owner",
182187 status: "active",
183188 billable: true,
184− joinedAt: now,
185− createdAt: now,
186− updatedAt: now,
189+ now,
187190 });
188191 }
189192
@@ -209,6 +212,13 @@ export async function createWorkspaceHandler( · 19 unmodified lines
209212 throw new Error("Failed to create workspace");
210213 }
211214
215+ await seedWorkspaceMembersFromOrganization(typedCtx, {
216+ organizationId,
217+ workspaceId,
218+ now,
219+ mapRole: mapOrganizationRoleToWorkspaceRole,
220+ });
221+
212222 await cleanupEmptyStarterWorkspace(typedCtx, organizationId, workspaceId);
213223
214224 return await toWorkspaceResult(typedCtx, workspace);
executor/scripts/test/run-suite.ts+92−0
@@ -0,0 +1,92 @@
1+#!/usr/bin/env bun
2+
3+export {};
4+
5+const INTEGRATION_TESTS = new Set([
6+ "packages/core/src/openapi/real-specs.test.ts",
7+]);
8+
9+type SuiteMode = "fast" | "integration" | "e2e" | "all";
10+
11+function normalizeMode(raw: string | undefined): SuiteMode {
12+ if (raw === "integration" || raw === "e2e" || raw === "all") {
13+ return raw;
14+ }
15+ return "fast";
16+}
17+
18+function isE2eTest(filePath: string): boolean {
19+ return filePath.endsWith(".e2e.test.ts");
20+}
21+
22+async function discoverAllTestFiles(): Promise<string[]> {
23+ const files = new Set<string>();
24+ const roots = ["apps", "packages", "scripts"];
25+
26+ for (const root of roots) {
27+ const glob = new Bun.Glob(`${root}/**/*.test.ts`);
28+ for await (const match of glob.scan({ cwd: process.cwd() })) {
29+ files.add(match);
30+ }
31+ }
32+
33+ return [...files].sort();
34+}
35+
36+function selectTests(mode: SuiteMode, allTests: string[]): string[] {
37+ if (mode === "all") {
38+ return allTests;
39+ }
40+
41+ if (mode === "integration") {
42+ return allTests.filter((filePath) => INTEGRATION_TESTS.has(filePath));
43+ }
44+
45+ if (mode === "e2e") {
46+ return allTests.filter(isE2eTest);
47+ }
48+
49+ return allTests.filter((filePath) => !isE2eTest(filePath) && !INTEGRATION_TESTS.has(filePath));
50+}
51+
52+async function main(): Promise<void> {
53+ const mode = normalizeMode(process.argv[2]);
54+ const rawArgs = process.argv.slice(3);
55+ const listOnly = rawArgs.includes("--list");
56+ const passthroughArgs = rawArgs.filter((arg) => arg !== "--list");
57+
58+ const allTests = await discoverAllTestFiles();
59+ const selectedTests = selectTests(mode, allTests);
60+
61+ if (selectedTests.length === 0) {
62+ console.error(`[test-suite] No tests selected for mode '${mode}'.`);
63+ process.exit(1);
64+ }
65+
66+ console.log(`[test-suite] mode=${mode} files=${selectedTests.length}`);
67+
68+ if (listOnly) {
69+ for (const filePath of selectedTests) {
70+ console.log(filePath);
71+ }
72+ return;
73+ }
74+
75+ const child = Bun.spawn([
76+ "bun",
77+ "test",
78+ ...selectedTests,
79+ ...passthroughArgs,
80+ ], {
81+ cwd: process.cwd(),
82+ stdin: "inherit",
83+ stdout: "inherit",
84+ stderr: "inherit",
85+ env: process.env,
86+ });
87+
88+ const exitCode = await child.exited;
89+ process.exit(exitCode);
90+}
91+
92+await main();
package.json+4−1
@@ -24,7 +24,10 @@ · 23 unmodified lines
2424 "dev:assistant": "bun run --cwd assistant/packages/server dev",
2525 "dev:bot": "bun run --cwd assistant/packages/bot dev",
2626 "test": "bun run test:executor && bun run test:assistant",
27− "test:executor": "DISABLE_CONVEX_SCHEDULER=1 bun test --cwd executor",
27+ "test:executor": "bun run --cwd executor test",
28+ "test:executor:integration": "bun run --cwd executor test:integration",
29+ "test:executor:e2e": "bun run --cwd executor test:e2e",
30+ "test:executor:all": "bun run --cwd executor test:all",
2831 "test:assistant": "bun run --cwd assistant test",
2932 "typecheck": "bun run typecheck:executor && bun run typecheck:assistant",
3033 "typecheck:executor": "bunx tsc --noEmit -p executor/tsconfig.json",