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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { parse as parseYaml } from "yaml";
4
5import { extractOpenApiManifest } from "./parse-openapi";
6
7const cloudflareSpecUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
8const vercelSpecUrl = "https://openapi.vercel.sh/";
9const httpMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const;
10
11const isRecord = (value: unknown): value is Record<string, unknown> =>
12 typeof value === "object" && value !== null && !Array.isArray(value);
13
14const 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
27const 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
36const 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
58const unescapeJsonPointer = (value: string): string =>
59 value.replace(/~1/g, "/").replace(/~0/g, "~");
60
61const 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
92const 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
105const 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
127const 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
140const 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
149const 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
177const 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
213const isUnknownLikeSchema = (value: unknown): boolean => !hasMeaningfulSchemaShape(value);
214
215type OperationExpectation = {
216 parameterSchemas: Record<string, unknown>;
217 requestBodySchema: unknown;
218 responseSchema: unknown;
219};
220
221const operationSignature = (method: string, path: string): string =>
222 `${method.toLowerCase()} ${path}`;
223
224const 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
289const 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
370describe("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
427 return table;427 return table;
428};428};
429 429
430const extractOpenApiManifest = (430export const extractOpenApiManifest = (
431 _sourceName: string,431 _sourceName: string,
432 openApiSpec: unknown,432 openApiSpec: unknown,
433): OpenApiManifest => {433): OpenApiManifest => {
v2/apps/api/package.json+4−1
@@ -10,7 +10,10 @@ · 9 unmodified lines
10 "yaml": "^2.8.1"10 "yaml": "^2.8.1"
11 },11 },
12 "devDependencies": {12 "devDependencies": {
13 "@effect/vitest": "^0.27.0",
13 "@types/node": "^20.19.9",14 "@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"
15 }18 }
16}19}
v2/apps/convex/convex/control_plane/openapi_ingest.ts+42−586
@@ -2,7 +2,6 @@ · 1 unmodified line
2 2
3import { createHash } from "node:crypto";3import { createHash } from "node:crypto";
4 4
5import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api";
6import { type Source } from "@executor-v2/schema";5import { type Source } from "@executor-v2/schema";
7import { Client } from "@modelcontextprotocol/sdk/client/index.js";6import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";7import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
@@ -16,49 +15,9 @@ import { internalAction } from "../_generated/server"; · 7 unmodified lines
16 15
17const runtimeInternal = internal as any;16const runtimeInternal = internal as any;
18 17
19const openApiExtractorVersion = "openapi_v2";
20const graphqlExtractorVersion = "graphql_v1";18const graphqlExtractorVersion = "graphql_v1";
21const mcpExtractorVersion = "mcp_v1";19const mcpExtractorVersion = "mcp_v1";
22const writeBatchSize = 500;20const writeBatchSize = 500;
23const stagedArtifactBatchSize = 40;
24
25const maxRefHintBytes = 900_000;
26
27const 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
39const 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};
62 21
63const graphqlSchemaRootsQuery = `query SchemaRoots {22const graphqlSchemaRootsQuery = `query SchemaRoots {
64 __schema {23 __schema {
@@ -975,34 +934,18 @@ type OpenApiParserIngestedPayload = { · 910 unmodified lines
975 error?: string;934 error?: string;
976};935};
977 936
978type OpenApiParserParsedPayload = {937const fetchOpenApiDocumentForIngest = async (
979 ok?: boolean;938 source: Source,
980 mode?: string;939): Promise<{
981 openApiSpec?: unknown;940 artifactId: string;
982 error?: string;941 sourceHash: string;
983};942 toolCount: number;
984 943 namespace: string;
985type OpenApiFetchResult =944 refHintTableJson: string | null;
986 | {945 artifactBatchCount: number;
987 mode: "parsed";946}> => {
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
1000const fetchOpenApiDocumentForIngest = async (source: Source): Promise<OpenApiFetchResult> => {
1001 if (openApiParserUrl.length === 0) {947 if (openApiParserUrl.length === 0) {
1002 return {948 throw new Error("OPENAPI_PARSE_API_URL is required for OpenAPI ingestion");
1003 mode: "parsed",
1004 openApiSpec: await fetchOpenApiDocument(source.endpoint),
1005 };
1006 }949 }
1007 950
1008 const response = await fetch(openApiParserUrl, {951 const response = await fetch(openApiParserUrl, {
@@ -1031,7 +974,7 @@ const fetchOpenApiDocumentForIngest = async (source: Source): Promise<OpenApiFet · 22 unmodified lines
1031 );974 );
1032 }975 }
1033 976
1034 const payload = (await response.json()) as OpenApiParserIngestedPayload & OpenApiParserParsedPayload;977 const payload = (await response.json()) as OpenApiParserIngestedPayload;
1035 978
1036 if (payload.ok !== true) {979 if (payload.ok !== true) {
1037 throw new Error(980 throw new Error(
@@ -1041,36 +984,32 @@ const fetchOpenApiDocumentForIngest = async (source: Source): Promise<OpenApiFet · 3 unmodified lines
1041 );984 );
1042 }985 }
1043 986
1044 if (payload.mode === "ingested") {987 if (payload.mode !== "ingested") {
1045 const artifactId = normalizeString(payload.artifactId);988 throw new Error("Parser endpoint must return mode='ingested' for OpenAPI ingestion");
1046 const sourceHash = normalizeString(payload.sourceHash);989 }
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 }
1058 990
1059 return {991 const artifactId = normalizeString(payload.artifactId);
1060 mode: "ingested",992 const sourceHash = normalizeString(payload.sourceHash);
1061 artifactId,993 const namespace = normalizeString(payload.namespace);
1062 sourceHash,994 const toolCount = Number.isFinite(payload.toolCount)
1063 toolCount,995 ? Math.max(0, Math.floor(payload.toolCount ?? 0))
1064 namespace,996 : null;
1065 refHintTableJson:997 const artifactBatchCount = Number.isFinite(payload.artifactBatchCount)
1066 typeof payload.refHintTableJson === "string" ? payload.refHintTableJson : null,998 ? Math.max(0, Math.floor(payload.artifactBatchCount ?? 0))
1067 artifactBatchCount,999 : 0;
1068 };1000
1001 if (!artifactId || !sourceHash || !namespace || toolCount === null) {
1002 throw new Error("Parser endpoint returned an invalid ingested payload");
1069 }1003 }
1070 1004
1071 return {1005 return {
1072 mode: "parsed",1006 artifactId,
1073 openApiSpec: payload.openApiSpec,1007 sourceHash,
1008 toolCount,
1009 namespace,
1010 refHintTableJson:
1011 typeof payload.refHintTableJson === "string" ? payload.refHintTableJson : null,
1012 artifactBatchCount,
1074 };1013 };
1075};1014};
1076 1015
@@ -1082,218 +1021,21 @@ type IngestOptions = { · 5 unmodified lines
1082const ingestOpenApiSource = (1021const ingestOpenApiSource = (
1083 ctx: any,1022 ctx: any,
1084 source: Source,1023 source: Source,
1085 options: IngestOptions,
1086): Effect.Effect<SourceIngestPrepared, SourceIngestError> =>1024): Effect.Effect<SourceIngestPrepared, SourceIngestError> =>
1087 Effect.gen(function* () {1025 Effect.gen(function* () {
1088 const openApiFetchResult = yield* trySourceIngestPromise(1026 const ingested = yield* trySourceIngestPromise(
1089 () => fetchOpenApiDocumentForIngest(source),1027 () => fetchOpenApiDocumentForIngest(source),
1090 "Failed to fetch OpenAPI document",1028 "Failed to fetch OpenAPI document",
1091 );1029 );
1092 1030
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
1287 return {1031 return {
1288 protocol: "openapi",1032 protocol: "openapi",
1289 artifactId: artifactMeta.artifactId,1033 artifactId: ingested.artifactId,
1290 sourceHash: manifest.sourceHash,1034 sourceHash: ingested.sourceHash,
1291 toolCount: manifest.tools.length,1035 toolCount: ingested.toolCount,
1292 namespace,1036 namespace: ingested.namespace,
1293 refHintTableJson,1037 refHintTableJson: ingested.refHintTableJson,
1294 artifactBatchCount: artifactMeta.created && options.stageArtifactWrites1038 artifactBatchCount: ingested.artifactBatchCount,
1295 ? Math.ceil(manifest.tools.length / stagedArtifactBatchSize)
1296 : 0,
1297 };1039 };
1298 });1040 });
1299 1041
@@ -1650,10 +1392,7 @@ export const ingestSourceArtifact = internalAction({ · 350 unmodified lines
1650 });1392 });
1651 1393
1652 const ingestEffect = source.kind === "openapi"1394 const ingestEffect = source.kind === "openapi"
1653 ? ingestOpenApiSource(ctx, source, {1395 ? ingestOpenApiSource(ctx, source)
1654 rebuildIndex: true,
1655 stageArtifactWrites: false,
1656 })
1657 : source.kind === "graphql"1396 : source.kind === "graphql"
1658 ? ingestGraphqlSource(ctx, source, {1397 ? ingestGraphqlSource(ctx, source, {
1659 rebuildIndex: true,1398 rebuildIndex: true,
@@ -1696,286 +1435,3 @@ export const ingestSourceArtifact = internalAction({ · 36 unmodified lines
1696 });1435 });
1697 },1436 },
1698});1437});
1699
1700export 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
1779export 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
1834export 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
1907export 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
142 });142 });
143 143
144 if (shouldIngest && source.kind !== "internal") {144 if (shouldIngest && source.kind !== "internal") {
145 await ctx.runAction(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {145 if (source.kind === "openapi") {
146 workspaceId: source.workspaceId,146 await ctx.runAction(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {
147 sourceId: source.id,147 workspaceId: source.workspaceId,
148 });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 }
149 }160 }
150 161
151 return source;162 return source;
v2/apps/web/app/(console)/console-shell.tsx+0−4
@@ -30,13 +30,9 @@ function ConsoleShellInner({ · 29 unmodified lines
30 authEnabled: boolean;30 authEnabled: boolean;
31 children: ReactNode;31 children: ReactNode;
32}) {32}) {
33 const { workspaceId, workspaceIdInput, setWorkspaceId } = useWorkspace();
34
35 return (33 return (
36 <AppShell34 <AppShell
37 authEnabled={authEnabled}35 authEnabled={authEnabled}
38 workspaceId={workspaceIdInput}
39 onWorkspaceChange={setWorkspaceId}
40 approvalsBadge={<ApprovalsBadge />}36 approvalsBadge={<ApprovalsBadge />}
41 >37 >
42 {children}38 {children}
v2/apps/web/components/shell/app-shell.tsx+2−12
@@ -6,6 +6,7 @@ import type { ReactNode } from "react"; · 5 unmodified lines
6 6
7import { cn } from "../../lib/utils";7import { cn } from "../../lib/utils";
8import { ThemeSwitcher } from "./theme-switcher";8import { ThemeSwitcher } from "./theme-switcher";
9import { WorkspaceSelector } from "./workspace-selector";
9 10
10type NavItem = {11type NavItem = {
11 href: string;12 href: string;
@@ -108,16 +109,12 @@ const isActivePath = (pathname: string, item: NavItem): boolean => { · 96 unmodified lines
108type AppShellProps = {109type AppShellProps = {
109 children: ReactNode;110 children: ReactNode;
110 authEnabled: boolean;111 authEnabled: boolean;
111 workspaceId: string;
112 onWorkspaceChange: (value: string) => void;
113 approvalsBadge?: ReactNode;112 approvalsBadge?: ReactNode;
114};113};
115 114
116export function AppShell({115export function AppShell({
117 children,116 children,
118 authEnabled,117 authEnabled,
119 workspaceId,
120 onWorkspaceChange,
121 approvalsBadge,118 approvalsBadge,
122}: AppShellProps) {119}: AppShellProps) {
123 const pathname = usePathname();120 const pathname = usePathname();
@@ -142,14 +139,7 @@ export function AppShell({ · 18 unmodified lines
142 139
143 {/* Workspace selector */}140 {/* Workspace selector */}
144 <div className="border-b border-sidebar-border px-3 py-2.5">141 <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">142 <WorkspaceSelector />
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 />
153 </div>143 </div>
154 144
155 {/* Nav items */}145 {/* Nav items */}
v2/apps/web/components/shell/workspace-selector.tsx+398−0
@@ -0,0 +1,398 @@
1"use client";
2
3import { useEffect, useMemo, useState, type FormEvent } from "react";
4import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
5import type { Organization, Workspace } from "@executor-v2/schema";
6
7import {
8 organizationsState,
9 toOrganizationUpsertPayload,
10 toWorkspaceUpsertPayload,
11 upsertOrganization,
12 upsertWorkspace,
13 workspacesState,
14} from "../../lib/control-plane/atoms";
15import { useWorkspace } from "../../lib/hooks/use-workspace";
16import { Button } from "../ui/button";
17import { Input } from "../ui/input";
18import { Select } from "../ui/select";
19
20type StatusState = {
21 message: string | null;
22 variant: "info" | "error";
23};
24
25type WorkspaceGroup = {
26 key: string;
27 label: string;
28 workspaces: Array<Workspace>;
29};
30
31const defaultStatus = (): StatusState => ({ message: null, variant: "info" });
32
33const 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
42export 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
18 "apps/api": {18 "apps/api": {
19 "name": "@executor-v2/app-api",19 "name": "@executor-v2/app-api",
20 "dependencies": {20 "dependencies": {
21 "convex": "^1.32.0",
21 "yaml": "^2.8.1",22 "yaml": "^2.8.1",
22 },23 },
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 },
23 },31 },
24 "apps/cli": {32 "apps/cli": {
25 "name": "@executor-v2/app-cli",33 "name": "@executor-v2/app-cli",