History

refactor(v2): centralize openapi extraction and trim ingest complexity

Rhys Sullivan committed 8 months ago

Commit 06e17301 parent 39811789 files · +931 −608Browse files

Unified · Split

9 files
  1. v2/apps/api/api/parse-openapi.fidelity.test.ts +461 −0
  2. v2/apps/api/api/parse-openapi.ts +1 −1
  3. v2/apps/api/package.json +4 −1
  4. v2/apps/convex/convex/control_plane/openapi_ingest.ts +42 −586
  5. v2/apps/convex/convex/control_plane/sources.ts +15 −4
  6. v2/apps/web/app/(console)/console-shell.tsx +0 −4
  7. v2/apps/web/components/shell/app-shell.tsx +2 −12
  8. v2/apps/web/components/shell/workspace-selector.tsx +398 −0
  9. v2/bun.lock +8 −0
v2/apps/api/api/parse-openapi.fidelity.test.ts+461−0
@@ -0,0 +1,461 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import { parse as parseYaml } from "yaml";
4+
5+import { extractOpenApiManifest } from "./parse-openapi";
6+
7+const cloudflareSpecUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
8+const vercelSpecUrl = "https://openapi.vercel.sh/";
9+const httpMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const;
10+
11+const isRecord = (value: unknown): value is Record<string, unknown> =>
12+ typeof value === "object" && value !== null && !Array.isArray(value);
13+
14+const parseSpecDocument = (input: string): unknown => {
15+ const trimmed = input.trim();
16+ if (trimmed.length === 0) {
17+ throw new Error("Spec document is empty");
18+ }
19+
20+ try {
21+ return JSON.parse(trimmed);
22+ } catch {
23+ return parseYaml(trimmed);
24+ }
25+};
26+
27+const loadSpec = async (url: string): Promise<unknown> => {
28+ const response = await fetch(url);
29+ if (!response.ok) {
30+ throw new Error(`Failed to fetch spec ${url}: ${response.status} ${response.statusText}`);
31+ }
32+
33+ return parseSpecDocument(await response.text());
34+};
35+
36+const collectRefKeys = (value: unknown, refs: Set<string>): void => {
37+ if (Array.isArray(value)) {
38+ for (const item of value) {
39+ collectRefKeys(item, refs);
40+ }
41+ return;
42+ }
43+
44+ if (!isRecord(value)) {
45+ return;
46+ }
47+
48+ const refValue = value.$ref;
49+ if (typeof refValue === "string" && refValue.startsWith("#/")) {
50+ refs.add(refValue);
51+ }
52+
53+ for (const nested of Object.values(value)) {
54+ collectRefKeys(nested, refs);
55+ }
56+};
57+
58+const unescapeJsonPointer = (value: string): string =>
59+ value.replace(/~1/g, "/").replace(/~0/g, "~");
60+
61+const getByJsonPointer = (root: unknown, pointer: string): unknown => {
62+ if (!pointer.startsWith("#/")) {
63+ return null;
64+ }
65+
66+ const segments = pointer
67+ .slice(2)
68+ .split("/")
69+ .map((segment) => unescapeJsonPointer(segment));
70+
71+ let current: unknown = root;
72+ for (const segment of segments) {
73+ if (Array.isArray(current)) {
74+ const index = Number(segment);
75+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
76+ return null;
77+ }
78+ current = current[index];
79+ continue;
80+ }
81+
82+ if (!isRecord(current) || !(segment in current)) {
83+ return null;
84+ }
85+
86+ current = current[segment];
87+ }
88+
89+ return current;
90+};
91+
92+const resolveLocalRef = (root: unknown, value: unknown, depth = 0): unknown => {
93+ if (depth > 8 || !isRecord(value) || typeof value.$ref !== "string") {
94+ return value;
95+ }
96+
97+ const resolved = getByJsonPointer(root, value.$ref);
98+ if (resolved === null) {
99+ return value;
100+ }
101+
102+ return resolveLocalRef(root, resolved, depth + 1);
103+};
104+
105+const extractSchemaFromContent = (content: unknown): unknown | null => {
106+ if (!isRecord(content)) {
107+ return null;
108+ }
109+
110+ const jsonMedia =
111+ (content["application/json"] as Record<string, unknown> | undefined)
112+ ?? (content["application/*+json"] as Record<string, unknown> | undefined);
113+
114+ if (jsonMedia && isRecord(jsonMedia.schema)) {
115+ return jsonMedia.schema;
116+ }
117+
118+ for (const mediaType of Object.values(content)) {
119+ if (isRecord(mediaType) && isRecord(mediaType.schema)) {
120+ return mediaType.schema;
121+ }
122+ }
123+
124+ return null;
125+};
126+
127+const extractParameterSchema = (root: unknown, value: unknown): unknown => {
128+ const parameter = resolveLocalRef(root, value);
129+ if (!isRecord(parameter)) {
130+ return null;
131+ }
132+
133+ if (isRecord(parameter.schema)) {
134+ return parameter.schema;
135+ }
136+
137+ return extractSchemaFromContent(parameter.content);
138+};
139+
140+const extractRequestBodySchema = (root: unknown, operation: Record<string, unknown>): unknown => {
141+ const requestBody = resolveLocalRef(root, operation.requestBody);
142+ if (!isRecord(requestBody)) {
143+ return null;
144+ }
145+
146+ return extractSchemaFromContent(requestBody.content);
147+};
148+
149+const extractResponseSchema = (root: unknown, operation: Record<string, unknown>): unknown => {
150+ const responses = resolveLocalRef(root, operation.responses);
151+ if (!isRecord(responses)) {
152+ return null;
153+ }
154+
155+ const preferred = Object.keys(responses)
156+ .filter((key) => /^2\d\d$/.test(key))
157+ .sort();
158+ if ("default" in responses) {
159+ preferred.push("default");
160+ }
161+
162+ for (const key of preferred) {
163+ const response = resolveLocalRef(root, responses[key]);
164+ if (!isRecord(response)) {
165+ continue;
166+ }
167+
168+ const schema = extractSchemaFromContent(response.content);
169+ if (schema !== null) {
170+ return schema;
171+ }
172+ }
173+
174+ return null;
175+};
176+
177+const hasMeaningfulSchemaShape = (value: unknown): boolean => {
178+ if (!isRecord(value)) {
179+ return false;
180+ }
181+
182+ if (typeof value.$ref === "string") {
183+ return true;
184+ }
185+
186+ if (typeof value.type === "string") {
187+ return true;
188+ }
189+
190+ if (Array.isArray(value.enum) && value.enum.length > 0) {
191+ return true;
192+ }
193+
194+ if (isRecord(value.properties) && Object.keys(value.properties).length > 0) {
195+ return true;
196+ }
197+
198+ if (value.items !== undefined && value.items !== null) {
199+ return true;
200+ }
201+
202+ if (Array.isArray(value.oneOf) || Array.isArray(value.anyOf) || Array.isArray(value.allOf)) {
203+ return true;
204+ }
205+
206+ if (value.additionalProperties !== undefined) {
207+ return true;
208+ }
209+
210+ return false;
211+};
212+
213+const isUnknownLikeSchema = (value: unknown): boolean => !hasMeaningfulSchemaShape(value);
214+
215+type OperationExpectation = {
216+ parameterSchemas: Record<string, unknown>;
217+ requestBodySchema: unknown;
218+ responseSchema: unknown;
219+};
220+
221+const operationSignature = (method: string, path: string): string =>
222+ `${method.toLowerCase()} ${path}`;
223+
224+const buildOperationExpectations = (spec: unknown): Map<string, OperationExpectation> => {
225+ if (!isRecord(spec) || !isRecord(spec.paths)) {
226+ throw new Error("Spec missing paths object");
227+ }
228+
229+ const expectations = new Map<string, OperationExpectation>();
230+
231+ for (const [path, pathItemValue] of Object.entries(spec.paths)) {
232+ if (!isRecord(pathItemValue)) {
233+ continue;
234+ }
235+
236+ const pathLevelParameters = Array.isArray(pathItemValue.parameters)
237+ ? pathItemValue.parameters
238+ : [];
239+
240+ for (const method of httpMethods) {
241+ const operationValue = pathItemValue[method];
242+ if (!isRecord(operationValue)) {
243+ continue;
244+ }
245+
246+ const parameterSchemas: Record<string, unknown> = {};
247+ const operationLevelParameters = Array.isArray(operationValue.parameters)
248+ ? operationValue.parameters
249+ : [];
250+
251+ const parameters = [...pathLevelParameters, ...operationLevelParameters];
252+ for (const parameter of parameters) {
253+ const resolved = resolveLocalRef(spec, parameter);
254+ if (!isRecord(resolved)) {
255+ continue;
256+ }
257+
258+ const name = typeof resolved.name === "string" ? resolved.name.trim() : "";
259+ const location = typeof resolved.in === "string" ? resolved.in.trim().toLowerCase() : "";
260+ if (
261+ name.length === 0
262+ || (
263+ location !== "path"
264+ && location !== "query"
265+ && location !== "header"
266+ && location !== "cookie"
267+ )
268+ ) {
269+ continue;
270+ }
271+
272+ const schema = extractParameterSchema(spec, resolved);
273+ if (schema !== null) {
274+ parameterSchemas[name] = schema;
275+ }
276+ }
277+
278+ expectations.set(operationSignature(method, path), {
279+ parameterSchemas,
280+ requestBodySchema: extractRequestBodySchema(spec, operationValue),
281+ responseSchema: extractResponseSchema(spec, operationValue),
282+ });
283+ }
284+ }
285+
286+ return expectations;
287+};
288+
289+const assertManifestFidelityForSpec = (
290+ specName: string,
291+ spec: unknown,
292+): void => {
293+ const manifest = extractOpenApiManifest(specName, spec);
294+ const expectations = buildOperationExpectations(spec);
295+
296+ for (const tool of manifest.tools) {
297+ const signature = operationSignature(tool.method, tool.path);
298+ const expectation = expectations.get(signature);
299+ expect(expectation, `Missing operation expectation for ${signature}`).toBeDefined();
300+
301+ const inputSchema = tool.typing?.inputSchemaJson
302+ ? JSON.parse(tool.typing.inputSchemaJson)
303+ : null;
304+ const outputSchema = tool.typing?.outputSchemaJson
305+ ? JSON.parse(tool.typing.outputSchemaJson)
306+ : null;
307+
308+ if (expectation) {
309+ const expectedParamSchemas = Object.entries(expectation.parameterSchemas);
310+ const hasStructuredParams = expectedParamSchemas.some(([, schema]) => hasMeaningfulSchemaShape(schema));
311+ const hasStructuredBody = hasMeaningfulSchemaShape(expectation.requestBodySchema);
312+ const hasStructuredOutput = hasMeaningfulSchemaShape(expectation.responseSchema);
313+
314+ if (hasStructuredParams || hasStructuredBody) {
315+ expect(tool.typing?.inputSchemaJson, `Missing input schema for ${signature}`).toBeDefined();
316+ expect(isRecord(inputSchema)).toBe(true);
317+ expect(isRecord(inputSchema?.properties)).toBe(true);
318+ }
319+
320+ if (hasStructuredParams && isRecord(inputSchema) && isRecord(inputSchema.properties)) {
321+ for (const [paramName, sourceSchema] of expectedParamSchemas) {
322+ if (!hasMeaningfulSchemaShape(sourceSchema)) {
323+ continue;
324+ }
325+
326+ expect(
327+ inputSchema.properties[paramName],
328+ `Missing input property '${paramName}' for ${signature}`,
329+ ).toBeDefined();
330+ expect(
331+ isUnknownLikeSchema(inputSchema.properties[paramName]),
332+ `Unknown-like input schema for '${paramName}' in ${signature}`,
333+ ).toBe(false);
334+ }
335+ }
336+
337+ if (hasStructuredBody && isRecord(inputSchema) && isRecord(inputSchema.properties)) {
338+ expect(inputSchema.properties.body, `Missing body schema for ${signature}`).toBeDefined();
339+ expect(
340+ isUnknownLikeSchema(inputSchema.properties.body),
341+ `Unknown-like body schema for ${signature}`,
342+ ).toBe(false);
343+ }
344+
345+ if (hasStructuredOutput) {
346+ expect(tool.typing?.outputSchemaJson, `Missing output schema for ${signature}`).toBeDefined();
347+ expect(
348+ isUnknownLikeSchema(outputSchema),
349+ `Unknown-like output schema for ${signature}`,
350+ ).toBe(false);
351+ }
352+ }
353+
354+ const refs = new Set<string>();
355+ if (inputSchema) {
356+ collectRefKeys(inputSchema, refs);
357+ }
358+ if (outputSchema) {
359+ collectRefKeys(outputSchema, refs);
360+ }
361+
362+ for (const refKey of refs) {
363+ const resolvedRef = manifest.refHintTable[refKey];
364+ expect(resolvedRef, `Missing refHintTable entry ${refKey} for ${signature}`).toBeDefined();
365+ expect(() => JSON.parse(resolvedRef!)).not.toThrow();
366+ }
367+ }
368+};
369+
370+describe("parse-openapi extractor fidelity against real specs", () => {
371+ it.effect("Cloudflare path params and output refs are preserved", () =>
372+ Effect.gen(function* () {
373+ const cloudflareSpec = yield* Effect.tryPromise(() => loadSpec(cloudflareSpecUrl));
374+ const manifest = extractOpenApiManifest("cloudflare", cloudflareSpec);
375+
376+ expect(manifest.tools.length).toBeGreaterThan(2000);
377+
378+ const accessDeleteTool = manifest.tools.find(
379+ (tool) =>
380+ tool.method === "delete"
381+ && tool.path === "/accounts/{account_id}/access/apps/{app_id}",
382+ );
383+ expect(accessDeleteTool).toBeDefined();
384+ expect(accessDeleteTool?.typing?.inputSchemaJson).toBeDefined();
385+
386+ const inputSchema = JSON.parse(accessDeleteTool!.typing!.inputSchemaJson!);
387+ expect(inputSchema.type).toBe("object");
388+ expect(Array.isArray(inputSchema.required)).toBe(true);
389+ expect(inputSchema.required).toContain("account_id");
390+ expect(inputSchema.required).toContain("app_id");
391+ expect(isRecord(inputSchema.properties)).toBe(true);
392+ expect(isRecord(inputSchema.properties.account_id)).toBe(true);
393+ expect(isRecord(inputSchema.properties.app_id)).toBe(true);
394+
395+ const ipDetailsTool = manifest.tools.find(
396+ (tool) =>
397+ tool.name.toLowerCase().includes("jd cloud ip details")
398+ || (tool.method === "get" && tool.path === "/ips"),
399+ );
400+ expect(ipDetailsTool).toBeDefined();
401+ expect(ipDetailsTool?.typing?.outputSchemaJson).toBeDefined();
402+
403+ const outputSchema = JSON.parse(ipDetailsTool!.typing!.outputSchemaJson!);
404+ const outputRefs = new Set<string>();
405+ collectRefKeys(outputSchema, outputRefs);
406+ expect(outputRefs.size).toBeGreaterThan(0);
407+
408+ for (const refKey of outputRefs) {
409+ const schemaJson = manifest.refHintTable[refKey];
410+ expect(schemaJson).toBeDefined();
411+ expect(() => JSON.parse(schemaJson)).not.toThrow();
412+ }
413+ }),
414+ 30_000,
415+ );
416+
417+ it.effect("Vercel extract keeps typed invocation + schema coverage", () =>
418+ Effect.gen(function* () {
419+ const vercelSpec = yield* Effect.tryPromise(() => loadSpec(vercelSpecUrl));
420+ const manifest = extractOpenApiManifest("vercel", vercelSpec);
421+
422+ expect(manifest.tools.length).toBeGreaterThan(100);
423+
424+ const toolWithPathParams = manifest.tools.find(
425+ (tool) => tool.invocation.parameters.some((parameter) => parameter.location === "path"),
426+ );
427+ expect(toolWithPathParams).toBeDefined();
428+ expect(toolWithPathParams?.typing?.inputSchemaJson).toBeDefined();
429+
430+ const inputSchema = JSON.parse(toolWithPathParams!.typing!.inputSchemaJson!);
431+ expect(isRecord(inputSchema)).toBe(true);
432+ expect(inputSchema.type).toBe("object");
433+ expect(isRecord(inputSchema.properties)).toBe(true);
434+
435+ for (const pathParameter of toolWithPathParams!.invocation.parameters.filter(
436+ (parameter) => parameter.location === "path",
437+ )) {
438+ expect(Array.isArray(inputSchema.required)).toBe(true);
439+ expect(inputSchema.required).toContain(pathParameter.name);
440+ expect(inputSchema.properties[pathParameter.name]).toBeDefined();
441+ }
442+ }),
443+ 30_000,
444+ );
445+
446+ it.effect("Cloudflare has no unknown-like schemas for structured operations", () =>
447+ Effect.gen(function* () {
448+ const cloudflareSpec = yield* Effect.tryPromise(() => loadSpec(cloudflareSpecUrl));
449+ assertManifestFidelityForSpec("cloudflare", cloudflareSpec);
450+ }),
451+ 60_000,
452+ );
453+
454+ it.effect("Vercel has no unknown-like schemas for structured operations", () =>
455+ Effect.gen(function* () {
456+ const vercelSpec = yield* Effect.tryPromise(() => loadSpec(vercelSpecUrl));
457+ assertManifestFidelityForSpec("vercel", vercelSpec);
458+ }),
459+ 60_000,
460+ );
461+});
v2/apps/api/api/parse-openapi.ts+1−1
@@ -427,7 +427,7 @@ const buildRefHintTable = ( · 426 unmodified lines
427427 return table;
428428 };
429429
430−const extractOpenApiManifest = (
430+export const extractOpenApiManifest = (
431431 _sourceName: string,
432432 openApiSpec: unknown,
433433 ): OpenApiManifest => {
v2/apps/api/package.json+4−1
@@ -10,7 +10,10 @@ · 9 unmodified lines
1010 "yaml": "^2.8.1"
1111 },
1212 "devDependencies": {
13+ "@effect/vitest": "^0.27.0",
1314 "@types/node": "^20.19.9",
14− "typescript": "^5.9.3"
15+ "effect": "^3.19.8",
16+ "typescript": "^5.9.3",
17+ "vitest": "^3.2.4"
1518 }
1619 }
v2/apps/convex/convex/control_plane/openapi_ingest.ts+42−586
@@ -2,7 +2,6 @@ · 1 unmodified line
22
33 import { createHash } from "node:crypto";
44
5−import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api";
65 import { type Source } from "@executor-v2/schema";
76 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
87 import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
@@ -16,49 +15,9 @@ import { internalAction } from "../_generated/server"; · 7 unmodified lines
1615
1716 const runtimeInternal = internal as any;
1817
19−const openApiExtractorVersion = "openapi_v2";
2018 const graphqlExtractorVersion = "graphql_v1";
2119 const mcpExtractorVersion = "mcp_v1";
2220 const writeBatchSize = 500;
23−const stagedArtifactBatchSize = 40;
24−
25−const maxRefHintBytes = 900_000;
26−
27−const safeRefHintTableJson = (
28− refHintTable: Record<string, unknown> | undefined,
29−): string | null => {
30− if (!refHintTable) {
31− return null;
32− }
33−
34− const serialized = JSON.stringify(refHintTable);
35− const bytes = new TextEncoder().encode(serialized).length;
36− return bytes <= maxRefHintBytes ? serialized : null;
37−};
38−
39−const refHintEntries = (
40− refHintTable: Record<string, unknown> | undefined,
41−): Array<{ refKey: string; schemaJson: string }> => {
42− if (!refHintTable) {
43− return [];
44− }
45−
46− return Object.entries(refHintTable)
47− .map(([refKey, value]) => {
48− if (typeof value === "string") {
49− return {
50− refKey,
51− schemaJson: value,
52− };
53− }
54−
55− return {
56− refKey,
57− schemaJson: JSON.stringify(value),
58− };
59− })
60− .sort((left, right) => left.refKey.localeCompare(right.refKey));
61−};
6221
6322 const graphqlSchemaRootsQuery = `query SchemaRoots {
6423 __schema {
@@ -975,34 +934,18 @@ type OpenApiParserIngestedPayload = { · 910 unmodified lines
975934 error?: string;
976935 };
977936
978−type OpenApiParserParsedPayload = {
979− ok?: boolean;
980− mode?: string;
981− openApiSpec?: unknown;
982− error?: string;
983−};
984−
985−type OpenApiFetchResult =
986− | {
987− mode: "parsed";
988− openApiSpec: unknown;
989− }
990− | {
991− mode: "ingested";
992− artifactId: string;
993− sourceHash: string;
994− toolCount: number;
995− namespace: string;
996− refHintTableJson: string | null;
997− artifactBatchCount: number;
998− };
999−
1000−const fetchOpenApiDocumentForIngest = async (source: Source): Promise<OpenApiFetchResult> => {
937+const fetchOpenApiDocumentForIngest = async (
938+ source: Source,
939+): Promise<{
940+ artifactId: string;
941+ sourceHash: string;
942+ toolCount: number;
943+ namespace: string;
944+ refHintTableJson: string | null;
945+ artifactBatchCount: number;
946+}> => {
1001947 if (openApiParserUrl.length === 0) {
1002− return {
1003− mode: "parsed",
1004− openApiSpec: await fetchOpenApiDocument(source.endpoint),
1005− };
948+ throw new Error("OPENAPI_PARSE_API_URL is required for OpenAPI ingestion");
1006949 }
1007950
1008951 const response = await fetch(openApiParserUrl, {
@@ -1031,7 +974,7 @@ const fetchOpenApiDocumentForIngest = async (source: Source): Promise<OpenApiFet · 22 unmodified lines
1031974 );
1032975 }
1033976
1034− const payload = (await response.json()) as OpenApiParserIngestedPayload & OpenApiParserParsedPayload;
977+ const payload = (await response.json()) as OpenApiParserIngestedPayload;
1035978
1036979 if (payload.ok !== true) {
1037980 throw new Error(
@@ -1041,36 +984,32 @@ const fetchOpenApiDocumentForIngest = async (source: Source): Promise<OpenApiFet · 3 unmodified lines
1041984 );
1042985 }
1043986
1044− if (payload.mode === "ingested") {
1045− const artifactId = normalizeString(payload.artifactId);
1046− const sourceHash = normalizeString(payload.sourceHash);
1047− const namespace = normalizeString(payload.namespace);
1048− const toolCount = Number.isFinite(payload.toolCount)
1049− ? Math.max(0, Math.floor(payload.toolCount ?? 0))
1050− : null;
1051− const artifactBatchCount = Number.isFinite(payload.artifactBatchCount)
1052− ? Math.max(0, Math.floor(payload.artifactBatchCount ?? 0))
1053− : 0;
1054−
1055− if (!artifactId || !sourceHash || !namespace || toolCount === null) {
1056− throw new Error("Parser endpoint returned an invalid ingested payload");
1057− }
987+ if (payload.mode !== "ingested") {
988+ throw new Error("Parser endpoint must return mode='ingested' for OpenAPI ingestion");
989+ }
1058990
1059− return {
1060− mode: "ingested",
1061− artifactId,
1062− sourceHash,
1063− toolCount,
1064− namespace,
1065− refHintTableJson:
1066− typeof payload.refHintTableJson === "string" ? payload.refHintTableJson : null,
1067− artifactBatchCount,
1068− };
991+ const artifactId = normalizeString(payload.artifactId);
992+ const sourceHash = normalizeString(payload.sourceHash);
993+ const namespace = normalizeString(payload.namespace);
994+ const toolCount = Number.isFinite(payload.toolCount)
995+ ? Math.max(0, Math.floor(payload.toolCount ?? 0))
996+ : null;
997+ const artifactBatchCount = Number.isFinite(payload.artifactBatchCount)
998+ ? Math.max(0, Math.floor(payload.artifactBatchCount ?? 0))
999+ : 0;
1000+
1001+ if (!artifactId || !sourceHash || !namespace || toolCount === null) {
1002+ throw new Error("Parser endpoint returned an invalid ingested payload");
10691003 }
10701004
10711005 return {
1072− mode: "parsed",
1073− openApiSpec: payload.openApiSpec,
1006+ artifactId,
1007+ sourceHash,
1008+ toolCount,
1009+ namespace,
1010+ refHintTableJson:
1011+ typeof payload.refHintTableJson === "string" ? payload.refHintTableJson : null,
1012+ artifactBatchCount,
10741013 };
10751014 };
10761015
@@ -1082,218 +1021,21 @@ type IngestOptions = { · 5 unmodified lines
10821021 const ingestOpenApiSource = (
10831022 ctx: any,
10841023 source: Source,
1085− options: IngestOptions,
10861024 ): Effect.Effect<SourceIngestPrepared, SourceIngestError> =>
10871025 Effect.gen(function* () {
1088− const openApiFetchResult = yield* trySourceIngestPromise(
1026+ const ingested = yield* trySourceIngestPromise(
10891027 () => fetchOpenApiDocumentForIngest(source),
10901028 "Failed to fetch OpenAPI document",
10911029 );
10921030
1093− if (openApiFetchResult.mode === "ingested") {
1094− return {
1095− protocol: "openapi",
1096− artifactId: openApiFetchResult.artifactId,
1097− sourceHash: openApiFetchResult.sourceHash,
1098− toolCount: openApiFetchResult.toolCount,
1099− namespace: openApiFetchResult.namespace,
1100− refHintTableJson: openApiFetchResult.refHintTableJson,
1101− artifactBatchCount: openApiFetchResult.artifactBatchCount,
1102− };
1103− }
1104−
1105− const openApiDocument = openApiFetchResult.openApiSpec;
1106−
1107− const manifest = yield* extractOpenApiManifest(source.name, openApiDocument).pipe(
1108− Effect.mapError((cause) =>
1109− SourceIngestError.make({
1110− status: "error",
1111− message: formatError(cause),
1112− }),
1113− ),
1114− );
1115−
1116− const artifactMeta = yield* trySourceIngestPromise<{
1117− artifactId: string;
1118− created: boolean;
1119− }>(
1120− () =>
1121− ctx.runMutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, {
1122− protocol: "openapi",
1123− contentHash: manifest.sourceHash,
1124− extractorVersion: openApiExtractorVersion,
1125− toolCount: manifest.tools.length,
1126− refHintTableJson: safeRefHintTableJson(manifest.refHintTable),
1127− }),
1128− "Failed to upsert OpenAPI artifact metadata",
1129− );
1130−
1131− const artifactRefEntries = refHintEntries(manifest.refHintTable);
1132−
1133− if (artifactRefEntries.length > 0) {
1134− let shouldPersistArtifactRefs = artifactMeta.created;
1135−
1136− if (!shouldPersistArtifactRefs) {
1137− const existingRefCount = yield* trySourceIngestPromise<number>(
1138− () =>
1139− ctx.runQuery(runtimeInternal.control_plane.tool_registry.countArtifactSchemaRefs, {
1140− artifactId: artifactMeta.artifactId,
1141− }),
1142− "Failed to read OpenAPI artifact schema ref count",
1143− );
1144−
1145− shouldPersistArtifactRefs = existingRefCount !== artifactRefEntries.length;
1146− }
1147−
1148− if (shouldPersistArtifactRefs) {
1149− yield* trySourceIngestPromise(
1150− () =>
1151− ctx.runMutation(runtimeInternal.control_plane.tool_registry.clearArtifactSchemaRefs, {
1152− artifactId: artifactMeta.artifactId,
1153− }),
1154− "Failed to clear OpenAPI artifact schema refs",
1155− );
1156−
1157− for (let index = 0; index < artifactRefEntries.length; index += writeBatchSize) {
1158− const batch = artifactRefEntries.slice(index, index + writeBatchSize);
1159− yield* trySourceIngestPromise(
1160− () =>
1161− ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactSchemaRefsBatch, {
1162− artifactId: artifactMeta.artifactId,
1163− refs: batch,
1164− }),
1165− "Failed to write OpenAPI artifact schema refs",
1166− );
1167− }
1168− }
1169− }
1170−
1171− if (artifactMeta.created) {
1172− const allTools = manifest.tools.map((tool) => ({
1173− toolId: tool.toolId,
1174− name: tool.name,
1175− description: tool.description,
1176− method: tool.method,
1177− path: tool.path,
1178− operationHash: tool.operationHash,
1179− invocationJson: JSON.stringify(tool.invocation),
1180− inputSchemaJson: tool.typing?.inputSchemaJson ?? null,
1181− outputSchemaJson: tool.typing?.outputSchemaJson ?? null,
1182− }));
1183−
1184− const artifactRows = allTools.map((tool) => ({
1185− toolId: tool.toolId,
1186− name: tool.name,
1187− description: tool.description,
1188− canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`,
1189− operationHash: tool.operationHash,
1190− invocationJson: tool.invocationJson,
1191− inputSchemaJson: tool.inputSchemaJson,
1192− outputSchemaJson: tool.outputSchemaJson,
1193− metadataJson: safeJsonStringify({
1194− method: tool.method,
1195− path: tool.path,
1196− }),
1197− }));
1198−
1199− if (options.stageArtifactWrites) {
1200− const serializedBatches = chunkArray(artifactRows, stagedArtifactBatchSize).map((batch) =>
1201− JSON.stringify(batch),
1202− );
1203−
1204− yield* trySourceIngestPromise(
1205− () =>
1206− ctx.runMutation(
1207− runtimeInternal.control_plane.tool_registry.replaceSourceIngestArtifactBatches,
1208− {
1209− workspaceId: source.workspaceId,
1210− sourceId: source.id,
1211− artifactId: artifactMeta.artifactId,
1212− protocol: "openapi",
1213− batches: serializedBatches,
1214− },
1215− ),
1216− "Failed to stage OpenAPI artifact tool batches",
1217− );
1218− } else {
1219− for (let index = 0; index < artifactRows.length; index += writeBatchSize) {
1220− const batch = artifactRows.slice(index, index + writeBatchSize);
1221− yield* trySourceIngestPromise(
1222− () =>
1223− ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
1224− artifactId: artifactMeta.artifactId,
1225− protocol: "openapi",
1226− insertOnly: true,
1227− tools: batch,
1228− }),
1229− "Failed to write OpenAPI artifact tools",
1230− );
1231− }
1232− }
1233− }
1234−
1235− yield* trySourceIngestPromise(
1236− () =>
1237− ctx.runMutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
1238− workspaceId: source.workspaceId,
1239− sourceId: source.id,
1240− artifactId: artifactMeta.artifactId,
1241− }),
1242− "Failed to bind source to OpenAPI artifact",
1243− );
1244−
1245− const namespace = sourceNamespace(source);
1246− const refHintTableJson = safeRefHintTableJson(manifest.refHintTable);
1247− const indexRows = manifest.tools.map((tool) => ({
1248− toolId: tool.toolId,
1249− protocol: "openapi",
1250− method: tool.method,
1251− path: sourceToolPath(source, tool.toolId),
1252− operationPath: tool.path,
1253− name: tool.name,
1254− description: tool.description,
1255− searchText: normalizedSearchText(
1256− source.name,
1257− source.endpoint,
1258− namespace,
1259− tool.toolId,
1260− tool.name,
1261− tool.description,
1262− tool.path,
1263− tool.method,
1264− ),
1265− operationHash: tool.operationHash,
1266− approvalMode: "auto",
1267− status: source.enabled ? "active" : "disabled",
1268− }));
1269−
1270− if (options.rebuildIndex) {
1271− yield* trySourceIngestPromise(
1272− () =>
1273− ctx.runMutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, {
1274− workspaceId: source.workspaceId,
1275− sourceId: source.id,
1276− sourceName: source.name,
1277− sourceKind: source.kind,
1278− artifactId: artifactMeta.artifactId,
1279− namespace,
1280− refHintTableJson,
1281− rows: indexRows,
1282− }),
1283− "Failed to rebuild workspace tool index for OpenAPI source",
1284− );
1285− }
1286−
12871031 return {
12881032 protocol: "openapi",
1289− artifactId: artifactMeta.artifactId,
1290− sourceHash: manifest.sourceHash,
1291− toolCount: manifest.tools.length,
1292− namespace,
1293− refHintTableJson,
1294− artifactBatchCount: artifactMeta.created && options.stageArtifactWrites
1295− ? Math.ceil(manifest.tools.length / stagedArtifactBatchSize)
1296− : 0,
1033+ artifactId: ingested.artifactId,
1034+ sourceHash: ingested.sourceHash,
1035+ toolCount: ingested.toolCount,
1036+ namespace: ingested.namespace,
1037+ refHintTableJson: ingested.refHintTableJson,
1038+ artifactBatchCount: ingested.artifactBatchCount,
12971039 };
12981040 });
12991041
@@ -1650,10 +1392,7 @@ export const ingestSourceArtifact = internalAction({ · 350 unmodified lines
16501392 });
16511393
16521394 const ingestEffect = source.kind === "openapi"
1653− ? ingestOpenApiSource(ctx, source, {
1654− rebuildIndex: true,
1655− stageArtifactWrites: false,
1656− })
1395+ ? ingestOpenApiSource(ctx, source)
16571396 : source.kind === "graphql"
16581397 ? ingestGraphqlSource(ctx, source, {
16591398 rebuildIndex: true,
@@ -1696,286 +1435,3 @@ export const ingestSourceArtifact = internalAction({ · 36 unmodified lines
16961435 });
16971436 },
16981437 });
1699−
1700−export const prepareSourceArtifactIngest = internalAction({
1701− args: {
1702− workspaceId: v.string(),
1703− sourceId: v.string(),
1704− },
1705− handler: async (ctx, args) => {
1706− const source = await ctx.runQuery(runtimeInternal.control_plane.sources.getSourceForIngest, {
1707− sourceId: args.sourceId,
1708− });
1709−
1710− if (!source || source.workspaceId !== args.workspaceId) {
1711− return {
1712− ok: false as const,
1713− status: "error" as const,
1714− message: "Source not found for workspace",
1715− };
1716− }
1717−
1718− const ingestEffect = source.kind === "openapi"
1719− ? ingestOpenApiSource(ctx, source, {
1720− rebuildIndex: false,
1721− stageArtifactWrites: true,
1722− })
1723− : source.kind === "graphql"
1724− ? ingestGraphqlSource(ctx, source, {
1725− rebuildIndex: false,
1726− stageArtifactWrites: true,
1727− })
1728− : source.kind === "mcp"
1729− ? ingestMcpSource(ctx, source, {
1730− rebuildIndex: false,
1731− stageArtifactWrites: true,
1732− })
1733− : Effect.fail(
1734− SourceIngestError.make({
1735− status: "error",
1736− message: `Source ingest for kind '${source.kind}' is not implemented yet`,
1737− }),
1738− );
1739−
1740− const result = await Effect.runPromise(
1741− ingestEffect.pipe(
1742− Effect.match({
1743− onFailure: (error) => ({
1744− ok: false as const,
1745− status: error.status,
1746− message: error.message,
1747− }),
1748− onSuccess: (prepared) => ({
1749− ok: true as const,
1750− workspaceId: source.workspaceId,
1751− sourceId: source.id,
1752− sourceName: source.name,
1753− sourceKind: source.kind,
1754− sourceEndpoint: source.endpoint,
1755− sourceEnabled: source.enabled,
1756− artifactId: prepared.artifactId,
1757− protocol: prepared.protocol,
1758− sourceHash: prepared.sourceHash,
1759− toolCount: prepared.toolCount,
1760− namespace: prepared.namespace,
1761− refHintTableJson: prepared.refHintTableJson,
1762− artifactBatchCount: prepared.artifactBatchCount,
1763− }),
1764− }),
1765− ),
1766− );
1767−
1768− if (result.ok && result.artifactBatchCount === 0) {
1769− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.clearSourceIngestArtifactBatches, {
1770− workspaceId: source.workspaceId,
1771− sourceId: source.id,
1772− });
1773− }
1774−
1775− return result;
1776− },
1777−});
1778−
1779−export const writeArtifactToolBatchFromStaging = internalAction({
1780− args: {
1781− workspaceId: v.string(),
1782− sourceId: v.string(),
1783− artifactId: v.string(),
1784− protocol: v.string(),
1785− batchIndex: v.number(),
1786− },
1787− handler: async (ctx, args) => {
1788− const staged = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.getSourceIngestArtifactBatch, {
1789− workspaceId: args.workspaceId,
1790− sourceId: args.sourceId,
1791− artifactId: args.artifactId,
1792− batchIndex: args.batchIndex,
1793− });
1794−
1795− if (!staged || typeof staged.toolsJson !== "string") {
1796− return { written: 0 };
1797− }
1798−
1799− let tools: Array<Record<string, unknown>> = [];
1800− try {
1801− const parsed = JSON.parse(staged.toolsJson) as unknown;
1802− if (Array.isArray(parsed)) {
1803− tools = parsed.filter((entry): entry is Record<string, unknown> => isUnknownRecord(entry));
1804− }
1805− } catch {
1806− tools = [];
1807− }
1808−
1809− if (tools.length === 0) {
1810− return { written: 0 };
1811− }
1812−
1813− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
1814− artifactId: args.artifactId,
1815− protocol: args.protocol,
1816− insertOnly: true,
1817− tools: tools.map((tool) => ({
1818− toolId: String(tool.toolId ?? ""),
1819− name: String(tool.name ?? ""),
1820− description: typeof tool.description === "string" ? tool.description : null,
1821− canonicalPath: String(tool.canonicalPath ?? ""),
1822− operationHash: String(tool.operationHash ?? ""),
1823− invocationJson: String(tool.invocationJson ?? "{}"),
1824− inputSchemaJson: typeof tool.inputSchemaJson === "string" ? tool.inputSchemaJson : null,
1825− outputSchemaJson: typeof tool.outputSchemaJson === "string" ? tool.outputSchemaJson : null,
1826− metadataJson: typeof tool.metadataJson === "string" ? tool.metadataJson : null,
1827− })),
1828− });
1829−
1830− return { written: tools.length };
1831− },
1832−});
1833−
1834−export const appendWorkspaceToolIndexChunk = internalAction({
1835− args: {
1836− workspaceId: v.string(),
1837− sourceId: v.string(),
1838− sourceName: v.string(),
1839− sourceKind: v.string(),
1840− sourceEndpoint: v.string(),
1841− sourceEnabled: v.boolean(),
1842− artifactId: v.string(),
1843− namespace: v.string(),
1844− refHintTableJson: v.optional(v.union(v.string(), v.null())),
1845− chunkIndex: v.number(),
1846− chunkSize: v.number(),
1847− },
1848− handler: async (ctx, args) => {
1849− const chunkIndex = Math.max(0, Math.floor(args.chunkIndex));
1850− const chunkSize = Math.max(1, Math.floor(args.chunkSize));
1851− const offset = chunkIndex * chunkSize;
1852−
1853− const tools = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listArtifactToolIndexSlice, {
1854− artifactId: args.artifactId,
1855− offset,
1856− limit: chunkSize,
1857− });
1858−
1859− if (!tools || tools.length === 0) {
1860− return { written: 0 };
1861− }
1862−
1863− const rows = tools.map((tool: any) => ({
1864− toolId: tool.toolId,
1865− protocol: tool.protocol,
1866− method: toWorkspaceToolMethod(tool.protocol, tool.metadataJson ?? null),
1867− path: `${args.namespace}.${tool.toolId}`,
1868− operationPath: tool.protocol === "openapi"
1869− ? (typeof tool.canonicalPath === "string" ? tool.canonicalPath : null)
1870− : null,
1871− name: tool.name,
1872− description: tool.description ?? null,
1873− searchText: normalizedSearchText(
1874− args.sourceName,
1875− args.sourceEndpoint,
1876− args.namespace,
1877− tool.toolId,
1878− tool.name,
1879− tool.description,
1880− ...metadataSearchTerms(tool.metadataJson ?? null),
1881− ),
1882− operationHash: tool.operationHash,
1883− approvalMode: "auto",
1884− status: args.sourceEnabled ? "active" : "disabled",
1885− }));
1886−
1887− const writeResult = await ctx.runMutation(
1888− runtimeInternal.control_plane.tool_registry.appendWorkspaceSourceToolIndexChunk,
1889− {
1890− workspaceId: args.workspaceId,
1891− sourceId: args.sourceId,
1892− sourceName: args.sourceName,
1893− sourceKind: args.sourceKind,
1894− artifactId: args.artifactId,
1895− namespace: args.namespace,
1896− refHintTableJson: args.refHintTableJson ?? null,
1897− rows,
1898− },
1899− );
1900−
1901− return {
1902− written: (writeResult.insertedCount ?? 0) + (writeResult.updatedCount ?? 0),
1903− };
1904− },
1905−});
1906−
1907−export const rebuildWorkspaceToolIndexFromArtifact = internalAction({
1908− args: {
1909− workspaceId: v.string(),
1910− sourceId: v.string(),
1911− sourceName: v.string(),
1912− sourceKind: v.string(),
1913− sourceEndpoint: v.string(),
1914− sourceEnabled: v.boolean(),
1915− artifactId: v.string(),
1916− namespace: v.string(),
1917− refHintTableJson: v.optional(v.union(v.string(), v.null())),
1918− chunkSize: v.optional(v.number()),
1919− },
1920− handler: async (ctx, args) => {
1921− const chunkSize = Math.max(1, Math.floor(args.chunkSize ?? 1000));
1922−
1923− const tools = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listArtifactTools, {
1924− artifactId: args.artifactId,
1925− });
1926−
1927− if (!tools || tools.length === 0) {
1928− return { written: 0, chunks: 0 };
1929− }
1930−
1931− const rows = tools.map((tool: any) => ({
1932− toolId: String(tool.toolId ?? ""),
1933− protocol: String(tool.protocol ?? "openapi"),
1934− method: toWorkspaceToolMethod(String(tool.protocol ?? "openapi"), tool.metadataJson ?? null),
1935− path: `${args.namespace}.${String(tool.toolId ?? "")}`,
1936− operationPath: typeof tool.canonicalPath === "string" ? tool.canonicalPath : null,
1937− name: String(tool.name ?? ""),
1938− description: typeof tool.description === "string" ? tool.description : null,
1939− searchText: normalizedSearchText(
1940− args.sourceName,
1941− args.sourceEndpoint,
1942− args.namespace,
1943− String(tool.toolId ?? ""),
1944− String(tool.name ?? ""),
1945− typeof tool.description === "string" ? tool.description : null,
1946− ...metadataSearchTerms(typeof tool.metadataJson === "string" ? tool.metadataJson : null),
1947− ),
1948− operationHash: String(tool.operationHash ?? ""),
1949− approvalMode: "auto",
1950− status: args.sourceEnabled ? "active" : "disabled",
1951− }));
1952−
1953− const chunks = chunkArray(rows, chunkSize);
1954− const results = await Promise.all(
1955− chunks.map((batch) =>
1956− ctx.runMutation(
1957− runtimeInternal.control_plane.tool_registry.appendWorkspaceSourceToolIndexChunk,
1958− {
1959− workspaceId: args.workspaceId,
1960− sourceId: args.sourceId,
1961− sourceName: args.sourceName,
1962− sourceKind: args.sourceKind,
1963− artifactId: args.artifactId,
1964− namespace: args.namespace,
1965− refHintTableJson: args.refHintTableJson ?? null,
1966− insertOnly: true,
1967− rows: batch,
1968− },
1969− ),
1970− ),
1971− );
1972−
1973− const written = results.reduce(
1974− (sum: number, result: any) =>
1975− sum + (result.insertedCount ?? 0) + (result.updatedCount ?? 0),
1976− 0,
1977− );
1978−
1979− return { written, chunks: chunks.length };
1980− },
1981−});
v2/apps/convex/convex/control_plane/sources.ts+15−4
@@ -142,10 +142,21 @@ export const upsertSource = action({ · 141 unmodified lines
142142 });
143143
144144 if (shouldIngest && source.kind !== "internal") {
145− await ctx.runAction(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {
146− workspaceId: source.workspaceId,
147− sourceId: source.id,
148− });
145+ if (source.kind === "openapi") {
146+ await ctx.runAction(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {
147+ workspaceId: source.workspaceId,
148+ sourceId: source.id,
149+ });
150+ } else {
151+ await ctx.scheduler.runAfter(
152+ 0,
153+ runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact,
154+ {
155+ workspaceId: source.workspaceId,
156+ sourceId: source.id,
157+ },
158+ );
159+ }
149160 }
150161
151162 return source;
v2/apps/web/app/(console)/console-shell.tsx+0−4
@@ -30,13 +30,9 @@ function ConsoleShellInner({ · 29 unmodified lines
3030 authEnabled: boolean;
3131 children: ReactNode;
3232 }) {
33− const { workspaceId, workspaceIdInput, setWorkspaceId } = useWorkspace();
34−
3533 return (
3634 <AppShell
3735 authEnabled={authEnabled}
38− workspaceId={workspaceIdInput}
39− onWorkspaceChange={setWorkspaceId}
4036 approvalsBadge={<ApprovalsBadge />}
4137 >
4238 {children}
v2/apps/web/components/shell/app-shell.tsx+2−12
@@ -6,6 +6,7 @@ import type { ReactNode } from "react"; · 5 unmodified lines
66
77 import { cn } from "../../lib/utils";
88 import { ThemeSwitcher } from "./theme-switcher";
9+import { WorkspaceSelector } from "./workspace-selector";
910
1011 type NavItem = {
1112 href: string;
@@ -108,16 +109,12 @@ const isActivePath = (pathname: string, item: NavItem): boolean => { · 96 unmodified lines
108109 type AppShellProps = {
109110 children: ReactNode;
110111 authEnabled: boolean;
111− workspaceId: string;
112− onWorkspaceChange: (value: string) => void;
113112 approvalsBadge?: ReactNode;
114113 };
115114
116115 export function AppShell({
117116 children,
118117 authEnabled,
119− workspaceId,
120− onWorkspaceChange,
121118 approvalsBadge,
122119 }: AppShellProps) {
123120 const pathname = usePathname();
@@ -142,14 +139,7 @@ export function AppShell({ · 18 unmodified lines
142139
143140 {/* Workspace selector */}
144141 <div className="border-b border-sidebar-border px-3 py-2.5">
145− <label className="mb-1 block text-[10px] uppercase tracking-widest text-sidebar-foreground/50">
146− Workspace
147− </label>
148− <input
149− value={workspaceId}
150− onChange={(e) => onWorkspaceChange(e.target.value)}
151− className="h-7 w-full rounded border border-sidebar-border bg-sidebar-active/50 px-2 text-[12px] text-sidebar-foreground outline-none transition-colors placeholder:text-sidebar-foreground/30 focus:border-primary/50 focus:ring-1 focus:ring-primary/30"
152− />
142+ <WorkspaceSelector />
153143 </div>
154144
155145 {/* Nav items */}
v2/apps/web/components/shell/workspace-selector.tsx+398−0
@@ -0,0 +1,398 @@
1+"use client";
2+
3+import { useEffect, useMemo, useState, type FormEvent } from "react";
4+import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
5+import type { Organization, Workspace } from "@executor-v2/schema";
6+
7+import {
8+ organizationsState,
9+ toOrganizationUpsertPayload,
10+ toWorkspaceUpsertPayload,
11+ upsertOrganization,
12+ upsertWorkspace,
13+ workspacesState,
14+} from "../../lib/control-plane/atoms";
15+import { useWorkspace } from "../../lib/hooks/use-workspace";
16+import { Button } from "../ui/button";
17+import { Input } from "../ui/input";
18+import { Select } from "../ui/select";
19+
20+type StatusState = {
21+ message: string | null;
22+ variant: "info" | "error";
23+};
24+
25+type WorkspaceGroup = {
26+ key: string;
27+ label: string;
28+ workspaces: Array<Workspace>;
29+};
30+
31+const defaultStatus = (): StatusState => ({ message: null, variant: "info" });
32+
33+const slugify = (value: string): string =>
34+ value
35+ .trim()
36+ .toLowerCase()
37+ .replace(/[^a-z0-9]+/g, "-")
38+ .replace(/^-+/, "")
39+ .replace(/-+$/, "")
40+ .replace(/-{2,}/g, "-");
41+
42+export function WorkspaceSelector() {
43+ const { workspaceId, setWorkspaceId } = useWorkspace();
44+ const organizations = useAtomValue(organizationsState);
45+ const workspaces = useAtomValue(workspacesState);
46+ const runUpsertOrganization = useAtomSet(upsertOrganization, { mode: "promise" });
47+ const runUpsertWorkspace = useAtomSet(upsertWorkspace, { mode: "promise" });
48+
49+ const [activeForm, setActiveForm] = useState<"workspace" | "organization" | null>(null);
50+ const [workspaceName, setWorkspaceName] = useState("");
51+ const [workspaceOrganizationId, setWorkspaceOrganizationId] = useState("");
52+ const [organizationName, setOrganizationName] = useState("");
53+ const [organizationSlug, setOrganizationSlug] = useState("");
54+ const [status, setStatus] = useState<StatusState>(defaultStatus);
55+ const [creatingWorkspace, setCreatingWorkspace] = useState(false);
56+ const [creatingOrganization, setCreatingOrganization] = useState(false);
57+
58+ const organizationNameById = useMemo(
59+ () =>
60+ new Map<string, string>(
61+ organizations.items.map((organization) => [organization.id, organization.name]),
62+ ),
63+ [organizations.items],
64+ );
65+
66+ const currentWorkspace = useMemo(
67+ () => workspaces.items.find((workspace) => workspace.id === workspaceId) ?? null,
68+ [workspaces.items, workspaceId],
69+ );
70+
71+ const currentOrganizationLabel = useMemo(() => {
72+ if (!currentWorkspace?.organizationId) {
73+ return "No organization";
74+ }
75+
76+ return organizationNameById.get(currentWorkspace.organizationId)
77+ ?? currentWorkspace.organizationId;
78+ }, [currentWorkspace, organizationNameById]);
79+
80+ const workspaceGroups = useMemo((): Array<WorkspaceGroup> => {
81+ const byGroup = new Map<string, WorkspaceGroup>();
82+
83+ for (const workspace of workspaces.items) {
84+ const key = workspace.organizationId ?? "__none__";
85+ const label =
86+ workspace.organizationId === null
87+ ? "No organization"
88+ : organizationNameById.get(workspace.organizationId) ?? workspace.organizationId;
89+
90+ const existing = byGroup.get(key);
91+ if (existing) {
92+ existing.workspaces.push(workspace);
93+ continue;
94+ }
95+
96+ byGroup.set(key, {
97+ key,
98+ label,
99+ workspaces: [workspace],
100+ });
101+ }
102+
103+ return [...byGroup.values()].sort((left, right) => {
104+ if (left.key === "__none__") return -1;
105+ if (right.key === "__none__") return 1;
106+ return left.label.localeCompare(right.label);
107+ });
108+ }, [organizationNameById, workspaces.items]);
109+
110+ useEffect(() => {
111+ if (workspaces.state !== "ready") {
112+ return;
113+ }
114+
115+ if (workspaces.items.length === 0) {
116+ return;
117+ }
118+
119+ const hasCurrentWorkspace = workspaces.items.some(
120+ (workspace) => workspace.id === workspaceId,
121+ );
122+
123+ if (!hasCurrentWorkspace) {
124+ setWorkspaceId(workspaces.items[0].id);
125+ }
126+ }, [setWorkspaceId, workspaceId, workspaces.items, workspaces.state]);
127+
128+ const openWorkspaceForm = () => {
129+ setStatus(defaultStatus());
130+ if (activeForm === "workspace") {
131+ setActiveForm(null);
132+ return;
133+ }
134+
135+ setActiveForm("workspace");
136+ setWorkspaceName("");
137+
138+ if (currentWorkspace?.organizationId) {
139+ setWorkspaceOrganizationId(currentWorkspace.organizationId);
140+ return;
141+ }
142+
143+ setWorkspaceOrganizationId(organizations.items[0]?.id ?? "");
144+ };
145+
146+ const openOrganizationForm = () => {
147+ setStatus(defaultStatus());
148+ if (activeForm === "organization") {
149+ setActiveForm(null);
150+ return;
151+ }
152+
153+ setActiveForm("organization");
154+ setOrganizationName("");
155+ setOrganizationSlug("");
156+ };
157+
158+ const handleCreateWorkspace = (event: FormEvent<HTMLFormElement>) => {
159+ event.preventDefault();
160+
161+ const name = workspaceName.trim();
162+ const organizationId = workspaceOrganizationId.trim();
163+
164+ if (name.length < 2) {
165+ setStatus({
166+ message: "Workspace name must be at least 2 characters.",
167+ variant: "error",
168+ });
169+ return;
170+ }
171+
172+ setCreatingWorkspace(true);
173+
174+ void runUpsertWorkspace({
175+ payload: toWorkspaceUpsertPayload({
176+ name,
177+ organizationId: organizationId.length > 0
178+ ? (organizationId as Workspace["organizationId"])
179+ : null,
180+ }),
181+ })
182+ .then((workspace) => {
183+ setWorkspaceId(workspace.id);
184+ setWorkspaceName("");
185+ setActiveForm(null);
186+ setStatus({
187+ message: `Created workspace ${workspace.name}.`,
188+ variant: "info",
189+ });
190+ })
191+ .catch(() => {
192+ setStatus({
193+ message: "Workspace creation failed.",
194+ variant: "error",
195+ });
196+ })
197+ .finally(() => {
198+ setCreatingWorkspace(false);
199+ });
200+ };
201+
202+ const handleCreateOrganization = (event: FormEvent<HTMLFormElement>) => {
203+ event.preventDefault();
204+
205+ const name = organizationName.trim();
206+ const generatedSlug = slugify(name);
207+ const slugInput = organizationSlug.trim();
208+ const slug = slugInput.length > 0 ? slugify(slugInput) : generatedSlug;
209+
210+ if (name.length < 2) {
211+ setStatus({
212+ message: "Organization name must be at least 2 characters.",
213+ variant: "error",
214+ });
215+ return;
216+ }
217+
218+ if (slug.length === 0) {
219+ setStatus({
220+ message: "Organization slug is required.",
221+ variant: "error",
222+ });
223+ return;
224+ }
225+
226+ setCreatingOrganization(true);
227+
228+ void runUpsertOrganization({
229+ payload: toOrganizationUpsertPayload({
230+ name,
231+ slug,
232+ status: "active",
233+ }),
234+ })
235+ .then(async (organization) => {
236+ const workspace = await runUpsertWorkspace({
237+ payload: toWorkspaceUpsertPayload({
238+ name,
239+ organizationId: organization.id as Workspace["organizationId"],
240+ }),
241+ });
242+
243+ setWorkspaceId(workspace.id);
244+ setWorkspaceOrganizationId(organization.id);
245+ setOrganizationName("");
246+ setOrganizationSlug("");
247+ setActiveForm(null);
248+ setStatus({
249+ message: `Created organization ${organization.name} with workspace ${workspace.name}.`,
250+ variant: "info",
251+ });
252+ })
253+ .catch(() => {
254+ setStatus({
255+ message: "Organization creation failed.",
256+ variant: "error",
257+ });
258+ })
259+ .finally(() => {
260+ setCreatingOrganization(false);
261+ });
262+ };
263+
264+ const selectorDisabled = workspaces.state === "loading" || workspaces.items.length === 0;
265+
266+ return (
267+ <div className="space-y-2">
268+ <div>
269+ <label className="block text-[10px] uppercase tracking-widest text-sidebar-foreground/50">
270+ Workspace
271+ </label>
272+ <p className="mt-0.5 truncate text-[10px] text-sidebar-foreground/50">
273+ {currentWorkspace ? currentOrganizationLabel : "Create your first workspace"}
274+ </p>
275+ </div>
276+
277+ <Select
278+ value={workspaceId}
279+ onChange={(event) => {
280+ setWorkspaceId(event.target.value);
281+ setStatus(defaultStatus());
282+ }}
283+ disabled={selectorDisabled}
284+ className="h-8 border-sidebar-border bg-sidebar-active/50 px-2 text-[12px] text-sidebar-foreground"
285+ >
286+ {workspaces.state === "loading" ? (
287+ <option value="">Loading workspaces...</option>
288+ ) : null}
289+
290+ {workspaces.state !== "loading" && workspaces.items.length === 0 ? (
291+ <option value="">No workspaces available</option>
292+ ) : null}
293+
294+ {workspaceGroups.map((group) => (
295+ <optgroup key={group.key} label={group.label}>
296+ {group.workspaces.map((workspace) => (
297+ <option key={workspace.id} value={workspace.id}>
298+ {workspace.name}
299+ </option>
300+ ))}
301+ </optgroup>
302+ ))}
303+ </Select>
304+
305+ <div className="flex gap-1">
306+ <Button
307+ type="button"
308+ size="sm"
309+ variant={activeForm === "workspace" ? "secondary" : "ghost"}
310+ className="h-7 flex-1 px-2 text-[11px]"
311+ onClick={openWorkspaceForm}
312+ >
313+ + Workspace
314+ </Button>
315+ <Button
316+ type="button"
317+ size="sm"
318+ variant={activeForm === "organization" ? "secondary" : "ghost"}
319+ className="h-7 flex-1 px-2 text-[11px]"
320+ onClick={openOrganizationForm}
321+ >
322+ + Org
323+ </Button>
324+ </div>
325+
326+ {activeForm === "workspace" ? (
327+ <form className="space-y-1.5 rounded-md border border-sidebar-border p-2" onSubmit={handleCreateWorkspace}>
328+ <Input
329+ value={workspaceName}
330+ onChange={(event) => setWorkspaceName(event.target.value)}
331+ placeholder="Workspace name"
332+ className="h-8 border-sidebar-border bg-sidebar px-2 text-[12px]"
333+ maxLength={64}
334+ />
335+ <Select
336+ value={workspaceOrganizationId}
337+ onChange={(event) => setWorkspaceOrganizationId(event.target.value)}
338+ className="h-8 border-sidebar-border bg-sidebar px-2 text-[12px]"
339+ >
340+ <option value="">No organization</option>
341+ {organizations.items.map((organization) => (
342+ <option key={organization.id} value={organization.id}>
343+ {organization.name}
344+ </option>
345+ ))}
346+ </Select>
347+ <Button
348+ type="submit"
349+ size="sm"
350+ className="h-7 w-full text-[11px]"
351+ disabled={creatingWorkspace}
352+ >
353+ {creatingWorkspace ? "Creating..." : "Create workspace"}
354+ </Button>
355+ </form>
356+ ) : null}
357+
358+ {activeForm === "organization" ? (
359+ <form className="space-y-1.5 rounded-md border border-sidebar-border p-2" onSubmit={handleCreateOrganization}>
360+ <Input
361+ value={organizationName}
362+ onChange={(event) => setOrganizationName(event.target.value)}
363+ placeholder="Organization name"
364+ className="h-8 border-sidebar-border bg-sidebar px-2 text-[12px]"
365+ maxLength={64}
366+ />
367+ <Input
368+ value={organizationSlug}
369+ onChange={(event) => setOrganizationSlug(event.target.value)}
370+ placeholder="Slug (optional)"
371+ className="h-8 border-sidebar-border bg-sidebar px-2 text-[12px]"
372+ maxLength={64}
373+ />
374+ <Button
375+ type="submit"
376+ size="sm"
377+ className="h-7 w-full text-[11px]"
378+ disabled={creatingOrganization}
379+ >
380+ {creatingOrganization ? "Creating..." : "Create organization"}
381+ </Button>
382+ </form>
383+ ) : null}
384+
385+ {workspaces.state === "error" ? (
386+ <p className="text-[11px] text-destructive">{workspaces.message}</p>
387+ ) : null}
388+ {organizations.state === "error" ? (
389+ <p className="text-[11px] text-destructive">{organizations.message}</p>
390+ ) : null}
391+ {status.message ? (
392+ <p className={status.variant === "error" ? "text-[11px] text-destructive" : "text-[11px] text-sidebar-foreground/70"}>
393+ {status.message}
394+ </p>
395+ ) : null}
396+ </div>
397+ );
398+}
v2/bun.lock+8−0
@@ -18,8 +18,16 @@ · 17 unmodified lines
1818 "apps/api": {
1919 "name": "@executor-v2/app-api",
2020 "dependencies": {
21+ "convex": "^1.32.0",
2122 "yaml": "^2.8.1",
2223 },
24+ "devDependencies": {
25+ "@effect/vitest": "^0.27.0",
26+ "@types/node": "^20.19.9",
27+ "effect": "^3.19.8",
28+ "typescript": "^5.9.3",
29+ "vitest": "^3.2.4",
30+ },
2331 },
2432 "apps/cli": {
2533 "name": "@executor-v2/app-cli",