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 | return table; | 427 | return table; |
| 428 | }; | 428 | }; |
| 429 | 429 | ||
| 430 | const extractOpenApiManifest = ( | 430 | export const extractOpenApiManifest = ( |
| 431 | _sourceName: string, | 431 | _sourceName: string, |
| 432 | openApiSpec: unknown, | 432 | openApiSpec: unknown, |
| 433 | ): OpenApiManifest => { | 433 | ): OpenApiManifest => { |
| @@ -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 | } |
| @@ -2,7 +2,6 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { createHash } from "node:crypto"; | 3 | import { createHash } from "node:crypto"; |
| 4 | 4 | ||
| 5 | import { extractOpenApiManifest, fetchOpenApiDocument } from "@executor-v2/management-api"; | ||
| 6 | import { type Source } from "@executor-v2/schema"; | 5 | import { type Source } from "@executor-v2/schema"; |
| 7 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 6 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 8 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | 7 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| @@ -16,49 +15,9 @@ import { internalAction } from "../_generated/server"; · 7 unmodified lines | |||
| 16 | 15 | ||
| 17 | const runtimeInternal = internal as any; | 16 | const runtimeInternal = internal as any; |
| 18 | 17 | ||
| 19 | const openApiExtractorVersion = "openapi_v2"; | ||
| 20 | const graphqlExtractorVersion = "graphql_v1"; | 18 | const graphqlExtractorVersion = "graphql_v1"; |
| 21 | const mcpExtractorVersion = "mcp_v1"; | 19 | const mcpExtractorVersion = "mcp_v1"; |
| 22 | const writeBatchSize = 500; | 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 | const graphqlSchemaRootsQuery = `query SchemaRoots { | 22 | const 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 | ||
| 978 | type OpenApiParserParsedPayload = { | 937 | const 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; | |
| 985 | type 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 | |||
| 1000 | const 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 | |||
| 1082 | const ingestOpenApiSource = ( | 1021 | const 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.stageArtifactWrites | 1038 | 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 | |||
| 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 | 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; |
| @@ -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 | <AppShell | 34 | <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} |
| @@ -6,6 +6,7 @@ import type { ReactNode } from "react"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { cn } from "../../lib/utils"; | 7 | import { cn } from "../../lib/utils"; |
| 8 | import { ThemeSwitcher } from "./theme-switcher"; | 8 | import { ThemeSwitcher } from "./theme-switcher"; |
| 9 | import { WorkspaceSelector } from "./workspace-selector"; | ||
| 9 | 10 | ||
| 10 | type NavItem = { | 11 | type NavItem = { |
| 11 | href: string; | 12 | href: string; |
| @@ -108,16 +109,12 @@ const isActivePath = (pathname: string, item: NavItem): boolean => { · 96 unmodified lines | |||
| 108 | type AppShellProps = { | 109 | type 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 | ||
| 116 | export function AppShell({ | 115 | export 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 */} |
| @@ -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 | "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", |