Unified · Split
| @@ -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 | +}); | |
| @@ -427,7 +427,7 @@ const buildRefHintTable = ( · 426 unmodified lines | ||
| 427 | 427 | return table; |
| 428 | 428 | }; |
| 429 | 429 | |
| 430 | −const extractOpenApiManifest = ( | |
| 430 | +export const extractOpenApiManifest = ( | |
| 431 | 431 | _sourceName: string, |
| 432 | 432 | openApiSpec: unknown, |
| 433 | 433 | ): OpenApiManifest => { |
| @@ -10,7 +10,10 @@ · 9 unmodified lines | ||
| 10 | 10 | "yaml": "^2.8.1" |
| 11 | 11 | }, |
| 12 | 12 | "devDependencies": { |
| 13 | + "@effect/vitest": "^0.27.0", | |
| 13 | 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 | } |
| @@ -2,7 +2,6 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { createHash } from "node:crypto"; |
| 4 | 4 | |
| 5 | −import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api"; | |
| 6 | 5 | import { type Source } from "@executor-v2/schema"; |
| 7 | 6 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 8 | 7 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| @@ -16,49 +15,9 @@ import { internalAction } from "../_generated/server"; · 7 unmodified lines | ||
| 16 | 15 | |
| 17 | 16 | const runtimeInternal = internal as any; |
| 18 | 17 | |
| 19 | −const openApiExtractorVersion = "openapi_v2"; | |
| 20 | 18 | const graphqlExtractorVersion = "graphql_v1"; |
| 21 | 19 | const mcpExtractorVersion = "mcp_v1"; |
| 22 | 20 | 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 | −}; | |
| 62 | 21 | |
| 63 | 22 | const graphqlSchemaRootsQuery = `query SchemaRoots { |
| 64 | 23 | __schema { |
| @@ -975,34 +934,18 @@ type OpenApiParserIngestedPayload = { · 910 unmodified lines | ||
| 975 | 934 | error?: string; |
| 976 | 935 | }; |
| 977 | 936 | |
| 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 | +}> => { | |
| 1001 | 947 | 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"); | |
| 1006 | 949 | } |
| 1007 | 950 | |
| 1008 | 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 | 979 | if (payload.ok !== true) { |
| 1037 | 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") { | |
| 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 | + } | |
| 1058 | 990 | |
| 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"); | |
| 1069 | 1003 | } |
| 1070 | 1004 | |
| 1071 | 1005 | 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, | |
| 1074 | 1013 | }; |
| 1075 | 1014 | }; |
| 1076 | 1015 | |
| @@ -1082,218 +1021,21 @@ type IngestOptions = { · 5 unmodified lines | ||
| 1082 | 1021 | const ingestOpenApiSource = ( |
| 1083 | 1022 | ctx: any, |
| 1084 | 1023 | source: Source, |
| 1085 | − options: IngestOptions, | |
| 1086 | 1024 | ): Effect.Effect<SourceIngestPrepared, SourceIngestError> => |
| 1087 | 1025 | Effect.gen(function* () { |
| 1088 | − const openApiFetchResult = yield* trySourceIngestPromise( | |
| 1026 | + const ingested = yield* trySourceIngestPromise( | |
| 1089 | 1027 | () => fetchOpenApiDocumentForIngest(source), |
| 1090 | 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 | 1031 | return { |
| 1288 | 1032 | 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, | |
| 1297 | 1039 | }; |
| 1298 | 1040 | }); |
| 1299 | 1041 | |
| @@ -1650,10 +1392,7 @@ export const ingestSourceArtifact = internalAction({ · 350 unmodified lines | ||
| 1650 | 1392 | }); |
| 1651 | 1393 | |
| 1652 | 1394 | const ingestEffect = source.kind === "openapi" |
| 1653 | − ? ingestOpenApiSource(ctx, source, { | |
| 1654 | − rebuildIndex: true, | |
| 1655 | − stageArtifactWrites: false, | |
| 1656 | − }) | |
| 1395 | + ? ingestOpenApiSource(ctx, source) | |
| 1657 | 1396 | : source.kind === "graphql" |
| 1658 | 1397 | ? ingestGraphqlSource(ctx, source, { |
| 1659 | 1398 | rebuildIndex: true, |
| @@ -1696,286 +1435,3 @@ export const ingestSourceArtifact = internalAction({ · 36 unmodified lines | ||
| 1696 | 1435 | }); |
| 1697 | 1436 | }, |
| 1698 | 1437 | }); |
| 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 | −}); | |
| @@ -142,10 +142,21 @@ export const upsertSource = action({ · 141 unmodified lines | ||
| 142 | 142 | }); |
| 143 | 143 | |
| 144 | 144 | 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 | + } | |
| 149 | 160 | } |
| 150 | 161 | |
| 151 | 162 | return source; |
| @@ -30,13 +30,9 @@ function ConsoleShellInner({ · 29 unmodified lines | ||
| 30 | 30 | authEnabled: boolean; |
| 31 | 31 | children: ReactNode; |
| 32 | 32 | }) { |
| 33 | − const { workspaceId, workspaceIdInput, setWorkspaceId } = useWorkspace(); | |
| 34 | − | |
| 35 | 33 | return ( |
| 36 | 34 | <AppShell |
| 37 | 35 | authEnabled={authEnabled} |
| 38 | − workspaceId={workspaceIdInput} | |
| 39 | − onWorkspaceChange={setWorkspaceId} | |
| 40 | 36 | approvalsBadge={<ApprovalsBadge />} |
| 41 | 37 | > |
| 42 | 38 | {children} |
| @@ -6,6 +6,7 @@ import type { ReactNode } from "react"; · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import { cn } from "../../lib/utils"; |
| 8 | 8 | import { ThemeSwitcher } from "./theme-switcher"; |
| 9 | +import { WorkspaceSelector } from "./workspace-selector"; | |
| 9 | 10 | |
| 10 | 11 | type NavItem = { |
| 11 | 12 | href: string; |
| @@ -108,16 +109,12 @@ const isActivePath = (pathname: string, item: NavItem): boolean => { · 96 unmodified lines | ||
| 108 | 109 | type AppShellProps = { |
| 109 | 110 | children: ReactNode; |
| 110 | 111 | authEnabled: boolean; |
| 111 | − workspaceId: string; | |
| 112 | − onWorkspaceChange: (value: string) => void; | |
| 113 | 112 | approvalsBadge?: ReactNode; |
| 114 | 113 | }; |
| 115 | 114 | |
| 116 | 115 | export function AppShell({ |
| 117 | 116 | children, |
| 118 | 117 | authEnabled, |
| 119 | − workspaceId, | |
| 120 | − onWorkspaceChange, | |
| 121 | 118 | approvalsBadge, |
| 122 | 119 | }: AppShellProps) { |
| 123 | 120 | const pathname = usePathname(); |
| @@ -142,14 +139,7 @@ export function AppShell({ · 18 unmodified lines | ||
| 142 | 139 | |
| 143 | 140 | {/* Workspace selector */} |
| 144 | 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"> | |
| 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 /> | |
| 153 | 143 | </div> |
| 154 | 144 | |
| 155 | 145 | {/* Nav items */} |
| @@ -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 | +} | |
| @@ -18,8 +18,16 @@ · 17 unmodified lines | ||
| 18 | 18 | "apps/api": { |
| 19 | 19 | "name": "@executor-v2/app-api", |
| 20 | 20 | "dependencies": { |
| 21 | + "convex": "^1.32.0", | |
| 21 | 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 | 32 | "apps/cli": { |
| 25 | 33 | "name": "@executor-v2/app-cli", |