Unified · Split
| @@ -11,7 +11,7 @@ Executor today is optimized for enterprise/team correctness (workspaces, org flo · 10 unmodified lines | ||
| 11 | 11 | v2 changes the default: |
| 12 | 12 | |
| 13 | 13 | - First run is local and immediate (`npx executor <agent>` works fast). |
| 14 | −- No database dependency in local-lite mode. | |
| 14 | +- No external database dependency in local-lite mode. | |
| 15 | 15 | - Cloud/self-host are upgrade targets, not prerequisites. |
| 16 | 16 | - One architecture for local and remote via shared contracts and thin adapters. |
| 17 | 17 | |
| @@ -105,7 +105,7 @@ Important boundary: · 87 unmodified lines | ||
| 105 | 105 | ### Modes |
| 106 | 106 | |
| 107 | 107 | - local-lite: PM daemon + in-memory hot state + file durability |
| 108 | −- remote: Convex-backed target (cloud and self-host are deployment variants of same target) | |
| 108 | +- remote: SQL-backed target (Postgres remote, SQLite local) | |
| 109 | 109 | |
| 110 | 110 | ### Runtime targets |
| 111 | 111 | |
| @@ -131,19 +131,19 @@ Important boundary: · 19 unmodified lines | ||
| 131 | 131 | 5. Runtime resumes and completes. |
| 132 | 132 | 6. Run/events persisted locally. |
| 133 | 133 | |
| 134 | −### Cloud flow (Convex + Cloudflare worker loader) | |
| 134 | +### Remote flow (SQL + Cloudflare worker loader) | |
| 135 | 135 | |
| 136 | −1. Request starts run in Convex. | |
| 137 | −2. Convex dispatches run to Cloudflare loader. | |
| 138 | −3. Worker `tools.*` call hits Convex runtime callback action (`handleToolCall`). | |
| 136 | +1. Request starts run in control-plane API. | |
| 137 | +2. Runtime dispatch executes through configured runtime adapter. | |
| 138 | +3. Worker `tools.*` call hits runtime callback action (`handleToolCall`). | |
| 139 | 139 | 4. Callback action invokes shared tool invocation pipeline: |
| 140 | 140 | - resolve tool |
| 141 | 141 | - policy check |
| 142 | 142 | - approval gating |
| 143 | − - credential resolution from Convex/vault | |
| 143 | + - credential resolution from SQL-backed state/secret stores | |
| 144 | 144 | - provider invoke |
| 145 | 145 | 5. Worker resumes and returns terminal status. |
| 146 | −6. Convex marks run terminal and emits events. | |
| 146 | +6. Control plane marks run terminal and emits events. | |
| 147 | 147 | |
| 148 | 148 | Serverless note: |
| 149 | 149 | - There is no long-lived in-memory `onToolCall` loop in cloud. |
| @@ -281,12 +281,10 @@ v2/ · 131 unmodified lines | ||
| 281 | 281 | cli/ |
| 282 | 282 | pm/ |
| 283 | 283 | web/ |
| 284 | − convex/ | |
| 285 | 284 | packages/ |
| 286 | − confect/ | |
| 287 | 285 | schema/ |
| 288 | 286 | state-contracts/ |
| 289 | − state-local-file/ | |
| 287 | + persistence-sql/ | |
| 290 | 288 | domain/ |
| 291 | 289 | approvals/ |
| 292 | 290 | source-manager/ |
| @@ -309,9 +307,9 @@ Planned follow-up split (after behavior stabilizes): · 16 unmodified lines | ||
| 309 | 307 | ## 14) Dependency direction |
| 310 | 308 | |
| 311 | 309 | ```text |
| 312 | −confect -> schema -> state-contracts | |
| 310 | +schema -> state-contracts | |
| 313 | 311 | |
| 314 | −state-contracts -> state-local-file | |
| 312 | +state-contracts -> persistence-sql | |
| 315 | 313 | |
| 316 | 314 | schema + state-contracts + oauth + secrets -> domain |
| 317 | 315 | domain -> approvals/source-manager/engine |
| @@ -319,8 +317,8 @@ source-manager + schema -> control-plane · 1 unmodified line | ||
| 319 | 317 | approvals/source-manager/engine -> mcp-gateway |
| 320 | 318 | domain/features -> sdk -> ai-sdk-adapter |
| 321 | 319 | |
| 322 | −apps/pm -> mcp-gateway + domain + state-local-file + control-plane | |
| 323 | −apps/convex -> mcp-gateway + domain + control-plane | |
| 320 | +apps/pm -> mcp-gateway + domain + persistence-sql + control-plane | |
| 321 | +apps/web -> management-api + persistence-sql + control-plane routes | |
| 324 | 322 | apps/cli/web -> sdk (and/or ai-sdk bridge) |
| 325 | 323 | ``` |
| 326 | 324 | Rules: |
| @@ -355,11 +353,11 @@ Rules: · 28 unmodified lines | ||
| 355 | 353 | |
| 356 | 354 | - persisted pending/resume/deny flow |
| 357 | 355 | - capability-aware UX fallbacks |
| 358 | −- callback-time credential resolver implementations (local + convex) | |
| 356 | +- callback-time credential resolver implementations (local + remote SQL) | |
| 359 | 357 | |
| 360 | 358 | ### Phase 4: remote parity |
| 361 | 359 | |
| 362 | −- Convex adapter wiring for kernel and control | |
| 360 | +- SQL adapter wiring for kernel and control | |
| 363 | 361 | - cloudflare loader integration through runtime dispatch adapter |
| 364 | 362 | - parity/conformance suite across local and remote |
| 365 | 363 | |
| @@ -395,10 +393,9 @@ Rules: · 29 unmodified lines | ||
| 395 | 393 | |
| 396 | 394 | Implemented in `v2` so far: |
| 397 | 395 | - monorepo app/package skeleton |
| 398 | −- `confect` imported from quickhub | |
| 399 | 396 | - schema package scaffold with Effect models/event envelope |
| 400 | 397 | - shared state contracts for source/tool artifacts |
| 401 | −- local file adapters for source/tool artifacts + snapshot/WAL state store | |
| 398 | +- SQL adapters for source/tool artifacts + snapshot/event-log state store | |
| 402 | 399 | - source manager OpenAPI extraction + artifact refresh/reuse |
| 403 | 400 | - provider registry contracts and routing in engine |
| 404 | 401 | - OpenAPI provider invocation + in-memory provider path through registry |
| @@ -420,9 +417,8 @@ Still missing: · 15 unmodified lines | ||
| 420 | 417 | ## 19) Immediate next steps |
| 421 | 418 | |
| 422 | 419 | 1. Land shared `ToolInvocationService` contract in v2 and wire PM execute callback path through it. |
| 423 | −2. Add `CredentialResolver` interfaces and local/convex implementations; integrate at invocation-time only. | |
| 420 | +2. Add `CredentialResolver` interfaces and local/remote implementations; integrate at invocation-time only. | |
| 424 | 421 | 3. Implement control handlers for sources (`add/list/remove`) and route both host API and `tools.executor.*` through same code path. |
| 425 | −4. Implement cloudflare-worker-loader runtime dispatch adapter in v2 and wire Convex callback endpoints. | |
| 422 | +4. Implement cloudflare-worker-loader runtime dispatch adapter in v2 and wire callback endpoints. | |
| 426 | 423 | 5. Add run API split for async + blocking convenience (`submit` + `wait/watch`) without duplicating execution logic. |
| 427 | 424 | 6. Add conformance tests that run same scenarios across local and remote adapters. |
| 428 | − | |
| @@ -1 +0,0 @@ | ||
| 1 | −.vercel | |
| @@ -1,461 +0,0 @@ | ||
| 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 | −}); | |
| @@ -1,812 +0,0 @@ | ||
| 1 | −import { createHash } from "node:crypto"; | |
| 2 | − | |
| 3 | −import { ConvexHttpClient } from "convex/browser"; | |
| 4 | −import { parse as parseYaml } from "yaml"; | |
| 5 | − | |
| 6 | −const maxBodyBytes = 256 * 1024; | |
| 7 | −const httpMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const; | |
| 8 | − | |
| 9 | −type HttpMethod = typeof httpMethods[number]; | |
| 10 | −type OpenApiParameterLocation = "path" | "query" | "header" | "cookie"; | |
| 11 | − | |
| 12 | −type ExtractedTool = { | |
| 13 | − toolId: string; | |
| 14 | − name: string; | |
| 15 | − description: string | null; | |
| 16 | − method: HttpMethod; | |
| 17 | − path: string; | |
| 18 | − invocation: { | |
| 19 | − method: HttpMethod; | |
| 20 | − pathTemplate: string; | |
| 21 | − parameters: Array<{ | |
| 22 | − name: string; | |
| 23 | − location: OpenApiParameterLocation; | |
| 24 | − required: boolean; | |
| 25 | − }>; | |
| 26 | − requestBody: { | |
| 27 | − required: boolean; | |
| 28 | − contentTypes: Array<string>; | |
| 29 | − } | null; | |
| 30 | − }; | |
| 31 | − operationHash: string; | |
| 32 | − typing?: { | |
| 33 | − inputSchemaJson?: string; | |
| 34 | − outputSchemaJson?: string; | |
| 35 | − refHintKeys?: Array<string>; | |
| 36 | − }; | |
| 37 | −}; | |
| 38 | − | |
| 39 | −type OpenApiManifest = { | |
| 40 | − sourceHash: string; | |
| 41 | − tools: Array<ExtractedTool>; | |
| 42 | − refHintTable: Record<string, string>; | |
| 43 | −}; | |
| 44 | − | |
| 45 | −const parseJsonDocument = (input: string): unknown => JSON.parse(input); | |
| 46 | −const parseYamlDocument = (input: string): unknown => parseYaml(input); | |
| 47 | − | |
| 48 | −const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 49 | − typeof value === "object" && value !== null && !Array.isArray(value); | |
| 50 | − | |
| 51 | −const stableStringify = (value: unknown): string => { | |
| 52 | − if (Array.isArray(value)) { | |
| 53 | − return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | |
| 54 | − } | |
| 55 | − | |
| 56 | − if (isRecord(value)) { | |
| 57 | − const keys = Object.keys(value).sort(); | |
| 58 | − return `{${keys | |
| 59 | − .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) | |
| 60 | − .join(",")}}`; | |
| 61 | − } | |
| 62 | − | |
| 63 | − return JSON.stringify(value); | |
| 64 | −}; | |
| 65 | − | |
| 66 | −const hashUnknown = (value: unknown): string => | |
| 67 | − createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16); | |
| 68 | − | |
| 69 | −const normalizeText = (value: unknown): string | null => { | |
| 70 | − if (typeof value !== "string") { | |
| 71 | − return null; | |
| 72 | − } | |
| 73 | − | |
| 74 | − const trimmed = value.trim(); | |
| 75 | − return trimmed.length > 0 ? trimmed : null; | |
| 76 | −}; | |
| 77 | − | |
| 78 | −const normalizeSegment = (value: string): string => | |
| 79 | − value | |
| 80 | − .toLowerCase() | |
| 81 | − .replace(/[^a-z0-9]+/g, "_") | |
| 82 | − .replace(/^_+|_+$/g, ""); | |
| 83 | − | |
| 84 | −const normalizeToolId = (value: string): string => | |
| 85 | − value | |
| 86 | − .toLowerCase() | |
| 87 | − .replace(/[^a-z0-9]+/g, ".") | |
| 88 | − .replace(/\.+/g, ".") | |
| 89 | − .replace(/^\.|\.$/g, ""); | |
| 90 | − | |
| 91 | −const escapeJsonPointer = (value: string): string => | |
| 92 | − value.replace(/~/g, "~0").replace(/\//g, "~1"); | |
| 93 | − | |
| 94 | −const unescapeJsonPointer = (value: string): string => | |
| 95 | − value.replace(/~1/g, "/").replace(/~0/g, "~"); | |
| 96 | − | |
| 97 | −const getByJsonPointer = (root: unknown, pointer: string): unknown => { | |
| 98 | − if (!pointer.startsWith("#/")) { | |
| 99 | − return null; | |
| 100 | − } | |
| 101 | − | |
| 102 | − const segments = pointer | |
| 103 | − .slice(2) | |
| 104 | − .split("/") | |
| 105 | − .map((segment) => unescapeJsonPointer(segment)); | |
| 106 | − | |
| 107 | − let current: unknown = root; | |
| 108 | − for (const segment of segments) { | |
| 109 | − if (Array.isArray(current)) { | |
| 110 | − const index = Number(segment); | |
| 111 | − if (!Number.isInteger(index) || index < 0 || index >= current.length) { | |
| 112 | − return null; | |
| 113 | − } | |
| 114 | − current = current[index]; | |
| 115 | − continue; | |
| 116 | − } | |
| 117 | − | |
| 118 | − if (!isRecord(current) || !(segment in current)) { | |
| 119 | − return null; | |
| 120 | − } | |
| 121 | − | |
| 122 | − current = current[segment]; | |
| 123 | − } | |
| 124 | − | |
| 125 | − return current; | |
| 126 | −}; | |
| 127 | − | |
| 128 | −const resolveLocalRef = (root: unknown, value: unknown, depth = 0): unknown => { | |
| 129 | − if (depth > 8 || !isRecord(value) || typeof value.$ref !== "string") { | |
| 130 | − return value; | |
| 131 | − } | |
| 132 | − | |
| 133 | − const resolved = getByJsonPointer(root, value.$ref); | |
| 134 | − if (resolved === null) { | |
| 135 | − return value; | |
| 136 | − } | |
| 137 | − | |
| 138 | − return resolveLocalRef(root, resolved, depth + 1); | |
| 139 | −}; | |
| 140 | − | |
| 141 | −const collectLocalRefKeys = (value: unknown, target: Set<string>): void => { | |
| 142 | − if (Array.isArray(value)) { | |
| 143 | − for (const entry of value) { | |
| 144 | − collectLocalRefKeys(entry, target); | |
| 145 | − } | |
| 146 | − return; | |
| 147 | − } | |
| 148 | − | |
| 149 | − if (!isRecord(value)) { | |
| 150 | − return; | |
| 151 | − } | |
| 152 | − | |
| 153 | − for (const [key, entry] of Object.entries(value)) { | |
| 154 | − if (key === "$ref" && typeof entry === "string" && entry.startsWith("#/")) { | |
| 155 | − target.add(entry); | |
| 156 | − continue; | |
| 157 | − } | |
| 158 | − | |
| 159 | − collectLocalRefKeys(entry, target); | |
| 160 | − } | |
| 161 | −}; | |
| 162 | − | |
| 163 | −const extractParameterSchema = ( | |
| 164 | − root: unknown, | |
| 165 | − parameter: Record<string, unknown>, | |
| 166 | −): unknown => { | |
| 167 | − const resolved = resolveLocalRef(root, parameter); | |
| 168 | − if (!isRecord(resolved)) { | |
| 169 | − return {}; | |
| 170 | − } | |
| 171 | − | |
| 172 | − if (isRecord(resolved.schema)) { | |
| 173 | − return resolved.schema; | |
| 174 | − } | |
| 175 | − | |
| 176 | − const content = resolved.content; | |
| 177 | − if (isRecord(content)) { | |
| 178 | − const jsonMedia = | |
| 179 | − (content["application/json"] as Record<string, unknown> | undefined) | |
| 180 | − ?? (content["application/*+json"] as Record<string, unknown> | undefined); | |
| 181 | − | |
| 182 | − if (jsonMedia && isRecord(jsonMedia.schema)) { | |
| 183 | − return jsonMedia.schema; | |
| 184 | − } | |
| 185 | − | |
| 186 | − for (const mediaType of Object.values(content)) { | |
| 187 | − if (isRecord(mediaType) && isRecord(mediaType.schema)) { | |
| 188 | − return mediaType.schema; | |
| 189 | − } | |
| 190 | − } | |
| 191 | − } | |
| 192 | − | |
| 193 | − return {}; | |
| 194 | −}; | |
| 195 | − | |
| 196 | −const extractRequestBody = ( | |
| 197 | − root: unknown, | |
| 198 | − operation: Record<string, unknown>, | |
| 199 | −): { | |
| 200 | − required: boolean; | |
| 201 | − contentTypes: Array<string>; | |
| 202 | − schema: unknown; | |
| 203 | −} | null => { | |
| 204 | − const requestBodyValue = resolveLocalRef(root, operation.requestBody); | |
| 205 | − if (!isRecord(requestBodyValue)) { | |
| 206 | − return null; | |
| 207 | − } | |
| 208 | − | |
| 209 | − const content = requestBodyValue.content; | |
| 210 | − if (!isRecord(content)) { | |
| 211 | − return { | |
| 212 | − required: requestBodyValue.required === true, | |
| 213 | − contentTypes: [], | |
| 214 | − schema: {}, | |
| 215 | − }; | |
| 216 | − } | |
| 217 | − | |
| 218 | − const contentTypes = Object.keys(content).filter((entry) => entry.trim().length > 0); | |
| 219 | − const jsonMedia = | |
| 220 | − (content["application/json"] as Record<string, unknown> | undefined) | |
| 221 | − ?? (content["application/*+json"] as Record<string, unknown> | undefined); | |
| 222 | − let schema: unknown = {}; | |
| 223 | − | |
| 224 | − if (jsonMedia && isRecord(jsonMedia.schema)) { | |
| 225 | − schema = jsonMedia.schema; | |
| 226 | − } else { | |
| 227 | − for (const mediaType of Object.values(content)) { | |
| 228 | − if (isRecord(mediaType) && isRecord(mediaType.schema)) { | |
| 229 | − schema = mediaType.schema; | |
| 230 | − break; | |
| 231 | − } | |
| 232 | − } | |
| 233 | − } | |
| 234 | − | |
| 235 | − return { | |
| 236 | − required: requestBodyValue.required === true, | |
| 237 | − contentTypes, | |
| 238 | − schema, | |
| 239 | − }; | |
| 240 | −}; | |
| 241 | − | |
| 242 | −const extractResponseSchema = ( | |
| 243 | − root: unknown, | |
| 244 | − operation: Record<string, unknown>, | |
| 245 | −): unknown | null => { | |
| 246 | − const responsesValue = resolveLocalRef(root, operation.responses); | |
| 247 | − if (!isRecord(responsesValue)) { | |
| 248 | − return null; | |
| 249 | − } | |
| 250 | − | |
| 251 | − const preferredKeys = Object.keys(responsesValue) | |
| 252 | − .filter((key) => /^2\d\d$/.test(key)) | |
| 253 | − .sort(); | |
| 254 | − if ("default" in responsesValue) { | |
| 255 | − preferredKeys.push("default"); | |
| 256 | − } | |
| 257 | − | |
| 258 | − for (const key of preferredKeys) { | |
| 259 | − const candidate = resolveLocalRef(root, responsesValue[key]); | |
| 260 | − if (!isRecord(candidate) || !isRecord(candidate.content)) { | |
| 261 | − continue; | |
| 262 | − } | |
| 263 | − | |
| 264 | − const content = candidate.content; | |
| 265 | − const jsonMedia = | |
| 266 | − (content["application/json"] as Record<string, unknown> | undefined) | |
| 267 | − ?? (content["application/*+json"] as Record<string, unknown> | undefined); | |
| 268 | − | |
| 269 | − if (jsonMedia && isRecord(jsonMedia.schema)) { | |
| 270 | − return jsonMedia.schema; | |
| 271 | − } | |
| 272 | − | |
| 273 | − for (const mediaType of Object.values(content)) { | |
| 274 | − if (isRecord(mediaType) && isRecord(mediaType.schema)) { | |
| 275 | − return mediaType.schema; | |
| 276 | − } | |
| 277 | − } | |
| 278 | − } | |
| 279 | − | |
| 280 | − return null; | |
| 281 | −}; | |
| 282 | − | |
| 283 | −const buildInputSchema = ( | |
| 284 | − root: unknown, | |
| 285 | − parameters: Array<Record<string, unknown>>, | |
| 286 | − requestBody: { | |
| 287 | − required: boolean; | |
| 288 | − schema: unknown; | |
| 289 | − } | null, | |
| 290 | −): { schemaJson: string; refHintKeys: Array<string> } | null => { | |
| 291 | − const properties: Record<string, unknown> = {}; | |
| 292 | − const required = new Set<string>(); | |
| 293 | − const refKeys = new Set<string>(); | |
| 294 | − | |
| 295 | − for (const parameter of parameters) { | |
| 296 | − const resolved = resolveLocalRef(root, parameter); | |
| 297 | − if (!isRecord(resolved)) { | |
| 298 | − continue; | |
| 299 | − } | |
| 300 | − | |
| 301 | − const location = normalizeText(resolved.in)?.toLowerCase(); | |
| 302 | − if ( | |
| 303 | − location !== "path" | |
| 304 | − && location !== "query" | |
| 305 | − && location !== "header" | |
| 306 | − && location !== "cookie" | |
| 307 | − ) { | |
| 308 | − continue; | |
| 309 | − } | |
| 310 | − | |
| 311 | − const name = normalizeText(resolved.name); | |
| 312 | − if (!name) { | |
| 313 | − continue; | |
| 314 | − } | |
| 315 | − | |
| 316 | − const schema = extractParameterSchema(root, resolved); | |
| 317 | − properties[name] = schema; | |
| 318 | − collectLocalRefKeys(schema, refKeys); | |
| 319 | − | |
| 320 | − const isRequired = resolved.required === true || location === "path"; | |
| 321 | − if (isRequired) { | |
| 322 | − required.add(name); | |
| 323 | − } | |
| 324 | − } | |
| 325 | − | |
| 326 | − if (requestBody) { | |
| 327 | − properties.body = requestBody.schema; | |
| 328 | − collectLocalRefKeys(requestBody.schema, refKeys); | |
| 329 | − if (requestBody.required) { | |
| 330 | − required.add("body"); | |
| 331 | − } | |
| 332 | − } | |
| 333 | − | |
| 334 | − if (Object.keys(properties).length === 0) { | |
| 335 | − return null; | |
| 336 | − } | |
| 337 | − | |
| 338 | − return { | |
| 339 | − schemaJson: JSON.stringify({ | |
| 340 | − type: "object", | |
| 341 | − properties, | |
| 342 | − ...(required.size > 0 ? { required: Array.from(required).sort() } : {}), | |
| 343 | − }), | |
| 344 | − refHintKeys: Array.from(refKeys).sort(), | |
| 345 | − }; | |
| 346 | −}; | |
| 347 | − | |
| 348 | −const buildToolId = ( | |
| 349 | − method: HttpMethod, | |
| 350 | − path: string, | |
| 351 | − operation: Record<string, unknown>, | |
| 352 | −): string => { | |
| 353 | − const operationId = normalizeText(operation.operationId); | |
| 354 | − if (operationId) { | |
| 355 | − const normalized = normalizeToolId(operationId); | |
| 356 | − if (normalized.length > 0) { | |
| 357 | − return normalized; | |
| 358 | − } | |
| 359 | − } | |
| 360 | − | |
| 361 | − const rawSegments = path | |
| 362 | − .split("/") | |
| 363 | − .map((segment) => segment.trim()) | |
| 364 | − .filter((segment) => segment.length > 0) | |
| 365 | − .map((segment) => { | |
| 366 | − if (segment.startsWith("{") && segment.endsWith("}")) { | |
| 367 | − return segment.slice(1, -1); | |
| 368 | − } | |
| 369 | − return segment; | |
| 370 | − }) | |
| 371 | − .map((segment) => normalizeSegment(segment)) | |
| 372 | − .filter((segment) => segment.length > 0); | |
| 373 | − | |
| 374 | − const segments = rawSegments.length > 0 ? rawSegments.slice(0, 6) : ["operation"]; | |
| 375 | − return normalizeToolId(`${segments.join(".")}.${method}`); | |
| 376 | −}; | |
| 377 | − | |
| 378 | −const dedupeToolIds = (tools: Array<ExtractedTool>): Array<ExtractedTool> => { | |
| 379 | − const counts = new Map<string, number>(); | |
| 380 | − | |
| 381 | − return tools.map((tool) => { | |
| 382 | − const count = (counts.get(tool.toolId) ?? 0) + 1; | |
| 383 | − counts.set(tool.toolId, count); | |
| 384 | − | |
| 385 | − if (count === 1) { | |
| 386 | − return tool; | |
| 387 | − } | |
| 388 | − | |
| 389 | − return { | |
| 390 | − ...tool, | |
| 391 | − toolId: `${tool.toolId}_${count}`, | |
| 392 | − }; | |
| 393 | − }); | |
| 394 | −}; | |
| 395 | − | |
| 396 | −const buildRefHintTable = ( | |
| 397 | − root: unknown, | |
| 398 | − initialRefs: ReadonlyArray<string>, | |
| 399 | −): Record<string, string> => { | |
| 400 | − const table: Record<string, string> = {}; | |
| 401 | − const queue = [...initialRefs]; | |
| 402 | − const visited = new Set<string>(); | |
| 403 | − | |
| 404 | − while (queue.length > 0) { | |
| 405 | − const ref = queue.shift(); | |
| 406 | − if (!ref || visited.has(ref)) { | |
| 407 | − continue; | |
| 408 | − } | |
| 409 | − visited.add(ref); | |
| 410 | − | |
| 411 | − const resolved = getByJsonPointer(root, ref); | |
| 412 | − if (resolved === null) { | |
| 413 | − continue; | |
| 414 | − } | |
| 415 | − | |
| 416 | − table[ref] = JSON.stringify(resolved); | |
| 417 | − | |
| 418 | − const nestedRefs = new Set<string>(); | |
| 419 | − collectLocalRefKeys(resolved, nestedRefs); | |
| 420 | − for (const nestedRef of nestedRefs) { | |
| 421 | − if (!visited.has(nestedRef)) { | |
| 422 | − queue.push(nestedRef); | |
| 423 | − } | |
| 424 | − } | |
| 425 | − } | |
| 426 | − | |
| 427 | − return table; | |
| 428 | −}; | |
| 429 | − | |
| 430 | −export const extractOpenApiManifest = ( | |
| 431 | − _sourceName: string, | |
| 432 | − openApiSpec: unknown, | |
| 433 | −): OpenApiManifest => { | |
| 434 | − if (!isRecord(openApiSpec)) { | |
| 435 | − throw new Error("OpenAPI document must be an object"); | |
| 436 | − } | |
| 437 | − | |
| 438 | − const paths = openApiSpec.paths; | |
| 439 | − if (!isRecord(paths)) { | |
| 440 | − throw new Error("OpenAPI document is missing paths"); | |
| 441 | − } | |
| 442 | − | |
| 443 | − const tools: Array<ExtractedTool> = []; | |
| 444 | − const globalRefKeys = new Set<string>(); | |
| 445 | − | |
| 446 | − for (const [path, pathItemValue] of Object.entries(paths)) { | |
| 447 | − if (!isRecord(pathItemValue)) { | |
| 448 | − continue; | |
| 449 | − } | |
| 450 | − | |
| 451 | − const sharedParameters = Array.isArray(pathItemValue.parameters) | |
| 452 | − ? pathItemValue.parameters.filter((entry): entry is Record<string, unknown> => isRecord(entry)) | |
| 453 | − : []; | |
| 454 | − | |
| 455 | − for (const method of httpMethods) { | |
| 456 | − const operationValue = pathItemValue[method]; | |
| 457 | − if (!isRecord(operationValue)) { | |
| 458 | − continue; | |
| 459 | − } | |
| 460 | − | |
| 461 | − const operationParameters = Array.isArray(operationValue.parameters) | |
| 462 | − ? operationValue.parameters.filter((entry): entry is Record<string, unknown> => isRecord(entry)) | |
| 463 | − : []; | |
| 464 | − const parameters = [...sharedParameters, ...operationParameters]; | |
| 465 | − const requestBody = extractRequestBody(openApiSpec, operationValue); | |
| 466 | − | |
| 467 | − const invocation = { | |
| 468 | − method, | |
| 469 | − pathTemplate: path, | |
| 470 | − parameters: parameters | |
| 471 | − .map((parameter) => resolveLocalRef(openApiSpec, parameter)) | |
| 472 | − .filter((parameter): parameter is Record<string, unknown> => isRecord(parameter)) | |
| 473 | − .map((parameter) => ({ | |
| 474 | − name: normalizeText(parameter.name) ?? "", | |
| 475 | − location: | |
| 476 | − (normalizeText(parameter.in)?.toLowerCase() as OpenApiParameterLocation | undefined) | |
| 477 | − ?? "query", | |
| 478 | − required: | |
| 479 | − parameter.required === true | |
| 480 | − || normalizeText(parameter.in)?.toLowerCase() === "path", | |
| 481 | − })) | |
| 482 | − .filter((parameter) => | |
| 483 | − parameter.name.length > 0 | |
| 484 | − && ( | |
| 485 | − parameter.location === "path" | |
| 486 | − || parameter.location === "query" | |
| 487 | − || parameter.location === "header" | |
| 488 | − || parameter.location === "cookie" | |
| 489 | − )) | |
| 490 | − .sort((left, right) => | |
| 491 | − left.location === right.location | |
| 492 | − ? left.name.localeCompare(right.name) | |
| 493 | − : left.location.localeCompare(right.location)), | |
| 494 | − requestBody: requestBody | |
| 495 | − ? { | |
| 496 | − required: requestBody.required, | |
| 497 | − contentTypes: requestBody.contentTypes, | |
| 498 | − } | |
| 499 | − : null, | |
| 500 | − }; | |
| 501 | − | |
| 502 | − const inputTyping = buildInputSchema(openApiSpec, parameters, requestBody); | |
| 503 | − const outputSchema = extractResponseSchema(openApiSpec, operationValue); | |
| 504 | − const outputSchemaJson = outputSchema === null ? null : JSON.stringify(outputSchema); | |
| 505 | − if (outputSchema !== null) { | |
| 506 | − collectLocalRefKeys(outputSchema, globalRefKeys); | |
| 507 | − } | |
| 508 | − | |
| 509 | − if (inputTyping) { | |
| 510 | − for (const refKey of inputTyping.refHintKeys) { | |
| 511 | − globalRefKeys.add(refKey); | |
| 512 | − } | |
| 513 | − } | |
| 514 | − | |
| 515 | − const name = | |
| 516 | − normalizeText(operationValue.summary) | |
| 517 | − ?? normalizeText(operationValue.operationId) | |
| 518 | − ?? `${method.toUpperCase()} ${path}`; | |
| 519 | − const description = | |
| 520 | − normalizeText(operationValue.description) | |
| 521 | − ?? normalizeText(operationValue.summary) | |
| 522 | − ?? null; | |
| 523 | − const toolId = buildToolId(method, path, operationValue); | |
| 524 | − | |
| 525 | − tools.push({ | |
| 526 | − toolId, | |
| 527 | − name, | |
| 528 | − description, | |
| 529 | − method, | |
| 530 | − path, | |
| 531 | − invocation, | |
| 532 | − operationHash: hashUnknown({ | |
| 533 | − method, | |
| 534 | − path, | |
| 535 | − invocation, | |
| 536 | − inputSchemaJson: inputTyping?.schemaJson ?? null, | |
| 537 | − outputSchemaJson, | |
| 538 | − }), | |
| 539 | − typing: { | |
| 540 | − ...(inputTyping ? { inputSchemaJson: inputTyping.schemaJson } : {}), | |
| 541 | − ...(outputSchemaJson ? { outputSchemaJson } : {}), | |
| 542 | − ...(inputTyping && inputTyping.refHintKeys.length > 0 | |
| 543 | − ? { refHintKeys: inputTyping.refHintKeys } | |
| 544 | − : {}), | |
| 545 | − }, | |
| 546 | − }); | |
| 547 | − } | |
| 548 | − } | |
| 549 | − | |
| 550 | − const deduped = dedupeToolIds(tools); | |
| 551 | − const refHintTable = buildRefHintTable(openApiSpec, Array.from(globalRefKeys)); | |
| 552 | − | |
| 553 | − return { | |
| 554 | − sourceHash: hashUnknown({ | |
| 555 | − tools: deduped.map((tool) => ({ | |
| 556 | − toolId: tool.toolId, | |
| 557 | − operationHash: tool.operationHash, | |
| 558 | − })), | |
| 559 | − refKeys: Object.keys(refHintTable).sort(), | |
| 560 | − }), | |
| 561 | − tools: deduped, | |
| 562 | − refHintTable, | |
| 563 | − }; | |
| 564 | −}; | |
| 565 | − | |
| 566 | −const parseOpenApiDocument = (input: string): unknown => { | |
| 567 | − const text = input.trim(); | |
| 568 | − if (text.length === 0) { | |
| 569 | − throw new Error("OpenAPI document is empty"); | |
| 570 | − } | |
| 571 | − | |
| 572 | − try { | |
| 573 | − return parseJsonDocument(text); | |
| 574 | − } catch { | |
| 575 | − try { | |
| 576 | − return parseYamlDocument(text); | |
| 577 | − } catch (cause) { | |
| 578 | − throw new Error( | |
| 579 | − `Unable to parse OpenAPI document as JSON or YAML: ${ | |
| 580 | − cause instanceof Error ? cause.message : String(cause) | |
| 581 | − }`, | |
| 582 | − ); | |
| 583 | − } | |
| 584 | − } | |
| 585 | −}; | |
| 586 | − | |
| 587 | −const readRawBody = async (request: AsyncIterable<unknown>): Promise<string> => { | |
| 588 | − const chunks: Array<Buffer> = []; | |
| 589 | − let bytes = 0; | |
| 590 | − | |
| 591 | − for await (const chunk of request) { | |
| 592 | − const buffer = Buffer.isBuffer(chunk) | |
| 593 | − ? chunk | |
| 594 | − : typeof chunk === "string" | |
| 595 | − ? Buffer.from(chunk) | |
| 596 | − : chunk instanceof Uint8Array | |
| 597 | − ? Buffer.from(chunk) | |
| 598 | − : Buffer.from(String(chunk)); | |
| 599 | − bytes += buffer.length; | |
| 600 | − if (bytes > maxBodyBytes) { | |
| 601 | − throw new Error("Request body too large"); | |
| 602 | − } | |
| 603 | − chunks.push(buffer); | |
| 604 | − } | |
| 605 | − | |
| 606 | − return Buffer.concat(chunks).toString("utf8"); | |
| 607 | −}; | |
| 608 | − | |
| 609 | −const json = ( | |
| 610 | − response: any, | |
| 611 | − status: number, | |
| 612 | − payload: Record<string, unknown>, | |
| 613 | −): void => { | |
| 614 | − response.status(status).setHeader("content-type", "application/json; charset=utf-8"); | |
| 615 | − response.send(JSON.stringify(payload)); | |
| 616 | −}; | |
| 617 | − | |
| 618 | −const normalizeNamespacePart = (value: string): string => { | |
| 619 | − const normalized = value | |
| 620 | − .trim() | |
| 621 | − .toLowerCase() | |
| 622 | − .replace(/[^a-z0-9]+/g, "_") | |
| 623 | − .replace(/^_+|_+$/g, ""); | |
| 624 | − | |
| 625 | − return normalized.length > 0 ? normalized : "source"; | |
| 626 | −}; | |
| 627 | − | |
| 628 | −const sourceNamespace = (sourceName: string, sourceId: string): string => { | |
| 629 | − const sourceIdSuffix = sourceId.slice(-6).toLowerCase(); | |
| 630 | − return `${normalizeNamespacePart(sourceName)}_${sourceIdSuffix}`; | |
| 631 | −}; | |
| 632 | − | |
| 633 | −const parseSourceEnabled = (value: unknown): boolean => | |
| 634 | − typeof value === "boolean" ? value : true; | |
| 635 | − | |
| 636 | −const toIngestPayload = async ( | |
| 637 | − openApiSpec: unknown, | |
| 638 | − input: { | |
| 639 | − convexUrl: string; | |
| 640 | − ingestToken: string; | |
| 641 | − workspaceId: string; | |
| 642 | − sourceId: string; | |
| 643 | − sourceName: string; | |
| 644 | − sourceEndpoint: string; | |
| 645 | − sourceEnabled: boolean; | |
| 646 | − }, | |
| 647 | −): Promise<{ | |
| 648 | − artifactId: string; | |
| 649 | − sourceHash: string; | |
| 650 | − toolCount: number; | |
| 651 | − namespace: string; | |
| 652 | −}> => { | |
| 653 | − const manifest = extractOpenApiManifest(input.sourceName, openApiSpec); | |
| 654 | − | |
| 655 | − const refs = Object.entries(manifest.refHintTable ?? {}).map(([refKey, schemaJson]) => ({ | |
| 656 | − refKey, | |
| 657 | − schemaJson, | |
| 658 | − })); | |
| 659 | − | |
| 660 | − const tools = manifest.tools.map((tool) => ({ | |
| 661 | − toolId: tool.toolId, | |
| 662 | − name: tool.name, | |
| 663 | − description: tool.description, | |
| 664 | − method: tool.method, | |
| 665 | − path: tool.path, | |
| 666 | − operationHash: tool.operationHash, | |
| 667 | − invocationJson: JSON.stringify(tool.invocation), | |
| 668 | − inputSchemaJson: tool.typing?.inputSchemaJson ?? null, | |
| 669 | − outputSchemaJson: tool.typing?.outputSchemaJson ?? null, | |
| 670 | − })); | |
| 671 | − | |
| 672 | − const convex = new ConvexHttpClient(input.convexUrl); | |
| 673 | − const result = await (convex as any).action("control_plane/tool_registry:ingestOpenApiManifest", { | |
| 674 | − token: input.ingestToken, | |
| 675 | − workspaceId: input.workspaceId, | |
| 676 | − sourceId: input.sourceId, | |
| 677 | − sourceName: input.sourceName, | |
| 678 | − sourceEndpoint: input.sourceEndpoint, | |
| 679 | − sourceEnabled: input.sourceEnabled, | |
| 680 | − sourceHash: manifest.sourceHash, | |
| 681 | − toolCount: manifest.tools.length, | |
| 682 | − refs, | |
| 683 | − tools, | |
| 684 | − }) as { | |
| 685 | − artifactId: string; | |
| 686 | − }; | |
| 687 | − | |
| 688 | − return { | |
| 689 | − artifactId: result.artifactId, | |
| 690 | − sourceHash: manifest.sourceHash, | |
| 691 | − toolCount: manifest.tools.length, | |
| 692 | − namespace: sourceNamespace(input.sourceName, input.sourceId), | |
| 693 | − }; | |
| 694 | −}; | |
| 695 | − | |
| 696 | −const parseEndpoint = async (request: any, response: any) => { | |
| 697 | − if (request.method !== "POST") { | |
| 698 | − response.setHeader("allow", "POST"); | |
| 699 | − json(response, 405, { error: "Method not allowed" }); | |
| 700 | − return; | |
| 701 | − } | |
| 702 | − | |
| 703 | − try { | |
| 704 | − const expectedToken = process.env.OPENAPI_PARSE_API_TOKEN?.trim(); | |
| 705 | − const provided = | |
| 706 | − typeof request.headers["x-openapi-parse-token"] === "string" | |
| 707 | − ? request.headers["x-openapi-parse-token"].trim() | |
| 708 | − : ""; | |
| 709 | − if (expectedToken && expectedToken.length > 0 && provided !== expectedToken) { | |
| 710 | − json(response, 401, { error: "Unauthorized" }); | |
| 711 | − return; | |
| 712 | − } | |
| 713 | − | |
| 714 | − const rawBody = await readRawBody(request); | |
| 715 | − const payload = JSON.parse(rawBody) as Record<string, unknown>; | |
| 716 | − const specUrl = typeof payload.specUrl === "string" ? payload.specUrl.trim() : ""; | |
| 717 | − | |
| 718 | − if (specUrl.length === 0) { | |
| 719 | − json(response, 400, { error: "specUrl is required" }); | |
| 720 | − return; | |
| 721 | − } | |
| 722 | − | |
| 723 | − const parsedUrl = new URL(specUrl); | |
| 724 | − const protocol = parsedUrl.protocol.toLowerCase(); | |
| 725 | − if (protocol !== "https:" && protocol !== "http:") { | |
| 726 | − json(response, 400, { error: "specUrl must use http:// or https://" }); | |
| 727 | − return; | |
| 728 | − } | |
| 729 | − | |
| 730 | − const upstream = await fetch(specUrl); | |
| 731 | − if (!upstream.ok) { | |
| 732 | − json(response, 502, { | |
| 733 | − error: "Failed fetching OpenAPI document", | |
| 734 | − status: upstream.status, | |
| 735 | − statusText: upstream.statusText, | |
| 736 | − }); | |
| 737 | − return; | |
| 738 | − } | |
| 739 | − | |
| 740 | − const bodyText = await upstream.text(); | |
| 741 | − const openApiSpec = parseOpenApiDocument(bodyText); | |
| 742 | − | |
| 743 | − const workspaceId = typeof payload.workspaceId === "string" ? payload.workspaceId.trim() : ""; | |
| 744 | − const sourceId = typeof payload.sourceId === "string" ? payload.sourceId.trim() : ""; | |
| 745 | − const sourceName = typeof payload.sourceName === "string" ? payload.sourceName.trim() : ""; | |
| 746 | − | |
| 747 | − const convexUrl = | |
| 748 | − process.env.CONVEX_URL?.trim() | |
| 749 | − || process.env.NEXT_PUBLIC_CONVEX_URL?.trim() | |
| 750 | − || ""; | |
| 751 | − const ingestToken = | |
| 752 | − process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || ""; | |
| 753 | − | |
| 754 | − const shouldIngest = | |
| 755 | − workspaceId.length > 0 | |
| 756 | − && sourceId.length > 0 | |
| 757 | − && sourceName.length > 0 | |
| 758 | − && convexUrl.length > 0; | |
| 759 | − | |
| 760 | − if (shouldIngest && ingestToken.length === 0) { | |
| 761 | − json(response, 503, { | |
| 762 | − ok: false, | |
| 763 | − error: "OPENAPI_INGEST_SERVICE_TOKEN is required for ingest mode", | |
| 764 | − }); | |
| 765 | − return; | |
| 766 | − } | |
| 767 | − | |
| 768 | − if (shouldIngest) { | |
| 769 | − const ingested = await toIngestPayload(openApiSpec, { | |
| 770 | − convexUrl, | |
| 771 | − ingestToken, | |
| 772 | − workspaceId, | |
| 773 | − sourceId, | |
| 774 | − sourceName, | |
| 775 | − sourceEndpoint: specUrl, | |
| 776 | − sourceEnabled: parseSourceEnabled(payload.sourceEnabled), | |
| 777 | − }); | |
| 778 | − | |
| 779 | − json(response, 200, { | |
| 780 | − ok: true, | |
| 781 | − mode: "ingested", | |
| 782 | − specUrl, | |
| 783 | − artifactId: ingested.artifactId, | |
| 784 | − sourceHash: ingested.sourceHash, | |
| 785 | − toolCount: ingested.toolCount, | |
| 786 | − namespace: ingested.namespace, | |
| 787 | − artifactBatchCount: 0, | |
| 788 | − }); | |
| 789 | − return; | |
| 790 | − } | |
| 791 | − | |
| 792 | − json(response, 200, { | |
| 793 | − ok: true, | |
| 794 | − mode: "parsed", | |
| 795 | − specUrl, | |
| 796 | − openApiSpec, | |
| 797 | − }); | |
| 798 | − } catch (cause) { | |
| 799 | − json(response, 500, { | |
| 800 | − ok: false, | |
| 801 | − error: cause instanceof Error ? cause.message : String(cause), | |
| 802 | − }); | |
| 803 | − } | |
| 804 | −}; | |
| 805 | − | |
| 806 | −export const config = { | |
| 807 | − api: { | |
| 808 | − bodyParser: false, | |
| 809 | − }, | |
| 810 | −}; | |
| 811 | − | |
| 812 | −export default parseEndpoint; | |
| @@ -1,19 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "name": "@executor-v2/app-api", | |
| 3 | − "private": true, | |
| 4 | − "type": "module", | |
| 5 | − "scripts": { | |
| 6 | − "dev:disabled": "bunx vercel dev --listen 8788" | |
| 7 | − }, | |
| 8 | − "dependencies": { | |
| 9 | − "convex": "^1.32.0", | |
| 10 | − "yaml": "^2.8.1" | |
| 11 | − }, | |
| 12 | − "devDependencies": { | |
| 13 | − "@effect/vitest": "^0.27.0", | |
| 14 | − "@types/node": "^20.19.9", | |
| 15 | − "effect": "^3.19.8", | |
| 16 | − "typescript": "^5.9.3", | |
| 17 | − "vitest": "^3.2.4" | |
| 18 | − } | |
| 19 | −} | |
| @@ -1,9 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "$schema": "https://openapi.vercel.sh/vercel.json", | |
| 3 | − "rewrites": [ | |
| 4 | − { | |
| 5 | − "source": "/parse-openapi", | |
| 6 | − "destination": "/api/parse-openapi" | |
| 7 | − } | |
| 8 | − ] | |
| 9 | −} | |
| @@ -1,2 +0,0 @@ | ||
| 1 | − | |
| 2 | −.env.local | |
| @@ -1,11 +0,0 @@ | ||
| 1 | −# app-convex | |
| 2 | − | |
| 3 | −Convex remote server app scaffold for Executor v2. | |
| 4 | − | |
| 5 | −Current scaffold includes: | |
| 6 | − Convex schema wiring lives directly in `convex/schema.ts` | |
| 7 | − MCP HTTP endpoint at `GET/POST/DELETE /v1/mcp` via `convex/http.ts` and `convex/mcp.ts` | |
| 8 | − runtime callback endpoint at `POST /v1/runtime/tool-call` | |
| 9 | − control-plane source endpoints at `GET/POST /v1/workspaces/:workspaceId/sources` | |
| 10 | − control-plane source removal endpoint at `DELETE /v1/workspaces/:workspaceId/sources/:sourceId` | |
| 11 | − generated OpenAPI spec endpoint at `GET /v1/openapi.json` | |
| @@ -1,90 +0,0 @@ | ||
| 1 | −# Welcome to your Convex functions directory! | |
| 2 | − | |
| 3 | −Write your Convex functions here. | |
| 4 | −See https://docs.convex.dev/functions for more. | |
| 5 | − | |
| 6 | −A query function that takes two arguments looks like: | |
| 7 | − | |
| 8 | −```ts | |
| 9 | −// convex/myFunctions.ts | |
| 10 | −import { query } from "./_generated/server"; | |
| 11 | −import { v } from "convex/values"; | |
| 12 | − | |
| 13 | −export const myQueryFunction = query({ | |
| 14 | − // Validators for arguments. | |
| 15 | − args: { | |
| 16 | − first: v.number(), | |
| 17 | − second: v.string(), | |
| 18 | − }, | |
| 19 | − | |
| 20 | − // Function implementation. | |
| 21 | − handler: async (ctx, args) => { | |
| 22 | − // Read the database as many times as you need here. | |
| 23 | − // See https://docs.convex.dev/database/reading-data. | |
| 24 | − const documents = await ctx.db.query("tablename").collect(); | |
| 25 | − | |
| 26 | − // Arguments passed from the client are properties of the args object. | |
| 27 | − console.log(args.first, args.second); | |
| 28 | − | |
| 29 | − // Write arbitrary JavaScript here: filter, aggregate, build derived data, | |
| 30 | − // remove non-public properties, or create new objects. | |
| 31 | − return documents; | |
| 32 | − }, | |
| 33 | −}); | |
| 34 | −``` | |
| 35 | − | |
| 36 | −Using this query function in a React component looks like: | |
| 37 | − | |
| 38 | −```ts | |
| 39 | −const data = useQuery(api.myFunctions.myQueryFunction, { | |
| 40 | − first: 10, | |
| 41 | − second: "hello", | |
| 42 | −}); | |
| 43 | −``` | |
| 44 | − | |
| 45 | −A mutation function looks like: | |
| 46 | − | |
| 47 | −```ts | |
| 48 | −// convex/myFunctions.ts | |
| 49 | −import { mutation } from "./_generated/server"; | |
| 50 | −import { v } from "convex/values"; | |
| 51 | − | |
| 52 | −export const myMutationFunction = mutation({ | |
| 53 | − // Validators for arguments. | |
| 54 | − args: { | |
| 55 | − first: v.string(), | |
| 56 | − second: v.string(), | |
| 57 | − }, | |
| 58 | − | |
| 59 | − // Function implementation. | |
| 60 | − handler: async (ctx, args) => { | |
| 61 | − // Insert or modify documents in the database here. | |
| 62 | − // Mutations can also read from the database like queries. | |
| 63 | − // See https://docs.convex.dev/database/writing-data. | |
| 64 | − const message = { body: args.first, author: args.second }; | |
| 65 | − const id = await ctx.db.insert("messages", message); | |
| 66 | − | |
| 67 | − // Optionally, return a value from your mutation. | |
| 68 | − return await ctx.db.get("messages", id); | |
| 69 | − }, | |
| 70 | −}); | |
| 71 | −``` | |
| 72 | − | |
| 73 | −Using this mutation function in a React component looks like: | |
| 74 | − | |
| 75 | −```ts | |
| 76 | −const mutation = useMutation(api.myFunctions.myMutationFunction); | |
| 77 | −function handleButtonPress() { | |
| 78 | − // fire and forget, the most common way to use mutations | |
| 79 | − mutation({ first: "Hello!", second: "me" }); | |
| 80 | − // OR | |
| 81 | − // use the result once the mutation has completed | |
| 82 | − mutation({ first: "Hello!", second: "me" }).then((result) => | |
| 83 | − console.log(result), | |
| 84 | − ); | |
| 85 | −} | |
| 86 | −``` | |
| 87 | − | |
| 88 | −Use the Convex CLI to push your functions to a deployment. See everything | |
| 89 | −the Convex CLI can do by running `npx convex -h` in your project root | |
| 90 | −directory. To learn more, launch the docs with `npx convex docs`. | |
| @@ -1,600 +0,0 @@ | ||
| 1 | −/* eslint-disable */ | |
| 2 | −/** | |
| 3 | − * Generated `api` utility. | |
| 4 | − * | |
| 5 | − * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | − * | |
| 7 | − * To regenerate, run `npx convex dev`. | |
| 8 | − * @module | |
| 9 | − */ | |
| 10 | − | |
| 11 | −import type * as controlPlane from "../controlPlane.js"; | |
| 12 | −import type * as control_plane_actor from "../control_plane/actor.js"; | |
| 13 | −import type * as control_plane_approvals from "../control_plane/approvals.js"; | |
| 14 | −import type * as control_plane_credentials from "../control_plane/credentials.js"; | |
| 15 | −import type * as control_plane_errors from "../control_plane/errors.js"; | |
| 16 | −import type * as control_plane_http from "../control_plane/http.js"; | |
| 17 | −import type * as control_plane_openapi_ingest from "../control_plane/openapi_ingest.js"; | |
| 18 | −import type * as control_plane_organizations from "../control_plane/organizations.js"; | |
| 19 | −import type * as control_plane_policies from "../control_plane/policies.js"; | |
| 20 | −import type * as control_plane_service from "../control_plane/service.js"; | |
| 21 | −import type * as control_plane_sources from "../control_plane/sources.js"; | |
| 22 | −import type * as control_plane_storage from "../control_plane/storage.js"; | |
| 23 | −import type * as control_plane_tool_registry from "../control_plane/tool_registry.js"; | |
| 24 | −import type * as control_plane_tools from "../control_plane/tools.js"; | |
| 25 | −import type * as control_plane_workspaces from "../control_plane/workspaces.js"; | |
| 26 | −import type * as credential_crypto from "../credential_crypto.js"; | |
| 27 | −import type * as executor from "../executor.js"; | |
| 28 | −import type * as http from "../http.js"; | |
| 29 | −import type * as http_security from "../http_security.js"; | |
| 30 | −import type * as mcp from "../mcp.js"; | |
| 31 | −import type * as mcp_auth from "../mcp_auth.js"; | |
| 32 | −import type * as run_executor from "../run_executor.js"; | |
| 33 | −import type * as runtimeCallbacks from "../runtimeCallbacks.js"; | |
| 34 | −import type * as runtime_adapter from "../runtime_adapter.js"; | |
| 35 | −import type * as runtime_execution_port from "../runtime_execution_port.js"; | |
| 36 | −import type * as source_tool_registry from "../source_tool_registry.js"; | |
| 37 | −import type * as task_runs from "../task_runs.js"; | |
| 38 | −import type * as workflow from "../workflow.js"; | |
| 39 | − | |
| 40 | −import type { | |
| 41 | − ApiFromModules, | |
| 42 | − FilterApi, | |
| 43 | − FunctionReference, | |
| 44 | −} from "convex/server"; | |
| 45 | − | |
| 46 | −declare const fullApi: ApiFromModules<{ | |
| 47 | − controlPlane: typeof controlPlane; | |
| 48 | − "control_plane/actor": typeof control_plane_actor; | |
| 49 | − "control_plane/approvals": typeof control_plane_approvals; | |
| 50 | − "control_plane/credentials": typeof control_plane_credentials; | |
| 51 | − "control_plane/errors": typeof control_plane_errors; | |
| 52 | − "control_plane/http": typeof control_plane_http; | |
| 53 | − "control_plane/openapi_ingest": typeof control_plane_openapi_ingest; | |
| 54 | − "control_plane/organizations": typeof control_plane_organizations; | |
| 55 | − "control_plane/policies": typeof control_plane_policies; | |
| 56 | − "control_plane/service": typeof control_plane_service; | |
| 57 | − "control_plane/sources": typeof control_plane_sources; | |
| 58 | − "control_plane/storage": typeof control_plane_storage; | |
| 59 | − "control_plane/tool_registry": typeof control_plane_tool_registry; | |
| 60 | − "control_plane/tools": typeof control_plane_tools; | |
| 61 | − "control_plane/workspaces": typeof control_plane_workspaces; | |
| 62 | − credential_crypto: typeof credential_crypto; | |
| 63 | − executor: typeof executor; | |
| 64 | − http: typeof http; | |
| 65 | − http_security: typeof http_security; | |
| 66 | − mcp: typeof mcp; | |
| 67 | − mcp_auth: typeof mcp_auth; | |
| 68 | − run_executor: typeof run_executor; | |
| 69 | − runtimeCallbacks: typeof runtimeCallbacks; | |
| 70 | − runtime_adapter: typeof runtime_adapter; | |
| 71 | − runtime_execution_port: typeof runtime_execution_port; | |
| 72 | − source_tool_registry: typeof source_tool_registry; | |
| 73 | − task_runs: typeof task_runs; | |
| 74 | − workflow: typeof workflow; | |
| 75 | −}>; | |
| 76 | − | |
| 77 | −/** | |
| 78 | − * A utility for referencing Convex functions in your app's public API. | |
| 79 | − * | |
| 80 | − * Usage: | |
| 81 | − * ```js | |
| 82 | − * const myFunctionReference = api.myModule.myFunction; | |
| 83 | − * ``` | |
| 84 | − */ | |
| 85 | −export declare const api: FilterApi< | |
| 86 | − typeof fullApi, | |
| 87 | − FunctionReference<any, "public"> | |
| 88 | −>; | |
| 89 | − | |
| 90 | −/** | |
| 91 | − * A utility for referencing Convex functions in your app's internal API. | |
| 92 | − * | |
| 93 | − * Usage: | |
| 94 | − * ```js | |
| 95 | − * const myFunctionReference = internal.myModule.myFunction; | |
| 96 | − * ``` | |
| 97 | − */ | |
| 98 | −export declare const internal: FilterApi< | |
| 99 | − typeof fullApi, | |
| 100 | − FunctionReference<any, "internal"> | |
| 101 | −>; | |
| 102 | − | |
| 103 | −export declare const components: { | |
| 104 | − actionCache: { | |
| 105 | − crons: { | |
| 106 | − purge: FunctionReference< | |
| 107 | − "mutation", | |
| 108 | − "internal", | |
| 109 | − { expiresAt?: number }, | |
| 110 | − null | |
| 111 | − >; | |
| 112 | − }; | |
| 113 | − lib: { | |
| 114 | − get: FunctionReference< | |
| 115 | − "query", | |
| 116 | − "internal", | |
| 117 | − { args: any; name: string; ttl: number | null }, | |
| 118 | − { kind: "hit"; value: any } | { expiredEntry?: string; kind: "miss" } | |
| 119 | − >; | |
| 120 | − put: FunctionReference< | |
| 121 | − "mutation", | |
| 122 | − "internal", | |
| 123 | − { | |
| 124 | − args: any; | |
| 125 | − expiredEntry?: string; | |
| 126 | − name: string; | |
| 127 | − ttl: number | null; | |
| 128 | − value: any; | |
| 129 | − }, | |
| 130 | − { cacheHit: boolean; deletedExpiredEntry: boolean } | |
| 131 | − >; | |
| 132 | − remove: FunctionReference< | |
| 133 | − "mutation", | |
| 134 | − "internal", | |
| 135 | − { args: any; name: string }, | |
| 136 | − null | |
| 137 | − >; | |
| 138 | − removeAll: FunctionReference< | |
| 139 | − "mutation", | |
| 140 | − "internal", | |
| 141 | − { batchSize?: number; before?: number; name?: string }, | |
| 142 | − null | |
| 143 | − >; | |
| 144 | − }; | |
| 145 | − }; | |
| 146 | − workflow: { | |
| 147 | − event: { | |
| 148 | − create: FunctionReference< | |
| 149 | − "mutation", | |
| 150 | − "internal", | |
| 151 | − { name: string; workflowId: string }, | |
| 152 | − string | |
| 153 | − >; | |
| 154 | − send: FunctionReference< | |
| 155 | − "mutation", | |
| 156 | − "internal", | |
| 157 | − { | |
| 158 | − eventId?: string; | |
| 159 | − name?: string; | |
| 160 | − result: | |
| 161 | − | { kind: "success"; returnValue: any } | |
| 162 | − | { error: string; kind: "failed" } | |
| 163 | − | { kind: "canceled" }; | |
| 164 | − workflowId?: string; | |
| 165 | − workpoolOptions?: { | |
| 166 | − defaultRetryBehavior?: { | |
| 167 | − base: number; | |
| 168 | − initialBackoffMs: number; | |
| 169 | − maxAttempts: number; | |
| 170 | − }; | |
| 171 | − logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; | |
| 172 | − maxParallelism?: number; | |
| 173 | − retryActionsByDefault?: boolean; | |
| 174 | − }; | |
| 175 | − }, | |
| 176 | − string | |
| 177 | − >; | |
| 178 | − }; | |
| 179 | − journal: { | |
| 180 | − load: FunctionReference< | |
| 181 | − "query", | |
| 182 | − "internal", | |
| 183 | − { shortCircuit?: boolean; workflowId: string }, | |
| 184 | − { | |
| 185 | − blocked?: boolean; | |
| 186 | − journalEntries: Array<{ | |
| 187 | − _creationTime: number; | |
| 188 | − _id: string; | |
| 189 | − step: | |
| 190 | − | { | |
| 191 | − args: any; | |
| 192 | − argsSize: number; | |
| 193 | − completedAt?: number; | |
| 194 | − functionType: "query" | "mutation" | "action"; | |
| 195 | − handle: string; | |
| 196 | − inProgress: boolean; | |
| 197 | − kind?: "function"; | |
| 198 | − name: string; | |
| 199 | − runResult?: | |
| 200 | − | { kind: "success"; returnValue: any } | |
| 201 | − | { error: string; kind: "failed" } | |
| 202 | − | { kind: "canceled" }; | |
| 203 | − startedAt: number; | |
| 204 | − workId?: string; | |
| 205 | − } | |
| 206 | − | { | |
| 207 | − args: any; | |
| 208 | − argsSize: number; | |
| 209 | − completedAt?: number; | |
| 210 | − handle: string; | |
| 211 | − inProgress: boolean; | |
| 212 | − kind: "workflow"; | |
| 213 | − name: string; | |
| 214 | − runResult?: | |
| 215 | − | { kind: "success"; returnValue: any } | |
| 216 | − | { error: string; kind: "failed" } | |
| 217 | − | { kind: "canceled" }; | |
| 218 | − startedAt: number; | |
| 219 | − workflowId?: string; | |
| 220 | − } | |
| 221 | − | { | |
| 222 | − args: { eventId?: string }; | |
| 223 | − argsSize: number; | |
| 224 | − completedAt?: number; | |
| 225 | − eventId?: string; | |
| 226 | − inProgress: boolean; | |
| 227 | − kind: "event"; | |
| 228 | − name: string; | |
| 229 | − runResult?: | |
| 230 | − | { kind: "success"; returnValue: any } | |
| 231 | − | { error: string; kind: "failed" } | |
| 232 | − | { kind: "canceled" }; | |
| 233 | − startedAt: number; | |
| 234 | − }; | |
| 235 | − stepNumber: number; | |
| 236 | − workflowId: string; | |
| 237 | − }>; | |
| 238 | − logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; | |
| 239 | − ok: boolean; | |
| 240 | − workflow: { | |
| 241 | − _creationTime: number; | |
| 242 | − _id: string; | |
| 243 | − args: any; | |
| 244 | − generationNumber: number; | |
| 245 | − logLevel?: any; | |
| 246 | − name?: string; | |
| 247 | − onComplete?: { context?: any; fnHandle: string }; | |
| 248 | − runResult?: | |
| 249 | − | { kind: "success"; returnValue: any } | |
| 250 | − | { error: string; kind: "failed" } | |
| 251 | − | { kind: "canceled" }; | |
| 252 | − startedAt?: any; | |
| 253 | − state?: any; | |
| 254 | − workflowHandle: string; | |
| 255 | − }; | |
| 256 | − } | |
| 257 | − >; | |
| 258 | − startSteps: FunctionReference< | |
| 259 | − "mutation", | |
| 260 | − "internal", | |
| 261 | − { | |
| 262 | − generationNumber: number; | |
| 263 | − steps: Array<{ | |
| 264 | − retry?: | |
| 265 | − | boolean | |
| 266 | − | { base: number; initialBackoffMs: number; maxAttempts: number }; | |
| 267 | − schedulerOptions?: { runAt?: number } | { runAfter?: number }; | |
| 268 | − step: | |
| 269 | − | { | |
| 270 | − args: any; | |
| 271 | − argsSize: number; | |
| 272 | − completedAt?: number; | |
| 273 | − functionType: "query" | "mutation" | "action"; | |
| 274 | − handle: string; | |
| 275 | − inProgress: boolean; | |
| 276 | − kind?: "function"; | |
| 277 | − name: string; | |
| 278 | − runResult?: | |
| 279 | − | { kind: "success"; returnValue: any } | |
| 280 | − | { error: string; kind: "failed" } | |
| 281 | − | { kind: "canceled" }; | |
| 282 | − startedAt: number; | |
| 283 | − workId?: string; | |
| 284 | − } | |
| 285 | − | { | |
| 286 | − args: any; | |
| 287 | − argsSize: number; | |
| 288 | − completedAt?: number; | |
| 289 | − handle: string; | |
| 290 | − inProgress: boolean; | |
| 291 | − kind: "workflow"; | |
| 292 | − name: string; | |
| 293 | − runResult?: | |
| 294 | − | { kind: "success"; returnValue: any } | |
| 295 | − | { error: string; kind: "failed" } | |
| 296 | − | { kind: "canceled" }; | |
| 297 | − startedAt: number; | |
| 298 | − workflowId?: string; | |
| 299 | − } | |
| 300 | − | { | |
| 301 | − args: { eventId?: string }; | |
| 302 | − argsSize: number; | |
| 303 | − completedAt?: number; | |
| 304 | − eventId?: string; | |
| 305 | − inProgress: boolean; | |
| 306 | − kind: "event"; | |
| 307 | − name: string; | |
| 308 | − runResult?: | |
| 309 | − | { kind: "success"; returnValue: any } | |
| 310 | − | { error: string; kind: "failed" } | |
| 311 | − | { kind: "canceled" }; | |
| 312 | − startedAt: number; | |
| 313 | − }; | |
| 314 | − }>; | |
| 315 | − workflowId: string; | |
| 316 | − workpoolOptions?: { | |
| 317 | − defaultRetryBehavior?: { | |
| 318 | − base: number; | |
| 319 | − initialBackoffMs: number; | |
| 320 | − maxAttempts: number; | |
| 321 | − }; | |
| 322 | − logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; | |
| 323 | − maxParallelism?: number; | |
| 324 | − retryActionsByDefault?: boolean; | |
| 325 | − }; | |
| 326 | − }, | |
| 327 | − Array<{ | |
| 328 | − _creationTime: number; | |
| 329 | − _id: string; | |
| 330 | − step: | |
| 331 | − | { | |
| 332 | − args: any; | |
| 333 | − argsSize: number; | |
| 334 | − completedAt?: number; | |
| 335 | − functionType: "query" | "mutation" | "action"; | |
| 336 | − handle: string; | |
| 337 | − inProgress: boolean; | |
| 338 | − kind?: "function"; | |
| 339 | − name: string; | |
| 340 | − runResult?: | |
| 341 | − | { kind: "success"; returnValue: any } | |
| 342 | − | { error: string; kind: "failed" } | |
| 343 | − | { kind: "canceled" }; | |
| 344 | − startedAt: number; | |
| 345 | − workId?: string; | |
| 346 | − } | |
| 347 | − | { | |
| 348 | − args: any; | |
| 349 | − argsSize: number; | |
| 350 | − completedAt?: number; | |
| 351 | − handle: string; | |
| 352 | − inProgress: boolean; | |
| 353 | − kind: "workflow"; | |
| 354 | − name: string; | |
| 355 | − runResult?: | |
| 356 | − | { kind: "success"; returnValue: any } | |
| 357 | − | { error: string; kind: "failed" } | |
| 358 | − | { kind: "canceled" }; | |
| 359 | − startedAt: number; | |
| 360 | − workflowId?: string; | |
| 361 | − } | |
| 362 | − | { | |
| 363 | − args: { eventId?: string }; | |
| 364 | − argsSize: number; | |
| 365 | − completedAt?: number; | |
| 366 | − eventId?: string; | |
| 367 | − inProgress: boolean; | |
| 368 | − kind: "event"; | |
| 369 | − name: string; | |
| 370 | − runResult?: | |
| 371 | − | { kind: "success"; returnValue: any } | |
| 372 | − | { error: string; kind: "failed" } | |
| 373 | − | { kind: "canceled" }; | |
| 374 | − startedAt: number; | |
| 375 | − }; | |
| 376 | − stepNumber: number; | |
| 377 | − workflowId: string; | |
| 378 | − }> | |
| 379 | − >; | |
| 380 | − }; | |
| 381 | − workflow: { | |
| 382 | − cancel: FunctionReference< | |
| 383 | − "mutation", | |
| 384 | − "internal", | |
| 385 | − { workflowId: string }, | |
| 386 | − null | |
| 387 | − >; | |
| 388 | − cleanup: FunctionReference< | |
| 389 | − "mutation", | |
| 390 | − "internal", | |
| 391 | − { workflowId: string }, | |
| 392 | − boolean | |
| 393 | − >; | |
| 394 | − complete: FunctionReference< | |
| 395 | − "mutation", | |
| 396 | − "internal", | |
| 397 | − { | |
| 398 | − generationNumber: number; | |
| 399 | − runResult: | |
| 400 | − | { kind: "success"; returnValue: any } | |
| 401 | − | { error: string; kind: "failed" } | |
| 402 | − | { kind: "canceled" }; | |
| 403 | − workflowId: string; | |
| 404 | − }, | |
| 405 | − null | |
| 406 | − >; | |
| 407 | − create: FunctionReference< | |
| 408 | − "mutation", | |
| 409 | − "internal", | |
| 410 | − { | |
| 411 | − maxParallelism?: number; | |
| 412 | − onComplete?: { context?: any; fnHandle: string }; | |
| 413 | − startAsync?: boolean; | |
| 414 | − workflowArgs: any; | |
| 415 | − workflowHandle: string; | |
| 416 | − workflowName: string; | |
| 417 | − }, | |
| 418 | − string | |
| 419 | − >; | |
| 420 | − getStatus: FunctionReference< | |
| 421 | − "query", | |
| 422 | − "internal", | |
| 423 | − { workflowId: string }, | |
| 424 | − { | |
| 425 | − inProgress: Array<{ | |
| 426 | − _creationTime: number; | |
| 427 | − _id: string; | |
| 428 | − step: | |
| 429 | − | { | |
| 430 | − args: any; | |
| 431 | − argsSize: number; | |
| 432 | − completedAt?: number; | |
| 433 | − functionType: "query" | "mutation" | "action"; | |
| 434 | − handle: string; | |
| 435 | − inProgress: boolean; | |
| 436 | − kind?: "function"; | |
| 437 | − name: string; | |
| 438 | − runResult?: | |
| 439 | − | { kind: "success"; returnValue: any } | |
| 440 | − | { error: string; kind: "failed" } | |
| 441 | − | { kind: "canceled" }; | |
| 442 | − startedAt: number; | |
| 443 | − workId?: string; | |
| 444 | − } | |
| 445 | − | { | |
| 446 | − args: any; | |
| 447 | − argsSize: number; | |
| 448 | − completedAt?: number; | |
| 449 | − handle: string; | |
| 450 | − inProgress: boolean; | |
| 451 | − kind: "workflow"; | |
| 452 | − name: string; | |
| 453 | − runResult?: | |
| 454 | − | { kind: "success"; returnValue: any } | |
| 455 | − | { error: string; kind: "failed" } | |
| 456 | − | { kind: "canceled" }; | |
| 457 | − startedAt: number; | |
| 458 | − workflowId?: string; | |
| 459 | − } | |
| 460 | − | { | |
| 461 | − args: { eventId?: string }; | |
| 462 | − argsSize: number; | |
| 463 | − completedAt?: number; | |
| 464 | − eventId?: string; | |
| 465 | − inProgress: boolean; | |
| 466 | − kind: "event"; | |
| 467 | − name: string; | |
| 468 | − runResult?: | |
| 469 | − | { kind: "success"; returnValue: any } | |
| 470 | − | { error: string; kind: "failed" } | |
| 471 | − | { kind: "canceled" }; | |
| 472 | − startedAt: number; | |
| 473 | − }; | |
| 474 | − stepNumber: number; | |
| 475 | − workflowId: string; | |
| 476 | − }>; | |
| 477 | − logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR"; | |
| 478 | − workflow: { | |
| 479 | − _creationTime: number; | |
| 480 | − _id: string; | |
| 481 | − args: any; | |
| 482 | − generationNumber: number; | |
| 483 | − logLevel?: any; | |
| 484 | − name?: string; | |
| 485 | − onComplete?: { context?: any; fnHandle: string }; | |
| 486 | − runResult?: | |
| 487 | − | { kind: "success"; returnValue: any } | |
| 488 | − | { error: string; kind: "failed" } | |
| 489 | − | { kind: "canceled" }; | |
| 490 | − startedAt?: any; | |
| 491 | − state?: any; | |
| 492 | − workflowHandle: string; | |
| 493 | − }; | |
| 494 | − } | |
| 495 | − >; | |
| 496 | − list: FunctionReference< | |
| 497 | − "query", | |
| 498 | − "internal", | |
| 499 | − { | |
| 500 | − order: "asc" | "desc"; | |
| 501 | − paginationOpts: { | |
| 502 | − cursor: string | null; | |
| 503 | − endCursor?: string | null; | |
| 504 | − id?: number; | |
| 505 | − maximumBytesRead?: number; | |
| 506 | − maximumRowsRead?: number; | |
| 507 | − numItems: number; | |
| 508 | − }; | |
| 509 | − }, | |
| 510 | − { | |
| 511 | − continueCursor: string; | |
| 512 | − isDone: boolean; | |
| 513 | − page: Array<{ | |
| 514 | − args: any; | |
| 515 | − context?: any; | |
| 516 | − name?: string; | |
| 517 | − runResult?: | |
| 518 | − | { kind: "success"; returnValue: any } | |
| 519 | − | { error: string; kind: "failed" } | |
| 520 | − | { kind: "canceled" }; | |
| 521 | − workflowId: string; | |
| 522 | − }>; | |
| 523 | − pageStatus?: "SplitRecommended" | "SplitRequired" | null; | |
| 524 | − splitCursor?: string | null; | |
| 525 | − } | |
| 526 | − >; | |
| 527 | − listByName: FunctionReference< | |
| 528 | − "query", | |
| 529 | − "internal", | |
| 530 | − { | |
| 531 | − name: string; | |
| 532 | − order: "asc" | "desc"; | |
| 533 | − paginationOpts: { | |
| 534 | − cursor: string | null; | |
| 535 | − endCursor?: string | null; | |
| 536 | − id?: number; | |
| 537 | − maximumBytesRead?: number; | |
| 538 | − maximumRowsRead?: number; | |
| 539 | − numItems: number; | |
| 540 | − }; | |
| 541 | − }, | |
| 542 | − { | |
| 543 | − continueCursor: string; | |
| 544 | − isDone: boolean; | |
| 545 | − page: Array<{ | |
| 546 | − args: any; | |
| 547 | − context?: any; | |
| 548 | − name?: string; | |
| 549 | − runResult?: | |
| 550 | − | { kind: "success"; returnValue: any } | |
| 551 | − | { error: string; kind: "failed" } | |
| 552 | − | { kind: "canceled" }; | |
| 553 | − workflowId: string; | |
| 554 | − }>; | |
| 555 | − pageStatus?: "SplitRecommended" | "SplitRequired" | null; | |
| 556 | − splitCursor?: string | null; | |
| 557 | − } | |
| 558 | − >; | |
| 559 | − listSteps: FunctionReference< | |
| 560 | − "query", | |
| 561 | − "internal", | |
| 562 | − { | |
| 563 | − order: "asc" | "desc"; | |
| 564 | − paginationOpts: { | |
| 565 | − cursor: string | null; | |
| 566 | − endCursor?: string | null; | |
| 567 | − id?: number; | |
| 568 | − maximumBytesRead?: number; | |
| 569 | − maximumRowsRead?: number; | |
| 570 | − numItems: number; | |
| 571 | − }; | |
| 572 | − workflowId: string; | |
| 573 | − }, | |
| 574 | − { | |
| 575 | − continueCursor: string; | |
| 576 | − isDone: boolean; | |
| 577 | − page: Array<{ | |
| 578 | − args: any; | |
| 579 | − completedAt?: number; | |
| 580 | − eventId?: string; | |
| 581 | − kind: "function" | "workflow" | "event"; | |
| 582 | − name: string; | |
| 583 | − nestedWorkflowId?: string; | |
| 584 | − runResult?: | |
| 585 | − | { kind: "success"; returnValue: any } | |
| 586 | − | { error: string; kind: "failed" } | |
| 587 | − | { kind: "canceled" }; | |
| 588 | − startedAt: number; | |
| 589 | − stepId: string; | |
| 590 | − stepNumber: number; | |
| 591 | − workId?: string; | |
| 592 | − workflowId: string; | |
| 593 | − }>; | |
| 594 | − pageStatus?: "SplitRecommended" | "SplitRequired" | null; | |
| 595 | − splitCursor?: string | null; | |
| 596 | − } | |
| 597 | − >; | |
| 598 | − }; | |
| 599 | − }; | |
| 600 | −}; | |
| @@ -1,23 +0,0 @@ | ||
| 1 | −/* eslint-disable */ | |
| 2 | −/** | |
| 3 | − * Generated `api` utility. | |
| 4 | − * | |
| 5 | − * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | − * | |
| 7 | − * To regenerate, run `npx convex dev`. | |
| 8 | − * @module | |
| 9 | − */ | |
| 10 | − | |
| 11 | −import { anyApi, componentsGeneric } from "convex/server"; | |
| 12 | − | |
| 13 | −/** | |
| 14 | − * A utility for referencing Convex functions in your app's API. | |
| 15 | − * | |
| 16 | − * Usage: | |
| 17 | − * ```js | |
| 18 | − * const myFunctionReference = api.myModule.myFunction; | |
| 19 | − * ``` | |
| 20 | − */ | |
| 21 | −export const api = anyApi; | |
| 22 | −export const internal = anyApi; | |
| 23 | −export const components = componentsGeneric(); | |
| @@ -1,60 +0,0 @@ | ||
| 1 | −/* eslint-disable */ | |
| 2 | −/** | |
| 3 | − * Generated data model types. | |
| 4 | − * | |
| 5 | − * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | − * | |
| 7 | − * To regenerate, run `npx convex dev`. | |
| 8 | − * @module | |
| 9 | − */ | |
| 10 | − | |
| 11 | −import type { | |
| 12 | − DataModelFromSchemaDefinition, | |
| 13 | − DocumentByName, | |
| 14 | − TableNamesInDataModel, | |
| 15 | − SystemTableNames, | |
| 16 | −} from "convex/server"; | |
| 17 | −import type { GenericId } from "convex/values"; | |
| 18 | −import schema from "../schema.js"; | |
| 19 | − | |
| 20 | −/** | |
| 21 | − * The names of all of your Convex tables. | |
| 22 | − */ | |
| 23 | −export type TableNames = TableNamesInDataModel<DataModel>; | |
| 24 | − | |
| 25 | −/** | |
| 26 | − * The type of a document stored in Convex. | |
| 27 | − * | |
| 28 | − * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 29 | − */ | |
| 30 | −export type Doc<TableName extends TableNames> = DocumentByName< | |
| 31 | − DataModel, | |
| 32 | − TableName | |
| 33 | −>; | |
| 34 | − | |
| 35 | −/** | |
| 36 | − * An identifier for a document in Convex. | |
| 37 | − * | |
| 38 | − * Convex documents are uniquely identified by their `Id`, which is accessible | |
| 39 | − * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). | |
| 40 | − * | |
| 41 | − * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. | |
| 42 | − * | |
| 43 | − * IDs are just strings at runtime, but this type can be used to distinguish them from other | |
| 44 | − * strings when type checking. | |
| 45 | − * | |
| 46 | − * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 47 | − */ | |
| 48 | −export type Id<TableName extends TableNames | SystemTableNames> = | |
| 49 | − GenericId<TableName>; | |
| 50 | − | |
| 51 | −/** | |
| 52 | − * A type describing your Convex data model. | |
| 53 | − * | |
| 54 | − * This type includes information about what tables you have, the type of | |
| 55 | − * documents stored in those tables, and the indexes defined on them. | |
| 56 | − * | |
| 57 | − * This type is used to parameterize methods like `queryGeneric` and | |
| 58 | − * `mutationGeneric` to make them type-safe. | |
| 59 | − */ | |
| 60 | −export type DataModel = DataModelFromSchemaDefinition<typeof schema>; | |
| @@ -1,143 +0,0 @@ | ||
| 1 | −/* eslint-disable */ | |
| 2 | −/** | |
| 3 | − * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | − * | |
| 5 | − * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | − * | |
| 7 | − * To regenerate, run `npx convex dev`. | |
| 8 | − * @module | |
| 9 | − */ | |
| 10 | − | |
| 11 | −import { | |
| 12 | − ActionBuilder, | |
| 13 | − HttpActionBuilder, | |
| 14 | − MutationBuilder, | |
| 15 | − QueryBuilder, | |
| 16 | − GenericActionCtx, | |
| 17 | − GenericMutationCtx, | |
| 18 | − GenericQueryCtx, | |
| 19 | − GenericDatabaseReader, | |
| 20 | − GenericDatabaseWriter, | |
| 21 | −} from "convex/server"; | |
| 22 | −import type { DataModel } from "./dataModel.js"; | |
| 23 | − | |
| 24 | −/** | |
| 25 | − * Define a query in this Convex app's public API. | |
| 26 | − * | |
| 27 | − * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 28 | − * | |
| 29 | − * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 30 | − * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 31 | − */ | |
| 32 | −export declare const query: QueryBuilder<DataModel, "public">; | |
| 33 | − | |
| 34 | −/** | |
| 35 | − * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 36 | − * | |
| 37 | − * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 38 | − * | |
| 39 | − * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 40 | − * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 41 | − */ | |
| 42 | −export declare const internalQuery: QueryBuilder<DataModel, "internal">; | |
| 43 | − | |
| 44 | −/** | |
| 45 | − * Define a mutation in this Convex app's public API. | |
| 46 | − * | |
| 47 | − * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 48 | − * | |
| 49 | − * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 50 | − * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 51 | − */ | |
| 52 | −export declare const mutation: MutationBuilder<DataModel, "public">; | |
| 53 | − | |
| 54 | −/** | |
| 55 | − * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 56 | − * | |
| 57 | − * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 58 | − * | |
| 59 | − * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 60 | − * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 61 | − */ | |
| 62 | −export declare const internalMutation: MutationBuilder<DataModel, "internal">; | |
| 63 | − | |
| 64 | −/** | |
| 65 | − * Define an action in this Convex app's public API. | |
| 66 | − * | |
| 67 | − * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 68 | − * code and code with side-effects, like calling third-party services. | |
| 69 | − * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 70 | − * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 71 | − * | |
| 72 | − * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 73 | − * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 74 | − */ | |
| 75 | −export declare const action: ActionBuilder<DataModel, "public">; | |
| 76 | − | |
| 77 | −/** | |
| 78 | − * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 79 | − * | |
| 80 | − * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 81 | − * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 82 | − */ | |
| 83 | −export declare const internalAction: ActionBuilder<DataModel, "internal">; | |
| 84 | − | |
| 85 | −/** | |
| 86 | − * Define an HTTP action. | |
| 87 | − * | |
| 88 | − * The wrapped function will be used to respond to HTTP requests received | |
| 89 | − * by a Convex deployment if the requests matches the path and method where | |
| 90 | − * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 91 | − * | |
| 92 | − * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 93 | − * and a Fetch API `Request` object as its second. | |
| 94 | − * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 95 | − */ | |
| 96 | −export declare const httpAction: HttpActionBuilder; | |
| 97 | − | |
| 98 | −/** | |
| 99 | − * A set of services for use within Convex query functions. | |
| 100 | − * | |
| 101 | − * The query context is passed as the first argument to any Convex query | |
| 102 | − * function run on the server. | |
| 103 | − * | |
| 104 | − * This differs from the {@link MutationCtx} because all of the services are | |
| 105 | − * read-only. | |
| 106 | − */ | |
| 107 | −export type QueryCtx = GenericQueryCtx<DataModel>; | |
| 108 | − | |
| 109 | −/** | |
| 110 | − * A set of services for use within Convex mutation functions. | |
| 111 | − * | |
| 112 | − * The mutation context is passed as the first argument to any Convex mutation | |
| 113 | − * function run on the server. | |
| 114 | − */ | |
| 115 | −export type MutationCtx = GenericMutationCtx<DataModel>; | |
| 116 | − | |
| 117 | −/** | |
| 118 | − * A set of services for use within Convex action functions. | |
| 119 | − * | |
| 120 | − * The action context is passed as the first argument to any Convex action | |
| 121 | − * function run on the server. | |
| 122 | − */ | |
| 123 | −export type ActionCtx = GenericActionCtx<DataModel>; | |
| 124 | − | |
| 125 | −/** | |
| 126 | − * An interface to read from the database within Convex query functions. | |
| 127 | − * | |
| 128 | − * The two entry points are {@link DatabaseReader.get}, which fetches a single | |
| 129 | − * document by its {@link Id}, or {@link DatabaseReader.query}, which starts | |
| 130 | − * building a query. | |
| 131 | − */ | |
| 132 | −export type DatabaseReader = GenericDatabaseReader<DataModel>; | |
| 133 | − | |
| 134 | −/** | |
| 135 | − * An interface to read from and write to the database within Convex mutation | |
| 136 | − * functions. | |
| 137 | − * | |
| 138 | − * Convex guarantees that all writes within a single mutation are | |
| 139 | − * executed atomically, so you never have to worry about partial writes leaving | |
| 140 | − * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) | |
| 141 | − * for the guarantees Convex provides your functions. | |
| 142 | − */ | |
| 143 | −export type DatabaseWriter = GenericDatabaseWriter<DataModel>; | |
| @@ -1,93 +0,0 @@ | ||
| 1 | −/* eslint-disable */ | |
| 2 | −/** | |
| 3 | − * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | − * | |
| 5 | − * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | − * | |
| 7 | − * To regenerate, run `npx convex dev`. | |
| 8 | − * @module | |
| 9 | − */ | |
| 10 | − | |
| 11 | −import { | |
| 12 | − actionGeneric, | |
| 13 | − httpActionGeneric, | |
| 14 | − queryGeneric, | |
| 15 | − mutationGeneric, | |
| 16 | − internalActionGeneric, | |
| 17 | − internalMutationGeneric, | |
| 18 | − internalQueryGeneric, | |
| 19 | −} from "convex/server"; | |
| 20 | − | |
| 21 | −/** | |
| 22 | − * Define a query in this Convex app's public API. | |
| 23 | − * | |
| 24 | − * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 25 | − * | |
| 26 | − * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 27 | − * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 28 | − */ | |
| 29 | −export const query = queryGeneric; | |
| 30 | − | |
| 31 | −/** | |
| 32 | − * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 33 | − * | |
| 34 | − * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 35 | − * | |
| 36 | − * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 37 | − * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 38 | − */ | |
| 39 | −export const internalQuery = internalQueryGeneric; | |
| 40 | − | |
| 41 | −/** | |
| 42 | − * Define a mutation in this Convex app's public API. | |
| 43 | − * | |
| 44 | − * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 45 | − * | |
| 46 | − * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 47 | − * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 48 | − */ | |
| 49 | −export const mutation = mutationGeneric; | |
| 50 | − | |
| 51 | −/** | |
| 52 | − * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 53 | − * | |
| 54 | − * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 55 | − * | |
| 56 | − * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 57 | − * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 58 | − */ | |
| 59 | −export const internalMutation = internalMutationGeneric; | |
| 60 | − | |
| 61 | −/** | |
| 62 | − * Define an action in this Convex app's public API. | |
| 63 | − * | |
| 64 | − * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 65 | − * code and code with side-effects, like calling third-party services. | |
| 66 | − * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 67 | − * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 68 | − * | |
| 69 | − * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 70 | − * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 71 | − */ | |
| 72 | −export const action = actionGeneric; | |
| 73 | − | |
| 74 | −/** | |
| 75 | − * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 76 | − * | |
| 77 | − * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 78 | − * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 79 | − */ | |
| 80 | −export const internalAction = internalActionGeneric; | |
| 81 | − | |
| 82 | −/** | |
| 83 | − * Define an HTTP action. | |
| 84 | − * | |
| 85 | − * The wrapped function will be used to respond to HTTP requests received | |
| 86 | − * by a Convex deployment if the requests matches the path and method where | |
| 87 | − * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 88 | − * | |
| 89 | − * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 90 | − * and a Fetch API `Request` object as its second. | |
| 91 | − * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 92 | − */ | |
| 93 | −export const httpAction = httpActionGeneric; | |
| @@ -1,33 +0,0 @@ | ||
| 1 | −import type { AuthConfig } from "convex/server"; | |
| 2 | − | |
| 3 | −const trim = (value: string | undefined): string | undefined => { | |
| 4 | − const candidate = value?.trim(); | |
| 5 | − return candidate && candidate.length > 0 ? candidate : undefined; | |
| 6 | −}; | |
| 7 | − | |
| 8 | −const rawClientId = trim(process.env.WORKOS_CLIENT_ID); | |
| 9 | −const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined; | |
| 10 | − | |
| 11 | −const providers: AuthConfig["providers"] = clientId | |
| 12 | − ? [ | |
| 13 | − { | |
| 14 | − type: "customJwt", | |
| 15 | − issuer: "https://api.workos.com/", | |
| 16 | − algorithm: "RS256", | |
| 17 | − applicationID: clientId, | |
| 18 | − jwks: `https://api.workos.com/sso/jwks/${clientId}`, | |
| 19 | − }, | |
| 20 | − { | |
| 21 | − type: "customJwt", | |
| 22 | − issuer: `https://api.workos.com/user_management/${clientId}`, | |
| 23 | − algorithm: "RS256", | |
| 24 | − jwks: `https://api.workos.com/sso/jwks/${clientId}`, | |
| 25 | − }, | |
| 26 | − ] | |
| 27 | − : []; | |
| 28 | − | |
| 29 | −const authConfig: AuthConfig = { | |
| 30 | − providers, | |
| 31 | −}; | |
| 32 | − | |
| 33 | −export default authConfig; | |
| @@ -1,72 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − listApprovals as listApprovalsImpl, | |
| 3 | − resolveApproval as resolveApprovalImpl, | |
| 4 | −} from "./control_plane/approvals"; | |
| 5 | −import { | |
| 6 | − listCredentialBindings as listCredentialBindingsImpl, | |
| 7 | − removeCredentialBinding as removeCredentialBindingImpl, | |
| 8 | − upsertCredentialBinding as upsertCredentialBindingImpl, | |
| 9 | −} from "./control_plane/credentials"; | |
| 10 | −import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http"; | |
| 11 | −import { | |
| 12 | − listOrganizations as listOrganizationsImpl, | |
| 13 | − upsertOrganization as upsertOrganizationImpl, | |
| 14 | −} from "./control_plane/organizations"; | |
| 15 | −import { | |
| 16 | − listPolicies as listPoliciesImpl, | |
| 17 | − removePolicy as removePolicyImpl, | |
| 18 | − upsertPolicy as upsertPolicyImpl, | |
| 19 | −} from "./control_plane/policies"; | |
| 20 | −import { | |
| 21 | − listSources as listSourcesImpl, | |
| 22 | − removeSource as removeSourceImpl, | |
| 23 | − upsertSource as upsertSourceImpl, | |
| 24 | −} from "./control_plane/sources"; | |
| 25 | −import { | |
| 26 | − closeStorageInstance as closeStorageInstanceImpl, | |
| 27 | − listStorageDirectory as listStorageDirectoryImpl, | |
| 28 | − listStorageInstances as listStorageInstancesImpl, | |
| 29 | − listStorageKv as listStorageKvImpl, | |
| 30 | − openStorageInstance as openStorageInstanceImpl, | |
| 31 | − queryStorageSql as queryStorageSqlImpl, | |
| 32 | − readStorageFile as readStorageFileImpl, | |
| 33 | − removeStorageInstance as removeStorageInstanceImpl, | |
| 34 | −} from "./control_plane/storage"; | |
| 35 | −import { | |
| 36 | − getToolDetail as getToolDetailImpl, | |
| 37 | − listSourceTools as listSourceToolsImpl, | |
| 38 | − listWorkspaceTools as listWorkspaceToolsImpl, | |
| 39 | −} from "./control_plane/tools"; | |
| 40 | −import { | |
| 41 | − listWorkspaces as listWorkspacesImpl, | |
| 42 | − upsertWorkspace as upsertWorkspaceImpl, | |
| 43 | −} from "./control_plane/workspaces"; | |
| 44 | − | |
| 45 | −export const listSources = listSourcesImpl; | |
| 46 | −export const upsertSource = upsertSourceImpl; | |
| 47 | −export const removeSource = removeSourceImpl; | |
| 48 | −export const listCredentialBindings = listCredentialBindingsImpl; | |
| 49 | −export const upsertCredentialBinding = upsertCredentialBindingImpl; | |
| 50 | −export const removeCredentialBinding = removeCredentialBindingImpl; | |
| 51 | −export const listPolicies = listPoliciesImpl; | |
| 52 | −export const upsertPolicy = upsertPolicyImpl; | |
| 53 | −export const removePolicy = removePolicyImpl; | |
| 54 | −export const listOrganizations = listOrganizationsImpl; | |
| 55 | −export const upsertOrganization = upsertOrganizationImpl; | |
| 56 | −export const listWorkspaces = listWorkspacesImpl; | |
| 57 | −export const upsertWorkspace = upsertWorkspaceImpl; | |
| 58 | −export const listWorkspaceTools = listWorkspaceToolsImpl; | |
| 59 | −export const listSourceTools = listSourceToolsImpl; | |
| 60 | −export const getToolDetail = getToolDetailImpl; | |
| 61 | −export const listStorageInstances = listStorageInstancesImpl; | |
| 62 | −export const openStorageInstance = openStorageInstanceImpl; | |
| 63 | −export const closeStorageInstance = closeStorageInstanceImpl; | |
| 64 | −export const removeStorageInstance = removeStorageInstanceImpl; | |
| 65 | −export const listStorageDirectory = listStorageDirectoryImpl; | |
| 66 | −export const readStorageFile = readStorageFileImpl; | |
| 67 | −export const listStorageKv = listStorageKvImpl; | |
| 68 | −export const queryStorageSql = queryStorageSqlImpl; | |
| 69 | −export const listApprovals = listApprovalsImpl; | |
| 70 | −export const resolveApproval = resolveApprovalImpl; | |
| 71 | − | |
| 72 | −export const controlPlaneHttpHandler = controlPlaneHttpHandlerImpl; | |
| @@ -1,403 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − ControlPlaneActorResolverLive, | |
| 3 | − deriveWorkspaceMembershipsForPrincipal, | |
| 4 | −} from "@executor-v2/management-api"; | |
| 5 | −import { ActorUnauthenticatedError, makeActor } from "@executor-v2/domain"; | |
| 6 | −import { | |
| 7 | − OrganizationMembershipSchema, | |
| 8 | − PrincipalSchema, | |
| 9 | − type Principal, | |
| 10 | − WorkspaceSchema, | |
| 11 | − type OrganizationMembership, | |
| 12 | − type Workspace, | |
| 13 | −} from "@executor-v2/schema"; | |
| 14 | −import { type UserIdentity } from "convex/server"; | |
| 15 | −import { v } from "convex/values"; | |
| 16 | −import * as Effect from "effect/Effect"; | |
| 17 | −import * as Schema from "effect/Schema"; | |
| 18 | − | |
| 19 | −import { internal } from "../_generated/api"; | |
| 20 | −import { | |
| 21 | − internalMutation, | |
| 22 | − internalQuery, | |
| 23 | − type ActionCtx, | |
| 24 | −} from "../_generated/server"; | |
| 25 | − | |
| 26 | −const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); | |
| 27 | −const decodeOrganizationMembership = Schema.decodeUnknownSync( | |
| 28 | − OrganizationMembershipSchema, | |
| 29 | −); | |
| 30 | −const decodePrincipal = Schema.decodeUnknownSync(PrincipalSchema); | |
| 31 | − | |
| 32 | −const stripConvexSystemFields = ( | |
| 33 | − value: Record<string, unknown>, | |
| 34 | −): Record<string, unknown> => { | |
| 35 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 36 | − return rest; | |
| 37 | −}; | |
| 38 | − | |
| 39 | −const toOrganizationSlug = (accountId: string): string => { | |
| 40 | − const slug = accountId | |
| 41 | − .trim() | |
| 42 | − .toLowerCase() | |
| 43 | − .replace(/[^a-z0-9]+/g, "-") | |
| 44 | − .replace(/^-+/, "") | |
| 45 | − .replace(/-+$/, ""); | |
| 46 | − | |
| 47 | − return slug.length > 0 ? `acct-${slug}` : `acct-${crypto.randomUUID().slice(0, 8)}`; | |
| 48 | −}; | |
| 49 | − | |
| 50 | −export const getWorkspaceForActor = internalQuery({ | |
| 51 | − args: { | |
| 52 | − workspaceId: v.string(), | |
| 53 | − }, | |
| 54 | − handler: async (ctx, args): Promise<Workspace | null> => { | |
| 55 | − const row = await ctx.db | |
| 56 | − .query("workspaces") | |
| 57 | − .withIndex("by_domainId", (q) => q.eq("id", args.workspaceId)) | |
| 58 | − .first(); | |
| 59 | − | |
| 60 | − if (row === null) { | |
| 61 | − return null; | |
| 62 | − } | |
| 63 | − | |
| 64 | − return decodeWorkspace( | |
| 65 | − stripConvexSystemFields(row as unknown as Record<string, unknown>), | |
| 66 | − ); | |
| 67 | − }, | |
| 68 | −}); | |
| 69 | − | |
| 70 | −export const listOrganizationMembershipsForActor = internalQuery({ | |
| 71 | − args: { | |
| 72 | − accountId: v.string(), | |
| 73 | − }, | |
| 74 | − handler: async (ctx, args): Promise<ReadonlyArray<OrganizationMembership>> => { | |
| 75 | − const rows = await ctx.db | |
| 76 | − .query("organizationMemberships") | |
| 77 | − .withIndex("by_accountId", (q) => q.eq("accountId", args.accountId)) | |
| 78 | − .collect(); | |
| 79 | − | |
| 80 | − return rows.map((row) => | |
| 81 | − decodeOrganizationMembership( | |
| 82 | − stripConvexSystemFields(row as unknown as Record<string, unknown>), | |
| 83 | − ), | |
| 84 | − ); | |
| 85 | − }, | |
| 86 | −}); | |
| 87 | − | |
| 88 | −export const listWorkspacesForActor = internalQuery({ | |
| 89 | − args: { | |
| 90 | − accountId: v.string(), | |
| 91 | − organizationIds: v.array(v.string()), | |
| 92 | − }, | |
| 93 | − handler: async (ctx, args): Promise<ReadonlyArray<Workspace>> => { | |
| 94 | − const ownRows = await ctx.db | |
| 95 | − .query("workspaces") | |
| 96 | − .withIndex("by_createdByAccountId", (q) => q.eq("createdByAccountId", args.accountId)) | |
| 97 | − .collect(); | |
| 98 | − | |
| 99 | − const organizationRows = await Promise.all( | |
| 100 | − args.organizationIds.map((organizationId) => | |
| 101 | − ctx.db | |
| 102 | − .query("workspaces") | |
| 103 | − .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId)) | |
| 104 | − .collect(), | |
| 105 | − ), | |
| 106 | − ); | |
| 107 | − | |
| 108 | − const workspaces = [...ownRows, ...organizationRows.flat()].map((row) => | |
| 109 | − decodeWorkspace(stripConvexSystemFields(row as unknown as Record<string, unknown>)), | |
| 110 | − ); | |
| 111 | − | |
| 112 | − return Array.from(new Map(workspaces.map((workspace) => [workspace.id, workspace])).values()); | |
| 113 | − }, | |
| 114 | −}); | |
| 115 | − | |
| 116 | −export const ensureWorkspaceForActor = internalMutation({ | |
| 117 | − args: { | |
| 118 | − workspaceId: v.string(), | |
| 119 | − accountId: v.string(), | |
| 120 | − }, | |
| 121 | − handler: async (ctx, args): Promise<Workspace> => { | |
| 122 | − const now = Date.now(); | |
| 123 | − | |
| 124 | − const ensureOrganizationMembership = async (organizationId: string) => { | |
| 125 | − const existingOrganization = await ctx.db | |
| 126 | − .query("organizations") | |
| 127 | − .withIndex("by_domainId", (q) => q.eq("id", organizationId)) | |
| 128 | − .first(); | |
| 129 | − | |
| 130 | − if (existingOrganization === null) { | |
| 131 | − await ctx.db.insert("organizations", { | |
| 132 | − id: organizationId, | |
| 133 | − slug: toOrganizationSlug(args.accountId), | |
| 134 | − name: `${args.accountId} Organization`, | |
| 135 | − status: "active", | |
| 136 | − createdByAccountId: args.accountId, | |
| 137 | − createdAt: now, | |
| 138 | − updatedAt: now, | |
| 139 | − }); | |
| 140 | − } | |
| 141 | − | |
| 142 | − const existingMembership = await ctx.db | |
| 143 | − .query("organizationMemberships") | |
| 144 | − .withIndex("by_organizationId_accountId", (q) => | |
| 145 | − q.eq("organizationId", organizationId).eq("accountId", args.accountId) | |
| 146 | − ) | |
| 147 | − .first(); | |
| 148 | − | |
| 149 | − if (existingMembership !== null) { | |
| 150 | − return; | |
| 151 | − } | |
| 152 | − | |
| 153 | − await ctx.db.insert("organizationMemberships", { | |
| 154 | − id: `org_member_${crypto.randomUUID()}`, | |
| 155 | − organizationId, | |
| 156 | − accountId: args.accountId, | |
| 157 | − role: "owner", | |
| 158 | − status: "active", | |
| 159 | − billable: false, | |
| 160 | − invitedByAccountId: null, | |
| 161 | − joinedAt: now, | |
| 162 | − createdAt: now, | |
| 163 | − updatedAt: now, | |
| 164 | − }); | |
| 165 | − }; | |
| 166 | − | |
| 167 | − const existing = await ctx.db | |
| 168 | − .query("workspaces") | |
| 169 | − .withIndex("by_domainId", (q) => q.eq("id", args.workspaceId)) | |
| 170 | − .first(); | |
| 171 | − | |
| 172 | − if (existing !== null) { | |
| 173 | − const existingWorkspace = decodeWorkspace( | |
| 174 | − stripConvexSystemFields(existing as unknown as Record<string, unknown>), | |
| 175 | − ); | |
| 176 | − | |
| 177 | − await ensureOrganizationMembership(existingWorkspace.organizationId); | |
| 178 | − | |
| 179 | − if (existingWorkspace.createdByAccountId !== null) { | |
| 180 | − return existingWorkspace; | |
| 181 | − } | |
| 182 | − | |
| 183 | − await ctx.db.patch(existing._id, { | |
| 184 | − createdByAccountId: args.accountId, | |
| 185 | − updatedAt: now, | |
| 186 | − }); | |
| 187 | − | |
| 188 | − return decodeWorkspace({ | |
| 189 | − ...existingWorkspace, | |
| 190 | − createdByAccountId: args.accountId, | |
| 191 | − updatedAt: now, | |
| 192 | − }); | |
| 193 | − } | |
| 194 | − | |
| 195 | − const organizationId = `org_${args.accountId}`; | |
| 196 | − await ensureOrganizationMembership(organizationId); | |
| 197 | − | |
| 198 | − await ctx.db.insert("workspaces", { | |
| 199 | − id: args.workspaceId, | |
| 200 | − organizationId, | |
| 201 | − name: args.workspaceId, | |
| 202 | − createdByAccountId: args.accountId, | |
| 203 | − createdAt: now, | |
| 204 | − updatedAt: now, | |
| 205 | − }); | |
| 206 | − | |
| 207 | − return decodeWorkspace({ | |
| 208 | − id: args.workspaceId, | |
| 209 | − organizationId, | |
| 210 | − name: args.workspaceId, | |
| 211 | − createdByAccountId: args.accountId, | |
| 212 | − createdAt: now, | |
| 213 | − updatedAt: now, | |
| 214 | − }); | |
| 215 | − }, | |
| 216 | −}); | |
| 217 | − | |
| 218 | −const runQueryEffect = <A>( | |
| 219 | − operation: string, | |
| 220 | − run: () => Promise<A>, | |
| 221 | −): Effect.Effect<A, ActorUnauthenticatedError> => | |
| 222 | − Effect.tryPromise({ | |
| 223 | − try: run, | |
| 224 | − catch: (cause) => | |
| 225 | − new ActorUnauthenticatedError({ | |
| 226 | − message: `Failed resolving actor (${operation}): ${String(cause)}`, | |
| 227 | − }), | |
| 228 | − }); | |
| 229 | − | |
| 230 | −const inferPrincipalProvider = ( | |
| 231 | − identity: UserIdentity, | |
| 232 | −): Principal["provider"] => | |
| 233 | − identity.issuer.trim().toLowerCase().includes("workos") | |
| 234 | − ? "workos" | |
| 235 | − : "local"; | |
| 236 | − | |
| 237 | −const toPrincipalFromIdentity = ( | |
| 238 | − identity: UserIdentity, | |
| 239 | −): Principal => { | |
| 240 | − const accountId = identity.subject.trim(); | |
| 241 | − | |
| 242 | − if (accountId.length === 0) { | |
| 243 | − throw new ActorUnauthenticatedError({ | |
| 244 | − message: "Authenticated identity subject is required", | |
| 245 | − }); | |
| 246 | − } | |
| 247 | − | |
| 248 | − const provider = inferPrincipalProvider(identity); | |
| 249 | − const tokenIdentifier = identity.tokenIdentifier.trim(); | |
| 250 | − | |
| 251 | − try { | |
| 252 | − return decodePrincipal({ | |
| 253 | − accountId, | |
| 254 | − provider, | |
| 255 | − subject: tokenIdentifier.length > 0 ? tokenIdentifier : `${provider}:${accountId}`, | |
| 256 | − email: identity.email?.trim() ?? null, | |
| 257 | − displayName: identity.name?.trim() ?? null, | |
| 258 | − }); | |
| 259 | − } catch (cause) { | |
| 260 | − throw new ActorUnauthenticatedError({ | |
| 261 | − message: `Failed decoding authenticated principal: ${String(cause)}`, | |
| 262 | − }); | |
| 263 | − } | |
| 264 | −}; | |
| 265 | − | |
| 266 | −const resolveAuthenticatedPrincipal = ( | |
| 267 | − ctx: ActionCtx, | |
| 268 | −): Effect.Effect<Principal, ActorUnauthenticatedError> => | |
| 269 | − Effect.tryPromise({ | |
| 270 | − try: async () => { | |
| 271 | − const identity = await ctx.auth.getUserIdentity(); | |
| 272 | − | |
| 273 | − if (identity === null) { | |
| 274 | − throw new ActorUnauthenticatedError({ | |
| 275 | − message: "Authenticated principal is required", | |
| 276 | − }); | |
| 277 | − } | |
| 278 | − | |
| 279 | − return toPrincipalFromIdentity(identity); | |
| 280 | − }, | |
| 281 | − catch: (cause) => | |
| 282 | − cause instanceof ActorUnauthenticatedError | |
| 283 | − ? cause | |
| 284 | − : new ActorUnauthenticatedError({ | |
| 285 | − message: `Failed resolving authenticated principal: ${String(cause)}`, | |
| 286 | − }), | |
| 287 | − }); | |
| 288 | − | |
| 289 | −export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => | |
| 290 | − ControlPlaneActorResolverLive({ | |
| 291 | − resolveActor: (input) => | |
| 292 | − Effect.gen(function* () { | |
| 293 | − void input; | |
| 294 | − | |
| 295 | − const principal = yield* resolveAuthenticatedPrincipal(ctx); | |
| 296 | − | |
| 297 | − let organizationMemberships = yield* runQueryEffect( | |
| 298 | − "organizationMembership.list", | |
| 299 | − () => | |
| 300 | − ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, { | |
| 301 | − accountId: principal.accountId, | |
| 302 | − }), | |
| 303 | − ); | |
| 304 | − | |
| 305 | − let organizationIds = organizationMemberships.map( | |
| 306 | − (membership) => membership.organizationId, | |
| 307 | − ); | |
| 308 | − | |
| 309 | − let workspaces = yield* runQueryEffect("workspace.list", () => | |
| 310 | − ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, { | |
| 311 | − accountId: principal.accountId, | |
| 312 | − organizationIds, | |
| 313 | − }), | |
| 314 | − ); | |
| 315 | − | |
| 316 | − if (organizationMemberships.length === 0) { | |
| 317 | − const workspaceId = workspaces[0]?.id ?? `ws_${principal.accountId}`; | |
| 318 | − | |
| 319 | − yield* runQueryEffect("workspace.ensureDefault", () => | |
| 320 | − ctx.runMutation(internal.control_plane.actor.ensureWorkspaceForActor, { | |
| 321 | − workspaceId, | |
| 322 | − accountId: principal.accountId, | |
| 323 | − }), | |
| 324 | − ); | |
| 325 | − | |
| 326 | − organizationMemberships = yield* runQueryEffect( | |
| 327 | − "organizationMembership.list", | |
| 328 | − () => | |
| 329 | − ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, { | |
| 330 | − accountId: principal.accountId, | |
| 331 | − }), | |
| 332 | − ); | |
| 333 | − | |
| 334 | − organizationIds = organizationMemberships.map( | |
| 335 | − (membership) => membership.organizationId, | |
| 336 | − ); | |
| 337 | − | |
| 338 | − workspaces = yield* runQueryEffect("workspace.list", () => | |
| 339 | − ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, { | |
| 340 | − accountId: principal.accountId, | |
| 341 | − organizationIds, | |
| 342 | − }), | |
| 343 | − ); | |
| 344 | − } | |
| 345 | − | |
| 346 | − const workspaceMemberships = workspaces.flatMap((workspace) => | |
| 347 | − deriveWorkspaceMembershipsForPrincipal({ | |
| 348 | − principalAccountId: principal.accountId, | |
| 349 | − workspaceId: workspace.id, | |
| 350 | − workspace, | |
| 351 | − organizationMemberships, | |
| 352 | − }), | |
| 353 | − ); | |
| 354 | − | |
| 355 | − return yield* makeActor({ | |
| 356 | − principal, | |
| 357 | − workspaceMemberships, | |
| 358 | − organizationMemberships, | |
| 359 | − }); | |
| 360 | − }), | |
| 361 | − resolveWorkspaceActor: (input) => | |
| 362 | − Effect.gen(function* () { | |
| 363 | − void input.headers; | |
| 364 | − | |
| 365 | − const principal = yield* resolveAuthenticatedPrincipal(ctx); | |
| 366 | − | |
| 367 | − let workspace = yield* runQueryEffect("workspace.read", () => | |
| 368 | − ctx.runQuery(internal.control_plane.actor.getWorkspaceForActor, { | |
| 369 | − workspaceId: input.workspaceId, | |
| 370 | − }), | |
| 371 | − ); | |
| 372 | − | |
| 373 | − if (workspace === null || workspace.createdByAccountId === null) { | |
| 374 | − workspace = yield* runQueryEffect("workspace.ensure", () => | |
| 375 | − ctx.runMutation(internal.control_plane.actor.ensureWorkspaceForActor, { | |
| 376 | − workspaceId: input.workspaceId, | |
| 377 | − accountId: principal.accountId, | |
| 378 | − }), | |
| 379 | − ); | |
| 380 | − } | |
| 381 | − | |
| 382 | − const organizationMemberships = yield* runQueryEffect( | |
| 383 | − "organizationMembership.list", | |
| 384 | − () => | |
| 385 | − ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, { | |
| 386 | − accountId: principal.accountId, | |
| 387 | − }), | |
| 388 | − ); | |
| 389 | − | |
| 390 | − const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({ | |
| 391 | − principalAccountId: principal.accountId, | |
| 392 | − workspaceId: input.workspaceId, | |
| 393 | − workspace, | |
| 394 | − organizationMemberships, | |
| 395 | − }); | |
| 396 | − | |
| 397 | − return yield* makeActor({ | |
| 398 | − principal, | |
| 399 | − workspaceMemberships, | |
| 400 | − organizationMemberships, | |
| 401 | − }); | |
| 402 | − }), | |
| 403 | − }); | |
| @@ -1,95 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type ResolveApprovalPayload, | |
| 3 | −} from "@executor-v2/management-api"; | |
| 4 | −import { | |
| 5 | − ApprovalSchema, | |
| 6 | − type Approval, | |
| 7 | −} from "@executor-v2/schema"; | |
| 8 | −import { v } from "convex/values"; | |
| 9 | −import * as Schema from "effect/Schema"; | |
| 10 | − | |
| 11 | −import { internalMutation, internalQuery } from "../_generated/server"; | |
| 12 | − | |
| 13 | −const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema); | |
| 14 | − | |
| 15 | −const stripConvexSystemFields = ( | |
| 16 | − value: Record<string, unknown>, | |
| 17 | −): Record<string, unknown> => { | |
| 18 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 19 | − return rest; | |
| 20 | −}; | |
| 21 | − | |
| 22 | −const approvalStatusValidator = v.union( | |
| 23 | − v.literal("approved"), | |
| 24 | − v.literal("denied"), | |
| 25 | −); | |
| 26 | − | |
| 27 | −const sortApprovals = (approvals: ReadonlyArray<Approval>): Array<Approval> => | |
| 28 | − [...approvals].sort((left, right) => right.requestedAt - left.requestedAt); | |
| 29 | − | |
| 30 | −export const listApprovals = internalQuery({ | |
| 31 | − args: { | |
| 32 | − workspaceId: v.string(), | |
| 33 | − }, | |
| 34 | − handler: async (ctx, args): Promise<Array<Approval>> => { | |
| 35 | − const rows = await ctx.db | |
| 36 | − .query("approvals") | |
| 37 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 38 | − .collect(); | |
| 39 | − | |
| 40 | − return sortApprovals( | |
| 41 | − rows.map((row) => | |
| 42 | − decodeApproval( | |
| 43 | − stripConvexSystemFields(row as unknown as Record<string, unknown>), | |
| 44 | − ), | |
| 45 | − ), | |
| 46 | − ); | |
| 47 | − }, | |
| 48 | −}); | |
| 49 | − | |
| 50 | −export const resolveApproval = internalMutation({ | |
| 51 | − args: { | |
| 52 | − workspaceId: v.string(), | |
| 53 | − approvalId: v.string(), | |
| 54 | − payload: v.object({ | |
| 55 | − status: approvalStatusValidator, | |
| 56 | − reason: v.optional(v.union(v.string(), v.null())), | |
| 57 | − }), | |
| 58 | − }, | |
| 59 | − handler: async (ctx, args): Promise<Approval> => { | |
| 60 | − const row = await ctx.db | |
| 61 | − .query("approvals") | |
| 62 | − .withIndex("by_domainId", (q) => q.eq("id", args.approvalId)) | |
| 63 | − .unique(); | |
| 64 | − | |
| 65 | − if (!row || row.workspaceId !== args.workspaceId) { | |
| 66 | − throw new Error(`Approval not found: ${args.approvalId}`); | |
| 67 | − } | |
| 68 | − | |
| 69 | − const approval = decodeApproval( | |
| 70 | − stripConvexSystemFields(row as unknown as Record<string, unknown>), | |
| 71 | − ); | |
| 72 | − | |
| 73 | − if (approval.status !== "pending") { | |
| 74 | − throw new Error(`Approval is not pending: ${args.approvalId}`); | |
| 75 | − } | |
| 76 | − | |
| 77 | − const payload = args.payload as ResolveApprovalPayload; | |
| 78 | − const updatedAt = Date.now(); | |
| 79 | − | |
| 80 | − const resolvedApproval: Approval = { | |
| 81 | − ...approval, | |
| 82 | − status: payload.status, | |
| 83 | − reason: payload.reason ?? approval.reason ?? null, | |
| 84 | − resolvedAt: updatedAt, | |
| 85 | − }; | |
| 86 | − | |
| 87 | − await ctx.db.patch(row._id, { | |
| 88 | − status: resolvedApproval.status, | |
| 89 | − reason: resolvedApproval.reason, | |
| 90 | − resolvedAt: resolvedApproval.resolvedAt, | |
| 91 | − }); | |
| 92 | − | |
| 93 | − return resolvedApproval; | |
| 94 | − }, | |
| 95 | −}); | |
| @@ -1,1780 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type UpsertCredentialBindingPayload, | |
| 3 | −} from "@executor-v2/management-api"; | |
| 4 | −import { | |
| 5 | − AuthConnectionSchema, | |
| 6 | − AuthMaterialSchema, | |
| 7 | − OAuthStateSchema, | |
| 8 | − SourceAuthBindingSchema, | |
| 9 | − SourceCredentialBindingSchema, | |
| 10 | − type AuthConnection, | |
| 11 | − type AuthConnectionStatus, | |
| 12 | − type AuthConnectionStrategy, | |
| 13 | − type AuthMaterial, | |
| 14 | − type OAuthState, | |
| 15 | − type SourceAuthBinding, | |
| 16 | − type SourceCredentialBinding, | |
| 17 | −} from "@executor-v2/schema"; | |
| 18 | −import { v } from "convex/values"; | |
| 19 | −import * as Schema from "effect/Schema"; | |
| 20 | − | |
| 21 | −import { internal } from "../_generated/api"; | |
| 22 | −import { | |
| 23 | − currentCredentialEncryptionKeyVersion, | |
| 24 | − decryptSecretValue, | |
| 25 | − encryptSecretValue, | |
| 26 | −} from "../credential_crypto"; | |
| 27 | −import { | |
| 28 | − internalAction, | |
| 29 | − internalMutation, | |
| 30 | − internalQuery, | |
| 31 | − type MutationCtx, | |
| 32 | − type QueryCtx, | |
| 33 | −} from "../_generated/server"; | |
| 34 | − | |
| 35 | −const runtimeInternal = internal; | |
| 36 | − | |
| 37 | −const decodeAuthConnection = Schema.decodeUnknownSync(AuthConnectionSchema); | |
| 38 | −const decodeSourceAuthBinding = Schema.decodeUnknownSync(SourceAuthBindingSchema); | |
| 39 | −const decodeAuthMaterial = Schema.decodeUnknownSync(AuthMaterialSchema); | |
| 40 | −const decodeOAuthState = Schema.decodeUnknownSync(OAuthStateSchema); | |
| 41 | −const decodeCompatSourceCredentialBinding = Schema.decodeUnknownSync( | |
| 42 | − SourceCredentialBindingSchema, | |
| 43 | −); | |
| 44 | − | |
| 45 | −const credentialProviderValidator = v.union( | |
| 46 | − v.literal("api_key"), | |
| 47 | − v.literal("bearer"), | |
| 48 | − v.literal("oauth2"), | |
| 49 | − v.literal("basic"), | |
| 50 | − v.literal("custom"), | |
| 51 | −); | |
| 52 | − | |
| 53 | −const credentialSecretProviderValidator = v.literal("local"); | |
| 54 | − | |
| 55 | −const credentialScopeTypeValidator = v.union( | |
| 56 | − v.literal("workspace"), | |
| 57 | − v.literal("organization"), | |
| 58 | − v.literal("account"), | |
| 59 | −); | |
| 60 | − | |
| 61 | −const sourceCredentialBindingPayloadValidator = v.object({ | |
| 62 | − id: v.optional(v.string()), | |
| 63 | − credentialId: v.string(), | |
| 64 | − scopeType: credentialScopeTypeValidator, | |
| 65 | − sourceKey: v.string(), | |
| 66 | − provider: credentialProviderValidator, | |
| 67 | − secretProvider: v.optional(credentialSecretProviderValidator), | |
| 68 | − secretRef: v.string(), | |
| 69 | − accountId: v.optional(v.union(v.string(), v.null())), | |
| 70 | − additionalHeadersJson: v.optional(v.union(v.string(), v.null())), | |
| 71 | − boundAuthFingerprint: v.optional(v.union(v.string(), v.null())), | |
| 72 | − oauthRefreshToken: v.optional(v.union(v.string(), v.null())), | |
| 73 | − oauthExpiresAt: v.optional(v.union(v.number(), v.null())), | |
| 74 | − oauthScope: v.optional(v.union(v.string(), v.null())), | |
| 75 | − oauthIssuer: v.optional(v.union(v.string(), v.null())), | |
| 76 | − oauthTokenEndpoint: v.optional(v.union(v.string(), v.null())), | |
| 77 | − oauthAuthorizationServer: v.optional(v.union(v.string(), v.null())), | |
| 78 | − oauthClientId: v.optional(v.union(v.string(), v.null())), | |
| 79 | − oauthClientSecret: v.optional(v.union(v.string(), v.null())), | |
| 80 | − oauthSourceUrl: v.optional(v.union(v.string(), v.null())), | |
| 81 | − oauthClientInformationJson: v.optional(v.union(v.string(), v.null())), | |
| 82 | −}); | |
| 83 | − | |
| 84 | −type ExtendedUpsertCredentialBindingPayload = UpsertCredentialBindingPayload & { | |
| 85 | − oauthRefreshToken?: string | null; | |
| 86 | − oauthExpiresAt?: number | null; | |
| 87 | − oauthScope?: string | null; | |
| 88 | − oauthIssuer?: string | null; | |
| 89 | − oauthTokenEndpoint?: string | null; | |
| 90 | − oauthAuthorizationServer?: string | null; | |
| 91 | − oauthClientId?: string | null; | |
| 92 | − oauthClientSecret?: string | null; | |
| 93 | − oauthSourceUrl?: string | null; | |
| 94 | − oauthClientInformationJson?: string | null; | |
| 95 | −}; | |
| 96 | − | |
| 97 | −const stripConvexSystemFields = ( | |
| 98 | − value: Record<string, unknown>, | |
| 99 | −): Record<string, unknown> => { | |
| 100 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 101 | − return rest; | |
| 102 | −}; | |
| 103 | − | |
| 104 | −const normalizeString = (value: unknown): string | null => { | |
| 105 | − if (typeof value !== "string") { | |
| 106 | − return null; | |
| 107 | − } | |
| 108 | − | |
| 109 | − const trimmed = value.trim(); | |
| 110 | − return trimmed.length > 0 ? trimmed : null; | |
| 111 | −}; | |
| 112 | − | |
| 113 | −const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { | |
| 114 | − if (!value) { | |
| 115 | − return {}; | |
| 116 | − } | |
| 117 | − | |
| 118 | − try { | |
| 119 | − const parsed = JSON.parse(value) as unknown; | |
| 120 | − if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 121 | − return {}; | |
| 122 | − } | |
| 123 | − | |
| 124 | − return parsed as Record<string, unknown>; | |
| 125 | − } catch { | |
| 126 | − return {}; | |
| 127 | − } | |
| 128 | −}; | |
| 129 | − | |
| 130 | −const toStringRecord = ( | |
| 131 | − value: Record<string, unknown>, | |
| 132 | −): Record<string, string> => { | |
| 133 | − const record: Record<string, string> = {}; | |
| 134 | − | |
| 135 | − for (const [rawKey, rawValue] of Object.entries(value)) { | |
| 136 | − const key = normalizeString(rawKey); | |
| 137 | − const normalizedValue = normalizeString(rawValue); | |
| 138 | − if (!key || !normalizedValue) { | |
| 139 | − continue; | |
| 140 | − } | |
| 141 | − | |
| 142 | − record[key] = normalizedValue; | |
| 143 | − } | |
| 144 | − | |
| 145 | − return record; | |
| 146 | −}; | |
| 147 | − | |
| 148 | −const parseAdditionalHeaders = ( | |
| 149 | − additionalHeadersJson: string | null, | |
| 150 | −): Record<string, string> => { | |
| 151 | − if (!additionalHeadersJson) { | |
| 152 | − return {}; | |
| 153 | − } | |
| 154 | − | |
| 155 | − return toStringRecord(parseJsonObject(additionalHeadersJson)); | |
| 156 | −}; | |
| 157 | − | |
| 158 | −const parseConnectionMetadata = ( | |
| 159 | − metadataJson: string | null, | |
| 160 | −): Record<string, unknown> => parseJsonObject(metadataJson); | |
| 161 | − | |
| 162 | −const base64Encode = (value: string): string => { | |
| 163 | − if (typeof btoa === "function") { | |
| 164 | − return btoa(value); | |
| 165 | − } | |
| 166 | − | |
| 167 | − const maybeBuffer = (globalThis as { Buffer?: typeof Buffer }).Buffer; | |
| 168 | − if (maybeBuffer) { | |
| 169 | − return maybeBuffer.from(value, "utf8").toString("base64"); | |
| 170 | − } | |
| 171 | − | |
| 172 | − throw new Error("No base64 encoder available"); | |
| 173 | −}; | |
| 174 | − | |
| 175 | −const strategyFromProvider = ( | |
| 176 | − provider: UpsertCredentialBindingPayload["provider"], | |
| 177 | −): AuthConnectionStrategy => { | |
| 178 | − if (provider === "api_key") return "api_key"; | |
| 179 | − if (provider === "bearer") return "bearer"; | |
| 180 | − if (provider === "oauth2") return "oauth2"; | |
| 181 | − if (provider === "basic") return "basic"; | |
| 182 | − return "custom"; | |
| 183 | −}; | |
| 184 | − | |
| 185 | −const providerFromStrategy = ( | |
| 186 | − strategy: AuthConnectionStrategy, | |
| 187 | −): SourceCredentialBinding["provider"] => { | |
| 188 | − if (strategy === "api_key") return "api_key"; | |
| 189 | − if (strategy === "bearer") return "bearer"; | |
| 190 | − if (strategy === "oauth2") return "oauth2"; | |
| 191 | − if (strategy === "basic") return "basic"; | |
| 192 | − return "custom"; | |
| 193 | −}; | |
| 194 | − | |
| 195 | −const scopeScore = ( | |
| 196 | − binding: SourceAuthBinding, | |
| 197 | − input: { | |
| 198 | − workspaceId: string; | |
| 199 | − organizationId: string; | |
| 200 | − accountId: string | null; | |
| 201 | − }, | |
| 202 | −): number => { | |
| 203 | − if (!binding.enabled) { | |
| 204 | − return -1; | |
| 205 | − } | |
| 206 | − | |
| 207 | − if (binding.scopeType === "account") { | |
| 208 | − if (!input.accountId || binding.accountId !== input.accountId) { | |
| 209 | − return -1; | |
| 210 | − } | |
| 211 | − | |
| 212 | − return binding.organizationId === input.organizationId ? 30 : -1; | |
| 213 | − } | |
| 214 | − | |
| 215 | − if (binding.scopeType === "workspace") { | |
| 216 | − return binding.workspaceId === input.workspaceId ? 20 : -1; | |
| 217 | − } | |
| 218 | − | |
| 219 | − if (binding.scopeType === "organization") { | |
| 220 | − return binding.organizationId === input.organizationId ? 10 : -1; | |
| 221 | − } | |
| 222 | − | |
| 223 | − return -1; | |
| 224 | −}; | |
| 225 | − | |
| 226 | −const selectBestBinding = ( | |
| 227 | − bindings: ReadonlyArray<SourceAuthBinding>, | |
| 228 | − input: { | |
| 229 | − workspaceId: string; | |
| 230 | − organizationId: string; | |
| 231 | − accountId: string | null; | |
| 232 | − }, | |
| 233 | −): SourceAuthBinding | null => { | |
| 234 | − const ranked = bindings | |
| 235 | − .map((binding) => ({ | |
| 236 | − binding, | |
| 237 | − score: scopeScore(binding, input), | |
| 238 | − })) | |
| 239 | − .filter((entry) => entry.score >= 0) | |
| 240 | − .sort((left, right) => { | |
| 241 | − if (left.score !== right.score) { | |
| 242 | − return right.score - left.score; | |
| 243 | − } | |
| 244 | − | |
| 245 | − if (left.binding.updatedAt !== right.binding.updatedAt) { | |
| 246 | − return right.binding.updatedAt - left.binding.updatedAt; | |
| 247 | − } | |
| 248 | − | |
| 249 | − return right.binding.createdAt - left.binding.createdAt; | |
| 250 | − }); | |
| 251 | − | |
| 252 | − return ranked[0]?.binding ?? null; | |
| 253 | −}; | |
| 254 | − | |
| 255 | −const sourceIdFromSourceKey = (sourceKey: string): string | null => { | |
| 256 | − const trimmed = sourceKey.trim(); | |
| 257 | − if (!trimmed.startsWith("source:")) { | |
| 258 | − return null; | |
| 259 | − } | |
| 260 | − | |
| 261 | − const sourceId = trimmed.slice("source:".length).trim(); | |
| 262 | − return sourceId.length > 0 ? sourceId : null; | |
| 263 | −}; | |
| 264 | − | |
| 265 | −const sourceKeyFromSourceId = (sourceId: string): string => `source:${sourceId}`; | |
| 266 | − | |
| 267 | −const toAuthConnection = (document: Record<string, unknown>): AuthConnection => | |
| 268 | − decodeAuthConnection(stripConvexSystemFields(document)); | |
| 269 | − | |
| 270 | −const toSourceAuthBinding = (document: Record<string, unknown>): SourceAuthBinding => | |
| 271 | − decodeSourceAuthBinding(stripConvexSystemFields(document)); | |
| 272 | − | |
| 273 | −const toAuthMaterial = (document: Record<string, unknown>): AuthMaterial => | |
| 274 | − decodeAuthMaterial(stripConvexSystemFields(document)); | |
| 275 | − | |
| 276 | −const toOAuthState = (document: Record<string, unknown>): OAuthState => | |
| 277 | − decodeOAuthState(stripConvexSystemFields(document)); | |
| 278 | − | |
| 279 | −const resolveWorkspaceOrganizationId = async ( | |
| 280 | − ctx: QueryCtx | MutationCtx, | |
| 281 | − workspaceId: string, | |
| 282 | −): Promise<string> => { | |
| 283 | − const workspace = await ctx.db | |
| 284 | − .query("workspaces") | |
| 285 | − .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | |
| 286 | − .unique(); | |
| 287 | − | |
| 288 | − if (!workspace) { | |
| 289 | − throw new Error(`Workspace not found: ${workspaceId}`); | |
| 290 | − } | |
| 291 | − | |
| 292 | − return workspace.organizationId; | |
| 293 | −}; | |
| 294 | − | |
| 295 | −const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | |
| 296 | − const merged: Record<string, string> = {}; | |
| 297 | − const keyByLower = new Map<string, string>(); | |
| 298 | − | |
| 299 | − for (const set of sets) { | |
| 300 | − for (const [rawKey, rawValue] of Object.entries(set)) { | |
| 301 | − const key = rawKey.trim(); | |
| 302 | − const value = rawValue.trim(); | |
| 303 | − if (key.length === 0 || value.length === 0) { | |
| 304 | − continue; | |
| 305 | − } | |
| 306 | − | |
| 307 | − const lower = key.toLowerCase(); | |
| 308 | − const existing = keyByLower.get(lower); | |
| 309 | − if (existing && existing !== key) { | |
| 310 | − delete merged[existing]; | |
| 311 | − } | |
| 312 | − | |
| 313 | − keyByLower.set(lower, key); | |
| 314 | − merged[key] = value; | |
| 315 | − } | |
| 316 | − } | |
| 317 | − | |
| 318 | − return merged; | |
| 319 | −}; | |
| 320 | − | |
| 321 | −const buildSecretHeaders = ( | |
| 322 | − strategy: AuthConnectionStrategy, | |
| 323 | − secret: string, | |
| 324 | − metadata: Record<string, unknown>, | |
| 325 | −): Record<string, string> => { | |
| 326 | − const trimmed = secret.trim(); | |
| 327 | − if (trimmed.length === 0) { | |
| 328 | − return {}; | |
| 329 | − } | |
| 330 | − | |
| 331 | − if (strategy === "api_key") { | |
| 332 | − const header = normalizeString(metadata.apiKeyHeader) ?? "x-api-key"; | |
| 333 | − return { [header]: trimmed }; | |
| 334 | − } | |
| 335 | − | |
| 336 | − if (strategy === "bearer") { | |
| 337 | − return { Authorization: `Bearer ${trimmed}` }; | |
| 338 | − } | |
| 339 | − | |
| 340 | − if (strategy === "basic") { | |
| 341 | − const asJson = parseJsonObject(trimmed); | |
| 342 | − const usernameFromJson = normalizeString(asJson.username); | |
| 343 | − const passwordFromJson = normalizeString(asJson.password); | |
| 344 | − | |
| 345 | − const pair = usernameFromJson && passwordFromJson | |
| 346 | − ? `${usernameFromJson}:${passwordFromJson}` | |
| 347 | − : trimmed.includes(":") | |
| 348 | − ? trimmed | |
| 349 | − : null; | |
| 350 | − | |
| 351 | − if (!pair) { | |
| 352 | − return {}; | |
| 353 | − } | |
| 354 | − | |
| 355 | − return { | |
| 356 | − Authorization: `Basic ${base64Encode(pair)}`, | |
| 357 | − }; | |
| 358 | − } | |
| 359 | − | |
| 360 | − if (strategy === "custom") { | |
| 361 | − const customHeaderName = normalizeString(metadata.customHeaderName); | |
| 362 | − if (customHeaderName) { | |
| 363 | − return { [customHeaderName]: trimmed }; | |
| 364 | − } | |
| 365 | − } | |
| 366 | − | |
| 367 | − return {}; | |
| 368 | −}; | |
| 369 | − | |
| 370 | −const sortCompatBindings = ( | |
| 371 | − bindings: ReadonlyArray<SourceCredentialBinding>, | |
| 372 | −): Array<SourceCredentialBinding> => | |
| 373 | − [...bindings].sort((left, right) => { | |
| 374 | − const leftKey = `${left.sourceKey}:${left.provider}`.toLowerCase(); | |
| 375 | − const rightKey = `${right.sourceKey}:${right.provider}`.toLowerCase(); | |
| 376 | − | |
| 377 | − if (leftKey === rightKey) { | |
| 378 | − return left.id.localeCompare(right.id); | |
| 379 | − } | |
| 380 | − | |
| 381 | − return leftKey.localeCompare(rightKey); | |
| 382 | − }); | |
| 383 | − | |
| 384 | −const maskedSecretRef = (connection: AuthConnection): string => { | |
| 385 | − if (connection.strategy === "oauth2") { | |
| 386 | − if (connection.status === "reauth_required") { | |
| 387 | − return "oauth2://reauth_required"; | |
| 388 | − } | |
| 389 | − | |
| 390 | − if (connection.status === "active") { | |
| 391 | − return "oauth2://connected"; | |
| 392 | − } | |
| 393 | − } | |
| 394 | − | |
| 395 | − return "********"; | |
| 396 | −}; | |
| 397 | − | |
| 398 | −const toCompatSourceCredentialBinding = ( | |
| 399 | − binding: SourceAuthBinding, | |
| 400 | − connection: AuthConnection, | |
| 401 | −): SourceCredentialBinding => | |
| 402 | − decodeCompatSourceCredentialBinding({ | |
| 403 | − id: binding.id, | |
| 404 | − credentialId: connection.id, | |
| 405 | − organizationId: binding.organizationId, | |
| 406 | − workspaceId: binding.workspaceId, | |
| 407 | − accountId: binding.accountId, | |
| 408 | − scopeType: binding.scopeType, | |
| 409 | − sourceKey: sourceKeyFromSourceId(binding.sourceId), | |
| 410 | − provider: providerFromStrategy(connection.strategy), | |
| 411 | − secretProvider: "local", | |
| 412 | − secretRef: maskedSecretRef(connection), | |
| 413 | − additionalHeadersJson: connection.additionalHeadersJson, | |
| 414 | − boundAuthFingerprint: null, | |
| 415 | − createdAt: binding.createdAt, | |
| 416 | − updatedAt: Math.max(binding.updatedAt, connection.updatedAt), | |
| 417 | − }); | |
| 418 | − | |
| 419 | −const normalizeAuthConnectionStatus = ( | |
| 420 | − connection: AuthConnection, | |
| 421 | −): AuthConnectionStatus => { | |
| 422 | − if (connection.status === "active") return "active"; | |
| 423 | − if (connection.status === "reauth_required") return "reauth_required"; | |
| 424 | − if (connection.status === "revoked") return "revoked"; | |
| 425 | − if (connection.status === "disabled") return "disabled"; | |
| 426 | − return "error"; | |
| 427 | −}; | |
| 428 | − | |
| 429 | −type OAuthRefreshConfig = { | |
| 430 | − tokenEndpoint?: string; | |
| 431 | − authorizationServer?: string; | |
| 432 | − clientId?: string; | |
| 433 | − clientSecretCiphertext?: string; | |
| 434 | − sourceUrl?: string; | |
| 435 | − clientInformationJson?: string; | |
| 436 | −}; | |
| 437 | − | |
| 438 | −const parseOAuthRefreshConfig = (value: string | null): OAuthRefreshConfig => { | |
| 439 | − if (!value) { | |
| 440 | − return {}; | |
| 441 | − } | |
| 442 | − | |
| 443 | − const parsed = parseJsonObject(value); | |
| 444 | − return { | |
| 445 | − ...(normalizeString(parsed.tokenEndpoint) | |
| 446 | − ? { tokenEndpoint: normalizeString(parsed.tokenEndpoint)! } | |
| 447 | − : {}), | |
| 448 | − ...(normalizeString(parsed.authorizationServer) | |
| 449 | − ? { authorizationServer: normalizeString(parsed.authorizationServer)! } | |
| 450 | − : {}), | |
| 451 | − ...(normalizeString(parsed.clientId) | |
| 452 | − ? { clientId: normalizeString(parsed.clientId)! } | |
| 453 | − : {}), | |
| 454 | − ...(normalizeString(parsed.clientSecretCiphertext) | |
| 455 | − ? { clientSecretCiphertext: normalizeString(parsed.clientSecretCiphertext)! } | |
| 456 | − : {}), | |
| 457 | − ...(normalizeString(parsed.sourceUrl) | |
| 458 | − ? { sourceUrl: normalizeString(parsed.sourceUrl)! } | |
| 459 | − : {}), | |
| 460 | − ...(normalizeString(parsed.clientInformationJson) | |
| 461 | − ? { clientInformationJson: normalizeString(parsed.clientInformationJson)! } | |
| 462 | − : {}), | |
| 463 | − }; | |
| 464 | −}; | |
| 465 | − | |
| 466 | −const encodeOAuthRefreshConfig = (config: OAuthRefreshConfig): string | null => { | |
| 467 | − const payload: Record<string, string> = {}; | |
| 468 | − | |
| 469 | − if (config.tokenEndpoint) payload.tokenEndpoint = config.tokenEndpoint; | |
| 470 | − if (config.authorizationServer) payload.authorizationServer = config.authorizationServer; | |
| 471 | − if (config.clientId) payload.clientId = config.clientId; | |
| 472 | − if (config.clientSecretCiphertext) { | |
| 473 | − payload.clientSecretCiphertext = config.clientSecretCiphertext; | |
| 474 | − } | |
| 475 | − if (config.sourceUrl) payload.sourceUrl = config.sourceUrl; | |
| 476 | − if (config.clientInformationJson) { | |
| 477 | − payload.clientInformationJson = config.clientInformationJson; | |
| 478 | − } | |
| 479 | − | |
| 480 | − if (Object.keys(payload).length === 0) { | |
| 481 | − return null; | |
| 482 | − } | |
| 483 | − | |
| 484 | − return JSON.stringify(payload); | |
| 485 | −}; | |
| 486 | − | |
| 487 | −const oauthRefreshWindowMs = 5 * 60 * 1000; | |
| 488 | −const oauthRefreshLeaseMs = 30 * 1000; | |
| 489 | − | |
| 490 | −type OAuthRefreshErrorClass = | |
| 491 | − | "transient" | |
| 492 | − | "invalid_grant" | |
| 493 | − | "invalid_client" | |
| 494 | − | "unsupported" | |
| 495 | − | "unknown"; | |
| 496 | − | |
| 497 | −const classifyOAuthRefreshFailure = ( | |
| 498 | − responseStatus: number, | |
| 499 | − payload: Record<string, unknown>, | |
| 500 | −): OAuthRefreshErrorClass => { | |
| 501 | − const errorCode = normalizeString(payload.error)?.toLowerCase() ?? ""; | |
| 502 | − | |
| 503 | − if (errorCode === "invalid_grant") { | |
| 504 | − return "invalid_grant"; | |
| 505 | − } | |
| 506 | − | |
| 507 | − if (errorCode === "invalid_client") { | |
| 508 | − return "invalid_client"; | |
| 509 | − } | |
| 510 | − | |
| 511 | − if (responseStatus >= 500) { | |
| 512 | − return "transient"; | |
| 513 | − } | |
| 514 | − | |
| 515 | − if (responseStatus === 400 || responseStatus === 401 || responseStatus === 403) { | |
| 516 | − return errorCode.length > 0 ? "unknown" : "transient"; | |
| 517 | − } | |
| 518 | − | |
| 519 | − return "unknown"; | |
| 520 | −}; | |
| 521 | − | |
| 522 | −const readJsonResponse = async ( | |
| 523 | − response: Response, | |
| 524 | −): Promise<Record<string, unknown>> => { | |
| 525 | − try { | |
| 526 | − const parsed = (await response.json()) as unknown; | |
| 527 | − if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 528 | − return {}; | |
| 529 | − } | |
| 530 | − | |
| 531 | − return parsed as Record<string, unknown>; | |
| 532 | − } catch { | |
| 533 | − return {}; | |
| 534 | − } | |
| 535 | −}; | |
| 536 | − | |
| 537 | −const resolveTokenEndpointFromAuthorizationServer = async ( | |
| 538 | − authorizationServer: string, | |
| 539 | −): Promise<string | null> => { | |
| 540 | − const normalized = normalizeString(authorizationServer); | |
| 541 | − if (!normalized) { | |
| 542 | − return null; | |
| 543 | − } | |
| 544 | − | |
| 545 | − try { | |
| 546 | − const endpoint = new URL("/.well-known/oauth-authorization-server", normalized); | |
| 547 | − const response = await fetch(endpoint.toString(), { | |
| 548 | − method: "GET", | |
| 549 | − headers: { | |
| 550 | − accept: "application/json", | |
| 551 | − }, | |
| 552 | − }); | |
| 553 | − | |
| 554 | − if (!response.ok) { | |
| 555 | − return null; | |
| 556 | − } | |
| 557 | − | |
| 558 | − const payload = await readJsonResponse(response); | |
| 559 | − const tokenEndpoint = normalizeString(payload.token_endpoint); | |
| 560 | − return tokenEndpoint ?? null; | |
| 561 | − } catch { | |
| 562 | − return null; | |
| 563 | − } | |
| 564 | −}; | |
| 565 | − | |
| 566 | −const refreshTokenFromOAuthEndpoint = async ( | |
| 567 | − oauthState: OAuthState, | |
| 568 | − refreshConfig: OAuthRefreshConfig, | |
| 569 | −): Promise< | |
| 570 | − | { | |
| 571 | − ok: true; | |
| 572 | − accessToken: string; | |
| 573 | − refreshToken: string | null; | |
| 574 | − expiresAt: number | null; | |
| 575 | − scope: string | null; | |
| 576 | − issuer: string | null; | |
| 577 | − tokenType: string | null; | |
| 578 | − } | |
| 579 | − | { | |
| 580 | − ok: false; | |
| 581 | − errorClass: OAuthRefreshErrorClass; | |
| 582 | − message: string; | |
| 583 | − } | |
| 584 | −> => { | |
| 585 | − const refreshTokenCiphertext = oauthState.refreshTokenCiphertext; | |
| 586 | − if (!refreshTokenCiphertext) { | |
| 587 | − return { | |
| 588 | − ok: false, | |
| 589 | − errorClass: "unsupported", | |
| 590 | − message: "Refresh token is not available", | |
| 591 | − }; | |
| 592 | − } | |
| 593 | − | |
| 594 | − const refreshToken = await decryptSecretValue(refreshTokenCiphertext); | |
| 595 | − const clientId = normalizeString(refreshConfig.clientId); | |
| 596 | − if (!clientId) { | |
| 597 | − return { | |
| 598 | − ok: false, | |
| 599 | − errorClass: "unsupported", | |
| 600 | − message: "OAuth client id is required to refresh token", | |
| 601 | − }; | |
| 602 | − } | |
| 603 | − | |
| 604 | − const tokenEndpoint = normalizeString(refreshConfig.tokenEndpoint) | |
| 605 | − ?? (refreshConfig.authorizationServer | |
| 606 | − ? await resolveTokenEndpointFromAuthorizationServer(refreshConfig.authorizationServer) | |
| 607 | − : null); | |
| 608 | − | |
| 609 | − if (!tokenEndpoint) { | |
| 610 | − return { | |
| 611 | − ok: false, | |
| 612 | − errorClass: "unsupported", | |
| 613 | − message: "OAuth token endpoint is not configured", | |
| 614 | − }; | |
| 615 | − } | |
| 616 | − | |
| 617 | − const body = new URLSearchParams(); | |
| 618 | − body.set("grant_type", "refresh_token"); | |
| 619 | − body.set("refresh_token", refreshToken); | |
| 620 | − body.set("client_id", clientId); | |
| 621 | − | |
| 622 | − const clientSecretCiphertext = normalizeString(refreshConfig.clientSecretCiphertext); | |
| 623 | − if (clientSecretCiphertext) { | |
| 624 | − const decryptedClientSecret = await decryptSecretValue(clientSecretCiphertext); | |
| 625 | − if (decryptedClientSecret.trim().length > 0) { | |
| 626 | − body.set("client_secret", decryptedClientSecret.trim()); | |
| 627 | − } | |
| 628 | − } | |
| 629 | − | |
| 630 | − let response: Response; | |
| 631 | − try { | |
| 632 | − response = await fetch(tokenEndpoint, { | |
| 633 | − method: "POST", | |
| 634 | − headers: { | |
| 635 | − "content-type": "application/x-www-form-urlencoded", | |
| 636 | − accept: "application/json", | |
| 637 | − }, | |
| 638 | − body: body.toString(), | |
| 639 | − }); | |
| 640 | − } catch (error) { | |
| 641 | − return { | |
| 642 | − ok: false, | |
| 643 | − errorClass: "transient", | |
| 644 | − message: error instanceof Error ? error.message : "OAuth refresh request failed", | |
| 645 | − }; | |
| 646 | − } | |
| 647 | − | |
| 648 | − const payload = await readJsonResponse(response); | |
| 649 | − | |
| 650 | − if (!response.ok) { | |
| 651 | − const errorClass = classifyOAuthRefreshFailure(response.status, payload); | |
| 652 | − const message = normalizeString(payload.error_description) | |
| 653 | − ?? normalizeString(payload.error) | |
| 654 | − ?? `OAuth refresh failed (${response.status})`; | |
| 655 | − return { | |
| 656 | − ok: false, | |
| 657 | − errorClass, | |
| 658 | − message, | |
| 659 | − }; | |
| 660 | − } | |
| 661 | − | |
| 662 | − const accessToken = normalizeString(payload.access_token); | |
| 663 | − if (!accessToken) { | |
| 664 | − return { | |
| 665 | − ok: false, | |
| 666 | − errorClass: "unknown", | |
| 667 | − message: "OAuth refresh response is missing access_token", | |
| 668 | − }; | |
| 669 | − } | |
| 670 | − | |
| 671 | − const maybeExpiresIn = payload.expires_in; | |
| 672 | − const expiresAt = typeof maybeExpiresIn === "number" && Number.isFinite(maybeExpiresIn) | |
| 673 | − ? Date.now() + Math.max(0, Math.floor(maybeExpiresIn)) * 1000 | |
| 674 | − : null; | |
| 675 | − | |
| 676 | − const refreshTokenFromResponse = normalizeString(payload.refresh_token); | |
| 677 | − const scope = normalizeString(payload.scope) ?? oauthState.scope; | |
| 678 | − const tokenType = normalizeString(payload.token_type) ?? oauthState.tokenType; | |
| 679 | − | |
| 680 | − return { | |
| 681 | − ok: true, | |
| 682 | − accessToken, | |
| 683 | − refreshToken: refreshTokenFromResponse, | |
| 684 | − expiresAt, | |
| 685 | − scope, | |
| 686 | − issuer: oauthState.issuer, | |
| 687 | − tokenType, | |
| 688 | − }; | |
| 689 | −}; | |
| 690 | − | |
| 691 | −const insertAuthAuditEvent = async ( | |
| 692 | − ctx: MutationCtx, | |
| 693 | − input: { | |
| 694 | − organizationId: string; | |
| 695 | − connectionId: string; | |
| 696 | − sourceId: string | null; | |
| 697 | − eventType: "created" | "updated" | "bound" | "unbound" | "refresh_success" | "refresh_failed" | "reauth_required" | "revoked" | "deleted"; | |
| 698 | − actorType: "system" | "account"; | |
| 699 | − actorId?: string | null; | |
| 700 | − outcome: string; | |
| 701 | − reasonCode?: string | null; | |
| 702 | − detailsJson?: string | null; | |
| 703 | − correlationId?: string | null; | |
| 704 | − }, | |
| 705 | −): Promise<void> => { | |
| 706 | − const now = Date.now(); | |
| 707 | − await ctx.db.insert("authAuditEvents", { | |
| 708 | − id: `auth_event_${crypto.randomUUID()}`, | |
| 709 | − organizationId: input.organizationId, | |
| 710 | − connectionId: input.connectionId, | |
| 711 | − sourceId: input.sourceId, | |
| 712 | − eventType: input.eventType, | |
| 713 | − actorType: input.actorType, | |
| 714 | − actorId: input.actorId ?? null, | |
| 715 | − outcome: input.outcome, | |
| 716 | − reasonCode: input.reasonCode ?? null, | |
| 717 | − detailsJson: input.detailsJson ?? null, | |
| 718 | − correlationId: input.correlationId ?? null, | |
| 719 | − createdAt: now, | |
| 720 | − }); | |
| 721 | −}; | |
| 722 | − | |
| 723 | −const loadAuthConnectionById = async ( | |
| 724 | − ctx: QueryCtx | MutationCtx, | |
| 725 | − connectionId: string, | |
| 726 | −): Promise<AuthConnection | null> => { | |
| 727 | − const row = await ctx.db | |
| 728 | − .query("authConnections") | |
| 729 | − .withIndex("by_domainId", (q) => q.eq("id", connectionId)) | |
| 730 | − .unique(); | |
| 731 | − | |
| 732 | − if (!row) { | |
| 733 | − return null; | |
| 734 | − } | |
| 735 | − | |
| 736 | − return toAuthConnection(row as unknown as Record<string, unknown>); | |
| 737 | −}; | |
| 738 | − | |
| 739 | −const loadOAuthStateByConnectionId = async ( | |
| 740 | − ctx: QueryCtx | MutationCtx, | |
| 741 | − connectionId: string, | |
| 742 | −): Promise<OAuthState | null> => { | |
| 743 | − const row = await ctx.db | |
| 744 | − .query("oauthState") | |
| 745 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId)) | |
| 746 | − .unique(); | |
| 747 | − | |
| 748 | − if (!row) { | |
| 749 | − return null; | |
| 750 | − } | |
| 751 | − | |
| 752 | − return toOAuthState(row as unknown as Record<string, unknown>); | |
| 753 | −}; | |
| 754 | − | |
| 755 | −const loadAuthMaterialByConnectionId = async ( | |
| 756 | − ctx: QueryCtx | MutationCtx, | |
| 757 | − connectionId: string, | |
| 758 | −): Promise<AuthMaterial | null> => { | |
| 759 | − const row = await ctx.db | |
| 760 | − .query("authMaterials") | |
| 761 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId)) | |
| 762 | − .unique(); | |
| 763 | − | |
| 764 | − if (!row) { | |
| 765 | − return null; | |
| 766 | − } | |
| 767 | − | |
| 768 | − return toAuthMaterial(row as unknown as Record<string, unknown>); | |
| 769 | −}; | |
| 770 | − | |
| 771 | −const listBindingsForWorkspace = async ( | |
| 772 | − ctx: QueryCtx, | |
| 773 | − input: { | |
| 774 | − workspaceId: string; | |
| 775 | − organizationId: string; | |
| 776 | − }, | |
| 777 | −): Promise<Array<SourceAuthBinding>> => { | |
| 778 | − const workspaceRows = await ctx.db | |
| 779 | − .query("sourceAuthBindings") | |
| 780 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", input.workspaceId)) | |
| 781 | − .collect(); | |
| 782 | − | |
| 783 | − const organizationRows = await ctx.db | |
| 784 | − .query("sourceAuthBindings") | |
| 785 | − .withIndex("by_organizationId", (q) => q.eq("organizationId", input.organizationId)) | |
| 786 | − .collect(); | |
| 787 | − | |
| 788 | − const bindings = [...workspaceRows, ...organizationRows] | |
| 789 | − .map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>)) | |
| 790 | − .filter((binding) => | |
| 791 | − binding.workspaceId === input.workspaceId | |
| 792 | − || (binding.workspaceId === null && binding.organizationId === input.organizationId) | |
| 793 | − ); | |
| 794 | − | |
| 795 | − return Array.from(new Map(bindings.map((binding) => [binding.id, binding])).values()); | |
| 796 | −}; | |
| 797 | − | |
| 798 | −export const listCredentialBindings = internalQuery({ | |
| 799 | − args: { | |
| 800 | − workspaceId: v.string(), | |
| 801 | − }, | |
| 802 | − handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => { | |
| 803 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 804 | − const bindings = await listBindingsForWorkspace(ctx, { | |
| 805 | − workspaceId: args.workspaceId, | |
| 806 | − organizationId, | |
| 807 | − }); | |
| 808 | − | |
| 809 | − const compatBindings: Array<SourceCredentialBinding> = []; | |
| 810 | − | |
| 811 | − for (const binding of bindings) { | |
| 812 | − const connection = await loadAuthConnectionById(ctx, binding.connectionId); | |
| 813 | − if (!connection) { | |
| 814 | − continue; | |
| 815 | − } | |
| 816 | − | |
| 817 | − compatBindings.push(toCompatSourceCredentialBinding(binding, connection)); | |
| 818 | − } | |
| 819 | − | |
| 820 | − return sortCompatBindings(compatBindings); | |
| 821 | − }, | |
| 822 | −}); | |
| 823 | − | |
| 824 | −export const resolveSourceAuthSelection = internalQuery({ | |
| 825 | − args: { | |
| 826 | − workspaceId: v.string(), | |
| 827 | − sourceId: v.string(), | |
| 828 | − accountId: v.optional(v.union(v.string(), v.null())), | |
| 829 | − }, | |
| 830 | − handler: async ( | |
| 831 | − ctx, | |
| 832 | − args, | |
| 833 | − ): Promise<{ | |
| 834 | − binding: SourceAuthBinding | null; | |
| 835 | − connection: AuthConnection | null; | |
| 836 | − oauthState: OAuthState | null; | |
| 837 | − }> => { | |
| 838 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 839 | − | |
| 840 | − const rows = await ctx.db | |
| 841 | − .query("sourceAuthBindings") | |
| 842 | − .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | |
| 843 | − .collect(); | |
| 844 | − | |
| 845 | − const binding = selectBestBinding( | |
| 846 | − rows.map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>)), | |
| 847 | − { | |
| 848 | − workspaceId: args.workspaceId, | |
| 849 | − organizationId, | |
| 850 | − accountId: args.accountId ?? null, | |
| 851 | − }, | |
| 852 | − ); | |
| 853 | − | |
| 854 | − if (!binding) { | |
| 855 | − return { | |
| 856 | − binding: null, | |
| 857 | − connection: null, | |
| 858 | − oauthState: null, | |
| 859 | − }; | |
| 860 | − } | |
| 861 | − | |
| 862 | − const connection = await loadAuthConnectionById(ctx, binding.connectionId); | |
| 863 | − if (!connection) { | |
| 864 | − return { | |
| 865 | − binding: null, | |
| 866 | − connection: null, | |
| 867 | − oauthState: null, | |
| 868 | − }; | |
| 869 | − } | |
| 870 | − | |
| 871 | − const oauthState = connection.strategy === "oauth2" | |
| 872 | − ? await loadOAuthStateByConnectionId(ctx, connection.id) | |
| 873 | − : null; | |
| 874 | − | |
| 875 | − return { | |
| 876 | − binding, | |
| 877 | − connection, | |
| 878 | − oauthState, | |
| 879 | − }; | |
| 880 | − }, | |
| 881 | −}); | |
| 882 | − | |
| 883 | −export const beginOAuthRefresh = internalMutation({ | |
| 884 | − args: { | |
| 885 | − connectionId: v.string(), | |
| 886 | − leaseHolder: v.string(), | |
| 887 | − leaseMs: v.number(), | |
| 888 | − }, | |
| 889 | − handler: async ( | |
| 890 | − ctx, | |
| 891 | − args, | |
| 892 | − ): Promise< | |
| 893 | − | { | |
| 894 | − kind: "missing"; | |
| 895 | − } | |
| 896 | − | { | |
| 897 | − kind: "locked"; | |
| 898 | − leaseExpiresAt: number | null; | |
| 899 | − } | |
| 900 | − | { | |
| 901 | − kind: "ready"; | |
| 902 | − oauthState: OAuthState; | |
| 903 | − connection: AuthConnection; | |
| 904 | − } | |
| 905 | − > => { | |
| 906 | − const stateRow = await ctx.db | |
| 907 | − .query("oauthState") | |
| 908 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | |
| 909 | − .unique(); | |
| 910 | − const connectionRow = await ctx.db | |
| 911 | − .query("authConnections") | |
| 912 | − .withIndex("by_domainId", (q) => q.eq("id", args.connectionId)) | |
| 913 | − .unique(); | |
| 914 | − | |
| 915 | − if (!stateRow || !connectionRow) { | |
| 916 | − return { kind: "missing" }; | |
| 917 | − } | |
| 918 | − | |
| 919 | − const state = toOAuthState(stateRow as unknown as Record<string, unknown>); | |
| 920 | − const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | |
| 921 | − const now = Date.now(); | |
| 922 | − const leaseExpiresAt = state.leaseExpiresAt; | |
| 923 | − const leaseActive = | |
| 924 | − state.leaseHolder !== null | |
| 925 | − && leaseExpiresAt !== null | |
| 926 | − && leaseExpiresAt > now | |
| 927 | − && state.leaseHolder !== args.leaseHolder; | |
| 928 | − | |
| 929 | − if (leaseActive) { | |
| 930 | − return { | |
| 931 | − kind: "locked", | |
| 932 | − leaseExpiresAt, | |
| 933 | − }; | |
| 934 | − } | |
| 935 | − | |
| 936 | − await ctx.db.patch(stateRow._id, { | |
| 937 | − leaseHolder: args.leaseHolder, | |
| 938 | − leaseExpiresAt: now + Math.max(1_000, Math.floor(args.leaseMs)), | |
| 939 | − leaseFence: state.leaseFence + 1, | |
| 940 | − lastRefreshErrorClass: null, | |
| 941 | − lastRefreshError: null, | |
| 942 | − updatedAt: now, | |
| 943 | − }); | |
| 944 | − | |
| 945 | − const nextState = await ctx.db | |
| 946 | − .query("oauthState") | |
| 947 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | |
| 948 | − .unique(); | |
| 949 | − | |
| 950 | − if (!nextState) { | |
| 951 | − return { kind: "missing" }; | |
| 952 | − } | |
| 953 | − | |
| 954 | − return { | |
| 955 | − kind: "ready", | |
| 956 | − oauthState: toOAuthState(nextState as unknown as Record<string, unknown>), | |
| 957 | − connection, | |
| 958 | − }; | |
| 959 | − }, | |
| 960 | −}); | |
| 961 | − | |
| 962 | −export const finalizeOAuthRefreshSuccess = internalMutation({ | |
| 963 | − args: { | |
| 964 | − connectionId: v.string(), | |
| 965 | − leaseHolder: v.string(), | |
| 966 | − expectedTokenVersion: v.number(), | |
| 967 | − expectedLeaseFence: v.number(), | |
| 968 | − accessTokenCiphertext: v.string(), | |
| 969 | − refreshTokenCiphertext: v.optional(v.union(v.string(), v.null())), | |
| 970 | − expiresAt: v.optional(v.union(v.number(), v.null())), | |
| 971 | − scope: v.optional(v.union(v.string(), v.null())), | |
| 972 | − tokenType: v.optional(v.union(v.string(), v.null())), | |
| 973 | − issuer: v.optional(v.union(v.string(), v.null())), | |
| 974 | − }, | |
| 975 | − handler: async (ctx, args): Promise<{ committed: boolean }> => { | |
| 976 | − const stateRow = await ctx.db | |
| 977 | − .query("oauthState") | |
| 978 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | |
| 979 | − .unique(); | |
| 980 | − const connectionRow = await ctx.db | |
| 981 | − .query("authConnections") | |
| 982 | − .withIndex("by_domainId", (q) => q.eq("id", args.connectionId)) | |
| 983 | − .unique(); | |
| 984 | − | |
| 985 | − if (!stateRow || !connectionRow) { | |
| 986 | − return { committed: false }; | |
| 987 | − } | |
| 988 | − | |
| 989 | − const state = toOAuthState(stateRow as unknown as Record<string, unknown>); | |
| 990 | − const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | |
| 991 | − | |
| 992 | − if (state.leaseHolder !== args.leaseHolder) { | |
| 993 | − return { committed: false }; | |
| 994 | − } | |
| 995 | − | |
| 996 | − if (state.tokenVersion !== args.expectedTokenVersion) { | |
| 997 | − return { committed: false }; | |
| 998 | − } | |
| 999 | − | |
| 1000 | − if (state.leaseFence !== args.expectedLeaseFence) { | |
| 1001 | − return { committed: false }; | |
| 1002 | − } | |
| 1003 | − | |
| 1004 | − const now = Date.now(); | |
| 1005 | − const nextRefreshToken = args.refreshTokenCiphertext === undefined | |
| 1006 | − ? state.refreshTokenCiphertext | |
| 1007 | − : args.refreshTokenCiphertext; | |
| 1008 | − | |
| 1009 | − await ctx.db.patch(stateRow._id, { | |
| 1010 | − accessTokenCiphertext: args.accessTokenCiphertext, | |
| 1011 | − refreshTokenCiphertext: nextRefreshToken, | |
| 1012 | − expiresAt: args.expiresAt !== undefined ? args.expiresAt : state.expiresAt, | |
| 1013 | − scope: args.scope !== undefined ? args.scope : state.scope, | |
| 1014 | − tokenType: args.tokenType !== undefined ? args.tokenType : state.tokenType, | |
| 1015 | − issuer: args.issuer !== undefined ? args.issuer : state.issuer, | |
| 1016 | − tokenVersion: state.tokenVersion + 1, | |
| 1017 | − leaseHolder: null, | |
| 1018 | − leaseExpiresAt: null, | |
| 1019 | − lastRefreshAt: now, | |
| 1020 | − lastRefreshErrorClass: null, | |
| 1021 | − lastRefreshError: null, | |
| 1022 | − reauthRequiredAt: null, | |
| 1023 | − updatedAt: now, | |
| 1024 | − }); | |
| 1025 | − | |
| 1026 | − await ctx.db.patch(connectionRow._id, { | |
| 1027 | − status: "active", | |
| 1028 | − statusReason: null, | |
| 1029 | − lastAuthErrorClass: null, | |
| 1030 | − lastUsedAt: now, | |
| 1031 | − updatedAt: now, | |
| 1032 | − }); | |
| 1033 | − | |
| 1034 | − await insertAuthAuditEvent(ctx, { | |
| 1035 | − organizationId: connection.organizationId, | |
| 1036 | − connectionId: connection.id, | |
| 1037 | − sourceId: null, | |
| 1038 | − eventType: "refresh_success", | |
| 1039 | − actorType: "system", | |
| 1040 | − outcome: "success", | |
| 1041 | − }); | |
| 1042 | − | |
| 1043 | − return { committed: true }; | |
| 1044 | − }, | |
| 1045 | −}); | |
| 1046 | − | |
| 1047 | −export const finalizeOAuthRefreshFailure = internalMutation({ | |
| 1048 | − args: { | |
| 1049 | − connectionId: v.string(), | |
| 1050 | − leaseHolder: v.string(), | |
| 1051 | − errorClass: v.string(), | |
| 1052 | − message: v.string(), | |
| 1053 | − reauthRequired: v.boolean(), | |
| 1054 | − }, | |
| 1055 | − handler: async (ctx, args): Promise<void> => { | |
| 1056 | − const stateRow = await ctx.db | |
| 1057 | − .query("oauthState") | |
| 1058 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId)) | |
| 1059 | − .unique(); | |
| 1060 | − const connectionRow = await ctx.db | |
| 1061 | − .query("authConnections") | |
| 1062 | − .withIndex("by_domainId", (q) => q.eq("id", args.connectionId)) | |
| 1063 | − .unique(); | |
| 1064 | − | |
| 1065 | − if (!stateRow || !connectionRow) { | |
| 1066 | − return; | |
| 1067 | − } | |
| 1068 | − | |
| 1069 | − const state = toOAuthState(stateRow as unknown as Record<string, unknown>); | |
| 1070 | − const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | |
| 1071 | − | |
| 1072 | − if (state.leaseHolder !== args.leaseHolder) { | |
| 1073 | − return; | |
| 1074 | − } | |
| 1075 | − | |
| 1076 | − const now = Date.now(); | |
| 1077 | − await ctx.db.patch(stateRow._id, { | |
| 1078 | − leaseHolder: null, | |
| 1079 | − leaseExpiresAt: null, | |
| 1080 | − lastRefreshErrorClass: args.errorClass, | |
| 1081 | − lastRefreshError: args.message, | |
| 1082 | − reauthRequiredAt: args.reauthRequired ? now : state.reauthRequiredAt, | |
| 1083 | − updatedAt: now, | |
| 1084 | − }); | |
| 1085 | − | |
| 1086 | − await ctx.db.patch(connectionRow._id, { | |
| 1087 | − status: args.reauthRequired ? "reauth_required" : "error", | |
| 1088 | − statusReason: args.message, | |
| 1089 | − lastAuthErrorClass: args.errorClass, | |
| 1090 | − updatedAt: now, | |
| 1091 | − }); | |
| 1092 | − | |
| 1093 | − await insertAuthAuditEvent(ctx, { | |
| 1094 | − organizationId: connection.organizationId, | |
| 1095 | − connectionId: connection.id, | |
| 1096 | − sourceId: null, | |
| 1097 | − eventType: args.reauthRequired ? "reauth_required" : "refresh_failed", | |
| 1098 | − actorType: "system", | |
| 1099 | − outcome: "failure", | |
| 1100 | − reasonCode: args.errorClass, | |
| 1101 | − detailsJson: JSON.stringify({ message: args.message }), | |
| 1102 | − }); | |
| 1103 | − }, | |
| 1104 | −}); | |
| 1105 | − | |
| 1106 | −const refreshOAuthStateIfNeeded = async ( | |
| 1107 | − ctx: any, | |
| 1108 | − input: { | |
| 1109 | − connectionId: string; | |
| 1110 | − forceRefresh: boolean; | |
| 1111 | − oauthState: OAuthState; | |
| 1112 | − }, | |
| 1113 | −): Promise<OAuthState | null> => { | |
| 1114 | − const now = Date.now(); | |
| 1115 | − const isExpiring = | |
| 1116 | − input.oauthState.expiresAt !== null | |
| 1117 | − && input.oauthState.expiresAt <= now + oauthRefreshWindowMs; | |
| 1118 | − const shouldAttemptRefresh = input.forceRefresh || isExpiring; | |
| 1119 | − | |
| 1120 | − if (!shouldAttemptRefresh) { | |
| 1121 | − return input.oauthState; | |
| 1122 | − } | |
| 1123 | − | |
| 1124 | − const leaseHolder = `oauth_refresh_${crypto.randomUUID()}`; | |
| 1125 | − const begin = await ctx.runMutation( | |
| 1126 | − runtimeInternal.control_plane.credentials.beginOAuthRefresh, | |
| 1127 | − { | |
| 1128 | − connectionId: input.connectionId, | |
| 1129 | − leaseHolder, | |
| 1130 | − leaseMs: oauthRefreshLeaseMs, | |
| 1131 | − }, | |
| 1132 | − ); | |
| 1133 | − | |
| 1134 | − if (begin.kind === "missing") { | |
| 1135 | − return null; | |
| 1136 | − } | |
| 1137 | − | |
| 1138 | − if (begin.kind === "locked") { | |
| 1139 | − const fallback = await ctx.runQuery( | |
| 1140 | − runtimeInternal.control_plane.credentials.getOAuthStateByConnection, | |
| 1141 | − { | |
| 1142 | − connectionId: input.connectionId, | |
| 1143 | − }, | |
| 1144 | − ); | |
| 1145 | − return fallback; | |
| 1146 | − } | |
| 1147 | − | |
| 1148 | − const state = begin.oauthState; | |
| 1149 | − const refreshConfig = parseOAuthRefreshConfig(state.refreshConfigJson); | |
| 1150 | − | |
| 1151 | − const refreshed = await refreshTokenFromOAuthEndpoint(state, refreshConfig); | |
| 1152 | − if (!refreshed.ok) { | |
| 1153 | − await ctx.runMutation( | |
| 1154 | − runtimeInternal.control_plane.credentials.finalizeOAuthRefreshFailure, | |
| 1155 | − { | |
| 1156 | − connectionId: input.connectionId, | |
| 1157 | − leaseHolder, | |
| 1158 | − errorClass: refreshed.errorClass, | |
| 1159 | − message: refreshed.message, | |
| 1160 | − reauthRequired: | |
| 1161 | − refreshed.errorClass === "invalid_grant" | |
| 1162 | − || refreshed.errorClass === "invalid_client" | |
| 1163 | − || refreshed.errorClass === "unsupported", | |
| 1164 | − }, | |
| 1165 | − ); | |
| 1166 | − | |
| 1167 | − const fallback = await ctx.runQuery( | |
| 1168 | − runtimeInternal.control_plane.credentials.getOAuthStateByConnection, | |
| 1169 | − { | |
| 1170 | − connectionId: input.connectionId, | |
| 1171 | − }, | |
| 1172 | − ); | |
| 1173 | − return fallback; | |
| 1174 | − } | |
| 1175 | − | |
| 1176 | − const accessTokenCiphertext = await encryptSecretValue(refreshed.accessToken); | |
| 1177 | − const refreshTokenCiphertext = refreshed.refreshToken | |
| 1178 | − ? await encryptSecretValue(refreshed.refreshToken) | |
| 1179 | − : undefined; | |
| 1180 | − | |
| 1181 | − await ctx.runMutation( | |
| 1182 | − runtimeInternal.control_plane.credentials.finalizeOAuthRefreshSuccess, | |
| 1183 | − { | |
| 1184 | − connectionId: input.connectionId, | |
| 1185 | − leaseHolder, | |
| 1186 | − expectedTokenVersion: state.tokenVersion, | |
| 1187 | − expectedLeaseFence: state.leaseFence, | |
| 1188 | − accessTokenCiphertext, | |
| 1189 | − ...(refreshTokenCiphertext !== undefined | |
| 1190 | − ? { refreshTokenCiphertext } | |
| 1191 | − : {}), | |
| 1192 | − ...(refreshed.expiresAt !== null ? { expiresAt: refreshed.expiresAt } : {}), | |
| 1193 | − ...(refreshed.scope !== null ? { scope: refreshed.scope } : {}), | |
| 1194 | − ...(refreshed.tokenType !== null ? { tokenType: refreshed.tokenType } : {}), | |
| 1195 | − ...(refreshed.issuer !== null ? { issuer: refreshed.issuer } : {}), | |
| 1196 | − }, | |
| 1197 | − ); | |
| 1198 | − | |
| 1199 | − return await ctx.runQuery( | |
| 1200 | − runtimeInternal.control_plane.credentials.getOAuthStateByConnection, | |
| 1201 | − { | |
| 1202 | − connectionId: input.connectionId, | |
| 1203 | − }, | |
| 1204 | − ); | |
| 1205 | −}; | |
| 1206 | − | |
| 1207 | −export const getOAuthStateByConnection = internalQuery({ | |
| 1208 | − args: { | |
| 1209 | − connectionId: v.string(), | |
| 1210 | − }, | |
| 1211 | − handler: async (ctx, args): Promise<OAuthState | null> => | |
| 1212 | − await loadOAuthStateByConnectionId(ctx, args.connectionId), | |
| 1213 | −}); | |
| 1214 | − | |
| 1215 | −export const resolveSourceCredentialHeaders = internalAction({ | |
| 1216 | − args: { | |
| 1217 | − workspaceId: v.string(), | |
| 1218 | − sourceId: v.string(), | |
| 1219 | − accountId: v.optional(v.union(v.string(), v.null())), | |
| 1220 | − forceRefresh: v.optional(v.boolean()), | |
| 1221 | − }, | |
| 1222 | − handler: async ( | |
| 1223 | − ctx, | |
| 1224 | − args, | |
| 1225 | − ): Promise<{ | |
| 1226 | − headers: Record<string, string>; | |
| 1227 | − oauthConnectionId: string | null; | |
| 1228 | − }> => { | |
| 1229 | − const selected = await ctx.runQuery( | |
| 1230 | − runtimeInternal.control_plane.credentials.resolveSourceAuthSelection, | |
| 1231 | − { | |
| 1232 | − workspaceId: args.workspaceId, | |
| 1233 | − sourceId: args.sourceId, | |
| 1234 | − accountId: args.accountId ?? null, | |
| 1235 | − }, | |
| 1236 | − ); | |
| 1237 | − | |
| 1238 | − if (!selected.binding || !selected.connection) { | |
| 1239 | − return { | |
| 1240 | − headers: {}, | |
| 1241 | − oauthConnectionId: null, | |
| 1242 | − }; | |
| 1243 | − } | |
| 1244 | − | |
| 1245 | − const connection = selected.connection; | |
| 1246 | − const connectionStatus = normalizeAuthConnectionStatus(connection); | |
| 1247 | − if (connectionStatus !== "active") { | |
| 1248 | − return { | |
| 1249 | − headers: {}, | |
| 1250 | − oauthConnectionId: connection.strategy === "oauth2" ? connection.id : null, | |
| 1251 | − }; | |
| 1252 | − } | |
| 1253 | − | |
| 1254 | − const metadata = parseConnectionMetadata(connection.metadataJson); | |
| 1255 | − const additionalHeaders = parseAdditionalHeaders(connection.additionalHeadersJson); | |
| 1256 | − | |
| 1257 | − if (connection.strategy === "oauth2") { | |
| 1258 | − let oauthState = selected.oauthState; | |
| 1259 | − if (!oauthState) { | |
| 1260 | − return { | |
| 1261 | − headers: additionalHeaders, | |
| 1262 | − oauthConnectionId: connection.id, | |
| 1263 | − }; | |
| 1264 | − } | |
| 1265 | − | |
| 1266 | − oauthState = await refreshOAuthStateIfNeeded(ctx, { | |
| 1267 | − connectionId: connection.id, | |
| 1268 | − forceRefresh: args.forceRefresh === true, | |
| 1269 | − oauthState, | |
| 1270 | − }); | |
| 1271 | − | |
| 1272 | − if (!oauthState) { | |
| 1273 | − return { | |
| 1274 | − headers: additionalHeaders, | |
| 1275 | − oauthConnectionId: connection.id, | |
| 1276 | − }; | |
| 1277 | − } | |
| 1278 | − | |
| 1279 | − const now = Date.now(); | |
| 1280 | − if (oauthState.expiresAt !== null && oauthState.expiresAt <= now) { | |
| 1281 | − return { | |
| 1282 | − headers: additionalHeaders, | |
| 1283 | − oauthConnectionId: connection.id, | |
| 1284 | − }; | |
| 1285 | − } | |
| 1286 | − | |
| 1287 | − const accessToken = await decryptSecretValue(oauthState.accessTokenCiphertext); | |
| 1288 | − const oauthHeaders = accessToken.trim().length > 0 | |
| 1289 | − ? ({ Authorization: `Bearer ${accessToken}` } as Record<string, string>) | |
| 1290 | − : {}; | |
| 1291 | − | |
| 1292 | − return { | |
| 1293 | − headers: mergeHeaders(oauthHeaders, additionalHeaders), | |
| 1294 | − oauthConnectionId: connection.id, | |
| 1295 | − }; | |
| 1296 | − } | |
| 1297 | − | |
| 1298 | − const material = await ctx.runQuery( | |
| 1299 | − runtimeInternal.control_plane.credentials.getAuthMaterialByConnection, | |
| 1300 | − { | |
| 1301 | − connectionId: connection.id, | |
| 1302 | − }, | |
| 1303 | − ); | |
| 1304 | − | |
| 1305 | − if (!material) { | |
| 1306 | − return { | |
| 1307 | − headers: additionalHeaders, | |
| 1308 | − oauthConnectionId: null, | |
| 1309 | − }; | |
| 1310 | − } | |
| 1311 | − | |
| 1312 | − const secret = await decryptSecretValue(material.ciphertext); | |
| 1313 | − const strategyHeaders = buildSecretHeaders(connection.strategy, secret, metadata); | |
| 1314 | − | |
| 1315 | − return { | |
| 1316 | − headers: mergeHeaders(strategyHeaders, additionalHeaders), | |
| 1317 | − oauthConnectionId: null, | |
| 1318 | − }; | |
| 1319 | − }, | |
| 1320 | −}); | |
| 1321 | − | |
| 1322 | −export const getAuthMaterialByConnection = internalQuery({ | |
| 1323 | − args: { | |
| 1324 | − connectionId: v.string(), | |
| 1325 | − }, | |
| 1326 | − handler: async (ctx, args): Promise<AuthMaterial | null> => | |
| 1327 | − await loadAuthMaterialByConnectionId(ctx, args.connectionId), | |
| 1328 | −}); | |
| 1329 | − | |
| 1330 | −const coerceScopeOwnerType = ( | |
| 1331 | − scopeType: SourceCredentialBinding["scopeType"], | |
| 1332 | −): AuthConnection["ownerType"] => { | |
| 1333 | − if (scopeType === "organization") return "organization"; | |
| 1334 | − if (scopeType === "account") return "account"; | |
| 1335 | − return "workspace"; | |
| 1336 | −}; | |
| 1337 | − | |
| 1338 | −const buildOAuthRefreshConfigFromPayload = ( | |
| 1339 | − payload: ExtendedUpsertCredentialBindingPayload, | |
| 1340 | − existing: OAuthRefreshConfig, | |
| 1341 | − encryptedClientSecret: string | null, | |
| 1342 | −): OAuthRefreshConfig => ({ | |
| 1343 | − tokenEndpoint: | |
| 1344 | − normalizeString(payload.oauthTokenEndpoint) | |
| 1345 | − ?? existing.tokenEndpoint, | |
| 1346 | − authorizationServer: | |
| 1347 | − normalizeString(payload.oauthAuthorizationServer) | |
| 1348 | − ?? existing.authorizationServer, | |
| 1349 | − clientId: normalizeString(payload.oauthClientId) ?? existing.clientId, | |
| 1350 | − clientSecretCiphertext: | |
| 1351 | − encryptedClientSecret | |
| 1352 | − ?? existing.clientSecretCiphertext, | |
| 1353 | − sourceUrl: normalizeString(payload.oauthSourceUrl) ?? existing.sourceUrl, | |
| 1354 | − clientInformationJson: | |
| 1355 | − normalizeString(payload.oauthClientInformationJson) | |
| 1356 | − ?? existing.clientInformationJson, | |
| 1357 | −}); | |
| 1358 | − | |
| 1359 | −export const upsertCredentialBindingRecord = internalMutation({ | |
| 1360 | − args: { | |
| 1361 | − workspaceId: v.string(), | |
| 1362 | − payload: sourceCredentialBindingPayloadValidator, | |
| 1363 | − secretCiphertext: v.string(), | |
| 1364 | − oauthRefreshCiphertext: v.optional(v.union(v.string(), v.null())), | |
| 1365 | − oauthClientSecretCiphertext: v.optional(v.union(v.string(), v.null())), | |
| 1366 | − }, | |
| 1367 | − handler: async (ctx, args): Promise<SourceCredentialBinding> => { | |
| 1368 | − const payload = args.payload as ExtendedUpsertCredentialBindingPayload; | |
| 1369 | − const now = Date.now(); | |
| 1370 | − const sourceId = sourceIdFromSourceKey(payload.sourceKey); | |
| 1371 | − if (!sourceId) { | |
| 1372 | − throw new Error("Credentials require sourceKey in the form 'source:<id>'"); | |
| 1373 | − } | |
| 1374 | − | |
| 1375 | − if (payload.scopeType === "account" && payload.accountId === null) { | |
| 1376 | − throw new Error("Account scope credentials require accountId"); | |
| 1377 | − } | |
| 1378 | − | |
| 1379 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 1380 | − | |
| 1381 | − const existingBindingRow = payload.id | |
| 1382 | − ? await ctx.db | |
| 1383 | − .query("sourceAuthBindings") | |
| 1384 | − .withIndex("by_domainId", (q) => q.eq("id", payload.id!)) | |
| 1385 | − .unique() | |
| 1386 | − : null; | |
| 1387 | − const existingBinding = existingBindingRow | |
| 1388 | − ? toSourceAuthBinding(existingBindingRow as unknown as Record<string, unknown>) | |
| 1389 | − : null; | |
| 1390 | − | |
| 1391 | − const scopeWorkspaceId = payload.scopeType === "workspace" ? args.workspaceId : null; | |
| 1392 | − const scopeAccountId = payload.scopeType === "account" ? (payload.accountId ?? null) : null; | |
| 1393 | − | |
| 1394 | − const resolvedBindingId = existingBinding?.id | |
| 1395 | − ?? payload.id | |
| 1396 | − ?? (() => { | |
| 1397 | − return `auth_binding_${crypto.randomUUID()}`; | |
| 1398 | − })(); | |
| 1399 | − | |
| 1400 | − const requestedConnectionId = normalizeString(payload.credentialId) | |
| 1401 | − ?? existingBinding?.connectionId | |
| 1402 | − ?? `conn_${crypto.randomUUID()}`; | |
| 1403 | − | |
| 1404 | − const existingConnectionRow = await ctx.db | |
| 1405 | − .query("authConnections") | |
| 1406 | − .withIndex("by_domainId", (q) => q.eq("id", requestedConnectionId)) | |
| 1407 | − .unique(); | |
| 1408 | − const existingConnection = existingConnectionRow | |
| 1409 | − ? toAuthConnection(existingConnectionRow as unknown as Record<string, unknown>) | |
| 1410 | − : null; | |
| 1411 | − | |
| 1412 | − if (existingConnection && existingConnection.organizationId !== organizationId) { | |
| 1413 | − throw new Error("Connection id belongs to another organization"); | |
| 1414 | − } | |
| 1415 | − | |
| 1416 | − const nextConnection = decodeAuthConnection({ | |
| 1417 | − id: requestedConnectionId, | |
| 1418 | − organizationId, | |
| 1419 | − workspaceId: scopeWorkspaceId, | |
| 1420 | − accountId: scopeAccountId, | |
| 1421 | − ownerType: coerceScopeOwnerType(payload.scopeType), | |
| 1422 | − strategy: strategyFromProvider(payload.provider), | |
| 1423 | − displayName: | |
| 1424 | − normalizeString(existingConnection?.displayName) | |
| 1425 | − ?? sourceKeyFromSourceId(sourceId), | |
| 1426 | − status: "active", | |
| 1427 | − statusReason: null, | |
| 1428 | − lastAuthErrorClass: null, | |
| 1429 | − metadataJson: existingConnection?.metadataJson ?? null, | |
| 1430 | − additionalHeadersJson: | |
| 1431 | − payload.additionalHeadersJson !== undefined | |
| 1432 | − ? payload.additionalHeadersJson | |
| 1433 | − : existingConnection?.additionalHeadersJson ?? null, | |
| 1434 | − createdByAccountId: existingConnection?.createdByAccountId ?? null, | |
| 1435 | − createdAt: existingConnection?.createdAt ?? now, | |
| 1436 | − updatedAt: now, | |
| 1437 | − lastUsedAt: existingConnection?.lastUsedAt ?? null, | |
| 1438 | − }); | |
| 1439 | − | |
| 1440 | − if (existingConnectionRow) { | |
| 1441 | − await ctx.db.patch(existingConnectionRow._id, nextConnection); | |
| 1442 | − } else { | |
| 1443 | − await ctx.db.insert("authConnections", nextConnection); | |
| 1444 | − } | |
| 1445 | − | |
| 1446 | − const nextBinding = decodeSourceAuthBinding({ | |
| 1447 | − id: resolvedBindingId, | |
| 1448 | − sourceId, | |
| 1449 | − connectionId: requestedConnectionId, | |
| 1450 | − organizationId, | |
| 1451 | − workspaceId: scopeWorkspaceId, | |
| 1452 | − accountId: scopeAccountId, | |
| 1453 | − scopeType: payload.scopeType, | |
| 1454 | − selector: existingBinding?.selector ?? null, | |
| 1455 | − enabled: true, | |
| 1456 | − createdAt: existingBinding?.createdAt ?? now, | |
| 1457 | − updatedAt: now, | |
| 1458 | − }); | |
| 1459 | − | |
| 1460 | − if (existingBindingRow) { | |
| 1461 | − await ctx.db.patch(existingBindingRow._id, nextBinding); | |
| 1462 | − } else { | |
| 1463 | − await ctx.db.insert("sourceAuthBindings", nextBinding); | |
| 1464 | − } | |
| 1465 | − | |
| 1466 | − if (nextConnection.strategy === "oauth2") { | |
| 1467 | − const existingOAuthRow = await ctx.db | |
| 1468 | − .query("oauthState") | |
| 1469 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | |
| 1470 | − .unique(); | |
| 1471 | − const existingOAuth = existingOAuthRow | |
| 1472 | − ? toOAuthState(existingOAuthRow as unknown as Record<string, unknown>) | |
| 1473 | − : null; | |
| 1474 | − | |
| 1475 | − const existingRefreshConfig = existingOAuth | |
| 1476 | − ? parseOAuthRefreshConfig(existingOAuth.refreshConfigJson) | |
| 1477 | − : {}; | |
| 1478 | − const refreshConfig = buildOAuthRefreshConfigFromPayload( | |
| 1479 | − payload, | |
| 1480 | − existingRefreshConfig, | |
| 1481 | − args.oauthClientSecretCiphertext ?? null, | |
| 1482 | − ); | |
| 1483 | − const refreshConfigJson = encodeOAuthRefreshConfig(refreshConfig); | |
| 1484 | − | |
| 1485 | − const oauthState = decodeOAuthState({ | |
| 1486 | − id: existingOAuth?.id ?? `oauth_state_${crypto.randomUUID()}`, | |
| 1487 | − connectionId: requestedConnectionId, | |
| 1488 | − accessTokenCiphertext: args.secretCiphertext, | |
| 1489 | − refreshTokenCiphertext: | |
| 1490 | − args.oauthRefreshCiphertext !== undefined | |
| 1491 | − ? args.oauthRefreshCiphertext | |
| 1492 | − : existingOAuth?.refreshTokenCiphertext ?? null, | |
| 1493 | − keyVersion: currentCredentialEncryptionKeyVersion(), | |
| 1494 | − expiresAt: | |
| 1495 | − payload.oauthExpiresAt !== undefined | |
| 1496 | − ? payload.oauthExpiresAt | |
| 1497 | − : existingOAuth?.expiresAt ?? null, | |
| 1498 | − scope: | |
| 1499 | − payload.oauthScope !== undefined | |
| 1500 | − ? payload.oauthScope | |
| 1501 | − : existingOAuth?.scope ?? null, | |
| 1502 | − tokenType: existingOAuth?.tokenType ?? "Bearer", | |
| 1503 | − issuer: | |
| 1504 | − payload.oauthIssuer !== undefined | |
| 1505 | − ? payload.oauthIssuer | |
| 1506 | − : existingOAuth?.issuer ?? null, | |
| 1507 | − refreshConfigJson, | |
| 1508 | − tokenVersion: (existingOAuth?.tokenVersion ?? 0) + 1, | |
| 1509 | − leaseHolder: null, | |
| 1510 | − leaseExpiresAt: null, | |
| 1511 | − leaseFence: existingOAuth?.leaseFence ?? 0, | |
| 1512 | − lastRefreshAt: existingOAuth?.lastRefreshAt ?? null, | |
| 1513 | − lastRefreshErrorClass: null, | |
| 1514 | − lastRefreshError: null, | |
| 1515 | − reauthRequiredAt: null, | |
| 1516 | − createdAt: existingOAuth?.createdAt ?? now, | |
| 1517 | − updatedAt: now, | |
| 1518 | − }); | |
| 1519 | − | |
| 1520 | − if (existingOAuthRow) { | |
| 1521 | − await ctx.db.patch(existingOAuthRow._id, oauthState); | |
| 1522 | − } else { | |
| 1523 | − await ctx.db.insert("oauthState", oauthState); | |
| 1524 | − } | |
| 1525 | − | |
| 1526 | − const existingMaterialRow = await ctx.db | |
| 1527 | − .query("authMaterials") | |
| 1528 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | |
| 1529 | − .unique(); | |
| 1530 | − if (existingMaterialRow) { | |
| 1531 | − await ctx.db.delete(existingMaterialRow._id); | |
| 1532 | − } | |
| 1533 | − } else { | |
| 1534 | − const existingMaterialRow = await ctx.db | |
| 1535 | − .query("authMaterials") | |
| 1536 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | |
| 1537 | − .unique(); | |
| 1538 | − const existingMaterial = existingMaterialRow | |
| 1539 | − ? toAuthMaterial(existingMaterialRow as unknown as Record<string, unknown>) | |
| 1540 | − : null; | |
| 1541 | − | |
| 1542 | − const material = decodeAuthMaterial({ | |
| 1543 | − id: existingMaterial?.id ?? `auth_material_${crypto.randomUUID()}`, | |
| 1544 | − connectionId: requestedConnectionId, | |
| 1545 | − ciphertext: args.secretCiphertext, | |
| 1546 | − keyVersion: currentCredentialEncryptionKeyVersion(), | |
| 1547 | − createdAt: existingMaterial?.createdAt ?? now, | |
| 1548 | − updatedAt: now, | |
| 1549 | − }); | |
| 1550 | − | |
| 1551 | − if (existingMaterialRow) { | |
| 1552 | − await ctx.db.patch(existingMaterialRow._id, material); | |
| 1553 | − } else { | |
| 1554 | − await ctx.db.insert("authMaterials", material); | |
| 1555 | − } | |
| 1556 | − | |
| 1557 | − const existingOAuthRow = await ctx.db | |
| 1558 | − .query("oauthState") | |
| 1559 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId)) | |
| 1560 | − .unique(); | |
| 1561 | − if (existingOAuthRow) { | |
| 1562 | − await ctx.db.delete(existingOAuthRow._id); | |
| 1563 | − } | |
| 1564 | − } | |
| 1565 | − | |
| 1566 | − await insertAuthAuditEvent(ctx, { | |
| 1567 | − organizationId, | |
| 1568 | − connectionId: requestedConnectionId, | |
| 1569 | − sourceId, | |
| 1570 | − eventType: existingConnection ? "updated" : "created", | |
| 1571 | − actorType: "system", | |
| 1572 | − outcome: "success", | |
| 1573 | − }); | |
| 1574 | − | |
| 1575 | − await insertAuthAuditEvent(ctx, { | |
| 1576 | − organizationId, | |
| 1577 | − connectionId: requestedConnectionId, | |
| 1578 | − sourceId, | |
| 1579 | − eventType: "bound", | |
| 1580 | − actorType: "system", | |
| 1581 | − outcome: "success", | |
| 1582 | − }); | |
| 1583 | − | |
| 1584 | − return toCompatSourceCredentialBinding(nextBinding, nextConnection); | |
| 1585 | − }, | |
| 1586 | −}); | |
| 1587 | − | |
| 1588 | −export const upsertCredentialBinding = internalAction({ | |
| 1589 | − args: { | |
| 1590 | − workspaceId: v.string(), | |
| 1591 | − payload: sourceCredentialBindingPayloadValidator, | |
| 1592 | − }, | |
| 1593 | − handler: async (ctx, args): Promise<SourceCredentialBinding> => { | |
| 1594 | − const payload = args.payload as ExtendedUpsertCredentialBindingPayload; | |
| 1595 | − const requestedSecretRef = payload.secretRef.trim(); | |
| 1596 | − if (requestedSecretRef.length === 0) { | |
| 1597 | − throw new Error("Credential secret is required"); | |
| 1598 | − } | |
| 1599 | − | |
| 1600 | − const encryptedSecretRef = await encryptSecretValue(requestedSecretRef); | |
| 1601 | − const refreshToken = normalizeString(payload.oauthRefreshToken); | |
| 1602 | − const encryptedOauthRefreshToken = refreshToken | |
| 1603 | − ? await encryptSecretValue(refreshToken) | |
| 1604 | − : payload.oauthRefreshToken !== undefined | |
| 1605 | − ? null | |
| 1606 | − : undefined; | |
| 1607 | − | |
| 1608 | − const clientSecret = normalizeString(payload.oauthClientSecret); | |
| 1609 | − const encryptedOauthClientSecret = clientSecret | |
| 1610 | − ? await encryptSecretValue(clientSecret) | |
| 1611 | − : payload.oauthClientSecret !== undefined | |
| 1612 | − ? null | |
| 1613 | − : undefined; | |
| 1614 | − | |
| 1615 | − return await ctx.runMutation( | |
| 1616 | − runtimeInternal.control_plane.credentials.upsertCredentialBindingRecord, | |
| 1617 | − { | |
| 1618 | − workspaceId: args.workspaceId, | |
| 1619 | − payload, | |
| 1620 | − secretCiphertext: encryptedSecretRef, | |
| 1621 | − ...(encryptedOauthRefreshToken !== undefined | |
| 1622 | − ? { oauthRefreshCiphertext: encryptedOauthRefreshToken } | |
| 1623 | − : {}), | |
| 1624 | − ...(encryptedOauthClientSecret !== undefined | |
| 1625 | − ? { oauthClientSecretCiphertext: encryptedOauthClientSecret } | |
| 1626 | − : {}), | |
| 1627 | − }, | |
| 1628 | − ); | |
| 1629 | − }, | |
| 1630 | −}); | |
| 1631 | − | |
| 1632 | −export const removeCredentialBinding = internalMutation({ | |
| 1633 | − args: { | |
| 1634 | − workspaceId: v.string(), | |
| 1635 | − credentialBindingId: v.string(), | |
| 1636 | − }, | |
| 1637 | − handler: async ( | |
| 1638 | − ctx, | |
| 1639 | − args, | |
| 1640 | − ): Promise<{ | |
| 1641 | − removed: boolean; | |
| 1642 | − }> => { | |
| 1643 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 1644 | − | |
| 1645 | − const bindingRow = await ctx.db | |
| 1646 | − .query("sourceAuthBindings") | |
| 1647 | − .withIndex("by_domainId", (q) => q.eq("id", args.credentialBindingId)) | |
| 1648 | − .unique(); | |
| 1649 | − | |
| 1650 | − if (!bindingRow) { | |
| 1651 | − return { removed: false }; | |
| 1652 | − } | |
| 1653 | − | |
| 1654 | − const binding = toSourceAuthBinding(bindingRow as unknown as Record<string, unknown>); | |
| 1655 | − if ( | |
| 1656 | − binding.workspaceId !== args.workspaceId | |
| 1657 | − && !(binding.workspaceId === null && binding.organizationId === organizationId) | |
| 1658 | − ) { | |
| 1659 | − return { removed: false }; | |
| 1660 | − } | |
| 1661 | − | |
| 1662 | − await ctx.db.delete(bindingRow._id); | |
| 1663 | − | |
| 1664 | − const remainingBindings = await ctx.db | |
| 1665 | − .query("sourceAuthBindings") | |
| 1666 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | |
| 1667 | − .collect(); | |
| 1668 | − | |
| 1669 | − const connectionRow = await ctx.db | |
| 1670 | − .query("authConnections") | |
| 1671 | − .withIndex("by_domainId", (q) => q.eq("id", binding.connectionId)) | |
| 1672 | − .unique(); | |
| 1673 | − | |
| 1674 | − if (remainingBindings.length === 0) { | |
| 1675 | − const materialRow = await ctx.db | |
| 1676 | − .query("authMaterials") | |
| 1677 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | |
| 1678 | − .unique(); | |
| 1679 | − if (materialRow) { | |
| 1680 | − await ctx.db.delete(materialRow._id); | |
| 1681 | − } | |
| 1682 | − | |
| 1683 | − const oauthRow = await ctx.db | |
| 1684 | − .query("oauthState") | |
| 1685 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | |
| 1686 | − .unique(); | |
| 1687 | − if (oauthRow) { | |
| 1688 | − await ctx.db.delete(oauthRow._id); | |
| 1689 | − } | |
| 1690 | − | |
| 1691 | − if (connectionRow) { | |
| 1692 | − await ctx.db.delete(connectionRow._id); | |
| 1693 | − } | |
| 1694 | − } | |
| 1695 | − | |
| 1696 | − if (connectionRow) { | |
| 1697 | − const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>); | |
| 1698 | − await insertAuthAuditEvent(ctx, { | |
| 1699 | − organizationId: connection.organizationId, | |
| 1700 | − connectionId: connection.id, | |
| 1701 | − sourceId: binding.sourceId, | |
| 1702 | − eventType: "unbound", | |
| 1703 | − actorType: "system", | |
| 1704 | − outcome: "success", | |
| 1705 | − }); | |
| 1706 | − | |
| 1707 | − if (remainingBindings.length === 0) { | |
| 1708 | − await insertAuthAuditEvent(ctx, { | |
| 1709 | − organizationId: connection.organizationId, | |
| 1710 | − connectionId: connection.id, | |
| 1711 | − sourceId: null, | |
| 1712 | − eventType: "deleted", | |
| 1713 | − actorType: "system", | |
| 1714 | − outcome: "success", | |
| 1715 | − }); | |
| 1716 | − } | |
| 1717 | − } | |
| 1718 | − | |
| 1719 | − return { removed: true }; | |
| 1720 | − }, | |
| 1721 | −}); | |
| 1722 | − | |
| 1723 | −export const removeSourceAuthBindings = internalMutation({ | |
| 1724 | − args: { | |
| 1725 | − sourceId: v.string(), | |
| 1726 | − }, | |
| 1727 | − handler: async (ctx, args): Promise<void> => { | |
| 1728 | − const rows = await ctx.db | |
| 1729 | − .query("sourceAuthBindings") | |
| 1730 | − .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId)) | |
| 1731 | − .collect(); | |
| 1732 | − | |
| 1733 | − const bindingIds = rows.map((row) => (row as unknown as { id: string }).id); | |
| 1734 | − | |
| 1735 | − for (const bindingId of bindingIds) { | |
| 1736 | − const row = await ctx.db | |
| 1737 | − .query("sourceAuthBindings") | |
| 1738 | − .withIndex("by_domainId", (q) => q.eq("id", bindingId)) | |
| 1739 | − .unique(); | |
| 1740 | − if (!row) { | |
| 1741 | − continue; | |
| 1742 | − } | |
| 1743 | − | |
| 1744 | − const binding = toSourceAuthBinding(row as unknown as Record<string, unknown>); | |
| 1745 | − await ctx.db.delete(row._id); | |
| 1746 | − | |
| 1747 | − const remaining = await ctx.db | |
| 1748 | − .query("sourceAuthBindings") | |
| 1749 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | |
| 1750 | − .collect(); | |
| 1751 | − if (remaining.length > 0) { | |
| 1752 | − continue; | |
| 1753 | − } | |
| 1754 | − | |
| 1755 | − const material = await ctx.db | |
| 1756 | − .query("authMaterials") | |
| 1757 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | |
| 1758 | − .unique(); | |
| 1759 | − if (material) { | |
| 1760 | − await ctx.db.delete(material._id); | |
| 1761 | − } | |
| 1762 | − | |
| 1763 | − const oauth = await ctx.db | |
| 1764 | − .query("oauthState") | |
| 1765 | − .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId)) | |
| 1766 | − .unique(); | |
| 1767 | − if (oauth) { | |
| 1768 | − await ctx.db.delete(oauth._id); | |
| 1769 | − } | |
| 1770 | − | |
| 1771 | − const connection = await ctx.db | |
| 1772 | − .query("authConnections") | |
| 1773 | − .withIndex("by_domainId", (q) => q.eq("id", binding.connectionId)) | |
| 1774 | − .unique(); | |
| 1775 | − if (connection) { | |
| 1776 | − await ctx.db.delete(connection._id); | |
| 1777 | − } | |
| 1778 | − } | |
| 1779 | − }, | |
| 1780 | −}); | |
| @@ -1,82 +0,0 @@ | ||
| 1 | −import { SourceStoreError } from "@executor-v2/persistence-ports"; | |
| 2 | − | |
| 3 | −type ControlPlaneErrorCode = | |
| 4 | − | "bad_request" | |
| 5 | − | "unauthorized" | |
| 6 | − | "forbidden" | |
| 7 | − | "not_found" | |
| 8 | − | "method_not_allowed" | |
| 9 | − | "internal"; | |
| 10 | − | |
| 11 | −type ControlPlaneErrorResponse = { | |
| 12 | − ok: false; | |
| 13 | − error: { | |
| 14 | − code: ControlPlaneErrorCode; | |
| 15 | − message: string; | |
| 16 | − details: string | null; | |
| 17 | − }; | |
| 18 | −}; | |
| 19 | − | |
| 20 | −const renderCause = (cause: unknown): string => String(cause); | |
| 21 | − | |
| 22 | −export const toSourceStoreError = ( | |
| 23 | − operation: string, | |
| 24 | − cause: unknown, | |
| 25 | −): SourceStoreError => | |
| 26 | − new SourceStoreError({ | |
| 27 | − operation, | |
| 28 | − backend: "convex", | |
| 29 | − location: "control-plane", | |
| 30 | − message: "Convex control plane call failed", | |
| 31 | − reason: null, | |
| 32 | − details: renderCause(cause), | |
| 33 | − }); | |
| 34 | − | |
| 35 | −const toErrorCode = (status: number): ControlPlaneErrorCode => { | |
| 36 | − switch (status) { | |
| 37 | − case 400: | |
| 38 | − return "bad_request"; | |
| 39 | − case 401: | |
| 40 | − return "unauthorized"; | |
| 41 | − case 403: | |
| 42 | − return "forbidden"; | |
| 43 | − case 404: | |
| 44 | − return "not_found"; | |
| 45 | − case 405: | |
| 46 | − return "method_not_allowed"; | |
| 47 | − default: | |
| 48 | − return "internal"; | |
| 49 | − } | |
| 50 | −}; | |
| 51 | − | |
| 52 | −export const controlPlaneErrorResponse = ( | |
| 53 | − status: number, | |
| 54 | − message: string, | |
| 55 | − details: string | null, | |
| 56 | −): Response => | |
| 57 | − Response.json( | |
| 58 | − { | |
| 59 | − ok: false, | |
| 60 | − error: { | |
| 61 | − code: toErrorCode(status), | |
| 62 | − message, | |
| 63 | − details, | |
| 64 | − }, | |
| 65 | − } satisfies ControlPlaneErrorResponse, | |
| 66 | − { status }, | |
| 67 | − ); | |
| 68 | − | |
| 69 | −const isJsonResponse = (response: Response): boolean => | |
| 70 | − response.headers.get("content-type")?.toLowerCase().includes("application/json") ?? false; | |
| 71 | − | |
| 72 | −export const normalizeControlPlaneErrorResponse = (response: Response): Response => { | |
| 73 | − if (response.ok || isJsonResponse(response)) { | |
| 74 | − return response; | |
| 75 | − } | |
| 76 | − | |
| 77 | − return controlPlaneErrorResponse( | |
| 78 | − response.status, | |
| 79 | − "Control plane request failed", | |
| 80 | − response.statusText || null, | |
| 81 | − ); | |
| 82 | −}; | |
| @@ -1,121 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − ControlPlaneService, | |
| 3 | − controlPlaneOpenApiSpec, | |
| 4 | − makeControlPlaneWebHandler, | |
| 5 | −} from "@executor-v2/management-api"; | |
| 6 | −import * as Effect from "effect/Effect"; | |
| 7 | −import * as Layer from "effect/Layer"; | |
| 8 | − | |
| 9 | −import { httpAction, type ActionCtx } from "../_generated/server"; | |
| 10 | −import { ConvexControlPlaneActorLive } from "./actor"; | |
| 11 | −import { | |
| 12 | − controlPlaneErrorResponse, | |
| 13 | − normalizeControlPlaneErrorResponse, | |
| 14 | − toSourceStoreError, | |
| 15 | −} from "./errors"; | |
| 16 | −import { makeConvexControlPlaneService } from "./service"; | |
| 17 | − | |
| 18 | −const isOpenApiRequest = (request: Request): boolean => | |
| 19 | − new URL(request.url).pathname === "/v1/openapi.json"; | |
| 20 | − | |
| 21 | −const controlPlaneCorsHeaders = (request: Request): Headers => { | |
| 22 | − const origin = request.headers.get("origin"); | |
| 23 | − const requestedHeaders = request.headers.get("access-control-request-headers"); | |
| 24 | − const headers = new Headers(); | |
| 25 | − | |
| 26 | − headers.set("access-control-allow-origin", origin && origin.length > 0 ? origin : "*"); | |
| 27 | − headers.set("vary", "origin"); | |
| 28 | − headers.set("access-control-allow-methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); | |
| 29 | − if (requestedHeaders && requestedHeaders.trim().length > 0) { | |
| 30 | − headers.set("access-control-allow-headers", requestedHeaders); | |
| 31 | − } else { | |
| 32 | − headers.set( | |
| 33 | − "access-control-allow-headers", | |
| 34 | − [ | |
| 35 | − "content-type", | |
| 36 | − "authorization", | |
| 37 | − "traceparent", | |
| 38 | − "b3", | |
| 39 | − ].join(", "), | |
| 40 | − ); | |
| 41 | − } | |
| 42 | − headers.set("access-control-max-age", "86400"); | |
| 43 | − | |
| 44 | − return headers; | |
| 45 | −}; | |
| 46 | − | |
| 47 | −const withControlPlaneCors = (request: Request, response: Response): Response => { | |
| 48 | − const headers = new Headers(response.headers); | |
| 49 | − const corsHeaders = controlPlaneCorsHeaders(request); | |
| 50 | − | |
| 51 | − corsHeaders.forEach((value, name) => { | |
| 52 | − headers.set(name, value); | |
| 53 | − }); | |
| 54 | − | |
| 55 | − return new Response(response.body, { | |
| 56 | − status: response.status, | |
| 57 | − statusText: response.statusText, | |
| 58 | − headers, | |
| 59 | − }); | |
| 60 | −}; | |
| 61 | − | |
| 62 | −const handleControlPlaneRequest = ( | |
| 63 | − ctx: ActionCtx, | |
| 64 | − request: Request, | |
| 65 | −): Effect.Effect<Response, never> => | |
| 66 | − Effect.scoped( | |
| 67 | − Effect.gen(function* () { | |
| 68 | − if (request.method === "OPTIONS") { | |
| 69 | − return withControlPlaneCors(request, new Response(null, { status: 204 })); | |
| 70 | − } | |
| 71 | − | |
| 72 | − if (isOpenApiRequest(request)) { | |
| 73 | − return withControlPlaneCors( | |
| 74 | − request, | |
| 75 | − Response.json(controlPlaneOpenApiSpec, { status: 200 }), | |
| 76 | − ); | |
| 77 | − } | |
| 78 | − | |
| 79 | − const webHandler = makeControlPlaneWebHandler( | |
| 80 | − Layer.succeed(ControlPlaneService, makeConvexControlPlaneService(ctx)), | |
| 81 | − ConvexControlPlaneActorLive(ctx), | |
| 82 | − ); | |
| 83 | − | |
| 84 | − yield* Effect.addFinalizer(() => | |
| 85 | − Effect.tryPromise({ | |
| 86 | − try: () => webHandler.dispose(), | |
| 87 | − catch: (cause) => toSourceStoreError("controlPlane.dispose", cause), | |
| 88 | − }).pipe( | |
| 89 | − Effect.asVoid, | |
| 90 | − Effect.catchAll(() => Effect.void), | |
| 91 | − ), | |
| 92 | − ); | |
| 93 | − | |
| 94 | − const response = yield* Effect.tryPromise({ | |
| 95 | − try: () => webHandler.handler(request), | |
| 96 | − catch: (cause) => toSourceStoreError("controlPlane.http", cause), | |
| 97 | − }); | |
| 98 | − | |
| 99 | − return withControlPlaneCors( | |
| 100 | − request, | |
| 101 | − normalizeControlPlaneErrorResponse(response), | |
| 102 | − ); | |
| 103 | − }), | |
| 104 | − ).pipe( | |
| 105 | − Effect.catchAll((error) => | |
| 106 | − Effect.succeed( | |
| 107 | − withControlPlaneCors( | |
| 108 | − request, | |
| 109 | − controlPlaneErrorResponse( | |
| 110 | − 500, | |
| 111 | − "Control plane request failed", | |
| 112 | − error.details, | |
| 113 | − ), | |
| 114 | − ), | |
| 115 | − ), | |
| 116 | − ), | |
| 117 | − ); | |
| 118 | − | |
| 119 | −export const controlPlaneHttpHandler = httpAction((ctx, request) => | |
| 120 | − Effect.runPromise(handleControlPlaneRequest(ctx, request)), | |
| 121 | −); | |
| @@ -1,1538 +0,0 @@ | ||
| 1 | −"use node"; | |
| 2 | − | |
| 3 | −import { createHash } from "node:crypto"; | |
| 4 | − | |
| 5 | −import { type Source } from "@executor-v2/schema"; | |
| 6 | −import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 7 | −import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | |
| 8 | −import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 9 | −import { v } from "convex/values"; | |
| 10 | −import * as Effect from "effect/Effect"; | |
| 11 | −import * as Schema from "effect/Schema"; | |
| 12 | − | |
| 13 | −import { internal } from "../_generated/api"; | |
| 14 | −import { internalAction } from "../_generated/server"; | |
| 15 | − | |
| 16 | −const runtimeInternal = internal as any; | |
| 17 | − | |
| 18 | −const graphqlExtractorVersion = "graphql_v1"; | |
| 19 | −const mcpExtractorVersion = "mcp_v1"; | |
| 20 | −const writeBatchSize = 500; | |
| 21 | −const stagedArtifactBatchSize = 500; | |
| 22 | − | |
| 23 | −const graphqlSchemaRootsQuery = `query SchemaRoots { | |
| 24 | − __schema { | |
| 25 | − queryType { name } | |
| 26 | − mutationType { name } | |
| 27 | − } | |
| 28 | −}`; | |
| 29 | − | |
| 30 | −const graphqlRootFieldsQuery = `query RootFields($name: String!) { | |
| 31 | − __type(name: $name) { | |
| 32 | − name | |
| 33 | − fields { | |
| 34 | − name | |
| 35 | − description | |
| 36 | − args { | |
| 37 | − name | |
| 38 | − description | |
| 39 | − defaultValue | |
| 40 | − type { | |
| 41 | − kind | |
| 42 | − name | |
| 43 | − ofType { | |
| 44 | − kind | |
| 45 | − name | |
| 46 | − ofType { | |
| 47 | − kind | |
| 48 | − name | |
| 49 | − } | |
| 50 | − } | |
| 51 | − } | |
| 52 | − } | |
| 53 | − } | |
| 54 | − } | |
| 55 | −}`; | |
| 56 | − | |
| 57 | −type GraphqlIngestTool = { | |
| 58 | − toolId: string; | |
| 59 | − name: string; | |
| 60 | − description: string | null; | |
| 61 | − operationType: string; | |
| 62 | − fieldName: string; | |
| 63 | − operationHash: string; | |
| 64 | − invocationJson: string; | |
| 65 | −}; | |
| 66 | − | |
| 67 | −type McpIngestTool = { | |
| 68 | − toolId: string; | |
| 69 | − name: string; | |
| 70 | − description: string | null; | |
| 71 | − toolName: string; | |
| 72 | − operationHash: string; | |
| 73 | − invocationJson: string; | |
| 74 | −}; | |
| 75 | − | |
| 76 | −type McpTransport = "sse" | "streamable-http"; | |
| 77 | − | |
| 78 | −type McpConnection = { | |
| 79 | − client: Client; | |
| 80 | − close: () => Promise<void>; | |
| 81 | − transport: McpTransport; | |
| 82 | −}; | |
| 83 | − | |
| 84 | −const SourceIngestStatusSchema = Schema.Union( | |
| 85 | − Schema.Literal("error"), | |
| 86 | − Schema.Literal("auth_required"), | |
| 87 | −); | |
| 88 | − | |
| 89 | −type SourceIngestStatus = typeof SourceIngestStatusSchema.Type; | |
| 90 | − | |
| 91 | −class SourceIngestError extends Schema.TaggedError<SourceIngestError>()( | |
| 92 | − "SourceIngestError", | |
| 93 | − { | |
| 94 | − status: SourceIngestStatusSchema, | |
| 95 | − message: Schema.String, | |
| 96 | − }, | |
| 97 | −) {} | |
| 98 | − | |
| 99 | −type SourceIngestPrepared = { | |
| 100 | − protocol: "openapi" | "graphql" | "mcp"; | |
| 101 | − artifactId: string; | |
| 102 | − sourceHash: string; | |
| 103 | − toolCount: number; | |
| 104 | − namespace: string; | |
| 105 | − refHintTableJson: string | null; | |
| 106 | − artifactBatchCount: number; | |
| 107 | −}; | |
| 108 | − | |
| 109 | −const isUnknownRecord = (value: unknown): value is Record<string, unknown> => | |
| 110 | − typeof value === "object" && value !== null && !Array.isArray(value); | |
| 111 | − | |
| 112 | −const normalizeString = (value: unknown): string | null => { | |
| 113 | − if (typeof value !== "string") { | |
| 114 | − return null; | |
| 115 | − } | |
| 116 | − | |
| 117 | − const trimmed = value.trim(); | |
| 118 | − return trimmed.length > 0 ? trimmed : null; | |
| 119 | −}; | |
| 120 | − | |
| 121 | −const normalizeHttpUrl = (value: string): string | null => { | |
| 122 | − const trimmed = value.trim(); | |
| 123 | − if (trimmed.length === 0) { | |
| 124 | − return null; | |
| 125 | − } | |
| 126 | − | |
| 127 | − try { | |
| 128 | − const parsed = new URL(trimmed); | |
| 129 | − if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { | |
| 130 | − return null; | |
| 131 | − } | |
| 132 | − | |
| 133 | − return parsed.toString(); | |
| 134 | − } catch { | |
| 135 | − return null; | |
| 136 | − } | |
| 137 | −}; | |
| 138 | − | |
| 139 | −const stableStringify = (value: unknown): string => { | |
| 140 | − if (Array.isArray(value)) { | |
| 141 | − return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; | |
| 142 | − } | |
| 143 | − | |
| 144 | − if (isUnknownRecord(value)) { | |
| 145 | − const keys = Object.keys(value).sort(); | |
| 146 | − return `{${keys | |
| 147 | − .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) | |
| 148 | − .join(",")}}`; | |
| 149 | − } | |
| 150 | − | |
| 151 | − return JSON.stringify(value); | |
| 152 | −}; | |
| 153 | − | |
| 154 | −const hashUnknown = (value: unknown): string => | |
| 155 | − createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16); | |
| 156 | − | |
| 157 | −const sourceSlug = (name: string): string => { | |
| 158 | − const slug = name | |
| 159 | − .toLowerCase() | |
| 160 | − .replaceAll(/[^a-z0-9]+/g, "_") | |
| 161 | − .replaceAll(/^_+|_+$/g, ""); | |
| 162 | − | |
| 163 | − return slug.length > 0 ? slug : "graphql"; | |
| 164 | −}; | |
| 165 | − | |
| 166 | −const toolSegment = (value: string): string => { | |
| 167 | − const segment = value | |
| 168 | − .toLowerCase() | |
| 169 | − .replaceAll(/[^a-z0-9]+/g, "_") | |
| 170 | − .replaceAll(/^_+|_+$/g, ""); | |
| 171 | − | |
| 172 | − return segment.length > 0 ? segment : "field"; | |
| 173 | −}; | |
| 174 | − | |
| 175 | −const normalizeNamespacePart = (value: string): string => { | |
| 176 | − const normalized = value | |
| 177 | − .trim() | |
| 178 | − .toLowerCase() | |
| 179 | − .replace(/[^a-z0-9]+/g, "_") | |
| 180 | − .replace(/^_+|_+$/g, ""); | |
| 181 | − | |
| 182 | − return normalized.length > 0 ? normalized : "source"; | |
| 183 | −}; | |
| 184 | − | |
| 185 | −const sourceNamespace = (source: Source): string => { | |
| 186 | − const sourceIdSuffix = source.id.slice(-6).toLowerCase(); | |
| 187 | − return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`; | |
| 188 | −}; | |
| 189 | − | |
| 190 | −const sourceToolPath = (source: Source, toolId: string): string => | |
| 191 | − `${sourceNamespace(source)}.${toolId}`; | |
| 192 | − | |
| 193 | −const normalizedSearchText = (...parts: ReadonlyArray<string | null | undefined>): string => | |
| 194 | − parts | |
| 195 | − .map((part) => (typeof part === "string" ? part.trim().toLowerCase() : "")) | |
| 196 | − .filter((part) => part.length > 0) | |
| 197 | − .join(" "); | |
| 198 | − | |
| 199 | −const metadataSearchTerms = (metadataJson: string | null | undefined): ReadonlyArray<string> => { | |
| 200 | − if (!metadataJson) { | |
| 201 | − return []; | |
| 202 | − } | |
| 203 | − | |
| 204 | − const parsed = parseJsonObject(metadataJson); | |
| 205 | − const keys = ["method", "path", "operationType", "fieldName", "toolName"] as const; | |
| 206 | − const terms: Array<string> = []; | |
| 207 | − | |
| 208 | − for (const key of keys) { | |
| 209 | − const value = parsed[key]; | |
| 210 | − if (typeof value === "string" && value.trim().length > 0) { | |
| 211 | − terms.push(value); | |
| 212 | − } | |
| 213 | − } | |
| 214 | − | |
| 215 | − return terms; | |
| 216 | −}; | |
| 217 | − | |
| 218 | −const toWorkspaceToolMethod = (protocol: string, metadataJson: string | null | undefined): string => { | |
| 219 | − if (protocol !== "openapi") { | |
| 220 | − return "post"; | |
| 221 | − } | |
| 222 | − | |
| 223 | − const parsed = metadataJson ? parseJsonObject(metadataJson) : {}; | |
| 224 | − const method = typeof parsed.method === "string" ? parsed.method.trim().toLowerCase() : ""; | |
| 225 | − | |
| 226 | − if ( | |
| 227 | − method === "get" || | |
| 228 | − method === "post" || | |
| 229 | − method === "put" || | |
| 230 | − method === "patch" || | |
| 231 | − method === "delete" || | |
| 232 | − method === "head" || | |
| 233 | − method === "options" || | |
| 234 | − method === "trace" | |
| 235 | − ) { | |
| 236 | − return method; | |
| 237 | − } | |
| 238 | − | |
| 239 | − return "get"; | |
| 240 | −}; | |
| 241 | − | |
| 242 | −const parseJsonObject = (value: string): Record<string, unknown> => { | |
| 243 | − try { | |
| 244 | − const parsed = JSON.parse(value) as unknown; | |
| 245 | − return isUnknownRecord(parsed) ? parsed : {}; | |
| 246 | − } catch { | |
| 247 | − return {}; | |
| 248 | − } | |
| 249 | −}; | |
| 250 | − | |
| 251 | −const safeJsonStringify = (value: unknown): string | null => { | |
| 252 | − try { | |
| 253 | − return JSON.stringify(value); | |
| 254 | − } catch { | |
| 255 | − return null; | |
| 256 | − } | |
| 257 | −}; | |
| 258 | − | |
| 259 | −const chunkArray = <A>(values: ReadonlyArray<A>, size: number): Array<Array<A>> => { | |
| 260 | − const chunkSize = Math.max(1, Math.floor(size)); | |
| 261 | − const chunks: Array<Array<A>> = []; | |
| 262 | − | |
| 263 | − for (let index = 0; index < values.length; index += chunkSize) { | |
| 264 | − chunks.push(values.slice(index, index + chunkSize)); | |
| 265 | − } | |
| 266 | − | |
| 267 | − return chunks; | |
| 268 | −}; | |
| 269 | − | |
| 270 | −const extractMcpSchemas = ( | |
| 271 | − invocationJson: string, | |
| 272 | −): { inputSchemaJson: string | null; outputSchemaJson: string | null } => { | |
| 273 | − const parsed = parseJsonObject(invocationJson); | |
| 274 | − return { | |
| 275 | − inputSchemaJson: safeJsonStringify(parsed.inputSchema) ?? null, | |
| 276 | − outputSchemaJson: safeJsonStringify(parsed.outputSchema) ?? null, | |
| 277 | − }; | |
| 278 | −}; | |
| 279 | − | |
| 280 | −const typeRefToLabel = (typeRef: unknown): string => { | |
| 281 | − if (!isUnknownRecord(typeRef)) { | |
| 282 | − return "Unknown"; | |
| 283 | − } | |
| 284 | − | |
| 285 | − const kind = normalizeString(typeRef.kind); | |
| 286 | − const name = normalizeString(typeRef.name); | |
| 287 | − const ofType = typeRef.ofType; | |
| 288 | − | |
| 289 | − if (kind === "NON_NULL") { | |
| 290 | − return `${typeRefToLabel(ofType)}!`; | |
| 291 | − } | |
| 292 | − | |
| 293 | − if (kind === "LIST") { | |
| 294 | − return `[${typeRefToLabel(ofType)}]`; | |
| 295 | − } | |
| 296 | − | |
| 297 | − if (name) { | |
| 298 | − return name; | |
| 299 | − } | |
| 300 | − | |
| 301 | − return kind ?? "Unknown"; | |
| 302 | −}; | |
| 303 | − | |
| 304 | −const isRequiredType = (typeRef: unknown): boolean => | |
| 305 | − isUnknownRecord(typeRef) && normalizeString(typeRef.kind) === "NON_NULL"; | |
| 306 | − | |
| 307 | −const parseSourceConfig = (configJson: string): Record<string, unknown> => { | |
| 308 | − try { | |
| 309 | − const parsed = JSON.parse(configJson) as unknown; | |
| 310 | − return isUnknownRecord(parsed) ? parsed : {}; | |
| 311 | − } catch { | |
| 312 | − return {}; | |
| 313 | − } | |
| 314 | −}; | |
| 315 | − | |
| 316 | −const collectConfiguredHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => { | |
| 317 | − const headers: Record<string, string> = {}; | |
| 318 | − const configuredHeaders = config.headers; | |
| 319 | − | |
| 320 | − if (!isUnknownRecord(configuredHeaders)) { | |
| 321 | − return headers; | |
| 322 | − } | |
| 323 | − | |
| 324 | − for (const [headerKey, headerValue] of Object.entries(configuredHeaders)) { | |
| 325 | − const key = normalizeString(headerKey); | |
| 326 | − const value = normalizeString(headerValue); | |
| 327 | − if (key && value) { | |
| 328 | − headers[key] = value; | |
| 329 | − } | |
| 330 | − } | |
| 331 | − | |
| 332 | − return headers; | |
| 333 | −}; | |
| 334 | − | |
| 335 | −const collectGraphqlHeadersFromSourceConfig = (source: Source): Record<string, string> => { | |
| 336 | − const config = parseSourceConfig(source.configJson); | |
| 337 | − return { | |
| 338 | − "content-type": "application/json", | |
| 339 | − ...collectConfiguredHeadersFromSourceConfig(config), | |
| 340 | − }; | |
| 341 | −}; | |
| 342 | − | |
| 343 | −const collectMcpHeadersFromSourceConfig = (source: Source): Record<string, string> => { | |
| 344 | − const config = parseSourceConfig(source.configJson); | |
| 345 | − return { | |
| 346 | − ...collectConfiguredHeadersFromSourceConfig(config), | |
| 347 | − }; | |
| 348 | −}; | |
| 349 | − | |
| 350 | −const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | |
| 351 | − const merged: Record<string, string> = {}; | |
| 352 | − const keyByLower = new Map<string, string>(); | |
| 353 | − | |
| 354 | − for (const headers of sets) { | |
| 355 | − for (const [rawKey, rawValue] of Object.entries(headers)) { | |
| 356 | − const key = rawKey.trim(); | |
| 357 | − const value = rawValue.trim(); | |
| 358 | − if (key.length === 0 || value.length === 0) { | |
| 359 | − continue; | |
| 360 | − } | |
| 361 | − | |
| 362 | − const normalizedKey = key.toLowerCase(); | |
| 363 | − const existingKey = keyByLower.get(normalizedKey); | |
| 364 | − if (existingKey && existingKey !== key) { | |
| 365 | − delete merged[existingKey]; | |
| 366 | − } | |
| 367 | − | |
| 368 | − keyByLower.set(normalizedKey, key); | |
| 369 | − merged[key] = value; | |
| 370 | − } | |
| 371 | − } | |
| 372 | − | |
| 373 | − return merged; | |
| 374 | −}; | |
| 375 | − | |
| 376 | −const graphqlAuthErrorCodes = new Set(["unauthenticated", "forbidden", "unauthorized"]); | |
| 377 | − | |
| 378 | −const payloadHasGraphqlAuthError = (payload: unknown): boolean => { | |
| 379 | − if (!isUnknownRecord(payload) || !Array.isArray(payload.errors)) { | |
| 380 | − return false; | |
| 381 | − } | |
| 382 | − | |
| 383 | − return payload.errors.some((entry) => { | |
| 384 | − if (!isUnknownRecord(entry)) { | |
| 385 | − return false; | |
| 386 | − } | |
| 387 | − | |
| 388 | − const message = normalizeString(entry.message)?.toLowerCase(); | |
| 389 | − if (message && (message.includes("unauthor") || message.includes("forbidden") || message.includes("auth"))) { | |
| 390 | − return true; | |
| 391 | − } | |
| 392 | − | |
| 393 | − if (!isUnknownRecord(entry.extensions)) { | |
| 394 | − return false; | |
| 395 | − } | |
| 396 | − | |
| 397 | − const code = normalizeString(entry.extensions.code)?.toLowerCase(); | |
| 398 | − return code ? graphqlAuthErrorCodes.has(code) : false; | |
| 399 | − }); | |
| 400 | −}; | |
| 401 | − | |
| 402 | −const looksLikeAuthErrorMessage = (value: string): boolean => { | |
| 403 | − const message = value.toLowerCase(); | |
| 404 | − return message.includes("unauthor") || message.includes("forbidden") || message.includes("auth required"); | |
| 405 | −}; | |
| 406 | − | |
| 407 | −const sourceIngestStatusFromCause = (cause: unknown): SourceIngestStatus => { | |
| 408 | − if (cause instanceof SourceIngestError) { | |
| 409 | − return cause.status; | |
| 410 | − } | |
| 411 | − | |
| 412 | − const message = formatError(cause); | |
| 413 | − return looksLikeAuthErrorMessage(message) ? "auth_required" : "error"; | |
| 414 | −}; | |
| 415 | − | |
| 416 | −const extractGraphqlErrorMessage = (payload: unknown, status: number): string => { | |
| 417 | − if (isUnknownRecord(payload) && Array.isArray(payload.errors) && payload.errors.length > 0) { | |
| 418 | − const firstError = payload.errors[0]; | |
| 419 | − if (isUnknownRecord(firstError)) { | |
| 420 | − const message = normalizeString(firstError.message); | |
| 421 | − if (message) { | |
| 422 | − return message; | |
| 423 | − } | |
| 424 | − } | |
| 425 | − } | |
| 426 | − | |
| 427 | − return `GraphQL schema request failed (${status})`; | |
| 428 | −}; | |
| 429 | − | |
| 430 | −const fetchGraphqlPayload = ( | |
| 431 | − source: Source, | |
| 432 | − headers: Record<string, string>, | |
| 433 | − body: Record<string, unknown>, | |
| 434 | −): Effect.Effect<Record<string, unknown>, SourceIngestError> => | |
| 435 | − Effect.gen(function* () { | |
| 436 | − const response = yield* Effect.tryPromise({ | |
| 437 | − try: () => | |
| 438 | − fetch(source.endpoint, { | |
| 439 | − method: "POST", | |
| 440 | − headers, | |
| 441 | − body: JSON.stringify(body), | |
| 442 | − cache: "no-store", | |
| 443 | − }), | |
| 444 | − catch: (cause) => | |
| 445 | − SourceIngestError.make({ | |
| 446 | − status: "error", | |
| 447 | − message: `GraphQL schema request failed: ${formatError(cause)}`, | |
| 448 | − }), | |
| 449 | − }); | |
| 450 | − | |
| 451 | − const payload = yield* Effect.tryPromise({ | |
| 452 | − try: () => response.json().catch(() => null), | |
| 453 | − catch: (cause) => | |
| 454 | − SourceIngestError.make({ | |
| 455 | − status: "error", | |
| 456 | − message: `GraphQL schema response parse failed: ${formatError(cause)}`, | |
| 457 | − }), | |
| 458 | − }); | |
| 459 | − | |
| 460 | − if (!response.ok) { | |
| 461 | − const message = extractGraphqlErrorMessage(payload, response.status); | |
| 462 | − const authRequired = | |
| 463 | − response.status === 401 || response.status === 403 || payloadHasGraphqlAuthError(payload); | |
| 464 | − | |
| 465 | − return yield* SourceIngestError.make({ | |
| 466 | − status: authRequired ? "auth_required" : "error", | |
| 467 | − message, | |
| 468 | − }); | |
| 469 | − } | |
| 470 | − | |
| 471 | − if (!isUnknownRecord(payload)) { | |
| 472 | − return yield* SourceIngestError.make({ | |
| 473 | − status: "error", | |
| 474 | − message: "GraphQL response is not an object", | |
| 475 | − }); | |
| 476 | − } | |
| 477 | − | |
| 478 | − return payload; | |
| 479 | − }); | |
| 480 | − | |
| 481 | −const loadGraphqlSchemaRoots = ( | |
| 482 | − source: Source, | |
| 483 | − headers: Record<string, string>, | |
| 484 | −): Effect.Effect< | |
| 485 | − { | |
| 486 | − queryTypeName: string | null; | |
| 487 | − mutationTypeName: string | null; | |
| 488 | − }, | |
| 489 | − SourceIngestError | |
| 490 | −> => | |
| 491 | − Effect.gen(function* () { | |
| 492 | − const payload = yield* fetchGraphqlPayload(source, headers, { | |
| 493 | − query: graphqlSchemaRootsQuery, | |
| 494 | − }); | |
| 495 | − | |
| 496 | − const data = payload.data; | |
| 497 | − const schema = isUnknownRecord(data) && isUnknownRecord(data.__schema) | |
| 498 | − ? data.__schema | |
| 499 | − : null; | |
| 500 | − | |
| 501 | − if (!schema) { | |
| 502 | − return yield* SourceIngestError.make({ | |
| 503 | − status: "error", | |
| 504 | − message: "GraphQL schema roots payload is missing __schema", | |
| 505 | − }); | |
| 506 | − } | |
| 507 | − | |
| 508 | − return { | |
| 509 | − queryTypeName: isUnknownRecord(schema.queryType) ? normalizeString(schema.queryType.name) : null, | |
| 510 | − mutationTypeName: isUnknownRecord(schema.mutationType) | |
| 511 | − ? normalizeString(schema.mutationType.name) | |
| 512 | − : null, | |
| 513 | − }; | |
| 514 | − }); | |
| 515 | − | |
| 516 | −const loadGraphqlRootFields = ( | |
| 517 | − source: Source, | |
| 518 | − headers: Record<string, string>, | |
| 519 | − rootTypeName: string | null, | |
| 520 | −): Effect.Effect<Array<Record<string, unknown>>, SourceIngestError> => | |
| 521 | − Effect.gen(function* () { | |
| 522 | − if (!rootTypeName) { | |
| 523 | − return []; | |
| 524 | − } | |
| 525 | − | |
| 526 | − const payload = yield* fetchGraphqlPayload(source, headers, { | |
| 527 | − query: graphqlRootFieldsQuery, | |
| 528 | − variables: { | |
| 529 | − name: rootTypeName, | |
| 530 | − }, | |
| 531 | − }); | |
| 532 | − | |
| 533 | − const data = payload.data; | |
| 534 | − const rootType = isUnknownRecord(data) && isUnknownRecord(data.__type) | |
| 535 | − ? data.__type | |
| 536 | − : null; | |
| 537 | − | |
| 538 | − if (!rootType) { | |
| 539 | − return []; | |
| 540 | − } | |
| 541 | − | |
| 542 | − const fields = rootType.fields; | |
| 543 | − if (!Array.isArray(fields)) { | |
| 544 | − return []; | |
| 545 | − } | |
| 546 | − | |
| 547 | − return fields.filter((field): field is Record<string, unknown> => isUnknownRecord(field)); | |
| 548 | − }); | |
| 549 | − | |
| 550 | −const ensureUniqueToolIds = (tools: Array<GraphqlIngestTool>): Array<GraphqlIngestTool> => { | |
| 551 | − const counts = new Map<string, number>(); | |
| 552 | − | |
| 553 | − return tools.map((tool) => { | |
| 554 | − const count = (counts.get(tool.toolId) ?? 0) + 1; | |
| 555 | − counts.set(tool.toolId, count); | |
| 556 | − | |
| 557 | − if (count === 1) { | |
| 558 | − return tool; | |
| 559 | − } | |
| 560 | − | |
| 561 | − return { | |
| 562 | − ...tool, | |
| 563 | − toolId: `${tool.toolId}_${count}`, | |
| 564 | − }; | |
| 565 | − }); | |
| 566 | −}; | |
| 567 | − | |
| 568 | −const extractGraphqlTools = ( | |
| 569 | − source: Source, | |
| 570 | − roots: { | |
| 571 | − queryTypeName: string | null; | |
| 572 | − mutationTypeName: string | null; | |
| 573 | − }, | |
| 574 | − queryFields: ReadonlyArray<Record<string, unknown>>, | |
| 575 | − mutationFields: ReadonlyArray<Record<string, unknown>>, | |
| 576 | −): { | |
| 577 | − schemaHash: string; | |
| 578 | − tools: Array<GraphqlIngestTool>; | |
| 579 | −} => { | |
| 580 | − const slug = sourceSlug(source.name); | |
| 581 | − | |
| 582 | − const rawInvocation = { | |
| 583 | − kind: "graphql_raw", | |
| 584 | − endpoint: source.endpoint, | |
| 585 | − }; | |
| 586 | − | |
| 587 | − const tools: Array<GraphqlIngestTool> = [ | |
| 588 | − { | |
| 589 | − toolId: `${slug}.graphql`, | |
| 590 | − name: `${source.name} GraphQL`, | |
| 591 | − description: `Run raw GraphQL queries against ${source.name}.`, | |
| 592 | − operationType: "raw", | |
| 593 | − fieldName: "graphql", | |
| 594 | − operationHash: hashUnknown(rawInvocation), | |
| 595 | − invocationJson: JSON.stringify(rawInvocation), | |
| 596 | − }, | |
| 597 | − ]; | |
| 598 | − | |
| 599 | − const fieldSignatures: Array<unknown> = []; | |
| 600 | − | |
| 601 | − const addOperationTools = ( | |
| 602 | − operationType: "query" | "mutation", | |
| 603 | − fields: ReadonlyArray<Record<string, unknown>>, | |
| 604 | − ): void => { | |
| 605 | − for (const field of fields) { | |
| 606 | − const fieldName = normalizeString(field.name); | |
| 607 | − if (!fieldName) { | |
| 608 | − continue; | |
| 609 | − } | |
| 610 | − | |
| 611 | − const args = Array.isArray(field.args) | |
| 612 | − ? field.args | |
| 613 | − .filter((arg): arg is Record<string, unknown> => isUnknownRecord(arg)) | |
| 614 | − .map((arg) => ({ | |
| 615 | − name: normalizeString(arg.name) ?? "arg", | |
| 616 | − description: normalizeString(arg.description), | |
| 617 | − required: isRequiredType(arg.type), | |
| 618 | − type: typeRefToLabel(arg.type), | |
| 619 | − defaultValue: normalizeString(arg.defaultValue), | |
| 620 | − })) | |
| 621 | − : []; | |
| 622 | − | |
| 623 | − const invocation = { | |
| 624 | − kind: "graphql_field", | |
| 625 | − endpoint: source.endpoint, | |
| 626 | − operationType, | |
| 627 | − fieldName, | |
| 628 | − args, | |
| 629 | − }; | |
| 630 | − | |
| 631 | − tools.push({ | |
| 632 | − toolId: `${slug}.${operationType}.${toolSegment(fieldName)}`, | |
| 633 | − name: fieldName, | |
| 634 | − description: normalizeString(field.description), | |
| 635 | − operationType, | |
| 636 | − fieldName, | |
| 637 | − operationHash: hashUnknown(invocation), | |
| 638 | − invocationJson: JSON.stringify(invocation), | |
| 639 | − }); | |
| 640 | − | |
| 641 | − fieldSignatures.push({ | |
| 642 | − operationType, | |
| 643 | − fieldName, | |
| 644 | − args: args.map((arg) => ({ | |
| 645 | − name: arg.name, | |
| 646 | − required: arg.required, | |
| 647 | − type: arg.type, | |
| 648 | − })), | |
| 649 | − }); | |
| 650 | − } | |
| 651 | − }; | |
| 652 | − | |
| 653 | − addOperationTools("query", queryFields); | |
| 654 | − addOperationTools("mutation", mutationFields); | |
| 655 | − | |
| 656 | − return { | |
| 657 | − schemaHash: hashUnknown({ | |
| 658 | − queryTypeName: roots.queryTypeName, | |
| 659 | − mutationTypeName: roots.mutationTypeName, | |
| 660 | − fieldSignatures, | |
| 661 | − }), | |
| 662 | − tools: ensureUniqueToolIds(tools), | |
| 663 | − }; | |
| 664 | −}; | |
| 665 | − | |
| 666 | −const parseMcpTransportPreference = (source: Source): McpTransport | null => { | |
| 667 | − const config = parseSourceConfig(source.configJson); | |
| 668 | − const transport = normalizeString(config.transport)?.toLowerCase(); | |
| 669 | − | |
| 670 | − if (transport === "sse") { | |
| 671 | − return "sse"; | |
| 672 | − } | |
| 673 | − | |
| 674 | − if (transport === "streamable-http" || transport === "streamable_http") { | |
| 675 | − return "streamable-http"; | |
| 676 | − } | |
| 677 | − | |
| 678 | − return null; | |
| 679 | −}; | |
| 680 | − | |
| 681 | −const parseMcpQueryParams = (source: Source): Record<string, string> => { | |
| 682 | − const config = parseSourceConfig(source.configJson); | |
| 683 | − const queryParams = config.queryParams; | |
| 684 | − | |
| 685 | − if (!isUnknownRecord(queryParams)) { | |
| 686 | − return {}; | |
| 687 | − } | |
| 688 | − | |
| 689 | − const normalized: Record<string, string> = {}; | |
| 690 | − for (const [rawKey, rawValue] of Object.entries(queryParams)) { | |
| 691 | − const key = normalizeString(rawKey); | |
| 692 | − const value = normalizeString(rawValue); | |
| 693 | − if (key && value) { | |
| 694 | − normalized[key] = value; | |
| 695 | − } | |
| 696 | − } | |
| 697 | − | |
| 698 | − return normalized; | |
| 699 | −}; | |
| 700 | − | |
| 701 | −const withMcpHeaders = ( | |
| 702 | − input: RequestInfo | URL, | |
| 703 | − init: RequestInit | undefined, | |
| 704 | − headers: Record<string, string>, | |
| 705 | −): Promise<Response> => { | |
| 706 | − const mergedHeaders = new Headers(init?.headers ?? {}); | |
| 707 | − for (const [key, value] of Object.entries(headers)) { | |
| 708 | − mergedHeaders.set(key, value); | |
| 709 | − } | |
| 710 | − | |
| 711 | − return fetch(input, { | |
| 712 | − ...init, | |
| 713 | − headers: mergedHeaders, | |
| 714 | − }); | |
| 715 | −}; | |
| 716 | − | |
| 717 | −const connectMcp = ( | |
| 718 | − source: Source, | |
| 719 | − headers: Record<string, string>, | |
| 720 | −): Effect.Effect<McpConnection, SourceIngestError> => | |
| 721 | − Effect.gen(function* () { | |
| 722 | − const endpoint = yield* Effect.try({ | |
| 723 | − try: () => new URL(source.endpoint), | |
| 724 | − catch: () => | |
| 725 | − SourceIngestError.make({ | |
| 726 | − status: "error", | |
| 727 | − message: `Invalid MCP endpoint URL: ${source.endpoint}`, | |
| 728 | − }), | |
| 729 | − }); | |
| 730 | − | |
| 731 | − const queryParams = parseMcpQueryParams(source); | |
| 732 | − for (const [key, value] of Object.entries(queryParams)) { | |
| 733 | − endpoint.searchParams.set(key, value); | |
| 734 | − } | |
| 735 | − | |
| 736 | − const connectWithTransport = ( | |
| 737 | − transport: McpTransport, | |
| 738 | − ): Effect.Effect<McpConnection, SourceIngestError> => | |
| 739 | − Effect.tryPromise({ | |
| 740 | − try: async () => { | |
| 741 | − const client = new Client( | |
| 742 | − { | |
| 743 | − name: "executor-v2-control-plane", | |
| 744 | − version: "0.1.0", | |
| 745 | − }, | |
| 746 | − { capabilities: {} }, | |
| 747 | − ); | |
| 748 | − | |
| 749 | − if (transport === "streamable-http") { | |
| 750 | − await client.connect(new StreamableHTTPClientTransport(endpoint, { | |
| 751 | − requestInit: { headers }, | |
| 752 | − })); | |
| 753 | − } else { | |
| 754 | − await client.connect(new SSEClientTransport(endpoint, { | |
| 755 | − requestInit: { headers }, | |
| 756 | − eventSourceInit: { | |
| 757 | − fetch: (input, init) => withMcpHeaders(input, init, headers), | |
| 758 | − }, | |
| 759 | − })); | |
| 760 | − } | |
| 761 | − | |
| 762 | − return { | |
| 763 | − client, | |
| 764 | − close: () => client.close(), | |
| 765 | − transport, | |
| 766 | − }; | |
| 767 | − }, | |
| 768 | − catch: (cause) => | |
| 769 | − SourceIngestError.make({ | |
| 770 | − status: sourceIngestStatusFromCause(cause), | |
| 771 | − message: `Failed to connect to MCP source: ${formatError(cause)}`, | |
| 772 | − }), | |
| 773 | − }); | |
| 774 | − | |
| 775 | − const preferred = parseMcpTransportPreference(source); | |
| 776 | − if (preferred === "streamable-http") { | |
| 777 | − return yield* connectWithTransport("streamable-http"); | |
| 778 | − } | |
| 779 | − | |
| 780 | − if (preferred === "sse") { | |
| 781 | − return yield* connectWithTransport("sse"); | |
| 782 | − } | |
| 783 | − | |
| 784 | − const streamableAttempt = yield* connectWithTransport("streamable-http").pipe(Effect.either); | |
| 785 | − if (streamableAttempt._tag === "Right") { | |
| 786 | − return streamableAttempt.right; | |
| 787 | − } | |
| 788 | − | |
| 789 | − return yield* connectWithTransport("sse"); | |
| 790 | − }); | |
| 791 | − | |
| 792 | −const normalizeJsonSchema = (value: unknown): Record<string, unknown> => { | |
| 793 | − if (value === true) { | |
| 794 | − return {}; | |
| 795 | − } | |
| 796 | − | |
| 797 | − if (value === false) { | |
| 798 | − return { not: {} }; | |
| 799 | − } | |
| 800 | − | |
| 801 | − return isUnknownRecord(value) ? value : {}; | |
| 802 | −}; | |
| 803 | − | |
| 804 | −const extractMcpListedToolRows = (value: unknown): Array<Record<string, unknown>> => { | |
| 805 | − if (!isUnknownRecord(value) || !Array.isArray(value.tools)) { | |
| 806 | − return []; | |
| 807 | − } | |
| 808 | − | |
| 809 | − return value.tools.filter((tool): tool is Record<string, unknown> => isUnknownRecord(tool)); | |
| 810 | −}; | |
| 811 | − | |
| 812 | −const ensureUniqueMcpToolIds = (tools: Array<McpIngestTool>): Array<McpIngestTool> => { | |
| 813 | − const counts = new Map<string, number>(); | |
| 814 | − | |
| 815 | − return tools.map((tool) => { | |
| 816 | − const count = (counts.get(tool.toolId) ?? 0) + 1; | |
| 817 | − counts.set(tool.toolId, count); | |
| 818 | − | |
| 819 | − if (count === 1) { | |
| 820 | − return tool; | |
| 821 | − } | |
| 822 | − | |
| 823 | − return { | |
| 824 | − ...tool, | |
| 825 | − toolId: `${tool.toolId}_${count}`, | |
| 826 | − }; | |
| 827 | − }); | |
| 828 | −}; | |
| 829 | − | |
| 830 | −const extractMcpTools = ( | |
| 831 | − source: Source, | |
| 832 | − transport: McpTransport, | |
| 833 | − listedTools: ReadonlyArray<Record<string, unknown>>, | |
| 834 | −): { | |
| 835 | − sourceHash: string; | |
| 836 | − tools: Array<McpIngestTool>; | |
| 837 | −} => { | |
| 838 | − const slug = sourceSlug(source.name); | |
| 839 | − const queryParams = parseMcpQueryParams(source); | |
| 840 | − | |
| 841 | − const tools: Array<McpIngestTool> = []; | |
| 842 | − const toolSignatures: Array<unknown> = []; | |
| 843 | − | |
| 844 | − for (const tool of listedTools) { | |
| 845 | − const toolName = normalizeString(tool.name); | |
| 846 | − if (!toolName) { | |
| 847 | − continue; | |
| 848 | − } | |
| 849 | − | |
| 850 | − const inputSchema = normalizeJsonSchema(tool.inputSchema); | |
| 851 | − const outputSchema = normalizeJsonSchema(tool.outputSchema); | |
| 852 | − const invocation = { | |
| 853 | − kind: "mcp_tool", | |
| 854 | − endpoint: source.endpoint, | |
| 855 | − transport, | |
| 856 | − queryParams, | |
| 857 | − toolName, | |
| 858 | − }; | |
| 859 | − | |
| 860 | − tools.push({ | |
| 861 | − toolId: `${slug}.mcp.${toolSegment(toolName)}`, | |
| 862 | − name: toolName, | |
| 863 | − description: normalizeString(tool.description), | |
| 864 | − toolName, | |
| 865 | − operationHash: hashUnknown({ invocation, inputSchema, outputSchema }), | |
| 866 | − invocationJson: JSON.stringify({ | |
| 867 | − ...invocation, | |
| 868 | − inputSchema, | |
| 869 | − outputSchema, | |
| 870 | − }), | |
| 871 | − }); | |
| 872 | − | |
| 873 | − toolSignatures.push({ toolName, inputSchema, outputSchema }); | |
| 874 | − } | |
| 875 | − | |
| 876 | − return { | |
| 877 | − sourceHash: hashUnknown({ | |
| 878 | − endpoint: source.endpoint, | |
| 879 | − transport, | |
| 880 | − queryParams, | |
| 881 | − toolSignatures, | |
| 882 | − }), | |
| 883 | − tools: ensureUniqueMcpToolIds(tools), | |
| 884 | − }; | |
| 885 | −}; | |
| 886 | − | |
| 887 | −const formatError = (cause: unknown): string => | |
| 888 | − cause instanceof Error ? cause.message : String(cause); | |
| 889 | − | |
| 890 | −const trySourceIngestPromise = <A>( | |
| 891 | − promise: () => Promise<A>, | |
| 892 | − message: string, | |
| 893 | −): Effect.Effect<A, SourceIngestError> => | |
| 894 | − Effect.tryPromise({ | |
| 895 | − try: promise, | |
| 896 | − catch: (cause) => | |
| 897 | − SourceIngestError.make({ | |
| 898 | − status: "error", | |
| 899 | − message: `${message}: ${formatError(cause)}`, | |
| 900 | − }), | |
| 901 | − }); | |
| 902 | − | |
| 903 | −const openApiParserUrl = process.env.OPENAPI_PARSE_API_URL?.trim() ?? ""; | |
| 904 | −const openApiParserToken = process.env.OPENAPI_PARSE_API_TOKEN?.trim() ?? ""; | |
| 905 | − | |
| 906 | −type OpenApiParserIngestedPayload = { | |
| 907 | − ok?: boolean; | |
| 908 | − mode?: string; | |
| 909 | − openApiSpec?: unknown; | |
| 910 | − artifactId?: string; | |
| 911 | − sourceHash?: string; | |
| 912 | − toolCount?: number; | |
| 913 | − namespace?: string; | |
| 914 | − refHintTableJson?: string | null; | |
| 915 | − artifactBatchCount?: number; | |
| 916 | − error?: string; | |
| 917 | −}; | |
| 918 | − | |
| 919 | −const requestOpenApiParser = async ( | |
| 920 | − payload: Record<string, unknown>, | |
| 921 | −): Promise<OpenApiParserIngestedPayload> => { | |
| 922 | − if (openApiParserUrl.length === 0) { | |
| 923 | − throw new Error("OPENAPI_PARSE_API_URL is required for OpenAPI ingestion"); | |
| 924 | − } | |
| 925 | − | |
| 926 | − const response = await fetch(openApiParserUrl, { | |
| 927 | − method: "POST", | |
| 928 | − headers: { | |
| 929 | − "content-type": "application/json", | |
| 930 | − ...(openApiParserToken.length > 0 | |
| 931 | − ? { "x-openapi-parse-token": openApiParserToken } | |
| 932 | − : {}), | |
| 933 | − }, | |
| 934 | − body: JSON.stringify(payload), | |
| 935 | − }); | |
| 936 | − | |
| 937 | − if (!response.ok) { | |
| 938 | − const details = await response.text().catch(() => ""); | |
| 939 | − throw new Error( | |
| 940 | − `Parser endpoint failed (${response.status}): ${ | |
| 941 | − details.trim().length > 0 ? details.trim() : response.statusText | |
| 942 | − }`, | |
| 943 | − ); | |
| 944 | − } | |
| 945 | − | |
| 946 | − return (await response.json()) as OpenApiParserIngestedPayload; | |
| 947 | −}; | |
| 948 | − | |
| 949 | −const withOpenApiServerVariables = ( | |
| 950 | − template: string, | |
| 951 | − variables: unknown, | |
| 952 | −): string => { | |
| 953 | − if (!isUnknownRecord(variables)) { | |
| 954 | − return template; | |
| 955 | − } | |
| 956 | − | |
| 957 | − return template.replaceAll(/\{([^}]+)\}/g, (_token, name: string) => { | |
| 958 | − const variable = variables[name]; | |
| 959 | − if (!isUnknownRecord(variable)) { | |
| 960 | − return `{${name}}`; | |
| 961 | − } | |
| 962 | − | |
| 963 | − return normalizeString(variable.default) ?? `{${name}}`; | |
| 964 | − }); | |
| 965 | −}; | |
| 966 | − | |
| 967 | −const resolveOpenApiServerUrl = ( | |
| 968 | − rawServerUrl: string, | |
| 969 | − variables: unknown, | |
| 970 | − specUrl: string, | |
| 971 | −): string | null => { | |
| 972 | − const resolvedTemplate = withOpenApiServerVariables(rawServerUrl, variables); | |
| 973 | − if (resolvedTemplate.includes("{")) { | |
| 974 | − return null; | |
| 975 | − } | |
| 976 | − | |
| 977 | − try { | |
| 978 | − const resolved = new URL(resolvedTemplate, specUrl).toString(); | |
| 979 | − return normalizeHttpUrl(resolved); | |
| 980 | − } catch { | |
| 981 | − return null; | |
| 982 | − } | |
| 983 | −}; | |
| 984 | − | |
| 985 | −const deriveOpenApiBaseUrlFromSpec = ( | |
| 986 | − openApiSpec: unknown, | |
| 987 | − specUrl: string, | |
| 988 | −): string | null => { | |
| 989 | − if (!isUnknownRecord(openApiSpec)) { | |
| 990 | − return null; | |
| 991 | − } | |
| 992 | − | |
| 993 | − const servers = openApiSpec.servers; | |
| 994 | − if (Array.isArray(servers)) { | |
| 995 | − for (const server of servers) { | |
| 996 | − if (!isUnknownRecord(server)) { | |
| 997 | − continue; | |
| 998 | − } | |
| 999 | − | |
| 1000 | − const rawServerUrl = normalizeString(server.url); | |
| 1001 | − if (!rawServerUrl) { | |
| 1002 | − continue; | |
| 1003 | − } | |
| 1004 | − | |
| 1005 | − const resolved = resolveOpenApiServerUrl(rawServerUrl, server.variables, specUrl); | |
| 1006 | − if (resolved) { | |
| 1007 | − return resolved; | |
| 1008 | − } | |
| 1009 | − } | |
| 1010 | − } | |
| 1011 | − | |
| 1012 | − const host = normalizeString(openApiSpec.host); | |
| 1013 | − if (host) { | |
| 1014 | − const schemes = Array.isArray(openApiSpec.schemes) ? openApiSpec.schemes : []; | |
| 1015 | − const firstScheme = normalizeString(schemes[0])?.toLowerCase(); | |
| 1016 | − const scheme = firstScheme === "http" || firstScheme === "https" ? firstScheme : "https"; | |
| 1017 | − const basePath = normalizeString(openApiSpec.basePath) ?? ""; | |
| 1018 | − const normalizedPath = basePath.length === 0 | |
| 1019 | − ? "" | |
| 1020 | − : basePath.startsWith("/") | |
| 1021 | − ? basePath | |
| 1022 | − : `/${basePath}`; | |
| 1023 | − return normalizeHttpUrl(`${scheme}://${host}${normalizedPath}`); | |
| 1024 | − } | |
| 1025 | − | |
| 1026 | − return null; | |
| 1027 | −}; | |
| 1028 | − | |
| 1029 | −const fetchOpenApiBaseUrlFromSpec = async (specUrl: string): Promise<string> => { | |
| 1030 | − const payload = await requestOpenApiParser({ specUrl }); | |
| 1031 | − if (payload.ok !== true) { | |
| 1032 | − throw new Error( | |
| 1033 | − payload.error && payload.error.trim().length > 0 | |
| 1034 | − ? payload.error | |
| 1035 | − : "Parser endpoint returned an invalid payload", | |
| 1036 | − ); | |
| 1037 | − } | |
| 1038 | − | |
| 1039 | − if (payload.mode !== "parsed") { | |
| 1040 | − throw new Error("Parser endpoint must return mode='parsed' when deriving OpenAPI baseUrl"); | |
| 1041 | − } | |
| 1042 | − | |
| 1043 | − const baseUrl = deriveOpenApiBaseUrlFromSpec(payload.openApiSpec, specUrl); | |
| 1044 | − if (!baseUrl) { | |
| 1045 | − throw new Error("OpenAPI source requires spec.servers (or Swagger host/schemes) to derive baseUrl"); | |
| 1046 | − } | |
| 1047 | − | |
| 1048 | − return baseUrl; | |
| 1049 | −}; | |
| 1050 | − | |
| 1051 | −const fetchOpenApiDocumentForIngest = async ( | |
| 1052 | − source: Source, | |
| 1053 | −): Promise<{ | |
| 1054 | − artifactId: string; | |
| 1055 | − sourceHash: string; | |
| 1056 | − toolCount: number; | |
| 1057 | − namespace: string; | |
| 1058 | − refHintTableJson: string | null; | |
| 1059 | − artifactBatchCount: number; | |
| 1060 | −}> => { | |
| 1061 | − const payload = await requestOpenApiParser({ | |
| 1062 | − specUrl: source.endpoint, | |
| 1063 | − workspaceId: source.workspaceId, | |
| 1064 | − sourceId: source.id, | |
| 1065 | − sourceName: source.name, | |
| 1066 | − sourceEnabled: source.enabled, | |
| 1067 | − }); | |
| 1068 | − | |
| 1069 | − if (payload.ok !== true) { | |
| 1070 | − throw new Error( | |
| 1071 | − payload.error && payload.error.trim().length > 0 | |
| 1072 | − ? payload.error | |
| 1073 | − : "Parser endpoint returned an invalid payload", | |
| 1074 | − ); | |
| 1075 | − } | |
| 1076 | − | |
| 1077 | − if (payload.mode !== "ingested") { | |
| 1078 | − throw new Error("Parser endpoint must return mode='ingested' for OpenAPI ingestion"); | |
| 1079 | − } | |
| 1080 | − | |
| 1081 | − const artifactId = normalizeString(payload.artifactId); | |
| 1082 | − const sourceHash = normalizeString(payload.sourceHash); | |
| 1083 | − const namespace = normalizeString(payload.namespace); | |
| 1084 | − const toolCount = Number.isFinite(payload.toolCount) | |
| 1085 | − ? Math.max(0, Math.floor(payload.toolCount ?? 0)) | |
| 1086 | − : null; | |
| 1087 | − const artifactBatchCount = Number.isFinite(payload.artifactBatchCount) | |
| 1088 | − ? Math.max(0, Math.floor(payload.artifactBatchCount ?? 0)) | |
| 1089 | − : 0; | |
| 1090 | − | |
| 1091 | − if (!artifactId || !sourceHash || !namespace || toolCount === null) { | |
| 1092 | − throw new Error("Parser endpoint returned an invalid ingested payload"); | |
| 1093 | − } | |
| 1094 | − | |
| 1095 | − return { | |
| 1096 | − artifactId, | |
| 1097 | − sourceHash, | |
| 1098 | − toolCount, | |
| 1099 | − namespace, | |
| 1100 | − refHintTableJson: | |
| 1101 | − typeof payload.refHintTableJson === "string" ? payload.refHintTableJson : null, | |
| 1102 | − artifactBatchCount, | |
| 1103 | − }; | |
| 1104 | −}; | |
| 1105 | − | |
| 1106 | −export const deriveOpenApiBaseUrl = internalAction({ | |
| 1107 | − args: { | |
| 1108 | − specUrl: v.string(), | |
| 1109 | − }, | |
| 1110 | − handler: async (_ctx, args): Promise<{ baseUrl: string }> => { | |
| 1111 | − const specUrl = args.specUrl.trim(); | |
| 1112 | − if (specUrl.length === 0) { | |
| 1113 | − throw new Error("specUrl is required"); | |
| 1114 | − } | |
| 1115 | − | |
| 1116 | − const baseUrl = await fetchOpenApiBaseUrlFromSpec(specUrl); | |
| 1117 | − return { baseUrl }; | |
| 1118 | − }, | |
| 1119 | −}); | |
| 1120 | − | |
| 1121 | −type IngestOptions = { | |
| 1122 | − rebuildIndex: boolean; | |
| 1123 | − stageArtifactWrites: boolean; | |
| 1124 | −}; | |
| 1125 | − | |
| 1126 | −const ingestOpenApiSource = ( | |
| 1127 | − ctx: any, | |
| 1128 | − source: Source, | |
| 1129 | −): Effect.Effect<SourceIngestPrepared, SourceIngestError> => | |
| 1130 | − Effect.gen(function* () { | |
| 1131 | − const ingested = yield* trySourceIngestPromise( | |
| 1132 | − () => fetchOpenApiDocumentForIngest(source), | |
| 1133 | − "Failed to fetch OpenAPI document", | |
| 1134 | − ); | |
| 1135 | − | |
| 1136 | − return { | |
| 1137 | − protocol: "openapi", | |
| 1138 | − artifactId: ingested.artifactId, | |
| 1139 | − sourceHash: ingested.sourceHash, | |
| 1140 | − toolCount: ingested.toolCount, | |
| 1141 | − namespace: ingested.namespace, | |
| 1142 | − refHintTableJson: ingested.refHintTableJson, | |
| 1143 | − artifactBatchCount: ingested.artifactBatchCount, | |
| 1144 | − }; | |
| 1145 | − }); | |
| 1146 | − | |
| 1147 | −const ingestGraphqlSource = ( | |
| 1148 | − ctx: any, | |
| 1149 | − source: Source, | |
| 1150 | − options: IngestOptions, | |
| 1151 | −): Effect.Effect<SourceIngestPrepared, SourceIngestError> => | |
| 1152 | − Effect.gen(function* () { | |
| 1153 | − const credentialHeadersResult = yield* trySourceIngestPromise<{ | |
| 1154 | − headers: Record<string, string>; | |
| 1155 | − }>( | |
| 1156 | − () => | |
| 1157 | − ctx.runAction(runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, { | |
| 1158 | − workspaceId: source.workspaceId, | |
| 1159 | − sourceId: source.id, | |
| 1160 | − }), | |
| 1161 | − "Failed to resolve source credentials for GraphQL ingest", | |
| 1162 | − ); | |
| 1163 | − | |
| 1164 | − const headers = mergeHeaders( | |
| 1165 | − collectGraphqlHeadersFromSourceConfig(source), | |
| 1166 | − credentialHeadersResult.headers, | |
| 1167 | − ); | |
| 1168 | − | |
| 1169 | − const roots = yield* loadGraphqlSchemaRoots(source, headers); | |
| 1170 | − const queryFields = yield* loadGraphqlRootFields(source, headers, roots.queryTypeName); | |
| 1171 | − const mutationFields = yield* loadGraphqlRootFields(source, headers, roots.mutationTypeName); | |
| 1172 | − const extracted = extractGraphqlTools(source, roots, queryFields, mutationFields); | |
| 1173 | − | |
| 1174 | − const artifactMeta = yield* trySourceIngestPromise<{ | |
| 1175 | − artifactId: string; | |
| 1176 | − created: boolean; | |
| 1177 | − }>( | |
| 1178 | − () => | |
| 1179 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, { | |
| 1180 | − protocol: "graphql", | |
| 1181 | − contentHash: extracted.schemaHash, | |
| 1182 | − extractorVersion: graphqlExtractorVersion, | |
| 1183 | − toolCount: extracted.tools.length, | |
| 1184 | − }), | |
| 1185 | − "Failed to upsert GraphQL artifact metadata", | |
| 1186 | − ); | |
| 1187 | − | |
| 1188 | − if (artifactMeta.created) { | |
| 1189 | − const artifactRows = extracted.tools.map((tool) => ({ | |
| 1190 | − toolId: tool.toolId, | |
| 1191 | − name: tool.name, | |
| 1192 | − description: tool.description, | |
| 1193 | − canonicalPath: `${tool.operationType}.${tool.fieldName}`, | |
| 1194 | − operationHash: tool.operationHash, | |
| 1195 | − invocationJson: tool.invocationJson, | |
| 1196 | − metadataJson: safeJsonStringify({ | |
| 1197 | − operationType: tool.operationType, | |
| 1198 | − fieldName: tool.fieldName, | |
| 1199 | − }), | |
| 1200 | − })); | |
| 1201 | − | |
| 1202 | − if (options.stageArtifactWrites) { | |
| 1203 | − const serializedBatches = chunkArray(artifactRows, stagedArtifactBatchSize).map((batch) => | |
| 1204 | − JSON.stringify(batch), | |
| 1205 | − ); | |
| 1206 | − | |
| 1207 | − yield* trySourceIngestPromise( | |
| 1208 | − () => | |
| 1209 | − ctx.runMutation( | |
| 1210 | − runtimeInternal.control_plane.tool_registry.replaceSourceIngestArtifactBatches, | |
| 1211 | − { | |
| 1212 | − workspaceId: source.workspaceId, | |
| 1213 | − sourceId: source.id, | |
| 1214 | − artifactId: artifactMeta.artifactId, | |
| 1215 | − protocol: "graphql", | |
| 1216 | − batches: serializedBatches, | |
| 1217 | − }, | |
| 1218 | − ), | |
| 1219 | − "Failed to stage GraphQL artifact tool batches", | |
| 1220 | − ); | |
| 1221 | − } else { | |
| 1222 | − for (let index = 0; index < artifactRows.length; index += writeBatchSize) { | |
| 1223 | − const batch = artifactRows.slice(index, index + writeBatchSize); | |
| 1224 | − yield* trySourceIngestPromise( | |
| 1225 | − () => | |
| 1226 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | |
| 1227 | − artifactId: artifactMeta.artifactId, | |
| 1228 | − protocol: "graphql", | |
| 1229 | − insertOnly: true, | |
| 1230 | − tools: batch, | |
| 1231 | − }), | |
| 1232 | − "Failed to write GraphQL artifact tools", | |
| 1233 | − ); | |
| 1234 | − } | |
| 1235 | − } | |
| 1236 | − } | |
| 1237 | − | |
| 1238 | − yield* trySourceIngestPromise( | |
| 1239 | − () => | |
| 1240 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | |
| 1241 | − workspaceId: source.workspaceId, | |
| 1242 | − sourceId: source.id, | |
| 1243 | − artifactId: artifactMeta.artifactId, | |
| 1244 | − }), | |
| 1245 | − "Failed to bind source to GraphQL artifact", | |
| 1246 | − ); | |
| 1247 | − | |
| 1248 | − const namespace = sourceNamespace(source); | |
| 1249 | − const indexRows = extracted.tools.map((tool) => ({ | |
| 1250 | − toolId: tool.toolId, | |
| 1251 | − protocol: "graphql", | |
| 1252 | − method: "post", | |
| 1253 | − path: sourceToolPath(source, tool.toolId), | |
| 1254 | − operationPath: null, | |
| 1255 | − name: tool.name, | |
| 1256 | − description: tool.description, | |
| 1257 | − searchText: normalizedSearchText( | |
| 1258 | − source.name, | |
| 1259 | − source.endpoint, | |
| 1260 | − namespace, | |
| 1261 | − tool.toolId, | |
| 1262 | − tool.name, | |
| 1263 | − tool.description, | |
| 1264 | − tool.operationType, | |
| 1265 | − tool.fieldName, | |
| 1266 | − ), | |
| 1267 | − operationHash: tool.operationHash, | |
| 1268 | − approvalMode: "auto", | |
| 1269 | − status: source.enabled ? "active" : "disabled", | |
| 1270 | − })); | |
| 1271 | − | |
| 1272 | − if (options.rebuildIndex) { | |
| 1273 | − yield* trySourceIngestPromise( | |
| 1274 | − () => | |
| 1275 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, { | |
| 1276 | − workspaceId: source.workspaceId, | |
| 1277 | − sourceId: source.id, | |
| 1278 | − sourceName: source.name, | |
| 1279 | − sourceKind: source.kind, | |
| 1280 | − artifactId: artifactMeta.artifactId, | |
| 1281 | − namespace, | |
| 1282 | − refHintTableJson: null, | |
| 1283 | − rows: indexRows, | |
| 1284 | − }), | |
| 1285 | − "Failed to rebuild workspace tool index for GraphQL source", | |
| 1286 | − ); | |
| 1287 | − } | |
| 1288 | − | |
| 1289 | − return { | |
| 1290 | − protocol: "graphql", | |
| 1291 | − artifactId: artifactMeta.artifactId, | |
| 1292 | − sourceHash: extracted.schemaHash, | |
| 1293 | − toolCount: extracted.tools.length, | |
| 1294 | − namespace, | |
| 1295 | − refHintTableJson: null, | |
| 1296 | − artifactBatchCount: artifactMeta.created && options.stageArtifactWrites | |
| 1297 | − ? Math.ceil(extracted.tools.length / stagedArtifactBatchSize) | |
| 1298 | − : 0, | |
| 1299 | − }; | |
| 1300 | − }); | |
| 1301 | − | |
| 1302 | −const ingestMcpSource = ( | |
| 1303 | − ctx: any, | |
| 1304 | − source: Source, | |
| 1305 | − options: IngestOptions, | |
| 1306 | −): Effect.Effect<SourceIngestPrepared, SourceIngestError> => | |
| 1307 | − Effect.gen(function* () { | |
| 1308 | − const credentialHeadersResult = yield* trySourceIngestPromise<{ | |
| 1309 | − headers: Record<string, string>; | |
| 1310 | − }>( | |
| 1311 | − () => | |
| 1312 | − ctx.runAction(runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, { | |
| 1313 | − workspaceId: source.workspaceId, | |
| 1314 | − sourceId: source.id, | |
| 1315 | − }), | |
| 1316 | − "Failed to resolve source credentials for MCP ingest", | |
| 1317 | − ); | |
| 1318 | − | |
| 1319 | − const headers = mergeHeaders( | |
| 1320 | − collectMcpHeadersFromSourceConfig(source), | |
| 1321 | − credentialHeadersResult.headers, | |
| 1322 | − ); | |
| 1323 | − | |
| 1324 | − const connection = yield* connectMcp(source, headers); | |
| 1325 | − | |
| 1326 | − const listedResult = yield* Effect.tryPromise({ | |
| 1327 | − try: () => connection.client.listTools(), | |
| 1328 | − catch: (cause) => | |
| 1329 | − SourceIngestError.make({ | |
| 1330 | − status: sourceIngestStatusFromCause(cause), | |
| 1331 | − message: `Failed to list MCP tools: ${formatError(cause)}`, | |
| 1332 | − }), | |
| 1333 | − }).pipe( | |
| 1334 | − Effect.ensuring( | |
| 1335 | − Effect.promise(() => connection.close().catch(() => undefined)), | |
| 1336 | − ), | |
| 1337 | − ); | |
| 1338 | − | |
| 1339 | − const listedTools = extractMcpListedToolRows(listedResult); | |
| 1340 | − const extracted = extractMcpTools(source, connection.transport, listedTools); | |
| 1341 | − | |
| 1342 | − const artifactMeta = yield* trySourceIngestPromise<{ | |
| 1343 | − artifactId: string; | |
| 1344 | − created: boolean; | |
| 1345 | − }>( | |
| 1346 | − () => | |
| 1347 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, { | |
| 1348 | − protocol: "mcp", | |
| 1349 | − contentHash: extracted.sourceHash, | |
| 1350 | − extractorVersion: mcpExtractorVersion, | |
| 1351 | − toolCount: extracted.tools.length, | |
| 1352 | − }), | |
| 1353 | − "Failed to upsert MCP artifact metadata", | |
| 1354 | − ); | |
| 1355 | − | |
| 1356 | − if (artifactMeta.created) { | |
| 1357 | − const artifactRows = extracted.tools.map((tool) => { | |
| 1358 | − const schemas = extractMcpSchemas(tool.invocationJson); | |
| 1359 | − return { | |
| 1360 | − toolId: tool.toolId, | |
| 1361 | − name: tool.name, | |
| 1362 | − description: tool.description, | |
| 1363 | − canonicalPath: tool.toolName, | |
| 1364 | − operationHash: tool.operationHash, | |
| 1365 | − invocationJson: tool.invocationJson, | |
| 1366 | − inputSchemaJson: schemas.inputSchemaJson, | |
| 1367 | − outputSchemaJson: schemas.outputSchemaJson, | |
| 1368 | − metadataJson: safeJsonStringify({ | |
| 1369 | − toolName: tool.toolName, | |
| 1370 | − }), | |
| 1371 | − }; | |
| 1372 | − }); | |
| 1373 | − | |
| 1374 | − if (options.stageArtifactWrites) { | |
| 1375 | − const serializedBatches = chunkArray(artifactRows, stagedArtifactBatchSize).map((batch) => | |
| 1376 | − JSON.stringify(batch), | |
| 1377 | − ); | |
| 1378 | − | |
| 1379 | − yield* trySourceIngestPromise( | |
| 1380 | − () => | |
| 1381 | − ctx.runMutation( | |
| 1382 | − runtimeInternal.control_plane.tool_registry.replaceSourceIngestArtifactBatches, | |
| 1383 | − { | |
| 1384 | − workspaceId: source.workspaceId, | |
| 1385 | − sourceId: source.id, | |
| 1386 | − artifactId: artifactMeta.artifactId, | |
| 1387 | − protocol: "mcp", | |
| 1388 | − batches: serializedBatches, | |
| 1389 | − }, | |
| 1390 | − ), | |
| 1391 | − "Failed to stage MCP artifact tool batches", | |
| 1392 | − ); | |
| 1393 | − } else { | |
| 1394 | − for (let index = 0; index < artifactRows.length; index += writeBatchSize) { | |
| 1395 | − const batch = artifactRows.slice(index, index + writeBatchSize); | |
| 1396 | − yield* trySourceIngestPromise( | |
| 1397 | − () => | |
| 1398 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | |
| 1399 | − artifactId: artifactMeta.artifactId, | |
| 1400 | − protocol: "mcp", | |
| 1401 | − insertOnly: true, | |
| 1402 | − tools: batch, | |
| 1403 | − }), | |
| 1404 | − "Failed to write MCP artifact tools", | |
| 1405 | − ); | |
| 1406 | − } | |
| 1407 | − } | |
| 1408 | − } | |
| 1409 | − | |
| 1410 | − yield* trySourceIngestPromise( | |
| 1411 | − () => | |
| 1412 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | |
| 1413 | − workspaceId: source.workspaceId, | |
| 1414 | − sourceId: source.id, | |
| 1415 | − artifactId: artifactMeta.artifactId, | |
| 1416 | − }), | |
| 1417 | − "Failed to bind source to MCP artifact", | |
| 1418 | − ); | |
| 1419 | − | |
| 1420 | − const namespace = sourceNamespace(source); | |
| 1421 | − const indexRows = extracted.tools.map((tool) => { | |
| 1422 | − return { | |
| 1423 | − toolId: tool.toolId, | |
| 1424 | − protocol: "mcp", | |
| 1425 | − method: "post", | |
| 1426 | − path: sourceToolPath(source, tool.toolId), | |
| 1427 | − operationPath: null, | |
| 1428 | − name: tool.name, | |
| 1429 | − description: tool.description, | |
| 1430 | − searchText: normalizedSearchText( | |
| 1431 | − source.name, | |
| 1432 | − source.endpoint, | |
| 1433 | − namespace, | |
| 1434 | − tool.toolId, | |
| 1435 | − tool.name, | |
| 1436 | − tool.description, | |
| 1437 | − tool.toolName, | |
| 1438 | − ), | |
| 1439 | − operationHash: tool.operationHash, | |
| 1440 | − approvalMode: "auto", | |
| 1441 | − status: source.enabled ? "active" : "disabled", | |
| 1442 | − }; | |
| 1443 | − }); | |
| 1444 | − | |
| 1445 | − if (options.rebuildIndex) { | |
| 1446 | − yield* trySourceIngestPromise( | |
| 1447 | − () => | |
| 1448 | − ctx.runMutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, { | |
| 1449 | − workspaceId: source.workspaceId, | |
| 1450 | − sourceId: source.id, | |
| 1451 | − sourceName: source.name, | |
| 1452 | − sourceKind: source.kind, | |
| 1453 | − artifactId: artifactMeta.artifactId, | |
| 1454 | − namespace, | |
| 1455 | − refHintTableJson: null, | |
| 1456 | − rows: indexRows, | |
| 1457 | − }), | |
| 1458 | − "Failed to rebuild workspace tool index for MCP source", | |
| 1459 | − ); | |
| 1460 | − } | |
| 1461 | − | |
| 1462 | − return { | |
| 1463 | − protocol: "mcp", | |
| 1464 | − artifactId: artifactMeta.artifactId, | |
| 1465 | − sourceHash: extracted.sourceHash, | |
| 1466 | − toolCount: extracted.tools.length, | |
| 1467 | − namespace, | |
| 1468 | − refHintTableJson: null, | |
| 1469 | − artifactBatchCount: artifactMeta.created && options.stageArtifactWrites | |
| 1470 | − ? Math.ceil(extracted.tools.length / stagedArtifactBatchSize) | |
| 1471 | − : 0, | |
| 1472 | − }; | |
| 1473 | − }); | |
| 1474 | − | |
| 1475 | −export const ingestSourceArtifact = internalAction({ | |
| 1476 | − args: { | |
| 1477 | − workspaceId: v.string(), | |
| 1478 | − sourceId: v.string(), | |
| 1479 | − }, | |
| 1480 | − handler: async (ctx, args): Promise<void> => { | |
| 1481 | − const source = await ctx.runQuery(runtimeInternal.control_plane.sources.getSourceForIngest, { | |
| 1482 | − sourceId: args.sourceId, | |
| 1483 | − }); | |
| 1484 | − | |
| 1485 | − if (!source || source.workspaceId !== args.workspaceId) { | |
| 1486 | − return; | |
| 1487 | − } | |
| 1488 | − | |
| 1489 | − await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, { | |
| 1490 | − sourceId: source.id, | |
| 1491 | − status: "probing", | |
| 1492 | − lastError: null, | |
| 1493 | − }); | |
| 1494 | − | |
| 1495 | − const ingestEffect = source.kind === "openapi" | |
| 1496 | − ? ingestOpenApiSource(ctx, source) | |
| 1497 | − : source.kind === "graphql" | |
| 1498 | − ? ingestGraphqlSource(ctx, source, { | |
| 1499 | − rebuildIndex: true, | |
| 1500 | − stageArtifactWrites: false, | |
| 1501 | − }) | |
| 1502 | − : source.kind === "mcp" | |
| 1503 | − ? ingestMcpSource(ctx, source, { | |
| 1504 | − rebuildIndex: true, | |
| 1505 | − stageArtifactWrites: false, | |
| 1506 | − }) | |
| 1507 | − : Effect.fail( | |
| 1508 | − SourceIngestError.make({ | |
| 1509 | − status: "error", | |
| 1510 | − message: `Source ingest for kind '${source.kind}' is not implemented yet`, | |
| 1511 | − }), | |
| 1512 | − ); | |
| 1513 | − | |
| 1514 | − const result = await Effect.runPromise( | |
| 1515 | − ingestEffect.pipe( | |
| 1516 | − Effect.match({ | |
| 1517 | − onFailure: (error) => ({ | |
| 1518 | − status: error.status, | |
| 1519 | − sourceHash: null as string | null, | |
| 1520 | − lastError: error.message, | |
| 1521 | − }), | |
| 1522 | − onSuccess: (prepared) => ({ | |
| 1523 | − status: "connected" as const, | |
| 1524 | − sourceHash: prepared.sourceHash, | |
| 1525 | − lastError: null as string | null, | |
| 1526 | − }), | |
| 1527 | − }), | |
| 1528 | − ), | |
| 1529 | − ); | |
| 1530 | − | |
| 1531 | − await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, { | |
| 1532 | − sourceId: source.id, | |
| 1533 | − status: result.status, | |
| 1534 | − sourceHash: result.sourceHash, | |
| 1535 | − lastError: result.lastError, | |
| 1536 | − }); | |
| 1537 | − }, | |
| 1538 | −}); | |
| @@ -1,124 +0,0 @@ | ||
| 1 | −import { type UpsertOrganizationPayload } from "@executor-v2/management-api"; | |
| 2 | −import { | |
| 3 | − OrganizationSchema, | |
| 4 | − type Organization, | |
| 5 | − type OrganizationMembership, | |
| 6 | −} from "@executor-v2/schema"; | |
| 7 | −import { v } from "convex/values"; | |
| 8 | −import * as Schema from "effect/Schema"; | |
| 9 | − | |
| 10 | −import { internalMutation, internalQuery } from "../_generated/server"; | |
| 11 | − | |
| 12 | −const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema); | |
| 13 | − | |
| 14 | −const stripConvexSystemFields = ( | |
| 15 | − value: Record<string, unknown>, | |
| 16 | −): Record<string, unknown> => { | |
| 17 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 18 | − return rest; | |
| 19 | −}; | |
| 20 | − | |
| 21 | −const toOrganization = (document: Record<string, unknown>): Organization => | |
| 22 | − decodeOrganization(stripConvexSystemFields(document)); | |
| 23 | − | |
| 24 | −const organizationStatusValidator = v.union( | |
| 25 | − v.literal("active"), | |
| 26 | − v.literal("suspended"), | |
| 27 | − v.literal("archived"), | |
| 28 | −); | |
| 29 | − | |
| 30 | −const sortOrganizations = ( | |
| 31 | − organizations: ReadonlyArray<Organization>, | |
| 32 | −): Array<Organization> => | |
| 33 | − [...organizations].sort((left, right) => { | |
| 34 | − const leftName = left.name.toLowerCase(); | |
| 35 | − const rightName = right.name.toLowerCase(); | |
| 36 | − | |
| 37 | − if (leftName === rightName) { | |
| 38 | − return left.id.localeCompare(right.id); | |
| 39 | − } | |
| 40 | − | |
| 41 | − return leftName.localeCompare(rightName); | |
| 42 | − }); | |
| 43 | − | |
| 44 | −export const listOrganizations = internalQuery({ | |
| 45 | − args: {}, | |
| 46 | − handler: async (ctx): Promise<Array<Organization>> => { | |
| 47 | − const rows = await ctx.db.query("organizations").collect(); | |
| 48 | − | |
| 49 | − return sortOrganizations( | |
| 50 | − rows.map((row) => toOrganization(row as unknown as Record<string, unknown>)), | |
| 51 | − ); | |
| 52 | − }, | |
| 53 | −}); | |
| 54 | − | |
| 55 | −export const upsertOrganization = internalMutation({ | |
| 56 | − args: { | |
| 57 | − payload: v.object({ | |
| 58 | − id: v.optional(v.string()), | |
| 59 | − slug: v.string(), | |
| 60 | − name: v.string(), | |
| 61 | − status: v.optional(organizationStatusValidator), | |
| 62 | − createdByAccountId: v.optional(v.union(v.string(), v.null())), | |
| 63 | − }), | |
| 64 | − }, | |
| 65 | − handler: async (ctx, args): Promise<Organization> => { | |
| 66 | − const payload = args.payload as UpsertOrganizationPayload; | |
| 67 | − const now = Date.now(); | |
| 68 | − const organizationId = payload.id ?? `org_${crypto.randomUUID()}`; | |
| 69 | − | |
| 70 | − const existing = await ctx.db | |
| 71 | − .query("organizations") | |
| 72 | − .withIndex("by_domainId", (q) => q.eq("id", organizationId)) | |
| 73 | − .unique(); | |
| 74 | − | |
| 75 | − const nextOrganization = decodeOrganization({ | |
| 76 | − id: organizationId, | |
| 77 | − slug: payload.slug, | |
| 78 | − name: payload.name, | |
| 79 | − status: payload.status ?? existing?.status ?? "active", | |
| 80 | − createdByAccountId: | |
| 81 | − existing?.createdByAccountId | |
| 82 | − ?? payload.createdByAccountId | |
| 83 | − ?? null, | |
| 84 | − createdAt: existing?.createdAt ?? now, | |
| 85 | − updatedAt: now, | |
| 86 | − }); | |
| 87 | − | |
| 88 | − if (existing) { | |
| 89 | − await ctx.db.patch(existing._id, nextOrganization); | |
| 90 | − } else { | |
| 91 | − await ctx.db.insert("organizations", nextOrganization); | |
| 92 | − | |
| 93 | − if (nextOrganization.createdByAccountId !== null) { | |
| 94 | − const existingMembership = await ctx.db | |
| 95 | − .query("organizationMemberships") | |
| 96 | − .withIndex("by_organizationId_accountId", (q) => | |
| 97 | − q | |
| 98 | − .eq("organizationId", nextOrganization.id) | |
| 99 | − .eq("accountId", nextOrganization.createdByAccountId as string) | |
| 100 | − ) | |
| 101 | − .first(); | |
| 102 | − | |
| 103 | − if (!existingMembership) { | |
| 104 | − const membership: OrganizationMembership = { | |
| 105 | − id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"], | |
| 106 | − organizationId: nextOrganization.id, | |
| 107 | − accountId: nextOrganization.createdByAccountId, | |
| 108 | − role: "owner", | |
| 109 | − status: "active", | |
| 110 | − billable: false, | |
| 111 | − invitedByAccountId: null, | |
| 112 | − joinedAt: now, | |
| 113 | − createdAt: now, | |
| 114 | − updatedAt: now, | |
| 115 | − }; | |
| 116 | − | |
| 117 | − await ctx.db.insert("organizationMemberships", membership); | |
| 118 | − } | |
| 119 | − } | |
| 120 | − } | |
| 121 | − | |
| 122 | − return nextOrganization; | |
| 123 | − }, | |
| 124 | −}); | |
| @@ -1,120 +0,0 @@ | ||
| 1 | −import { type UpsertPolicyPayload } from "@executor-v2/management-api"; | |
| 2 | −import { PolicySchema, type Policy } from "@executor-v2/schema"; | |
| 3 | −import { v } from "convex/values"; | |
| 4 | −import * as Schema from "effect/Schema"; | |
| 5 | − | |
| 6 | −import { internalMutation, internalQuery } from "../_generated/server"; | |
| 7 | − | |
| 8 | −const decodePolicy = Schema.decodeUnknownSync(PolicySchema); | |
| 9 | − | |
| 10 | −const stripConvexSystemFields = ( | |
| 11 | − value: Record<string, unknown>, | |
| 12 | −): Record<string, unknown> => { | |
| 13 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 14 | − return rest; | |
| 15 | −}; | |
| 16 | − | |
| 17 | −const toPolicy = (document: Record<string, unknown>): Policy => | |
| 18 | − decodePolicy(stripConvexSystemFields(document)); | |
| 19 | − | |
| 20 | −const policyDecisionValidator = v.union( | |
| 21 | − v.literal("allow"), | |
| 22 | − v.literal("require_approval"), | |
| 23 | − v.literal("deny"), | |
| 24 | −); | |
| 25 | − | |
| 26 | −const sortPolicies = (policies: ReadonlyArray<Policy>): Array<Policy> => | |
| 27 | − [...policies].sort((left, right) => { | |
| 28 | − const leftPattern = left.toolPathPattern.toLowerCase(); | |
| 29 | − const rightPattern = right.toolPathPattern.toLowerCase(); | |
| 30 | − | |
| 31 | − if (leftPattern === rightPattern) { | |
| 32 | − return left.id.localeCompare(right.id); | |
| 33 | − } | |
| 34 | − | |
| 35 | − return leftPattern.localeCompare(rightPattern); | |
| 36 | − }); | |
| 37 | − | |
| 38 | −export const listPolicies = internalQuery({ | |
| 39 | − args: { | |
| 40 | − workspaceId: v.string(), | |
| 41 | − }, | |
| 42 | − handler: async (ctx, args): Promise<Array<Policy>> => { | |
| 43 | − const rows = await ctx.db | |
| 44 | − .query("policies") | |
| 45 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 46 | − .collect(); | |
| 47 | − | |
| 48 | − return sortPolicies( | |
| 49 | − rows.map((row) => toPolicy(row as unknown as Record<string, unknown>)), | |
| 50 | − ); | |
| 51 | − }, | |
| 52 | −}); | |
| 53 | − | |
| 54 | −export const upsertPolicy = internalMutation({ | |
| 55 | − args: { | |
| 56 | − workspaceId: v.string(), | |
| 57 | − payload: v.object({ | |
| 58 | − id: v.optional(v.string()), | |
| 59 | − toolPathPattern: v.string(), | |
| 60 | − decision: policyDecisionValidator, | |
| 61 | − }), | |
| 62 | − }, | |
| 63 | − handler: async (ctx, args): Promise<Policy> => { | |
| 64 | − const payload = args.payload as UpsertPolicyPayload; | |
| 65 | − const now = Date.now(); | |
| 66 | − const policyId = payload.id ?? `pol_${crypto.randomUUID()}`; | |
| 67 | − | |
| 68 | − const existing = await ctx.db | |
| 69 | − .query("policies") | |
| 70 | − .withIndex("by_domainId", (q) => q.eq("id", policyId)) | |
| 71 | − .unique(); | |
| 72 | − | |
| 73 | − if (existing && existing.workspaceId !== args.workspaceId) { | |
| 74 | − throw new Error(`Policy not found: ${policyId}`); | |
| 75 | − } | |
| 76 | − | |
| 77 | − const nextPolicy = decodePolicy({ | |
| 78 | − id: policyId, | |
| 79 | − workspaceId: args.workspaceId, | |
| 80 | − toolPathPattern: payload.toolPathPattern, | |
| 81 | − decision: payload.decision, | |
| 82 | − createdAt: existing?.createdAt ?? now, | |
| 83 | − updatedAt: now, | |
| 84 | − }); | |
| 85 | − | |
| 86 | − if (existing) { | |
| 87 | − await ctx.db.patch(existing._id, nextPolicy); | |
| 88 | − } else { | |
| 89 | − await ctx.db.insert("policies", nextPolicy); | |
| 90 | − } | |
| 91 | − | |
| 92 | − return nextPolicy; | |
| 93 | − }, | |
| 94 | −}); | |
| 95 | − | |
| 96 | −export const removePolicy = internalMutation({ | |
| 97 | − args: { | |
| 98 | − workspaceId: v.string(), | |
| 99 | − policyId: v.string(), | |
| 100 | − }, | |
| 101 | − handler: async ( | |
| 102 | − ctx, | |
| 103 | − args, | |
| 104 | − ): Promise<{ | |
| 105 | − removed: boolean; | |
| 106 | − }> => { | |
| 107 | − const existing = await ctx.db | |
| 108 | − .query("policies") | |
| 109 | − .withIndex("by_domainId", (q) => q.eq("id", args.policyId)) | |
| 110 | − .unique(); | |
| 111 | − | |
| 112 | − if (!existing || existing.workspaceId !== args.workspaceId) { | |
| 113 | − return { removed: false }; | |
| 114 | − } | |
| 115 | − | |
| 116 | − await ctx.db.delete(existing._id); | |
| 117 | − | |
| 118 | − return { removed: true }; | |
| 119 | − }, | |
| 120 | −}); | |
| @@ -1,283 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − ControlPlaneService, | |
| 3 | − type ControlPlaneServiceShape, | |
| 4 | −} from "@executor-v2/management-api"; | |
| 5 | −import type { SourceToolSummary } from "@executor-v2/management-api/tools/api"; | |
| 6 | −import * as Effect from "effect/Effect"; | |
| 7 | − | |
| 8 | −import { internal } from "../_generated/api"; | |
| 9 | −import type { ActionCtx } from "../_generated/server"; | |
| 10 | −import { toSourceStoreError } from "./errors"; | |
| 11 | − | |
| 12 | −const runtimeInternal = internal as any; | |
| 13 | −runtimeInternal.controlPlane = runtimeInternal.controlPlane ?? runtimeInternal.control_plane; | |
| 14 | −const toolPageSize = 250; | |
| 15 | − | |
| 16 | −const collectWorkspaceToolSummaries = async ( | |
| 17 | − ctx: ActionCtx, | |
| 18 | − input: { | |
| 19 | − workspaceId: string; | |
| 20 | − sourceId?: string; | |
| 21 | − }, | |
| 22 | −): Promise<Array<SourceToolSummary>> => { | |
| 23 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 24 | − workspaceId: input.workspaceId, | |
| 25 | − ...(input.sourceId ? { sourceId: input.sourceId } : {}), | |
| 26 | − }); | |
| 27 | − | |
| 28 | − const summaries: Array<SourceToolSummary> = []; | |
| 29 | − let cursor: string | null = null; | |
| 30 | − | |
| 31 | − do { | |
| 32 | − const page: { | |
| 33 | − summaries: Array<SourceToolSummary>; | |
| 34 | − continueCursor: string | null; | |
| 35 | − isDone: boolean; | |
| 36 | − } = await ctx.runQuery(runtimeInternal.control_plane.tools.listWorkspaceToolSummaryPage, { | |
| 37 | − workspaceId: input.workspaceId, | |
| 38 | − ...(input.sourceId ? { sourceId: input.sourceId } : {}), | |
| 39 | − cursor, | |
| 40 | − pageSize: toolPageSize, | |
| 41 | − }); | |
| 42 | − | |
| 43 | − summaries.push(...page.summaries); | |
| 44 | − cursor = page.isDone ? null : page.continueCursor; | |
| 45 | − } while (cursor); | |
| 46 | − | |
| 47 | − return summaries; | |
| 48 | −}; | |
| 49 | − | |
| 50 | −export const makeConvexControlPlaneService = ( | |
| 51 | − ctx: ActionCtx, | |
| 52 | −): ControlPlaneServiceShape => | |
| 53 | − ControlPlaneService.of({ | |
| 54 | − listSources: (workspaceId) => | |
| 55 | − Effect.tryPromise({ | |
| 56 | − try: () => | |
| 57 | − ctx.runQuery(runtimeInternal.controlPlane.listSources, { | |
| 58 | − workspaceId, | |
| 59 | − }), | |
| 60 | − catch: (cause) => toSourceStoreError("controlPlane.listSources", cause), | |
| 61 | − }), | |
| 62 | − upsertSource: (input) => | |
| 63 | − Effect.tryPromise({ | |
| 64 | − try: () => | |
| 65 | − ctx.runAction(runtimeInternal.controlPlane.upsertSource, { | |
| 66 | − workspaceId: input.workspaceId, | |
| 67 | − payload: input.payload, | |
| 68 | − }), | |
| 69 | − catch: (cause) => toSourceStoreError("controlPlane.upsertSource", cause), | |
| 70 | − }), | |
| 71 | − removeSource: (input) => | |
| 72 | − Effect.tryPromise({ | |
| 73 | − try: () => | |
| 74 | − ctx.runMutation(runtimeInternal.controlPlane.removeSource, { | |
| 75 | − workspaceId: input.workspaceId, | |
| 76 | − sourceId: input.sourceId, | |
| 77 | − }), | |
| 78 | − catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause), | |
| 79 | − }), | |
| 80 | − listCredentialBindings: (workspaceId) => | |
| 81 | − Effect.tryPromise({ | |
| 82 | − try: () => | |
| 83 | − ctx.runQuery(runtimeInternal.controlPlane.listCredentialBindings, { | |
| 84 | − workspaceId, | |
| 85 | − }), | |
| 86 | − catch: (cause) => | |
| 87 | − toSourceStoreError("controlPlane.listCredentialBindings", cause), | |
| 88 | − }), | |
| 89 | − upsertCredentialBinding: (input) => | |
| 90 | − Effect.tryPromise({ | |
| 91 | − try: () => | |
| 92 | − ctx.runAction(runtimeInternal.controlPlane.upsertCredentialBinding, { | |
| 93 | − workspaceId: input.workspaceId, | |
| 94 | − payload: input.payload, | |
| 95 | − }), | |
| 96 | − catch: (cause) => | |
| 97 | − toSourceStoreError("controlPlane.upsertCredentialBinding", cause), | |
| 98 | − }), | |
| 99 | − removeCredentialBinding: (input) => | |
| 100 | − Effect.tryPromise({ | |
| 101 | − try: () => | |
| 102 | − ctx.runMutation(runtimeInternal.controlPlane.removeCredentialBinding, { | |
| 103 | − workspaceId: input.workspaceId, | |
| 104 | − credentialBindingId: input.credentialBindingId, | |
| 105 | − }), | |
| 106 | − catch: (cause) => | |
| 107 | − toSourceStoreError("controlPlane.removeCredentialBinding", cause), | |
| 108 | − }), | |
| 109 | − listPolicies: (workspaceId) => | |
| 110 | − Effect.tryPromise({ | |
| 111 | − try: () => | |
| 112 | − ctx.runQuery(runtimeInternal.controlPlane.listPolicies, { | |
| 113 | − workspaceId, | |
| 114 | − }), | |
| 115 | − catch: (cause) => toSourceStoreError("controlPlane.listPolicies", cause), | |
| 116 | − }), | |
| 117 | − upsertPolicy: (input) => | |
| 118 | − Effect.tryPromise({ | |
| 119 | − try: () => | |
| 120 | − ctx.runMutation(runtimeInternal.controlPlane.upsertPolicy, { | |
| 121 | − workspaceId: input.workspaceId, | |
| 122 | − payload: input.payload, | |
| 123 | − }), | |
| 124 | − catch: (cause) => toSourceStoreError("controlPlane.upsertPolicy", cause), | |
| 125 | − }), | |
| 126 | − removePolicy: (input) => | |
| 127 | − Effect.tryPromise({ | |
| 128 | − try: () => | |
| 129 | − ctx.runMutation(runtimeInternal.controlPlane.removePolicy, { | |
| 130 | − workspaceId: input.workspaceId, | |
| 131 | − policyId: input.policyId, | |
| 132 | − }), | |
| 133 | − catch: (cause) => toSourceStoreError("controlPlane.removePolicy", cause), | |
| 134 | − }), | |
| 135 | − listOrganizations: () => | |
| 136 | − Effect.tryPromise({ | |
| 137 | − try: () => ctx.runQuery(runtimeInternal.controlPlane.listOrganizations, {}), | |
| 138 | − catch: (cause) => toSourceStoreError("controlPlane.listOrganizations", cause), | |
| 139 | − }), | |
| 140 | − upsertOrganization: (input) => | |
| 141 | − Effect.tryPromise({ | |
| 142 | − try: () => | |
| 143 | − ctx.runMutation(runtimeInternal.controlPlane.upsertOrganization, { | |
| 144 | − payload: input.payload, | |
| 145 | − }), | |
| 146 | − catch: (cause) => toSourceStoreError("controlPlane.upsertOrganization", cause), | |
| 147 | − }), | |
| 148 | − listWorkspaces: () => | |
| 149 | − Effect.tryPromise({ | |
| 150 | − try: () => ctx.runQuery(runtimeInternal.controlPlane.listWorkspaces, {}), | |
| 151 | − catch: (cause) => toSourceStoreError("controlPlane.listWorkspaces", cause), | |
| 152 | − }), | |
| 153 | − upsertWorkspace: (input) => | |
| 154 | − Effect.tryPromise({ | |
| 155 | − try: () => | |
| 156 | − ctx.runMutation(runtimeInternal.controlPlane.upsertWorkspace, { | |
| 157 | − payload: input.payload, | |
| 158 | − }), | |
| 159 | − catch: (cause) => toSourceStoreError("controlPlane.upsertWorkspace", cause), | |
| 160 | − }), | |
| 161 | − listWorkspaceTools: (workspaceId) => | |
| 162 | − Effect.tryPromise({ | |
| 163 | − try: () => collectWorkspaceToolSummaries(ctx, { workspaceId }), | |
| 164 | − catch: (cause) => toSourceStoreError("controlPlane.listWorkspaceTools", cause), | |
| 165 | − }), | |
| 166 | − listSourceTools: (input) => | |
| 167 | − Effect.tryPromise({ | |
| 168 | − try: () => | |
| 169 | − collectWorkspaceToolSummaries(ctx, { | |
| 170 | − workspaceId: input.workspaceId, | |
| 171 | − sourceId: input.sourceId, | |
| 172 | − }), | |
| 173 | − catch: (cause) => toSourceStoreError("controlPlane.listSourceTools", cause), | |
| 174 | − }), | |
| 175 | − getToolDetail: (input) => | |
| 176 | − Effect.tryPromise({ | |
| 177 | − try: () => | |
| 178 | − ctx.runQuery(runtimeInternal.controlPlane.getToolDetail, { | |
| 179 | − workspaceId: input.workspaceId, | |
| 180 | − sourceId: input.sourceId, | |
| 181 | − operationHash: input.operationHash, | |
| 182 | − }), | |
| 183 | − catch: (cause) => toSourceStoreError("controlPlane.getToolDetail", cause), | |
| 184 | − }), | |
| 185 | − listStorageInstances: (workspaceId) => | |
| 186 | − Effect.tryPromise({ | |
| 187 | − try: () => | |
| 188 | − ctx.runQuery(runtimeInternal.controlPlane.listStorageInstances, { | |
| 189 | − workspaceId, | |
| 190 | − }), | |
| 191 | − catch: (cause) => | |
| 192 | − toSourceStoreError("controlPlane.listStorageInstances", cause), | |
| 193 | − }), | |
| 194 | − openStorageInstance: (input) => | |
| 195 | − Effect.tryPromise({ | |
| 196 | − try: () => | |
| 197 | − ctx.runMutation(runtimeInternal.controlPlane.openStorageInstance, { | |
| 198 | − workspaceId: input.workspaceId, | |
| 199 | − payload: input.payload, | |
| 200 | − }), | |
| 201 | − catch: (cause) => | |
| 202 | − toSourceStoreError("controlPlane.openStorageInstance", cause), | |
| 203 | − }), | |
| 204 | − closeStorageInstance: (input) => | |
| 205 | − Effect.tryPromise({ | |
| 206 | − try: () => | |
| 207 | − ctx.runMutation(runtimeInternal.controlPlane.closeStorageInstance, { | |
| 208 | − workspaceId: input.workspaceId, | |
| 209 | − storageInstanceId: input.storageInstanceId, | |
| 210 | − }), | |
| 211 | − catch: (cause) => | |
| 212 | − toSourceStoreError("controlPlane.closeStorageInstance", cause), | |
| 213 | − }), | |
| 214 | − removeStorageInstance: (input) => | |
| 215 | − Effect.tryPromise({ | |
| 216 | − try: () => | |
| 217 | − ctx.runMutation(runtimeInternal.controlPlane.removeStorageInstance, { | |
| 218 | − workspaceId: input.workspaceId, | |
| 219 | − storageInstanceId: input.storageInstanceId, | |
| 220 | − }), | |
| 221 | − catch: (cause) => | |
| 222 | − toSourceStoreError("controlPlane.removeStorageInstance", cause), | |
| 223 | − }), | |
| 224 | − listStorageDirectory: (input) => | |
| 225 | − Effect.tryPromise({ | |
| 226 | − try: () => | |
| 227 | − ctx.runQuery(runtimeInternal.controlPlane.listStorageDirectory, { | |
| 228 | − workspaceId: input.workspaceId, | |
| 229 | − storageInstanceId: input.storageInstanceId, | |
| 230 | − payload: input.payload, | |
| 231 | − }), | |
| 232 | − catch: (cause) => | |
| 233 | − toSourceStoreError("controlPlane.listStorageDirectory", cause), | |
| 234 | − }), | |
| 235 | − readStorageFile: (input) => | |
| 236 | − Effect.tryPromise({ | |
| 237 | − try: () => | |
| 238 | − ctx.runQuery(runtimeInternal.controlPlane.readStorageFile, { | |
| 239 | − workspaceId: input.workspaceId, | |
| 240 | − storageInstanceId: input.storageInstanceId, | |
| 241 | − payload: input.payload, | |
| 242 | − }), | |
| 243 | − catch: (cause) => toSourceStoreError("controlPlane.readStorageFile", cause), | |
| 244 | − }), | |
| 245 | − listStorageKv: (input) => | |
| 246 | − Effect.tryPromise({ | |
| 247 | − try: () => | |
| 248 | − ctx.runQuery(runtimeInternal.controlPlane.listStorageKv, { | |
| 249 | − workspaceId: input.workspaceId, | |
| 250 | − storageInstanceId: input.storageInstanceId, | |
| 251 | − payload: input.payload, | |
| 252 | − }), | |
| 253 | − catch: (cause) => toSourceStoreError("controlPlane.listStorageKv", cause), | |
| 254 | − }), | |
| 255 | − queryStorageSql: (input) => | |
| 256 | − Effect.tryPromise({ | |
| 257 | − try: () => | |
| 258 | − ctx.runMutation(runtimeInternal.controlPlane.queryStorageSql, { | |
| 259 | − workspaceId: input.workspaceId, | |
| 260 | − storageInstanceId: input.storageInstanceId, | |
| 261 | − payload: input.payload, | |
| 262 | − }), | |
| 263 | − catch: (cause) => toSourceStoreError("controlPlane.queryStorageSql", cause), | |
| 264 | − }), | |
| 265 | − listApprovals: (workspaceId) => | |
| 266 | − Effect.tryPromise({ | |
| 267 | − try: () => | |
| 268 | − ctx.runQuery(runtimeInternal.controlPlane.listApprovals, { | |
| 269 | − workspaceId, | |
| 270 | − }), | |
| 271 | − catch: (cause) => toSourceStoreError("controlPlane.listApprovals", cause), | |
| 272 | − }), | |
| 273 | − resolveApproval: (input) => | |
| 274 | − Effect.tryPromise({ | |
| 275 | − try: () => | |
| 276 | − ctx.runMutation(runtimeInternal.controlPlane.resolveApproval, { | |
| 277 | − workspaceId: input.workspaceId, | |
| 278 | − approvalId: input.approvalId, | |
| 279 | − payload: input.payload, | |
| 280 | − }), | |
| 281 | − catch: (cause) => toSourceStoreError("controlPlane.resolveApproval", cause), | |
| 282 | − }), | |
| 283 | − }); | |
| @@ -1,395 +0,0 @@ | ||
| 1 | −import { type UpsertSourcePayload } from "@executor-v2/management-api"; | |
| 2 | −import { SourceSchema, type Source } from "@executor-v2/schema"; | |
| 3 | −import { v } from "convex/values"; | |
| 4 | −import * as Schema from "effect/Schema"; | |
| 5 | − | |
| 6 | −import { internal } from "../_generated/api"; | |
| 7 | −import { | |
| 8 | − internalAction, | |
| 9 | − internalMutation, | |
| 10 | − internalQuery, | |
| 11 | −} from "../_generated/server"; | |
| 12 | − | |
| 13 | −const runtimeInternal = internal as any; | |
| 14 | − | |
| 15 | −const decodeSource = Schema.decodeUnknownSync(SourceSchema); | |
| 16 | − | |
| 17 | −const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`; | |
| 18 | − | |
| 19 | −const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 20 | − Boolean(value) && typeof value === "object" && !Array.isArray(value); | |
| 21 | − | |
| 22 | −const parseConfigJson = (configJson: string | undefined): Record<string, unknown> => { | |
| 23 | − if (typeof configJson !== "string") { | |
| 24 | − return {}; | |
| 25 | − } | |
| 26 | − | |
| 27 | − try { | |
| 28 | − const parsed = JSON.parse(configJson) as unknown; | |
| 29 | − return isRecord(parsed) ? parsed : {}; | |
| 30 | − } catch { | |
| 31 | − return {}; | |
| 32 | − } | |
| 33 | −}; | |
| 34 | − | |
| 35 | −const protectedHeaderNames = new Set([ | |
| 36 | − "authorization", | |
| 37 | − "proxy-authorization", | |
| 38 | − "x-api-key", | |
| 39 | −]); | |
| 40 | − | |
| 41 | −const validateSourceConfigSecurity = (config: Record<string, unknown>): void => { | |
| 42 | − if (Object.prototype.hasOwnProperty.call(config, "auth")) { | |
| 43 | − throw new Error("Source config must not include auth; configure credentials via connections"); | |
| 44 | − } | |
| 45 | − | |
| 46 | − if (Object.prototype.hasOwnProperty.call(config, "useCredentialedFetch")) { | |
| 47 | − throw new Error("Source config must not include useCredentialedFetch"); | |
| 48 | − } | |
| 49 | − | |
| 50 | − const headers = config.headers; | |
| 51 | − if (!isRecord(headers)) { | |
| 52 | − return; | |
| 53 | − } | |
| 54 | − | |
| 55 | − for (const headerName of Object.keys(headers)) { | |
| 56 | − const normalized = headerName.trim().toLowerCase(); | |
| 57 | − if (protectedHeaderNames.has(normalized)) { | |
| 58 | − throw new Error( | |
| 59 | − `Source config headers must not include '${headerName}'; use credential connections instead`, | |
| 60 | − ); | |
| 61 | − } | |
| 62 | − } | |
| 63 | −}; | |
| 64 | − | |
| 65 | −const normalizeHttpUrl = (value: string): string | null => { | |
| 66 | − const trimmed = value.trim(); | |
| 67 | − if (trimmed.length === 0) { | |
| 68 | − return null; | |
| 69 | − } | |
| 70 | − | |
| 71 | − try { | |
| 72 | − const parsed = new URL(trimmed); | |
| 73 | − if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { | |
| 74 | − return null; | |
| 75 | − } | |
| 76 | − | |
| 77 | − return parsed.toString(); | |
| 78 | − } catch { | |
| 79 | − return null; | |
| 80 | − } | |
| 81 | −}; | |
| 82 | − | |
| 83 | −const requireHttpUrl = (value: unknown, errorMessage: string): string => { | |
| 84 | − if (typeof value !== "string") { | |
| 85 | − throw new Error(errorMessage); | |
| 86 | − } | |
| 87 | − | |
| 88 | − const normalized = normalizeHttpUrl(value); | |
| 89 | − if (!normalized) { | |
| 90 | − throw new Error(errorMessage); | |
| 91 | − } | |
| 92 | − | |
| 93 | − return normalized; | |
| 94 | −}; | |
| 95 | − | |
| 96 | −const buildOpenApiConfigJson = ( | |
| 97 | − payload: UpsertSourcePayload, | |
| 98 | − baseUrl: string, | |
| 99 | −): string => { | |
| 100 | − const config = parseConfigJson(payload.configJson); | |
| 101 | − config.baseUrl = baseUrl; | |
| 102 | − | |
| 103 | − const specUrl = typeof config.specUrl === "string" ? config.specUrl.trim() : ""; | |
| 104 | − if (specUrl.length === 0) { | |
| 105 | − config.specUrl = payload.endpoint; | |
| 106 | − } | |
| 107 | − | |
| 108 | − return JSON.stringify(config); | |
| 109 | −}; | |
| 110 | − | |
| 111 | −const sortSources = (sources: ReadonlyArray<Source>): Array<Source> => | |
| 112 | − [...sources].sort((left, right) => { | |
| 113 | − const leftName = left.name.toLowerCase(); | |
| 114 | − const rightName = right.name.toLowerCase(); | |
| 115 | − | |
| 116 | − if (leftName === rightName) { | |
| 117 | − return sourceStoreKey(left).localeCompare(sourceStoreKey(right)); | |
| 118 | − } | |
| 119 | − | |
| 120 | − return leftName.localeCompare(rightName); | |
| 121 | − }); | |
| 122 | − | |
| 123 | −const toSource = (document: Record<string, unknown>): Source => { | |
| 124 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...source } = document; | |
| 125 | − return decodeSource(source); | |
| 126 | −}; | |
| 127 | − | |
| 128 | −const sourceKindValidator = v.union( | |
| 129 | − v.literal("mcp"), | |
| 130 | − v.literal("openapi"), | |
| 131 | − v.literal("graphql"), | |
| 132 | − v.literal("internal"), | |
| 133 | −); | |
| 134 | − | |
| 135 | −const sourceStatusValidator = v.union( | |
| 136 | − v.literal("draft"), | |
| 137 | − v.literal("probing"), | |
| 138 | − v.literal("auth_required"), | |
| 139 | − v.literal("connected"), | |
| 140 | − v.literal("error"), | |
| 141 | −); | |
| 142 | − | |
| 143 | −export const listSources = internalQuery({ | |
| 144 | − args: { | |
| 145 | − workspaceId: v.string(), | |
| 146 | − }, | |
| 147 | − handler: async (ctx, args): Promise<Array<Source>> => { | |
| 148 | − const documents = await ctx.db | |
| 149 | − .query("sources") | |
| 150 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 151 | − .collect(); | |
| 152 | − | |
| 153 | − return sortSources( | |
| 154 | − documents.map((document) => | |
| 155 | − toSource(document as unknown as Record<string, unknown>), | |
| 156 | − ), | |
| 157 | − ); | |
| 158 | − }, | |
| 159 | −}); | |
| 160 | − | |
| 161 | −export const upsertSourceRecord = internalMutation({ | |
| 162 | − args: { | |
| 163 | − workspaceId: v.string(), | |
| 164 | − payload: v.object({ | |
| 165 | − id: v.optional(v.string()), | |
| 166 | − name: v.string(), | |
| 167 | − kind: sourceKindValidator, | |
| 168 | − endpoint: v.string(), | |
| 169 | − status: v.optional(sourceStatusValidator), | |
| 170 | − enabled: v.optional(v.boolean()), | |
| 171 | − configJson: v.optional(v.string()), | |
| 172 | − sourceHash: v.optional(v.union(v.string(), v.null())), | |
| 173 | − lastError: v.optional(v.union(v.string(), v.null())), | |
| 174 | − }), | |
| 175 | − }, | |
| 176 | − handler: async (ctx, args): Promise<Source> => { | |
| 177 | − const now = Date.now(); | |
| 178 | − const sourceId = args.payload.id ?? `src_${crypto.randomUUID()}`; | |
| 179 | − | |
| 180 | − const existing = await ctx.db | |
| 181 | − .query("sources") | |
| 182 | − .withIndex("by_domainId", (q) => q.eq("id", sourceId)) | |
| 183 | − .unique(); | |
| 184 | − | |
| 185 | − const existingInWorkspace = | |
| 186 | − existing && existing.workspaceId === args.workspaceId ? existing : null; | |
| 187 | − | |
| 188 | − const payload = args.payload as UpsertSourcePayload; | |
| 189 | − | |
| 190 | − const configJson = (() => { | |
| 191 | − const parsedConfig = parseConfigJson(payload.configJson); | |
| 192 | − validateSourceConfigSecurity(parsedConfig); | |
| 193 | − | |
| 194 | − if (payload.kind !== "openapi") { | |
| 195 | − return JSON.stringify(parsedConfig); | |
| 196 | − } | |
| 197 | − | |
| 198 | − const configuredBaseUrl = requireHttpUrl( | |
| 199 | − parsedConfig.baseUrl, | |
| 200 | − "OpenAPI source requires configJson.baseUrl", | |
| 201 | − ); | |
| 202 | − | |
| 203 | − return buildOpenApiConfigJson( | |
| 204 | − { | |
| 205 | − ...payload, | |
| 206 | − configJson: JSON.stringify(parsedConfig), | |
| 207 | − }, | |
| 208 | − configuredBaseUrl, | |
| 209 | − ); | |
| 210 | − })(); | |
| 211 | − | |
| 212 | − const source = decodeSource({ | |
| 213 | − id: sourceId, | |
| 214 | − workspaceId: args.workspaceId, | |
| 215 | − name: payload.name, | |
| 216 | − kind: payload.kind, | |
| 217 | − endpoint: payload.endpoint, | |
| 218 | − status: payload.status ?? "draft", | |
| 219 | − enabled: payload.enabled ?? true, | |
| 220 | − configJson, | |
| 221 | − sourceHash: payload.sourceHash ?? null, | |
| 222 | − lastError: payload.lastError ?? null, | |
| 223 | − createdAt: existingInWorkspace?.createdAt ?? now, | |
| 224 | − updatedAt: now, | |
| 225 | − }); | |
| 226 | − | |
| 227 | − if (existingInWorkspace) { | |
| 228 | − await ctx.db.patch(existingInWorkspace._id, source); | |
| 229 | − } else { | |
| 230 | − await ctx.db.insert("sources", source); | |
| 231 | − } | |
| 232 | − | |
| 233 | − return source; | |
| 234 | − }, | |
| 235 | −}); | |
| 236 | − | |
| 237 | −export const upsertSource = internalAction({ | |
| 238 | − args: { | |
| 239 | − workspaceId: v.string(), | |
| 240 | − payload: v.object({ | |
| 241 | − id: v.optional(v.string()), | |
| 242 | − name: v.string(), | |
| 243 | − kind: sourceKindValidator, | |
| 244 | − endpoint: v.string(), | |
| 245 | − status: v.optional(sourceStatusValidator), | |
| 246 | − enabled: v.optional(v.boolean()), | |
| 247 | − configJson: v.optional(v.string()), | |
| 248 | − sourceHash: v.optional(v.union(v.string(), v.null())), | |
| 249 | − lastError: v.optional(v.union(v.string(), v.null())), | |
| 250 | − }), | |
| 251 | − }, | |
| 252 | − handler: async (ctx, args): Promise<Source> => { | |
| 253 | − const shouldIngest = args.payload.status !== "draft"; | |
| 254 | − let payload = args.payload as UpsertSourcePayload; | |
| 255 | − | |
| 256 | − if (payload.kind === "openapi") { | |
| 257 | − const config = parseConfigJson(payload.configJson); | |
| 258 | − validateSourceConfigSecurity(config); | |
| 259 | − let resolvedBaseUrl = typeof config.baseUrl === "string" | |
| 260 | − ? normalizeHttpUrl(config.baseUrl) | |
| 261 | − : null; | |
| 262 | − | |
| 263 | − if (!resolvedBaseUrl) { | |
| 264 | − const derived = await ctx.runAction( | |
| 265 | − runtimeInternal.control_plane.openapi_ingest.deriveOpenApiBaseUrl, | |
| 266 | − { | |
| 267 | − specUrl: payload.endpoint, | |
| 268 | − }, | |
| 269 | − ); | |
| 270 | − resolvedBaseUrl = requireHttpUrl( | |
| 271 | − derived?.baseUrl, | |
| 272 | − "OpenAPI source requires configJson.baseUrl", | |
| 273 | − ); | |
| 274 | − } | |
| 275 | − const baseUrl = requireHttpUrl( | |
| 276 | − resolvedBaseUrl, | |
| 277 | − "OpenAPI source requires configJson.baseUrl", | |
| 278 | − ); | |
| 279 | − | |
| 280 | − payload = { | |
| 281 | − ...payload, | |
| 282 | − configJson: buildOpenApiConfigJson(payload, baseUrl), | |
| 283 | − }; | |
| 284 | − } else { | |
| 285 | − validateSourceConfigSecurity(parseConfigJson(payload.configJson)); | |
| 286 | − } | |
| 287 | − | |
| 288 | − const source = await ctx.runMutation(runtimeInternal.control_plane.sources.upsertSourceRecord, { | |
| 289 | − workspaceId: args.workspaceId, | |
| 290 | − payload: { | |
| 291 | − ...payload, | |
| 292 | − status: payload.status ?? (shouldIngest ? "probing" : "draft"), | |
| 293 | − }, | |
| 294 | − }); | |
| 295 | − | |
| 296 | − if (shouldIngest && source.kind !== "internal") { | |
| 297 | − if (source.kind === "openapi") { | |
| 298 | − await ctx.runAction(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, { | |
| 299 | − workspaceId: source.workspaceId, | |
| 300 | − sourceId: source.id, | |
| 301 | − }); | |
| 302 | − } else { | |
| 303 | − await ctx.scheduler.runAfter( | |
| 304 | − 0, | |
| 305 | − runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, | |
| 306 | − { | |
| 307 | − workspaceId: source.workspaceId, | |
| 308 | − sourceId: source.id, | |
| 309 | − }, | |
| 310 | − ); | |
| 311 | − } | |
| 312 | − } | |
| 313 | − | |
| 314 | − return source; | |
| 315 | − }, | |
| 316 | −}); | |
| 317 | − | |
| 318 | −export const getSourceForIngest = internalQuery({ | |
| 319 | − args: { | |
| 320 | − sourceId: v.string(), | |
| 321 | − }, | |
| 322 | − handler: async (ctx, args): Promise<Source | null> => { | |
| 323 | − const sourceRow = await ctx.db | |
| 324 | − .query("sources") | |
| 325 | − .withIndex("by_domainId", (q) => q.eq("id", args.sourceId)) | |
| 326 | − .unique(); | |
| 327 | − | |
| 328 | − if (!sourceRow) { | |
| 329 | − return null; | |
| 330 | − } | |
| 331 | − | |
| 332 | − return toSource(sourceRow as unknown as Record<string, unknown>); | |
| 333 | − }, | |
| 334 | −}); | |
| 335 | − | |
| 336 | −export const setSourceIngestState = internalMutation({ | |
| 337 | − args: { | |
| 338 | − sourceId: v.string(), | |
| 339 | − status: sourceStatusValidator, | |
| 340 | − sourceHash: v.optional(v.union(v.string(), v.null())), | |
| 341 | − lastError: v.optional(v.union(v.string(), v.null())), | |
| 342 | − }, | |
| 343 | − handler: async (ctx, args): Promise<void> => { | |
| 344 | − const sourceRow = await ctx.db | |
| 345 | − .query("sources") | |
| 346 | − .withIndex("by_domainId", (q) => q.eq("id", args.sourceId)) | |
| 347 | − .unique(); | |
| 348 | − | |
| 349 | − if (!sourceRow) { | |
| 350 | − return; | |
| 351 | − } | |
| 352 | − | |
| 353 | − await ctx.db.patch(sourceRow._id, { | |
| 354 | − status: args.status, | |
| 355 | − sourceHash: args.sourceHash !== undefined ? args.sourceHash : sourceRow.sourceHash, | |
| 356 | − lastError: args.lastError !== undefined ? args.lastError : sourceRow.lastError, | |
| 357 | − updatedAt: Date.now(), | |
| 358 | − }); | |
| 359 | − }, | |
| 360 | −}); | |
| 361 | − | |
| 362 | −export const removeSource = internalMutation({ | |
| 363 | − args: { | |
| 364 | − workspaceId: v.string(), | |
| 365 | − sourceId: v.string(), | |
| 366 | − }, | |
| 367 | − handler: async ( | |
| 368 | − ctx, | |
| 369 | − args, | |
| 370 | − ): Promise<{ | |
| 371 | − removed: boolean; | |
| 372 | − }> => { | |
| 373 | − const existing = await ctx.db | |
| 374 | − .query("sources") | |
| 375 | − .withIndex("by_domainId", (q) => q.eq("id", args.sourceId)) | |
| 376 | − .unique(); | |
| 377 | − | |
| 378 | − if (!existing || existing.workspaceId !== args.workspaceId) { | |
| 379 | − return { removed: false }; | |
| 380 | − } | |
| 381 | − | |
| 382 | − await ctx.runMutation(runtimeInternal.control_plane.tool_registry.removeSourceBindingsAndIndex, { | |
| 383 | − workspaceId: args.workspaceId, | |
| 384 | − sourceId: args.sourceId, | |
| 385 | − }); | |
| 386 | − | |
| 387 | − await ctx.runMutation(runtimeInternal.control_plane.credentials.removeSourceAuthBindings, { | |
| 388 | − sourceId: args.sourceId, | |
| 389 | − }); | |
| 390 | − | |
| 391 | − await ctx.db.delete(existing._id); | |
| 392 | − | |
| 393 | − return { removed: true }; | |
| 394 | − }, | |
| 395 | −}); | |
| @@ -1,1068 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type ListStorageDirectoryResult, | |
| 3 | − type ListStorageKvResult, | |
| 4 | − type OpenStorageInstancePayload, | |
| 5 | − type QueryStorageSqlResult, | |
| 6 | − type ReadStorageFileResult, | |
| 7 | −} from "@executor-v2/management-api"; | |
| 8 | −import { StorageInstanceSchema, type StorageInstance } from "@executor-v2/schema"; | |
| 9 | −import { v } from "convex/values"; | |
| 10 | −import * as Schema from "effect/Schema"; | |
| 11 | − | |
| 12 | −import { | |
| 13 | − internalQuery, | |
| 14 | − internalMutation, | |
| 15 | − type MutationCtx, | |
| 16 | − type QueryCtx, | |
| 17 | −} from "../_generated/server"; | |
| 18 | − | |
| 19 | −const decodeStorageInstance = Schema.decodeUnknownSync(StorageInstanceSchema); | |
| 20 | − | |
| 21 | −const StorageFileEntrySchema = Schema.Struct({ | |
| 22 | − id: Schema.String, | |
| 23 | − storageInstanceId: Schema.String, | |
| 24 | − path: Schema.String, | |
| 25 | − contentBase64: Schema.String, | |
| 26 | − sizeBytes: Schema.Number, | |
| 27 | − updatedAt: Schema.Number, | |
| 28 | −}); | |
| 29 | − | |
| 30 | −type StorageFileEntry = typeof StorageFileEntrySchema.Type; | |
| 31 | −const decodeStorageFileEntry = Schema.decodeUnknownSync(StorageFileEntrySchema); | |
| 32 | − | |
| 33 | −const StorageKvEntrySchema = Schema.Struct({ | |
| 34 | − id: Schema.String, | |
| 35 | − storageInstanceId: Schema.String, | |
| 36 | − key: Schema.String, | |
| 37 | − valueJson: Schema.String, | |
| 38 | − updatedAt: Schema.Number, | |
| 39 | −}); | |
| 40 | − | |
| 41 | −type StorageKvEntry = typeof StorageKvEntrySchema.Type; | |
| 42 | −const decodeStorageKvEntry = Schema.decodeUnknownSync(StorageKvEntrySchema); | |
| 43 | − | |
| 44 | −const StorageSqlKvEntrySchema = Schema.Struct({ | |
| 45 | − id: Schema.String, | |
| 46 | − storageInstanceId: Schema.String, | |
| 47 | − key: Schema.String, | |
| 48 | − value: Schema.String, | |
| 49 | − updatedAt: Schema.Number, | |
| 50 | −}); | |
| 51 | − | |
| 52 | −type StorageSqlKvEntry = typeof StorageSqlKvEntrySchema.Type; | |
| 53 | −const decodeStorageSqlKvEntry = Schema.decodeUnknownSync(StorageSqlKvEntrySchema); | |
| 54 | − | |
| 55 | −const DEFAULT_KV_LIMIT = 100; | |
| 56 | −const MAX_KV_LIMIT = 1000; | |
| 57 | −const DEFAULT_SQL_MAX_ROWS = 200; | |
| 58 | −const MAX_SQL_MAX_ROWS = 5000; | |
| 59 | − | |
| 60 | −const stripConvexSystemFields = ( | |
| 61 | − value: Record<string, unknown>, | |
| 62 | −): Record<string, unknown> => { | |
| 63 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 64 | − return rest; | |
| 65 | −}; | |
| 66 | − | |
| 67 | −const toStorageInstance = (document: Record<string, unknown>): StorageInstance => | |
| 68 | − decodeStorageInstance(stripConvexSystemFields(document)); | |
| 69 | − | |
| 70 | −const toStorageFileEntry = (document: Record<string, unknown>): StorageFileEntry => | |
| 71 | − decodeStorageFileEntry(stripConvexSystemFields(document)); | |
| 72 | − | |
| 73 | −const toStorageKvEntry = (document: Record<string, unknown>): StorageKvEntry => | |
| 74 | − decodeStorageKvEntry(stripConvexSystemFields(document)); | |
| 75 | − | |
| 76 | −const toStorageSqlKvEntry = (document: Record<string, unknown>): StorageSqlKvEntry => | |
| 77 | − decodeStorageSqlKvEntry(stripConvexSystemFields(document)); | |
| 78 | − | |
| 79 | −const storageScopeTypeValidator = v.union( | |
| 80 | − v.literal("scratch"), | |
| 81 | − v.literal("account"), | |
| 82 | − v.literal("workspace"), | |
| 83 | − v.literal("organization"), | |
| 84 | −); | |
| 85 | − | |
| 86 | −const storageDurabilityValidator = v.union( | |
| 87 | − v.literal("ephemeral"), | |
| 88 | − v.literal("durable"), | |
| 89 | −); | |
| 90 | − | |
| 91 | −const storageProviderValidator = v.union( | |
| 92 | − v.literal("agentfs-local"), | |
| 93 | − v.literal("agentfs-cloudflare"), | |
| 94 | −); | |
| 95 | − | |
| 96 | −const readStorageEncodingValidator = v.union( | |
| 97 | − v.literal("utf8"), | |
| 98 | − v.literal("base64"), | |
| 99 | −); | |
| 100 | − | |
| 101 | −const sortStorageInstances = ( | |
| 102 | − storageInstances: ReadonlyArray<StorageInstance>, | |
| 103 | −): Array<StorageInstance> => | |
| 104 | − [...storageInstances].sort((left, right) => { | |
| 105 | − if (left.updatedAt !== right.updatedAt) { | |
| 106 | − return right.updatedAt - left.updatedAt; | |
| 107 | − } | |
| 108 | − | |
| 109 | − return left.id.localeCompare(right.id); | |
| 110 | − }); | |
| 111 | − | |
| 112 | −const resolveWorkspaceOrganizationId = async ( | |
| 113 | − ctx: QueryCtx | MutationCtx, | |
| 114 | − workspaceId: string, | |
| 115 | −): Promise<string> => { | |
| 116 | − const workspace = await ctx.db | |
| 117 | − .query("workspaces") | |
| 118 | − .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | |
| 119 | − .unique(); | |
| 120 | − | |
| 121 | − if (!workspace) { | |
| 122 | − throw new Error(`Workspace not found: ${workspaceId}`); | |
| 123 | − } | |
| 124 | − | |
| 125 | − return workspace.organizationId; | |
| 126 | −}; | |
| 127 | − | |
| 128 | −const canAccessStorageInstance = ( | |
| 129 | − storageInstance: StorageInstance, | |
| 130 | − input: { | |
| 131 | − workspaceId: string; | |
| 132 | − organizationId: string; | |
| 133 | − }, | |
| 134 | −): boolean => | |
| 135 | − storageInstance.workspaceId === input.workspaceId | |
| 136 | − || ( | |
| 137 | − storageInstance.workspaceId === null | |
| 138 | − && storageInstance.organizationId === input.organizationId | |
| 139 | − ); | |
| 140 | − | |
| 141 | −const normalizeStoragePath = (pathValue: string): string => { | |
| 142 | − const trimmed = pathValue.trim().replaceAll("\\", "/"); | |
| 143 | − const prefixed = trimmed.length > 0 | |
| 144 | − ? (trimmed.startsWith("/") ? trimmed : `/${trimmed}`) | |
| 145 | − : "/"; | |
| 146 | − | |
| 147 | − const parts = prefixed.split("/"); | |
| 148 | − const normalized: Array<string> = []; | |
| 149 | − | |
| 150 | − for (const part of parts) { | |
| 151 | − if (part.length === 0 || part === ".") { | |
| 152 | − continue; | |
| 153 | − } | |
| 154 | − | |
| 155 | − if (part === "..") { | |
| 156 | − if (normalized.length === 0) { | |
| 157 | − throw new Error("Path escapes storage root"); | |
| 158 | − } | |
| 159 | − normalized.pop(); | |
| 160 | − continue; | |
| 161 | − } | |
| 162 | − | |
| 163 | − normalized.push(part); | |
| 164 | − } | |
| 165 | − | |
| 166 | − if (normalized.length === 0) { | |
| 167 | − return "/"; | |
| 168 | − } | |
| 169 | − | |
| 170 | − return `/${normalized.join("/")}`; | |
| 171 | −}; | |
| 172 | − | |
| 173 | −const encodeUtf8Base64 = (value: string): string => { | |
| 174 | − if (typeof Buffer !== "undefined") { | |
| 175 | − return Buffer.from(value, "utf8").toString("base64"); | |
| 176 | − } | |
| 177 | − | |
| 178 | − const bytes = new TextEncoder().encode(value); | |
| 179 | − let binary = ""; | |
| 180 | − for (const byte of bytes) { | |
| 181 | − binary += String.fromCharCode(byte); | |
| 182 | − } | |
| 183 | − return btoa(binary); | |
| 184 | −}; | |
| 185 | − | |
| 186 | −const decodeBase64Utf8 = (value: string): string => { | |
| 187 | − if (typeof Buffer !== "undefined") { | |
| 188 | − return Buffer.from(value, "base64").toString("utf8"); | |
| 189 | − } | |
| 190 | − | |
| 191 | − const binary = atob(value); | |
| 192 | − const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); | |
| 193 | − return new TextDecoder().decode(bytes); | |
| 194 | −}; | |
| 195 | − | |
| 196 | −const parseJsonOrString = (value: string): unknown => { | |
| 197 | − try { | |
| 198 | − return JSON.parse(value) as unknown; | |
| 199 | − } catch { | |
| 200 | − return value; | |
| 201 | − } | |
| 202 | −}; | |
| 203 | − | |
| 204 | −const resolveAccessibleStorageInstance = async ( | |
| 205 | − ctx: QueryCtx | MutationCtx, | |
| 206 | − input: { | |
| 207 | − workspaceId: string; | |
| 208 | − storageInstanceId: string; | |
| 209 | − }, | |
| 210 | −): Promise<StorageInstance | null> => { | |
| 211 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, input.workspaceId); | |
| 212 | − const storageInstanceRow = await ctx.db | |
| 213 | − .query("storageInstances") | |
| 214 | − .withIndex("by_domainId", (q) => q.eq("id", input.storageInstanceId)) | |
| 215 | − .unique(); | |
| 216 | − | |
| 217 | − if (!storageInstanceRow) { | |
| 218 | − return null; | |
| 219 | − } | |
| 220 | − | |
| 221 | − const storageInstance = toStorageInstance( | |
| 222 | − storageInstanceRow as unknown as Record<string, unknown>, | |
| 223 | − ); | |
| 224 | − | |
| 225 | − if ( | |
| 226 | − !canAccessStorageInstance(storageInstance, { | |
| 227 | − workspaceId: input.workspaceId, | |
| 228 | − organizationId, | |
| 229 | − }) | |
| 230 | − ) { | |
| 231 | − return null; | |
| 232 | − } | |
| 233 | − | |
| 234 | − return storageInstance; | |
| 235 | −}; | |
| 236 | − | |
| 237 | −export const upsertStorageFileEntry = internalMutation({ | |
| 238 | − args: { | |
| 239 | − workspaceId: v.string(), | |
| 240 | − storageInstanceId: v.string(), | |
| 241 | − payload: v.object({ | |
| 242 | − path: v.string(), | |
| 243 | − content: v.string(), | |
| 244 | − }), | |
| 245 | − }, | |
| 246 | − handler: async (ctx, args): Promise<StorageFileEntry> => { | |
| 247 | − const storageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 248 | − workspaceId: args.workspaceId, | |
| 249 | − storageInstanceId: args.storageInstanceId, | |
| 250 | − }); | |
| 251 | − | |
| 252 | − if (!storageInstance) { | |
| 253 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 254 | − } | |
| 255 | − | |
| 256 | − const normalizedPath = normalizeStoragePath(args.payload.path); | |
| 257 | − const now = Date.now(); | |
| 258 | − const contentBase64 = encodeUtf8Base64(args.payload.content); | |
| 259 | − const sizeBytes = args.payload.content.length; | |
| 260 | − | |
| 261 | − const existingRow = await ctx.db | |
| 262 | − .query("storageFileEntries") | |
| 263 | − .withIndex("by_storageInstanceId_path", (q) => | |
| 264 | − q.eq("storageInstanceId", args.storageInstanceId).eq("path", normalizedPath) | |
| 265 | − ) | |
| 266 | − .unique(); | |
| 267 | − | |
| 268 | − const nextEntry = decodeStorageFileEntry({ | |
| 269 | − id: | |
| 270 | − (existingRow | |
| 271 | − ? toStorageFileEntry(existingRow as unknown as Record<string, unknown>).id | |
| 272 | − : `storage_file_${crypto.randomUUID()}`), | |
| 273 | − storageInstanceId: args.storageInstanceId, | |
| 274 | − path: normalizedPath, | |
| 275 | − contentBase64, | |
| 276 | − sizeBytes, | |
| 277 | − updatedAt: now, | |
| 278 | − }); | |
| 279 | − | |
| 280 | − if (existingRow) { | |
| 281 | − await ctx.db.patch(existingRow._id, nextEntry); | |
| 282 | − } else { | |
| 283 | − await ctx.db.insert("storageFileEntries", nextEntry); | |
| 284 | − } | |
| 285 | − | |
| 286 | − return nextEntry; | |
| 287 | − }, | |
| 288 | −}); | |
| 289 | − | |
| 290 | −export const upsertStorageKvEntry = internalMutation({ | |
| 291 | − args: { | |
| 292 | − workspaceId: v.string(), | |
| 293 | − storageInstanceId: v.string(), | |
| 294 | − payload: v.object({ | |
| 295 | − key: v.string(), | |
| 296 | − valueJson: v.string(), | |
| 297 | − }), | |
| 298 | − }, | |
| 299 | − handler: async (ctx, args): Promise<StorageKvEntry> => { | |
| 300 | − const storageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 301 | − workspaceId: args.workspaceId, | |
| 302 | − storageInstanceId: args.storageInstanceId, | |
| 303 | − }); | |
| 304 | − | |
| 305 | − if (!storageInstance) { | |
| 306 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 307 | − } | |
| 308 | − | |
| 309 | − const now = Date.now(); | |
| 310 | − | |
| 311 | − const existingRow = await ctx.db | |
| 312 | − .query("storageKvEntries") | |
| 313 | − .withIndex("by_storageInstanceId_key", (q) => | |
| 314 | − q.eq("storageInstanceId", args.storageInstanceId).eq("key", args.payload.key) | |
| 315 | − ) | |
| 316 | − .unique(); | |
| 317 | − | |
| 318 | − const nextEntry = decodeStorageKvEntry({ | |
| 319 | − id: | |
| 320 | − (existingRow | |
| 321 | − ? toStorageKvEntry(existingRow as unknown as Record<string, unknown>).id | |
| 322 | − : `storage_kv_${crypto.randomUUID()}`), | |
| 323 | − storageInstanceId: args.storageInstanceId, | |
| 324 | − key: args.payload.key, | |
| 325 | − valueJson: args.payload.valueJson, | |
| 326 | − updatedAt: now, | |
| 327 | − }); | |
| 328 | − | |
| 329 | − if (existingRow) { | |
| 330 | − await ctx.db.patch(existingRow._id, nextEntry); | |
| 331 | − } else { | |
| 332 | − await ctx.db.insert("storageKvEntries", nextEntry); | |
| 333 | − } | |
| 334 | − | |
| 335 | − return nextEntry; | |
| 336 | − }, | |
| 337 | −}); | |
| 338 | − | |
| 339 | −const decodeSqlStringLiteral = (value: string): string => value.replaceAll("''", "'"); | |
| 340 | − | |
| 341 | −const parseSqlWhere = ( | |
| 342 | − whereClause: string | undefined, | |
| 343 | −): ((key: string) => boolean) => { | |
| 344 | − if (whereClause === undefined) { | |
| 345 | − return () => true; | |
| 346 | − } | |
| 347 | − | |
| 348 | − const equalsMatch = /^key\s*=\s*'((?:''|[^'])*)'$/i.exec(whereClause.trim()); | |
| 349 | − if (equalsMatch) { | |
| 350 | − const expectedKey = decodeSqlStringLiteral(equalsMatch[1] ?? ""); | |
| 351 | − return (key) => key === expectedKey; | |
| 352 | − } | |
| 353 | − | |
| 354 | − const likeMatch = /^key\s+like\s+'((?:''|[^'])*)'$/i.exec(whereClause.trim()); | |
| 355 | − if (likeMatch) { | |
| 356 | − const pattern = decodeSqlStringLiteral(likeMatch[1] ?? ""); | |
| 357 | − if (!pattern.endsWith("%") || pattern.slice(0, -1).includes("%")) { | |
| 358 | − throw new Error("Only trailing % LIKE patterns are supported"); | |
| 359 | − } | |
| 360 | − | |
| 361 | − const prefix = pattern.slice(0, -1); | |
| 362 | − return (key) => key.startsWith(prefix); | |
| 363 | − } | |
| 364 | − | |
| 365 | − throw new Error("Unsupported SQL WHERE clause for kv_store"); | |
| 366 | −}; | |
| 367 | − | |
| 368 | −const parseSelectColumns = (columnsClause: string): Array<"key" | "value"> => { | |
| 369 | − const normalized = columnsClause.trim().toLowerCase(); | |
| 370 | − if (normalized === "*") { | |
| 371 | − return ["key", "value"]; | |
| 372 | − } | |
| 373 | − | |
| 374 | − const columns = normalized | |
| 375 | − .split(",") | |
| 376 | − .map((column) => column.trim()) | |
| 377 | − .filter((column) => column.length > 0); | |
| 378 | − | |
| 379 | − if (columns.length === 0) { | |
| 380 | − throw new Error("SQL SELECT columns are required"); | |
| 381 | − } | |
| 382 | − | |
| 383 | − const parsedColumns: Array<"key" | "value"> = []; | |
| 384 | − | |
| 385 | − for (const column of columns) { | |
| 386 | − if (column !== "key" && column !== "value") { | |
| 387 | − throw new Error("Only key/value columns are supported for kv_store"); | |
| 388 | − } | |
| 389 | − | |
| 390 | − if (!parsedColumns.includes(column)) { | |
| 391 | − parsedColumns.push(column); | |
| 392 | − } | |
| 393 | − } | |
| 394 | − | |
| 395 | − return parsedColumns; | |
| 396 | −}; | |
| 397 | − | |
| 398 | −type SqlWrite = | |
| 399 | − | { | |
| 400 | − type: "upsert"; | |
| 401 | − key: string; | |
| 402 | − value: string; | |
| 403 | − insertOnly: boolean; | |
| 404 | − } | |
| 405 | − | { | |
| 406 | − type: "delete"; | |
| 407 | − key: string; | |
| 408 | − }; | |
| 409 | − | |
| 410 | −const executeSqlForStorage = ( | |
| 411 | − sqlText: string, | |
| 412 | − sqlRows: ReadonlyArray<StorageSqlKvEntry>, | |
| 413 | − maxRows: number, | |
| 414 | −): { | |
| 415 | − result: QueryStorageSqlResult; | |
| 416 | − writes: Array<SqlWrite>; | |
| 417 | −} => { | |
| 418 | − const trimmed = sqlText.trim(); | |
| 419 | − | |
| 420 | − if (/^create\s+table\s+if\s+not\s+exists\s+kv_store\b/i.test(trimmed)) { | |
| 421 | − return { | |
| 422 | − result: { | |
| 423 | − rows: [], | |
| 424 | − columns: [], | |
| 425 | − rowCount: 0, | |
| 426 | − }, | |
| 427 | − writes: [], | |
| 428 | − }; | |
| 429 | − } | |
| 430 | − | |
| 431 | − if (/^(begin|commit|rollback)\b/i.test(trimmed)) { | |
| 432 | − return { | |
| 433 | − result: { | |
| 434 | − rows: [], | |
| 435 | − columns: [], | |
| 436 | − rowCount: 0, | |
| 437 | − }, | |
| 438 | − writes: [], | |
| 439 | − }; | |
| 440 | − } | |
| 441 | − | |
| 442 | − const sortedRows = [...sqlRows].sort((left, right) => left.key.localeCompare(right.key)); | |
| 443 | − | |
| 444 | − const selectMatch = /^select\s+(.+?)\s+from\s+kv_store(?:\s+where\s+(.+?))?(?:\s+limit\s+(\d+))?\s*;?$/i.exec(trimmed); | |
| 445 | − if (selectMatch) { | |
| 446 | − const columns = parseSelectColumns(selectMatch[1] ?? ""); | |
| 447 | − const where = parseSqlWhere(selectMatch[2]); | |
| 448 | − const sqlLimit = | |
| 449 | − selectMatch[3] !== undefined | |
| 450 | − ? Math.max(1, Math.min(maxRows, Number.parseInt(selectMatch[3], 10))) | |
| 451 | − : maxRows; | |
| 452 | − | |
| 453 | − const rows = sortedRows | |
| 454 | − .filter((row) => where(row.key)) | |
| 455 | − .slice(0, sqlLimit) | |
| 456 | − .map((row) => { | |
| 457 | − const resultRow: Record<string, unknown> = {}; | |
| 458 | − | |
| 459 | − for (const column of columns) { | |
| 460 | − if (column === "key") { | |
| 461 | − resultRow.key = row.key; | |
| 462 | − } else { | |
| 463 | − resultRow.value = row.value; | |
| 464 | − } | |
| 465 | − } | |
| 466 | − | |
| 467 | − return resultRow; | |
| 468 | − }); | |
| 469 | − | |
| 470 | − return { | |
| 471 | − result: { | |
| 472 | − rows, | |
| 473 | − columns, | |
| 474 | − rowCount: rows.length, | |
| 475 | − }, | |
| 476 | − writes: [], | |
| 477 | − }; | |
| 478 | − } | |
| 479 | − | |
| 480 | − const insertMatch = /^insert\s+into\s+kv_store\s*\(\s*key\s*,\s*value\s*\)\s*values\s*\(\s*'((?:''|[^'])*)'\s*,\s*'((?:''|[^'])*)'\s*\)\s*;?$/i.exec(trimmed); | |
| 481 | − if (insertMatch) { | |
| 482 | − return { | |
| 483 | − result: { | |
| 484 | − rows: [], | |
| 485 | − columns: [], | |
| 486 | − rowCount: 0, | |
| 487 | − }, | |
| 488 | − writes: [{ | |
| 489 | − type: "upsert", | |
| 490 | − key: decodeSqlStringLiteral(insertMatch[1] ?? ""), | |
| 491 | − value: decodeSqlStringLiteral(insertMatch[2] ?? ""), | |
| 492 | − insertOnly: true, | |
| 493 | − }], | |
| 494 | − }; | |
| 495 | − } | |
| 496 | − | |
| 497 | − const replaceMatch = /^replace\s+into\s+kv_store\s*\(\s*key\s*,\s*value\s*\)\s*values\s*\(\s*'((?:''|[^'])*)'\s*,\s*'((?:''|[^'])*)'\s*\)\s*;?$/i.exec(trimmed); | |
| 498 | − if (replaceMatch) { | |
| 499 | − return { | |
| 500 | − result: { | |
| 501 | − rows: [], | |
| 502 | − columns: [], | |
| 503 | − rowCount: 0, | |
| 504 | − }, | |
| 505 | − writes: [{ | |
| 506 | − type: "upsert", | |
| 507 | − key: decodeSqlStringLiteral(replaceMatch[1] ?? ""), | |
| 508 | − value: decodeSqlStringLiteral(replaceMatch[2] ?? ""), | |
| 509 | − insertOnly: false, | |
| 510 | − }], | |
| 511 | − }; | |
| 512 | − } | |
| 513 | − | |
| 514 | − const updateMatch = /^update\s+kv_store\s+set\s+value\s*=\s*'((?:''|[^'])*)'\s+where\s+key\s*=\s*'((?:''|[^'])*)'\s*;?$/i.exec(trimmed); | |
| 515 | − if (updateMatch) { | |
| 516 | − return { | |
| 517 | − result: { | |
| 518 | − rows: [], | |
| 519 | − columns: [], | |
| 520 | − rowCount: 0, | |
| 521 | − }, | |
| 522 | − writes: [{ | |
| 523 | − type: "upsert", | |
| 524 | − key: decodeSqlStringLiteral(updateMatch[2] ?? ""), | |
| 525 | − value: decodeSqlStringLiteral(updateMatch[1] ?? ""), | |
| 526 | − insertOnly: false, | |
| 527 | − }], | |
| 528 | − }; | |
| 529 | − } | |
| 530 | − | |
| 531 | − const deleteMatch = /^delete\s+from\s+kv_store\s+where\s+key\s*=\s*'((?:''|[^'])*)'\s*;?$/i.exec(trimmed); | |
| 532 | − if (deleteMatch) { | |
| 533 | − return { | |
| 534 | − result: { | |
| 535 | − rows: [], | |
| 536 | − columns: [], | |
| 537 | − rowCount: 0, | |
| 538 | − }, | |
| 539 | − writes: [{ | |
| 540 | − type: "delete", | |
| 541 | − key: decodeSqlStringLiteral(deleteMatch[1] ?? ""), | |
| 542 | − }], | |
| 543 | − }; | |
| 544 | − } | |
| 545 | − | |
| 546 | − throw new Error("Unsupported SQL statement for Convex storage inspector"); | |
| 547 | −}; | |
| 548 | − | |
| 549 | −export const listStorageInstances = internalQuery({ | |
| 550 | − args: { | |
| 551 | − workspaceId: v.string(), | |
| 552 | − }, | |
| 553 | − handler: async (ctx, args): Promise<Array<StorageInstance>> => { | |
| 554 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 555 | − | |
| 556 | − const workspaceRows = await ctx.db | |
| 557 | − .query("storageInstances") | |
| 558 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 559 | − .collect(); | |
| 560 | − | |
| 561 | − const organizationRows = await ctx.db | |
| 562 | − .query("storageInstances") | |
| 563 | − .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId)) | |
| 564 | − .collect(); | |
| 565 | − | |
| 566 | − const storageInstances = [...workspaceRows, ...organizationRows] | |
| 567 | − .map((row) => | |
| 568 | − toStorageInstance(row as unknown as Record<string, unknown>), | |
| 569 | − ) | |
| 570 | − .filter((storageInstance) => | |
| 571 | − canAccessStorageInstance(storageInstance, { | |
| 572 | − workspaceId: args.workspaceId, | |
| 573 | − organizationId, | |
| 574 | − }) | |
| 575 | − ); | |
| 576 | − | |
| 577 | − const uniqueStorageInstances = Array.from( | |
| 578 | − new Map(storageInstances.map((storageInstance) => [storageInstance.id, storageInstance])) | |
| 579 | − .values(), | |
| 580 | − ); | |
| 581 | − | |
| 582 | − return sortStorageInstances(uniqueStorageInstances); | |
| 583 | − }, | |
| 584 | −}); | |
| 585 | − | |
| 586 | −export const openStorageInstance = internalMutation({ | |
| 587 | − args: { | |
| 588 | − workspaceId: v.string(), | |
| 589 | − payload: v.object({ | |
| 590 | − scopeType: storageScopeTypeValidator, | |
| 591 | − durability: storageDurabilityValidator, | |
| 592 | − provider: v.optional(storageProviderValidator), | |
| 593 | − purpose: v.optional(v.string()), | |
| 594 | − ttlHours: v.optional(v.number()), | |
| 595 | − accountId: v.optional(v.string()), | |
| 596 | − sessionId: v.optional(v.string()), | |
| 597 | − }), | |
| 598 | − }, | |
| 599 | − handler: async (ctx, args): Promise<StorageInstance> => { | |
| 600 | − const payload = args.payload as OpenStorageInstancePayload; | |
| 601 | − | |
| 602 | − if (payload.scopeType === "account" && payload.accountId === undefined) { | |
| 603 | − throw new Error("Account scope storage requires accountId"); | |
| 604 | − } | |
| 605 | − | |
| 606 | − const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId); | |
| 607 | − const now = Date.now(); | |
| 608 | − const storageInstanceId = `storage_${crypto.randomUUID()}`; | |
| 609 | − const ttlHours = | |
| 610 | − payload.ttlHours !== undefined && Number.isFinite(payload.ttlHours) | |
| 611 | − ? Math.max(1, Math.floor(payload.ttlHours)) | |
| 612 | − : 24; | |
| 613 | − | |
| 614 | − const nextStorageInstance = decodeStorageInstance({ | |
| 615 | − id: storageInstanceId, | |
| 616 | − scopeType: payload.scopeType, | |
| 617 | − durability: payload.durability, | |
| 618 | − status: "active", | |
| 619 | − provider: payload.provider ?? "agentfs-local", | |
| 620 | − backendKey: `convex:${storageInstanceId}`, | |
| 621 | − organizationId, | |
| 622 | − workspaceId: | |
| 623 | − payload.scopeType === "workspace" || payload.scopeType === "scratch" | |
| 624 | − ? args.workspaceId | |
| 625 | − : null, | |
| 626 | − accountId: payload.scopeType === "account" ? (payload.accountId ?? null) : null, | |
| 627 | − createdByAccountId: payload.accountId ?? null, | |
| 628 | − purpose: | |
| 629 | − payload.purpose !== undefined && payload.purpose.trim().length > 0 | |
| 630 | − ? payload.purpose.trim() | |
| 631 | − : null, | |
| 632 | − sizeBytes: null, | |
| 633 | − fileCount: null, | |
| 634 | − createdAt: now, | |
| 635 | − updatedAt: now, | |
| 636 | − lastSeenAt: now, | |
| 637 | − closedAt: null, | |
| 638 | − expiresAt: | |
| 639 | − payload.durability === "ephemeral" | |
| 640 | − ? now + ttlHours * 3_600_000 | |
| 641 | − : null, | |
| 642 | − }); | |
| 643 | − | |
| 644 | − await ctx.db.insert("storageInstances", nextStorageInstance); | |
| 645 | − | |
| 646 | − return nextStorageInstance; | |
| 647 | − }, | |
| 648 | −}); | |
| 649 | − | |
| 650 | −export const closeStorageInstance = internalMutation({ | |
| 651 | − args: { | |
| 652 | − workspaceId: v.string(), | |
| 653 | − storageInstanceId: v.string(), | |
| 654 | − }, | |
| 655 | − handler: async (ctx, args): Promise<StorageInstance> => { | |
| 656 | − const existingStorageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 657 | − workspaceId: args.workspaceId, | |
| 658 | − storageInstanceId: args.storageInstanceId, | |
| 659 | − }); | |
| 660 | − | |
| 661 | − if (!existingStorageInstance) { | |
| 662 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 663 | − } | |
| 664 | − | |
| 665 | − const existingRow = await ctx.db | |
| 666 | − .query("storageInstances") | |
| 667 | − .withIndex("by_domainId", (q) => q.eq("id", args.storageInstanceId)) | |
| 668 | − .unique(); | |
| 669 | − | |
| 670 | − if (!existingRow) { | |
| 671 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 672 | − } | |
| 673 | − | |
| 674 | − const now = Date.now(); | |
| 675 | − const nextStorageInstance = decodeStorageInstance({ | |
| 676 | − ...existingStorageInstance, | |
| 677 | − status: "closed", | |
| 678 | − updatedAt: now, | |
| 679 | − lastSeenAt: now, | |
| 680 | − closedAt: existingStorageInstance.closedAt ?? now, | |
| 681 | − }); | |
| 682 | − | |
| 683 | − await ctx.db.patch(existingRow._id, nextStorageInstance); | |
| 684 | − | |
| 685 | − return nextStorageInstance; | |
| 686 | − }, | |
| 687 | −}); | |
| 688 | − | |
| 689 | −export const removeStorageInstance = internalMutation({ | |
| 690 | − args: { | |
| 691 | − workspaceId: v.string(), | |
| 692 | − storageInstanceId: v.string(), | |
| 693 | − }, | |
| 694 | − handler: async ( | |
| 695 | − ctx, | |
| 696 | − args, | |
| 697 | − ): Promise<{ | |
| 698 | − removed: boolean; | |
| 699 | − }> => { | |
| 700 | − const existingStorageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 701 | − workspaceId: args.workspaceId, | |
| 702 | − storageInstanceId: args.storageInstanceId, | |
| 703 | − }); | |
| 704 | − | |
| 705 | − if (!existingStorageInstance) { | |
| 706 | − return { removed: false }; | |
| 707 | − } | |
| 708 | − | |
| 709 | − const storageInstanceRow = await ctx.db | |
| 710 | − .query("storageInstances") | |
| 711 | − .withIndex("by_domainId", (q) => q.eq("id", args.storageInstanceId)) | |
| 712 | − .unique(); | |
| 713 | − | |
| 714 | − if (!storageInstanceRow) { | |
| 715 | − return { removed: false }; | |
| 716 | − } | |
| 717 | − | |
| 718 | − const storageFileRows = await ctx.db | |
| 719 | − .query("storageFileEntries") | |
| 720 | − .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | |
| 721 | − .collect(); | |
| 722 | − | |
| 723 | − for (const storageFileRow of storageFileRows) { | |
| 724 | − await ctx.db.delete(storageFileRow._id); | |
| 725 | − } | |
| 726 | − | |
| 727 | − const storageKvRows = await ctx.db | |
| 728 | − .query("storageKvEntries") | |
| 729 | − .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | |
| 730 | − .collect(); | |
| 731 | − | |
| 732 | − for (const storageKvRow of storageKvRows) { | |
| 733 | − await ctx.db.delete(storageKvRow._id); | |
| 734 | − } | |
| 735 | − | |
| 736 | − const storageSqlKvRows = await ctx.db | |
| 737 | − .query("storageSqlKvEntries") | |
| 738 | − .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | |
| 739 | − .collect(); | |
| 740 | − | |
| 741 | − for (const storageSqlKvRow of storageSqlKvRows) { | |
| 742 | − await ctx.db.delete(storageSqlKvRow._id); | |
| 743 | − } | |
| 744 | − | |
| 745 | − await ctx.db.delete(storageInstanceRow._id); | |
| 746 | − | |
| 747 | − return { removed: true }; | |
| 748 | − }, | |
| 749 | −}); | |
| 750 | − | |
| 751 | −export const listStorageDirectory = internalQuery({ | |
| 752 | − args: { | |
| 753 | − workspaceId: v.string(), | |
| 754 | − storageInstanceId: v.string(), | |
| 755 | − payload: v.object({ | |
| 756 | − path: v.string(), | |
| 757 | − }), | |
| 758 | − }, | |
| 759 | − handler: async (ctx, args): Promise<ListStorageDirectoryResult> => { | |
| 760 | − const storageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 761 | − workspaceId: args.workspaceId, | |
| 762 | − storageInstanceId: args.storageInstanceId, | |
| 763 | − }); | |
| 764 | − | |
| 765 | − if (!storageInstance) { | |
| 766 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 767 | − } | |
| 768 | − | |
| 769 | − const normalizedPath = normalizeStoragePath(args.payload.path); | |
| 770 | − | |
| 771 | − const storageFileRows = await ctx.db | |
| 772 | − .query("storageFileEntries") | |
| 773 | − .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | |
| 774 | − .collect(); | |
| 775 | − | |
| 776 | − const storageFiles = storageFileRows.map((row) => | |
| 777 | − toStorageFileEntry(row as unknown as Record<string, unknown>), | |
| 778 | − ); | |
| 779 | − | |
| 780 | − const exactFile = storageFiles.find((storageFile) => storageFile.path === normalizedPath); | |
| 781 | − if (exactFile) { | |
| 782 | − throw new Error(`Cannot list a file path as directory: ${normalizedPath}`); | |
| 783 | − } | |
| 784 | − | |
| 785 | − const prefix = normalizedPath === "/" ? "/" : `${normalizedPath}/`; | |
| 786 | − const directoryByPath = new Map<string, { | |
| 787 | − name: string; | |
| 788 | − path: string; | |
| 789 | − kind: "directory"; | |
| 790 | − sizeBytes: null; | |
| 791 | − updatedAt: number; | |
| 792 | − }>(); | |
| 793 | − const fileEntries: Array<{ | |
| 794 | − name: string; | |
| 795 | − path: string; | |
| 796 | − kind: "file"; | |
| 797 | − sizeBytes: number; | |
| 798 | − updatedAt: number; | |
| 799 | − }> = []; | |
| 800 | − | |
| 801 | − for (const storageFile of storageFiles) { | |
| 802 | − if (!storageFile.path.startsWith(prefix)) { | |
| 803 | − continue; | |
| 804 | − } | |
| 805 | − | |
| 806 | − const remainder = storageFile.path.slice(prefix.length); | |
| 807 | − if (remainder.length === 0) { | |
| 808 | − continue; | |
| 809 | − } | |
| 810 | − | |
| 811 | − const separatorIndex = remainder.indexOf("/"); | |
| 812 | − if (separatorIndex < 0) { | |
| 813 | − const fileName = remainder; | |
| 814 | − fileEntries.push({ | |
| 815 | − name: fileName, | |
| 816 | − path: storageFile.path, | |
| 817 | − kind: "file", | |
| 818 | − sizeBytes: storageFile.sizeBytes, | |
| 819 | − updatedAt: storageFile.updatedAt, | |
| 820 | − }); | |
| 821 | − continue; | |
| 822 | − } | |
| 823 | − | |
| 824 | − const directoryName = remainder.slice(0, separatorIndex); | |
| 825 | − const directoryPath = normalizedPath === "/" | |
| 826 | − ? `/${directoryName}` | |
| 827 | − : `${normalizedPath}/${directoryName}`; | |
| 828 | − const existingDirectory = directoryByPath.get(directoryPath); | |
| 829 | − | |
| 830 | − if (!existingDirectory) { | |
| 831 | − directoryByPath.set(directoryPath, { | |
| 832 | − name: directoryName, | |
| 833 | − path: directoryPath, | |
| 834 | − kind: "directory", | |
| 835 | − sizeBytes: null, | |
| 836 | − updatedAt: storageFile.updatedAt, | |
| 837 | − }); | |
| 838 | − continue; | |
| 839 | − } | |
| 840 | − | |
| 841 | − if (storageFile.updatedAt > existingDirectory.updatedAt) { | |
| 842 | − directoryByPath.set(directoryPath, { | |
| 843 | − ...existingDirectory, | |
| 844 | − updatedAt: storageFile.updatedAt, | |
| 845 | − }); | |
| 846 | − } | |
| 847 | − } | |
| 848 | − | |
| 849 | − if ( | |
| 850 | − normalizedPath !== "/" | |
| 851 | − && directoryByPath.size === 0 | |
| 852 | − && fileEntries.length === 0 | |
| 853 | − ) { | |
| 854 | − throw new Error(`Directory not found: ${normalizedPath}`); | |
| 855 | − } | |
| 856 | − | |
| 857 | − const directoryEntries = [...directoryByPath.values()].sort((left, right) => | |
| 858 | − left.name.localeCompare(right.name) | |
| 859 | − ); | |
| 860 | − const sortedFileEntries = [...fileEntries].sort((left, right) => | |
| 861 | − left.name.localeCompare(right.name) | |
| 862 | − ); | |
| 863 | − | |
| 864 | − return { | |
| 865 | − path: normalizedPath, | |
| 866 | − entries: [...directoryEntries, ...sortedFileEntries], | |
| 867 | − }; | |
| 868 | − }, | |
| 869 | −}); | |
| 870 | − | |
| 871 | −export const readStorageFile = internalQuery({ | |
| 872 | − args: { | |
| 873 | − workspaceId: v.string(), | |
| 874 | − storageInstanceId: v.string(), | |
| 875 | − payload: v.object({ | |
| 876 | − path: v.string(), | |
| 877 | − encoding: v.optional(readStorageEncodingValidator), | |
| 878 | − }), | |
| 879 | − }, | |
| 880 | − handler: async (ctx, args): Promise<ReadStorageFileResult> => { | |
| 881 | − const storageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 882 | − workspaceId: args.workspaceId, | |
| 883 | − storageInstanceId: args.storageInstanceId, | |
| 884 | − }); | |
| 885 | − | |
| 886 | − if (!storageInstance) { | |
| 887 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 888 | − } | |
| 889 | − | |
| 890 | − const normalizedPath = normalizeStoragePath(args.payload.path); | |
| 891 | − const storageFileRow = await ctx.db | |
| 892 | − .query("storageFileEntries") | |
| 893 | − .withIndex("by_storageInstanceId_path", (q) => | |
| 894 | − q.eq("storageInstanceId", args.storageInstanceId).eq("path", normalizedPath) | |
| 895 | − ) | |
| 896 | − .unique(); | |
| 897 | − | |
| 898 | − if (!storageFileRow) { | |
| 899 | − throw new Error(`Storage file not found: ${normalizedPath}`); | |
| 900 | − } | |
| 901 | − | |
| 902 | − const storageFile = toStorageFileEntry(storageFileRow as unknown as Record<string, unknown>); | |
| 903 | − const encoding = args.payload.encoding ?? "utf8"; | |
| 904 | − | |
| 905 | − return { | |
| 906 | − path: normalizedPath, | |
| 907 | − encoding, | |
| 908 | − content: encoding === "base64" | |
| 909 | − ? storageFile.contentBase64 | |
| 910 | − : decodeBase64Utf8(storageFile.contentBase64), | |
| 911 | − bytes: storageFile.sizeBytes, | |
| 912 | − }; | |
| 913 | − }, | |
| 914 | −}); | |
| 915 | − | |
| 916 | −export const listStorageKv = internalQuery({ | |
| 917 | − args: { | |
| 918 | − workspaceId: v.string(), | |
| 919 | − storageInstanceId: v.string(), | |
| 920 | − payload: v.object({ | |
| 921 | − prefix: v.optional(v.string()), | |
| 922 | − limit: v.optional(v.number()), | |
| 923 | − }), | |
| 924 | − }, | |
| 925 | − handler: async (ctx, args): Promise<ListStorageKvResult> => { | |
| 926 | − const storageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 927 | − workspaceId: args.workspaceId, | |
| 928 | − storageInstanceId: args.storageInstanceId, | |
| 929 | − }); | |
| 930 | − | |
| 931 | − if (!storageInstance) { | |
| 932 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 933 | − } | |
| 934 | − | |
| 935 | − const prefix = args.payload.prefix ?? ""; | |
| 936 | − const requestedLimit = | |
| 937 | − args.payload.limit !== undefined && Number.isFinite(args.payload.limit) | |
| 938 | − ? Math.floor(args.payload.limit) | |
| 939 | − : DEFAULT_KV_LIMIT; | |
| 940 | − const limit = Math.max(1, Math.min(MAX_KV_LIMIT, requestedLimit)); | |
| 941 | − | |
| 942 | − const storageKvRows = await ctx.db | |
| 943 | − .query("storageKvEntries") | |
| 944 | − .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | |
| 945 | − .collect(); | |
| 946 | − | |
| 947 | − const items = storageKvRows | |
| 948 | − .map((row) => toStorageKvEntry(row as unknown as Record<string, unknown>)) | |
| 949 | − .filter((entry) => entry.key.startsWith(prefix)) | |
| 950 | − .sort((left, right) => left.key.localeCompare(right.key)) | |
| 951 | − .slice(0, limit) | |
| 952 | − .map((entry) => ({ | |
| 953 | − key: entry.key, | |
| 954 | − value: parseJsonOrString(entry.valueJson), | |
| 955 | − })); | |
| 956 | − | |
| 957 | − return { | |
| 958 | − items, | |
| 959 | − }; | |
| 960 | − }, | |
| 961 | −}); | |
| 962 | − | |
| 963 | −export const queryStorageSql = internalMutation({ | |
| 964 | − args: { | |
| 965 | − workspaceId: v.string(), | |
| 966 | − storageInstanceId: v.string(), | |
| 967 | − payload: v.object({ | |
| 968 | − sql: v.string(), | |
| 969 | − maxRows: v.optional(v.number()), | |
| 970 | − }), | |
| 971 | − }, | |
| 972 | − handler: async (ctx, args): Promise<QueryStorageSqlResult> => { | |
| 973 | − const storageInstance = await resolveAccessibleStorageInstance(ctx, { | |
| 974 | − workspaceId: args.workspaceId, | |
| 975 | − storageInstanceId: args.storageInstanceId, | |
| 976 | − }); | |
| 977 | − | |
| 978 | − if (!storageInstance) { | |
| 979 | − throw new Error(`Storage instance not found: ${args.storageInstanceId}`); | |
| 980 | − } | |
| 981 | − | |
| 982 | − const sqlText = args.payload.sql.trim(); | |
| 983 | − if (sqlText.length === 0) { | |
| 984 | − throw new Error("SQL query is required"); | |
| 985 | − } | |
| 986 | − | |
| 987 | − const requestedMaxRows = | |
| 988 | − args.payload.maxRows !== undefined && Number.isFinite(args.payload.maxRows) | |
| 989 | − ? Math.floor(args.payload.maxRows) | |
| 990 | − : DEFAULT_SQL_MAX_ROWS; | |
| 991 | − const maxRows = Math.max(1, Math.min(MAX_SQL_MAX_ROWS, requestedMaxRows)); | |
| 992 | − | |
| 993 | − const sqlRowsRaw = await ctx.db | |
| 994 | − .query("storageSqlKvEntries") | |
| 995 | − .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId)) | |
| 996 | − .collect(); | |
| 997 | − | |
| 998 | − const sqlRows = sqlRowsRaw.map((row) => | |
| 999 | − toStorageSqlKvEntry(row as unknown as Record<string, unknown>), | |
| 1000 | − ); | |
| 1001 | − const byKey = new Map<string, { | |
| 1002 | − row: StorageSqlKvEntry; | |
| 1003 | − docId: unknown; | |
| 1004 | − }>(); | |
| 1005 | − | |
| 1006 | − for (let index = 0; index < sqlRows.length; index += 1) { | |
| 1007 | − const row = sqlRows[index]; | |
| 1008 | − const rawRow = sqlRowsRaw[index]; | |
| 1009 | − if (row && rawRow) { | |
| 1010 | − byKey.set(row.key, { | |
| 1011 | − row, | |
| 1012 | − docId: rawRow._id, | |
| 1013 | − }); | |
| 1014 | − } | |
| 1015 | − } | |
| 1016 | − | |
| 1017 | − const execution = executeSqlForStorage(sqlText, sqlRows, maxRows); | |
| 1018 | − | |
| 1019 | − for (const write of execution.writes) { | |
| 1020 | − if (write.type === "delete") { | |
| 1021 | − const existing = byKey.get(write.key); | |
| 1022 | − if (existing) { | |
| 1023 | − await ctx.db.delete(existing.docId as any); | |
| 1024 | − byKey.delete(write.key); | |
| 1025 | − } | |
| 1026 | − continue; | |
| 1027 | − } | |
| 1028 | − | |
| 1029 | − const existing = byKey.get(write.key); | |
| 1030 | − if (existing && write.insertOnly) { | |
| 1031 | − throw new Error(`UNIQUE constraint failed: kv_store.key (${write.key})`); | |
| 1032 | − } | |
| 1033 | − | |
| 1034 | − const now = Date.now(); | |
| 1035 | − | |
| 1036 | − if (existing) { | |
| 1037 | − const nextRow = decodeStorageSqlKvEntry({ | |
| 1038 | − ...existing.row, | |
| 1039 | − value: write.value, | |
| 1040 | − updatedAt: now, | |
| 1041 | − }); | |
| 1042 | − | |
| 1043 | − await ctx.db.patch(existing.docId as any, nextRow); | |
| 1044 | − byKey.set(write.key, { | |
| 1045 | − row: nextRow, | |
| 1046 | − docId: existing.docId, | |
| 1047 | − }); | |
| 1048 | − continue; | |
| 1049 | − } | |
| 1050 | − | |
| 1051 | − const nextRow = decodeStorageSqlKvEntry({ | |
| 1052 | − id: `storage_sql_kv_${crypto.randomUUID()}`, | |
| 1053 | − storageInstanceId: args.storageInstanceId, | |
| 1054 | − key: write.key, | |
| 1055 | − value: write.value, | |
| 1056 | − updatedAt: now, | |
| 1057 | − }); | |
| 1058 | − | |
| 1059 | − const insertedId = await ctx.db.insert("storageSqlKvEntries", nextRow); | |
| 1060 | − byKey.set(write.key, { | |
| 1061 | − row: nextRow, | |
| 1062 | − docId: insertedId, | |
| 1063 | − }); | |
| 1064 | − } | |
| 1065 | − | |
| 1066 | − return execution.result; | |
| 1067 | − }, | |
| 1068 | −}); | |
| @@ -1,1611 +0,0 @@ | ||
| 1 | −import { ActionCache } from "@convex-dev/action-cache"; | |
| 2 | −import { v } from "convex/values"; | |
| 3 | −import { components, internal } from "../_generated/api"; | |
| 4 | − | |
| 5 | −import { action, internalAction, internalMutation, internalQuery } from "../_generated/server"; | |
| 6 | − | |
| 7 | −const defaultSearchLimit = 50; | |
| 8 | −const maxSearchLimit = 5_000; | |
| 9 | −const writeBatchSize = 500; | |
| 10 | −const runtimeInternal = internal as any; | |
| 11 | −const listWorkspaceNamespacesActionCache = new ActionCache((components as any).actionCache, { | |
| 12 | − action: runtimeInternal.control_plane.tool_registry.listWorkspaceNamespacesUncached, | |
| 13 | − name: "workspace_namespaces_v1", | |
| 14 | − ttl: 15_000, | |
| 15 | −}); | |
| 16 | − | |
| 17 | −const stripConvexSystemFields = ( | |
| 18 | − value: Record<string, unknown>, | |
| 19 | −): Record<string, unknown> => { | |
| 20 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 21 | − return rest; | |
| 22 | −}; | |
| 23 | − | |
| 24 | −const normalizePathForLookup = (path: string): string => | |
| 25 | − path | |
| 26 | − .trim() | |
| 27 | − .toLowerCase() | |
| 28 | − .replace(/[^a-z0-9]+/g, ".") | |
| 29 | − .replace(/\.+/g, ".") | |
| 30 | − .replace(/^\.|\.$/g, ""); | |
| 31 | − | |
| 32 | −const normalizeLimit = (value: number | undefined): number => { | |
| 33 | − if (!Number.isFinite(value)) { | |
| 34 | − return defaultSearchLimit; | |
| 35 | − } | |
| 36 | − | |
| 37 | − return Math.max(1, Math.min(maxSearchLimit, Math.floor(value ?? defaultSearchLimit))); | |
| 38 | −}; | |
| 39 | − | |
| 40 | −const artifactSchemaRefId = (artifactId: string, refKey: string): string => | |
| 41 | − `${artifactId}:ref:${encodeURIComponent(refKey)}`; | |
| 42 | − | |
| 43 | −const normalizeNamespacePart = (value: string): string => { | |
| 44 | − const normalized = value | |
| 45 | − .trim() | |
| 46 | − .toLowerCase() | |
| 47 | − .replace(/[^a-z0-9]+/g, "_") | |
| 48 | − .replace(/^_+|_+$/g, ""); | |
| 49 | − | |
| 50 | − return normalized.length > 0 ? normalized : "source"; | |
| 51 | −}; | |
| 52 | − | |
| 53 | −const sourceNamespace = (source: { | |
| 54 | − id: string; | |
| 55 | − name: string; | |
| 56 | −}): string => { | |
| 57 | − const sourceIdSuffix = source.id.slice(-6).toLowerCase(); | |
| 58 | − return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`; | |
| 59 | −}; | |
| 60 | − | |
| 61 | −const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { | |
| 62 | − if (!value) { | |
| 63 | − return {}; | |
| 64 | − } | |
| 65 | − | |
| 66 | − try { | |
| 67 | − const parsed = JSON.parse(value) as unknown; | |
| 68 | − return parsed && typeof parsed === "object" && !Array.isArray(parsed) | |
| 69 | − ? (parsed as Record<string, unknown>) | |
| 70 | − : {}; | |
| 71 | − } catch { | |
| 72 | − return {}; | |
| 73 | − } | |
| 74 | −}; | |
| 75 | − | |
| 76 | −const metadataSearchTerms = (metadataJson: string | null | undefined): ReadonlyArray<string> => { | |
| 77 | − if (!metadataJson) { | |
| 78 | − return []; | |
| 79 | − } | |
| 80 | − | |
| 81 | − const parsed = parseJsonObject(metadataJson); | |
| 82 | − const keys = ["method", "path", "operationType", "fieldName", "toolName"] as const; | |
| 83 | − const terms: Array<string> = []; | |
| 84 | − | |
| 85 | − for (const key of keys) { | |
| 86 | − const value = parsed[key]; | |
| 87 | − if (typeof value === "string" && value.trim().length > 0) { | |
| 88 | − terms.push(value); | |
| 89 | − } | |
| 90 | − } | |
| 91 | − | |
| 92 | − return terms; | |
| 93 | −}; | |
| 94 | − | |
| 95 | −const normalizedSearchText = (...parts: ReadonlyArray<string | null | undefined>): string => | |
| 96 | − parts | |
| 97 | − .map((part) => (typeof part === "string" ? part.trim().toLowerCase() : "")) | |
| 98 | − .filter((part) => part.length > 0) | |
| 99 | − .join(" "); | |
| 100 | − | |
| 101 | −const methodFromCanonicalPath = (canonicalPath: string | null | undefined): string | null => { | |
| 102 | − if (!canonicalPath) { | |
| 103 | − return null; | |
| 104 | − } | |
| 105 | − | |
| 106 | − const candidate = canonicalPath.trim().split(/\s+/)[0]?.toLowerCase() ?? null; | |
| 107 | − if ( | |
| 108 | − candidate === "get" || | |
| 109 | − candidate === "post" || | |
| 110 | − candidate === "put" || | |
| 111 | − candidate === "patch" || | |
| 112 | − candidate === "delete" || | |
| 113 | − candidate === "head" || | |
| 114 | − candidate === "options" || | |
| 115 | − candidate === "trace" | |
| 116 | − ) { | |
| 117 | − return candidate; | |
| 118 | − } | |
| 119 | − | |
| 120 | − return null; | |
| 121 | −}; | |
| 122 | − | |
| 123 | −const toWorkspaceToolMethod = ( | |
| 124 | − protocol: string | null | undefined, | |
| 125 | − metadataJson: string | null | undefined, | |
| 126 | − canonicalPath: string | null | undefined, | |
| 127 | −): string => { | |
| 128 | − const canonicalMethod = methodFromCanonicalPath(canonicalPath); | |
| 129 | − if (canonicalMethod) { | |
| 130 | − return canonicalMethod; | |
| 131 | − } | |
| 132 | − | |
| 133 | − const normalizedProtocol = (protocol ?? "").toLowerCase(); | |
| 134 | − if (normalizedProtocol !== "openapi") { | |
| 135 | − return "post"; | |
| 136 | − } | |
| 137 | − | |
| 138 | − const metadata = parseJsonObject(metadataJson); | |
| 139 | − const method = typeof metadata.method === "string" ? metadata.method.trim().toLowerCase() : ""; | |
| 140 | − | |
| 141 | − if ( | |
| 142 | − method === "get" || | |
| 143 | − method === "post" || | |
| 144 | − method === "put" || | |
| 145 | − method === "patch" || | |
| 146 | − method === "delete" || | |
| 147 | − method === "head" || | |
| 148 | − method === "options" || | |
| 149 | − method === "trace" | |
| 150 | − ) { | |
| 151 | − return method; | |
| 152 | − } | |
| 153 | − | |
| 154 | − return "get"; | |
| 155 | −}; | |
| 156 | − | |
| 157 | −const tokenizeSearchQuery = (query: string): Array<string> => | |
| 158 | − Array.from( | |
| 159 | − new Set( | |
| 160 | − query | |
| 161 | − .toLowerCase() | |
| 162 | − .split(/[\s._:/-]+/) | |
| 163 | − .map((part) => part.trim()) | |
| 164 | − .filter((part) => part.length > 0), | |
| 165 | − ), | |
| 166 | − ); | |
| 167 | − | |
| 168 | −const scoreSearchRow = (row: Record<string, unknown>, queryLower: string): number => { | |
| 169 | − const path = String(row.path ?? "").toLowerCase(); | |
| 170 | − const name = String(row.name ?? "").toLowerCase(); | |
| 171 | − const description = String(row.description ?? "").toLowerCase(); | |
| 172 | − const sourceName = String(row.sourceName ?? "").toLowerCase(); | |
| 173 | − const searchText = String(row.searchText ?? "").toLowerCase(); | |
| 174 | − | |
| 175 | − if (path === queryLower) { | |
| 176 | − return 100; | |
| 177 | − } | |
| 178 | − | |
| 179 | − if (path.startsWith(queryLower)) { | |
| 180 | − return 85; | |
| 181 | − } | |
| 182 | − | |
| 183 | − if (name.includes(queryLower)) { | |
| 184 | − return 75; | |
| 185 | − } | |
| 186 | − | |
| 187 | − if (path.includes(queryLower)) { | |
| 188 | − return 70; | |
| 189 | − } | |
| 190 | − | |
| 191 | − if (sourceName.includes(queryLower)) { | |
| 192 | − return 60; | |
| 193 | − } | |
| 194 | − | |
| 195 | − if (description.includes(queryLower)) { | |
| 196 | − return 50; | |
| 197 | − } | |
| 198 | − | |
| 199 | − if (searchText.includes(queryLower)) { | |
| 200 | − return 40; | |
| 201 | − } | |
| 202 | − | |
| 203 | − const tokens = tokenizeSearchQuery(queryLower); | |
| 204 | − if (tokens.length === 0) { | |
| 205 | − return 0; | |
| 206 | − } | |
| 207 | − | |
| 208 | − let pathMatches = 0; | |
| 209 | − let nameMatches = 0; | |
| 210 | − let sourceMatches = 0; | |
| 211 | − let descriptionMatches = 0; | |
| 212 | − let searchTextMatches = 0; | |
| 213 | − | |
| 214 | − for (const token of tokens) { | |
| 215 | − if (path.includes(token)) { | |
| 216 | − pathMatches += 1; | |
| 217 | − continue; | |
| 218 | − } | |
| 219 | − | |
| 220 | − if (name.includes(token)) { | |
| 221 | − nameMatches += 1; | |
| 222 | − continue; | |
| 223 | − } | |
| 224 | − | |
| 225 | − if (sourceName.includes(token)) { | |
| 226 | − sourceMatches += 1; | |
| 227 | − continue; | |
| 228 | − } | |
| 229 | − | |
| 230 | − if (description.includes(token)) { | |
| 231 | − descriptionMatches += 1; | |
| 232 | − continue; | |
| 233 | − } | |
| 234 | − | |
| 235 | − if (searchText.includes(token)) { | |
| 236 | − searchTextMatches += 1; | |
| 237 | − } | |
| 238 | − } | |
| 239 | − | |
| 240 | − const matchedTokens = | |
| 241 | − pathMatches + | |
| 242 | − nameMatches + | |
| 243 | − sourceMatches + | |
| 244 | − descriptionMatches + | |
| 245 | − searchTextMatches; | |
| 246 | − | |
| 247 | − if (matchedTokens === 0) { | |
| 248 | − return 0; | |
| 249 | − } | |
| 250 | − | |
| 251 | − let score = | |
| 252 | − pathMatches * 20 + | |
| 253 | − nameMatches * 16 + | |
| 254 | − sourceMatches * 12 + | |
| 255 | − descriptionMatches * 8 + | |
| 256 | − searchTextMatches * 6; | |
| 257 | − | |
| 258 | − score += matchedTokens === tokens.length ? 20 : 5; | |
| 259 | − | |
| 260 | − return score; | |
| 261 | −}; | |
| 262 | − | |
| 263 | −const chunkArray = <A>(values: ReadonlyArray<A>, size: number): Array<Array<A>> => { | |
| 264 | − const chunkSize = Math.max(1, Math.floor(size)); | |
| 265 | − const chunks: Array<Array<A>> = []; | |
| 266 | − | |
| 267 | − for (let index = 0; index < values.length; index += chunkSize) { | |
| 268 | − chunks.push(values.slice(index, index + chunkSize)); | |
| 269 | − } | |
| 270 | − | |
| 271 | − return chunks; | |
| 272 | −}; | |
| 273 | − | |
| 274 | −const workspaceToolPageSize = 250; | |
| 275 | − | |
| 276 | −const listWorkspaceToolIndexRows = async ( | |
| 277 | − ctx: any, | |
| 278 | − args: { | |
| 279 | − workspaceId: string; | |
| 280 | − limit: number; | |
| 281 | − sourceId?: string; | |
| 282 | − namespace?: string; | |
| 283 | − includeDisabled?: boolean; | |
| 284 | − }, | |
| 285 | −): Promise<Array<Record<string, unknown>>> => { | |
| 286 | − const limit = Math.max(0, args.limit); | |
| 287 | − if (limit === 0) { | |
| 288 | − return []; | |
| 289 | − } | |
| 290 | − | |
| 291 | − const baseQuery: any = (() => { | |
| 292 | − if (args.sourceId) { | |
| 293 | − return ctx.db | |
| 294 | − .query("workspaceToolIndex") | |
| 295 | − .withIndex("by_workspaceId_sourceId", (q: any) => | |
| 296 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 297 | − ); | |
| 298 | − } | |
| 299 | − | |
| 300 | − if (args.namespace) { | |
| 301 | − return ctx.db | |
| 302 | − .query("workspaceToolIndex") | |
| 303 | − .withIndex("by_workspaceId_namespace", (q: any) => | |
| 304 | − q.eq("workspaceId", args.workspaceId).eq("namespace", args.namespace), | |
| 305 | − ); | |
| 306 | − } | |
| 307 | − | |
| 308 | − return ctx.db | |
| 309 | − .query("workspaceToolIndex") | |
| 310 | − .withIndex("by_workspaceId", (q: any) => q.eq("workspaceId", args.workspaceId)); | |
| 311 | − })(); | |
| 312 | − | |
| 313 | − const rows: Array<Record<string, unknown>> = []; | |
| 314 | − let cursor: string | null = null; | |
| 315 | − | |
| 316 | − do { | |
| 317 | − const page: { | |
| 318 | − page: Array<Record<string, unknown>>; | |
| 319 | − continueCursor: string | null; | |
| 320 | − isDone: boolean; | |
| 321 | − } = await baseQuery.paginate({ | |
| 322 | − cursor, | |
| 323 | − numItems: Math.min(workspaceToolPageSize, Math.max(1, limit - rows.length)), | |
| 324 | − }); | |
| 325 | − | |
| 326 | − for (const row of page.page) { | |
| 327 | − if (args.includeDisabled !== true && row.status !== "active") { | |
| 328 | − continue; | |
| 329 | − } | |
| 330 | − if (args.sourceId && row.sourceId !== args.sourceId) { | |
| 331 | − continue; | |
| 332 | − } | |
| 333 | − if (args.namespace && row.namespace !== args.namespace) { | |
| 334 | − continue; | |
| 335 | − } | |
| 336 | − | |
| 337 | − rows.push(stripConvexSystemFields(row as unknown as Record<string, unknown>)); | |
| 338 | − if (rows.length >= limit) { | |
| 339 | − break; | |
| 340 | − } | |
| 341 | − } | |
| 342 | − | |
| 343 | − if (rows.length >= limit || page.isDone) { | |
| 344 | − break; | |
| 345 | − } | |
| 346 | − | |
| 347 | − cursor = page.continueCursor; | |
| 348 | − } while (cursor !== null); | |
| 349 | − | |
| 350 | − rows.sort((left, right) => String(left.path).localeCompare(String(right.path))); | |
| 351 | − return rows; | |
| 352 | −}; | |
| 353 | − | |
| 354 | −export const ingestOpenApiManifest = action({ | |
| 355 | − args: { | |
| 356 | − token: v.string(), | |
| 357 | − workspaceId: v.string(), | |
| 358 | − sourceId: v.string(), | |
| 359 | − sourceName: v.string(), | |
| 360 | − sourceEndpoint: v.string(), | |
| 361 | − sourceEnabled: v.boolean(), | |
| 362 | − sourceHash: v.string(), | |
| 363 | − toolCount: v.number(), | |
| 364 | − refs: v.array( | |
| 365 | − v.object({ | |
| 366 | − refKey: v.string(), | |
| 367 | − schemaJson: v.string(), | |
| 368 | − }), | |
| 369 | − ), | |
| 370 | − tools: v.array( | |
| 371 | − v.object({ | |
| 372 | − toolId: v.string(), | |
| 373 | − name: v.string(), | |
| 374 | − description: v.union(v.string(), v.null()), | |
| 375 | − method: v.string(), | |
| 376 | − path: v.string(), | |
| 377 | − operationHash: v.string(), | |
| 378 | − invocationJson: v.string(), | |
| 379 | − inputSchemaJson: v.union(v.string(), v.null()), | |
| 380 | − outputSchemaJson: v.union(v.string(), v.null()), | |
| 381 | − }), | |
| 382 | − ), | |
| 383 | − }, | |
| 384 | − handler: async (ctx, args) => { | |
| 385 | − const expectedToken = | |
| 386 | − process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || null; | |
| 387 | − | |
| 388 | − if (!expectedToken) { | |
| 389 | − throw new Error("OpenAPI ingest token is not configured"); | |
| 390 | − } | |
| 391 | − | |
| 392 | − if (args.token.trim() !== expectedToken) { | |
| 393 | − throw new Error("Unauthorized OpenAPI ingest request"); | |
| 394 | − } | |
| 395 | − | |
| 396 | − const artifactMeta = await ctx.runMutation( | |
| 397 | − runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, | |
| 398 | − { | |
| 399 | − protocol: "openapi", | |
| 400 | − contentHash: args.sourceHash, | |
| 401 | − extractorVersion: "openapi_v2", | |
| 402 | − toolCount: args.toolCount, | |
| 403 | − refHintTableJson: null, | |
| 404 | − }, | |
| 405 | − ); | |
| 406 | − | |
| 407 | − if (args.refs.length > 0) { | |
| 408 | − let shouldPersistRefs = artifactMeta.created; | |
| 409 | − | |
| 410 | − if (!shouldPersistRefs) { | |
| 411 | − const existingRefCount = await ctx.runQuery( | |
| 412 | − runtimeInternal.control_plane.tool_registry.countArtifactSchemaRefs, | |
| 413 | − { | |
| 414 | − artifactId: artifactMeta.artifactId, | |
| 415 | − }, | |
| 416 | − ); | |
| 417 | − shouldPersistRefs = existingRefCount !== args.refs.length; | |
| 418 | − } | |
| 419 | − | |
| 420 | − if (shouldPersistRefs) { | |
| 421 | − await ctx.runMutation( | |
| 422 | − runtimeInternal.control_plane.tool_registry.clearArtifactSchemaRefs, | |
| 423 | − { | |
| 424 | − artifactId: artifactMeta.artifactId, | |
| 425 | − }, | |
| 426 | − ); | |
| 427 | − | |
| 428 | − for (const batch of chunkArray(args.refs, writeBatchSize)) { | |
| 429 | − await ctx.runMutation( | |
| 430 | − runtimeInternal.control_plane.tool_registry.putArtifactSchemaRefsBatch, | |
| 431 | − { | |
| 432 | − artifactId: artifactMeta.artifactId, | |
| 433 | − refs: batch, | |
| 434 | − }, | |
| 435 | − ); | |
| 436 | − } | |
| 437 | − } | |
| 438 | − } | |
| 439 | − | |
| 440 | − if (artifactMeta.created && args.tools.length > 0) { | |
| 441 | − const artifactRows = args.tools.map((tool) => ({ | |
| 442 | − toolId: tool.toolId, | |
| 443 | − name: tool.name, | |
| 444 | − description: tool.description, | |
| 445 | − canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`, | |
| 446 | − operationHash: tool.operationHash, | |
| 447 | − invocationJson: tool.invocationJson, | |
| 448 | − inputSchemaJson: tool.inputSchemaJson, | |
| 449 | − outputSchemaJson: tool.outputSchemaJson, | |
| 450 | − metadataJson: JSON.stringify({ | |
| 451 | − method: tool.method, | |
| 452 | − path: tool.path, | |
| 453 | − }), | |
| 454 | − })); | |
| 455 | − | |
| 456 | − for (const batch of chunkArray(artifactRows, writeBatchSize)) { | |
| 457 | − await ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | |
| 458 | − artifactId: artifactMeta.artifactId, | |
| 459 | − protocol: "openapi", | |
| 460 | − insertOnly: true, | |
| 461 | − tools: batch, | |
| 462 | − }); | |
| 463 | − } | |
| 464 | − } | |
| 465 | − | |
| 466 | − await ctx.runMutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | |
| 467 | − workspaceId: args.workspaceId, | |
| 468 | − sourceId: args.sourceId, | |
| 469 | − artifactId: artifactMeta.artifactId, | |
| 470 | − }); | |
| 471 | − | |
| 472 | − const namespace = sourceNamespace({ id: args.sourceId, name: args.sourceName }); | |
| 473 | − await ctx.runMutation(runtimeInternal.control_plane.tool_registry.clearWorkspaceSourceToolIndex, { | |
| 474 | − workspaceId: args.workspaceId, | |
| 475 | − sourceId: args.sourceId, | |
| 476 | − }); | |
| 477 | − | |
| 478 | − for (const batch of chunkArray(args.tools, writeBatchSize)) { | |
| 479 | − await ctx.runMutation(runtimeInternal.control_plane.tool_registry.appendWorkspaceSourceToolIndexChunk, { | |
| 480 | − workspaceId: args.workspaceId, | |
| 481 | − sourceId: args.sourceId, | |
| 482 | − sourceName: args.sourceName, | |
| 483 | − sourceKind: "openapi", | |
| 484 | − artifactId: artifactMeta.artifactId, | |
| 485 | − namespace, | |
| 486 | − refHintTableJson: null, | |
| 487 | − insertOnly: true, | |
| 488 | − rows: batch.map((tool) => { | |
| 489 | − const operationPath = `${tool.method.toUpperCase()} ${tool.path}`; | |
| 490 | − const path = `${namespace}.${tool.toolId}`; | |
| 491 | − | |
| 492 | − return { | |
| 493 | − toolId: tool.toolId, | |
| 494 | − protocol: "openapi", | |
| 495 | − method: tool.method.toLowerCase(), | |
| 496 | − path, | |
| 497 | − operationPath, | |
| 498 | − name: tool.name, | |
| 499 | − description: tool.description, | |
| 500 | − searchText: normalizedSearchText( | |
| 501 | − args.sourceName, | |
| 502 | − args.sourceEndpoint, | |
| 503 | − namespace, | |
| 504 | − tool.toolId, | |
| 505 | − tool.name, | |
| 506 | − tool.description, | |
| 507 | − tool.method, | |
| 508 | − tool.path, | |
| 509 | − ), | |
| 510 | − operationHash: tool.operationHash, | |
| 511 | − approvalMode: "auto", | |
| 512 | − status: args.sourceEnabled ? "active" : "disabled", | |
| 513 | − }; | |
| 514 | − }), | |
| 515 | − }); | |
| 516 | − } | |
| 517 | − | |
| 518 | − return { | |
| 519 | − artifactId: artifactMeta.artifactId, | |
| 520 | − created: artifactMeta.created, | |
| 521 | − sourceHash: args.sourceHash, | |
| 522 | − toolCount: args.toolCount, | |
| 523 | − sourceName: args.sourceName, | |
| 524 | − sourceEndpoint: args.sourceEndpoint, | |
| 525 | − sourceEnabled: args.sourceEnabled, | |
| 526 | − }; | |
| 527 | − }, | |
| 528 | −}); | |
| 529 | − | |
| 530 | −export const upsertArtifactMeta = internalMutation({ | |
| 531 | − args: { | |
| 532 | − protocol: v.string(), | |
| 533 | − contentHash: v.string(), | |
| 534 | − extractorVersion: v.string(), | |
| 535 | − toolCount: v.number(), | |
| 536 | − refHintTableJson: v.optional(v.union(v.string(), v.null())), | |
| 537 | − }, | |
| 538 | − handler: async (ctx, args) => { | |
| 539 | − const now = Date.now(); | |
| 540 | − const existing = await ctx.db | |
| 541 | − .query("artifacts") | |
| 542 | − .withIndex("by_protocol_contentHash_extractorVersion", (q) => | |
| 543 | − q | |
| 544 | − .eq("protocol", args.protocol) | |
| 545 | − .eq("contentHash", args.contentHash) | |
| 546 | − .eq("extractorVersion", args.extractorVersion), | |
| 547 | − ) | |
| 548 | − .unique(); | |
| 549 | − | |
| 550 | − if (existing) { | |
| 551 | − await ctx.db.patch(existing._id, { | |
| 552 | − toolCount: args.toolCount, | |
| 553 | − refHintTableJson: args.refHintTableJson ?? null, | |
| 554 | − updatedAt: now, | |
| 555 | − }); | |
| 556 | − | |
| 557 | − return { | |
| 558 | − artifactId: existing.id, | |
| 559 | − created: false, | |
| 560 | − }; | |
| 561 | − } | |
| 562 | − | |
| 563 | − const artifactId = `art_${args.protocol}_${args.contentHash}_${args.extractorVersion}`; | |
| 564 | − await ctx.db.insert("artifacts", { | |
| 565 | − id: artifactId, | |
| 566 | − protocol: args.protocol, | |
| 567 | − contentHash: args.contentHash, | |
| 568 | − extractorVersion: args.extractorVersion, | |
| 569 | − toolCount: args.toolCount, | |
| 570 | − refHintTableJson: args.refHintTableJson ?? null, | |
| 571 | − createdAt: now, | |
| 572 | − updatedAt: now, | |
| 573 | − }); | |
| 574 | − | |
| 575 | − return { | |
| 576 | − artifactId, | |
| 577 | − created: true, | |
| 578 | − }; | |
| 579 | − }, | |
| 580 | −}); | |
| 581 | − | |
| 582 | −export const clearArtifactSchemaRefs = internalMutation({ | |
| 583 | − args: { | |
| 584 | − artifactId: v.string(), | |
| 585 | − }, | |
| 586 | − handler: async (ctx, args) => { | |
| 587 | − const existing = await ctx.db | |
| 588 | − .query("artifactSchemaRefs") | |
| 589 | − .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId)) | |
| 590 | − .collect(); | |
| 591 | − | |
| 592 | − for (const row of existing) { | |
| 593 | − await ctx.db.delete(row._id); | |
| 594 | − } | |
| 595 | − | |
| 596 | − return { removedCount: existing.length }; | |
| 597 | − }, | |
| 598 | −}); | |
| 599 | − | |
| 600 | −export const putArtifactSchemaRefsBatch = internalMutation({ | |
| 601 | − args: { | |
| 602 | − artifactId: v.string(), | |
| 603 | − refs: v.array( | |
| 604 | − v.object({ | |
| 605 | − refKey: v.string(), | |
| 606 | − schemaJson: v.string(), | |
| 607 | − }), | |
| 608 | − ), | |
| 609 | − }, | |
| 610 | − handler: async (ctx, args) => { | |
| 611 | − const now = Date.now(); | |
| 612 | − let insertedCount = 0; | |
| 613 | − | |
| 614 | − for (const ref of args.refs) { | |
| 615 | − await ctx.db.insert("artifactSchemaRefs", { | |
| 616 | − id: artifactSchemaRefId(args.artifactId, ref.refKey), | |
| 617 | − artifactId: args.artifactId, | |
| 618 | − refKey: ref.refKey, | |
| 619 | − schemaJson: ref.schemaJson, | |
| 620 | − createdAt: now, | |
| 621 | − updatedAt: now, | |
| 622 | − }); | |
| 623 | − insertedCount += 1; | |
| 624 | − } | |
| 625 | − | |
| 626 | − return { insertedCount }; | |
| 627 | − }, | |
| 628 | −}); | |
| 629 | − | |
| 630 | −export const countArtifactSchemaRefs = internalQuery({ | |
| 631 | − args: { | |
| 632 | − artifactId: v.string(), | |
| 633 | − }, | |
| 634 | − handler: async (ctx, args) => { | |
| 635 | − const rows = await ctx.db | |
| 636 | − .query("artifactSchemaRefs") | |
| 637 | − .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId)) | |
| 638 | − .collect(); | |
| 639 | − | |
| 640 | − return rows.length; | |
| 641 | − }, | |
| 642 | −}); | |
| 643 | − | |
| 644 | −export const putArtifactToolsBatch = internalMutation({ | |
| 645 | − args: { | |
| 646 | − artifactId: v.string(), | |
| 647 | − protocol: v.string(), | |
| 648 | − insertOnly: v.optional(v.boolean()), | |
| 649 | − tools: v.array( | |
| 650 | − v.object({ | |
| 651 | − toolId: v.string(), | |
| 652 | − name: v.string(), | |
| 653 | − description: v.union(v.string(), v.null()), | |
| 654 | − canonicalPath: v.string(), | |
| 655 | − operationHash: v.string(), | |
| 656 | − invocationJson: v.string(), | |
| 657 | − inputSchemaJson: v.optional(v.union(v.string(), v.null())), | |
| 658 | − outputSchemaJson: v.optional(v.union(v.string(), v.null())), | |
| 659 | − metadataJson: v.optional(v.union(v.string(), v.null())), | |
| 660 | − }), | |
| 661 | − ), | |
| 662 | − }, | |
| 663 | − handler: async (ctx, args) => { | |
| 664 | − const now = Date.now(); | |
| 665 | − let insertedCount = 0; | |
| 666 | − | |
| 667 | − if (args.insertOnly === true) { | |
| 668 | − for (const tool of args.tools) { | |
| 669 | − await ctx.db.insert("artifactTools", { | |
| 670 | − id: `${args.artifactId}:${tool.toolId}`, | |
| 671 | − artifactId: args.artifactId, | |
| 672 | − protocol: args.protocol, | |
| 673 | − toolId: tool.toolId, | |
| 674 | − name: tool.name, | |
| 675 | − description: tool.description, | |
| 676 | − canonicalPath: tool.canonicalPath, | |
| 677 | − operationHash: tool.operationHash, | |
| 678 | − invocationJson: tool.invocationJson, | |
| 679 | − inputSchemaJson: tool.inputSchemaJson ?? null, | |
| 680 | − outputSchemaJson: tool.outputSchemaJson ?? null, | |
| 681 | − metadataJson: tool.metadataJson ?? null, | |
| 682 | − createdAt: now, | |
| 683 | − updatedAt: now, | |
| 684 | − }); | |
| 685 | − insertedCount += 1; | |
| 686 | − } | |
| 687 | − | |
| 688 | − return { insertedCount }; | |
| 689 | − } | |
| 690 | − | |
| 691 | − for (const tool of args.tools) { | |
| 692 | − const existing = await ctx.db | |
| 693 | − .query("artifactTools") | |
| 694 | − .withIndex("by_artifactId_toolId", (q) => | |
| 695 | − q.eq("artifactId", args.artifactId).eq("toolId", tool.toolId), | |
| 696 | − ) | |
| 697 | − .unique(); | |
| 698 | − | |
| 699 | − const row = { | |
| 700 | − id: `${args.artifactId}:${tool.toolId}`, | |
| 701 | − artifactId: args.artifactId, | |
| 702 | − protocol: args.protocol, | |
| 703 | − toolId: tool.toolId, | |
| 704 | − name: tool.name, | |
| 705 | − description: tool.description, | |
| 706 | − canonicalPath: tool.canonicalPath, | |
| 707 | − operationHash: tool.operationHash, | |
| 708 | − invocationJson: tool.invocationJson, | |
| 709 | − inputSchemaJson: tool.inputSchemaJson ?? null, | |
| 710 | − outputSchemaJson: tool.outputSchemaJson ?? null, | |
| 711 | − metadataJson: tool.metadataJson ?? null, | |
| 712 | − createdAt: existing?.createdAt ?? now, | |
| 713 | − updatedAt: now, | |
| 714 | − }; | |
| 715 | − | |
| 716 | − if (existing) { | |
| 717 | − await ctx.db.patch(existing._id, row); | |
| 718 | − } else { | |
| 719 | − await ctx.db.insert("artifactTools", row); | |
| 720 | − insertedCount += 1; | |
| 721 | − } | |
| 722 | − } | |
| 723 | − | |
| 724 | − return { insertedCount }; | |
| 725 | − }, | |
| 726 | −}); | |
| 727 | − | |
| 728 | −export const bindSourceToArtifact = internalMutation({ | |
| 729 | − args: { | |
| 730 | − workspaceId: v.string(), | |
| 731 | − sourceId: v.string(), | |
| 732 | − artifactId: v.string(), | |
| 733 | − }, | |
| 734 | − handler: async (ctx, args) => { | |
| 735 | − const existing = await ctx.db | |
| 736 | − .query("sourceArtifactBindings") | |
| 737 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 738 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 739 | − ) | |
| 740 | − .unique(); | |
| 741 | − | |
| 742 | − const row = { | |
| 743 | − id: existing?.id ?? `sab_${args.workspaceId}_${args.sourceId}`, | |
| 744 | − workspaceId: args.workspaceId, | |
| 745 | − sourceId: args.sourceId, | |
| 746 | − artifactId: args.artifactId, | |
| 747 | − updatedAt: Date.now(), | |
| 748 | − }; | |
| 749 | − | |
| 750 | − if (existing) { | |
| 751 | − await ctx.db.patch(existing._id, row); | |
| 752 | − } else { | |
| 753 | − await ctx.db.insert("sourceArtifactBindings", row); | |
| 754 | − } | |
| 755 | − | |
| 756 | − return row; | |
| 757 | − }, | |
| 758 | −}); | |
| 759 | − | |
| 760 | −export const replaceSourceIngestArtifactBatches = internalMutation({ | |
| 761 | − args: { | |
| 762 | − workspaceId: v.string(), | |
| 763 | − sourceId: v.string(), | |
| 764 | − artifactId: v.string(), | |
| 765 | − protocol: v.string(), | |
| 766 | − batches: v.array(v.string()), | |
| 767 | − }, | |
| 768 | − handler: async (ctx, args) => { | |
| 769 | − const existing = await ctx.db | |
| 770 | − .query("sourceIngestArtifactBatches") | |
| 771 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 772 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 773 | − ) | |
| 774 | − .collect(); | |
| 775 | − | |
| 776 | − for (const row of existing) { | |
| 777 | − await ctx.db.delete(row._id); | |
| 778 | − } | |
| 779 | − | |
| 780 | − const now = Date.now(); | |
| 781 | − for (let batchIndex = 0; batchIndex < args.batches.length; batchIndex += 1) { | |
| 782 | − await ctx.db.insert("sourceIngestArtifactBatches", { | |
| 783 | − id: `siab_${args.workspaceId}_${args.sourceId}_${args.artifactId}_${batchIndex}`, | |
| 784 | − workspaceId: args.workspaceId, | |
| 785 | − sourceId: args.sourceId, | |
| 786 | − artifactId: args.artifactId, | |
| 787 | − protocol: args.protocol, | |
| 788 | − batchIndex, | |
| 789 | − toolsJson: args.batches[batchIndex] ?? "[]", | |
| 790 | − updatedAt: now, | |
| 791 | − }); | |
| 792 | − } | |
| 793 | − | |
| 794 | − return { batchCount: args.batches.length }; | |
| 795 | − }, | |
| 796 | −}); | |
| 797 | − | |
| 798 | −export const clearSourceIngestArtifactBatches = internalMutation({ | |
| 799 | − args: { | |
| 800 | − workspaceId: v.string(), | |
| 801 | − sourceId: v.string(), | |
| 802 | − }, | |
| 803 | − handler: async (ctx, args) => { | |
| 804 | − const existing = await ctx.db | |
| 805 | − .query("sourceIngestArtifactBatches") | |
| 806 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 807 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 808 | − ) | |
| 809 | − .collect(); | |
| 810 | − | |
| 811 | − for (const row of existing) { | |
| 812 | − await ctx.db.delete(row._id); | |
| 813 | − } | |
| 814 | − | |
| 815 | − return { removedCount: existing.length }; | |
| 816 | − }, | |
| 817 | −}); | |
| 818 | − | |
| 819 | −export const getSourceIngestArtifactBatch = internalQuery({ | |
| 820 | − args: { | |
| 821 | − workspaceId: v.string(), | |
| 822 | − sourceId: v.string(), | |
| 823 | − artifactId: v.string(), | |
| 824 | − batchIndex: v.number(), | |
| 825 | − }, | |
| 826 | − handler: async (ctx, args) => { | |
| 827 | − const row = await ctx.db | |
| 828 | − .query("sourceIngestArtifactBatches") | |
| 829 | − .withIndex("by_workspaceId_sourceId_artifactId_batchIndex", (q) => | |
| 830 | − q | |
| 831 | − .eq("workspaceId", args.workspaceId) | |
| 832 | − .eq("sourceId", args.sourceId) | |
| 833 | − .eq("artifactId", args.artifactId) | |
| 834 | − .eq("batchIndex", Math.max(0, Math.floor(args.batchIndex))), | |
| 835 | − ) | |
| 836 | − .unique(); | |
| 837 | − | |
| 838 | − return row ? stripConvexSystemFields(row as unknown as Record<string, unknown>) : null; | |
| 839 | − }, | |
| 840 | −}); | |
| 841 | − | |
| 842 | −export const replaceWorkspaceSourceToolIndex = internalMutation({ | |
| 843 | − args: { | |
| 844 | − workspaceId: v.string(), | |
| 845 | − sourceId: v.string(), | |
| 846 | − sourceName: v.string(), | |
| 847 | − sourceKind: v.string(), | |
| 848 | − artifactId: v.string(), | |
| 849 | − namespace: v.string(), | |
| 850 | − refHintTableJson: v.optional(v.union(v.string(), v.null())), | |
| 851 | − rows: v.array( | |
| 852 | − v.object({ | |
| 853 | − toolId: v.string(), | |
| 854 | − protocol: v.string(), | |
| 855 | − method: v.string(), | |
| 856 | − path: v.string(), | |
| 857 | − operationPath: v.optional(v.union(v.string(), v.null())), | |
| 858 | − name: v.string(), | |
| 859 | − description: v.union(v.string(), v.null()), | |
| 860 | − searchText: v.string(), | |
| 861 | − operationHash: v.string(), | |
| 862 | − approvalMode: v.string(), | |
| 863 | − status: v.string(), | |
| 864 | − }), | |
| 865 | − ), | |
| 866 | − }, | |
| 867 | − handler: async (ctx, args) => { | |
| 868 | − const existingRows = await ctx.db | |
| 869 | − .query("workspaceToolIndex") | |
| 870 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 871 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 872 | − ) | |
| 873 | − .collect(); | |
| 874 | − | |
| 875 | − for (const row of existingRows) { | |
| 876 | − await ctx.db.delete(row._id); | |
| 877 | − } | |
| 878 | − | |
| 879 | − const now = Date.now(); | |
| 880 | − let insertedCount = 0; | |
| 881 | − | |
| 882 | − for (const row of args.rows) { | |
| 883 | − await ctx.db.insert("workspaceToolIndex", { | |
| 884 | − id: `wti_${args.workspaceId}_${args.sourceId}_${row.toolId}`, | |
| 885 | − workspaceId: args.workspaceId, | |
| 886 | − sourceId: args.sourceId, | |
| 887 | − sourceName: args.sourceName, | |
| 888 | − sourceKind: args.sourceKind, | |
| 889 | − artifactId: args.artifactId, | |
| 890 | − toolId: row.toolId, | |
| 891 | − protocol: row.protocol, | |
| 892 | − method: row.method, | |
| 893 | − namespace: args.namespace, | |
| 894 | − path: row.path, | |
| 895 | − pathLower: row.path.toLowerCase(), | |
| 896 | − normalizedPath: normalizePathForLookup(row.path), | |
| 897 | − operationPath: row.operationPath ?? null, | |
| 898 | − name: row.name, | |
| 899 | − description: row.description, | |
| 900 | − searchText: row.searchText, | |
| 901 | − operationHash: row.operationHash, | |
| 902 | − approvalMode: row.approvalMode, | |
| 903 | − status: row.status, | |
| 904 | − refHintTableJson: args.refHintTableJson ?? null, | |
| 905 | − updatedAt: now, | |
| 906 | − }); | |
| 907 | − insertedCount += 1; | |
| 908 | − } | |
| 909 | − | |
| 910 | − return { insertedCount }; | |
| 911 | − }, | |
| 912 | −}); | |
| 913 | − | |
| 914 | −export const clearWorkspaceSourceToolIndex = internalMutation({ | |
| 915 | − args: { | |
| 916 | − workspaceId: v.string(), | |
| 917 | − sourceId: v.string(), | |
| 918 | − }, | |
| 919 | − handler: async (ctx, args) => { | |
| 920 | − const existingRows = await ctx.db | |
| 921 | − .query("workspaceToolIndex") | |
| 922 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 923 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 924 | − ) | |
| 925 | − .collect(); | |
| 926 | − | |
| 927 | − for (const row of existingRows) { | |
| 928 | − await ctx.db.delete(row._id); | |
| 929 | − } | |
| 930 | − | |
| 931 | − return { removedCount: existingRows.length }; | |
| 932 | − }, | |
| 933 | −}); | |
| 934 | − | |
| 935 | −export const appendWorkspaceSourceToolIndexChunk = internalMutation({ | |
| 936 | − args: { | |
| 937 | − workspaceId: v.string(), | |
| 938 | − sourceId: v.string(), | |
| 939 | − sourceName: v.string(), | |
| 940 | − sourceKind: v.string(), | |
| 941 | − artifactId: v.string(), | |
| 942 | − namespace: v.string(), | |
| 943 | − refHintTableJson: v.optional(v.union(v.string(), v.null())), | |
| 944 | − insertOnly: v.optional(v.boolean()), | |
| 945 | − rows: v.array( | |
| 946 | − v.object({ | |
| 947 | − toolId: v.string(), | |
| 948 | − protocol: v.string(), | |
| 949 | − method: v.string(), | |
| 950 | − path: v.string(), | |
| 951 | − operationPath: v.optional(v.union(v.string(), v.null())), | |
| 952 | − name: v.string(), | |
| 953 | − description: v.union(v.string(), v.null()), | |
| 954 | − searchText: v.string(), | |
| 955 | − operationHash: v.string(), | |
| 956 | − approvalMode: v.string(), | |
| 957 | − status: v.string(), | |
| 958 | − }), | |
| 959 | − ), | |
| 960 | − }, | |
| 961 | − handler: async (ctx, args) => { | |
| 962 | − const now = Date.now(); | |
| 963 | − let insertedCount = 0; | |
| 964 | − let updatedCount = 0; | |
| 965 | − | |
| 966 | − if (args.insertOnly === true) { | |
| 967 | − for (const row of args.rows) { | |
| 968 | − const nextRow = { | |
| 969 | − id: `wti_${args.workspaceId}_${args.sourceId}_${row.toolId}`, | |
| 970 | − workspaceId: args.workspaceId, | |
| 971 | − sourceId: args.sourceId, | |
| 972 | − sourceName: args.sourceName, | |
| 973 | − sourceKind: args.sourceKind, | |
| 974 | − artifactId: args.artifactId, | |
| 975 | − toolId: row.toolId, | |
| 976 | − protocol: row.protocol, | |
| 977 | − method: row.method, | |
| 978 | − namespace: args.namespace, | |
| 979 | − path: row.path, | |
| 980 | − pathLower: row.path.toLowerCase(), | |
| 981 | − normalizedPath: normalizePathForLookup(row.path), | |
| 982 | − operationPath: row.operationPath ?? null, | |
| 983 | − name: row.name, | |
| 984 | − description: row.description, | |
| 985 | − searchText: row.searchText, | |
| 986 | − operationHash: row.operationHash, | |
| 987 | − approvalMode: row.approvalMode, | |
| 988 | − status: row.status, | |
| 989 | − refHintTableJson: args.refHintTableJson ?? null, | |
| 990 | − updatedAt: now, | |
| 991 | − }; | |
| 992 | − | |
| 993 | − await ctx.db.insert("workspaceToolIndex", nextRow); | |
| 994 | − insertedCount += 1; | |
| 995 | − } | |
| 996 | − | |
| 997 | − return { insertedCount, updatedCount }; | |
| 998 | − } | |
| 999 | − | |
| 1000 | − for (const row of args.rows) { | |
| 1001 | − const id = `wti_${args.workspaceId}_${args.sourceId}_${row.toolId}`; | |
| 1002 | − const existing = await ctx.db | |
| 1003 | − .query("workspaceToolIndex") | |
| 1004 | − .withIndex("by_domainId", (q) => q.eq("id", id)) | |
| 1005 | − .unique(); | |
| 1006 | − | |
| 1007 | − const nextRow = { | |
| 1008 | − id, | |
| 1009 | − workspaceId: args.workspaceId, | |
| 1010 | − sourceId: args.sourceId, | |
| 1011 | − sourceName: args.sourceName, | |
| 1012 | − sourceKind: args.sourceKind, | |
| 1013 | − artifactId: args.artifactId, | |
| 1014 | − toolId: row.toolId, | |
| 1015 | − protocol: row.protocol, | |
| 1016 | − method: row.method, | |
| 1017 | − namespace: args.namespace, | |
| 1018 | − path: row.path, | |
| 1019 | − pathLower: row.path.toLowerCase(), | |
| 1020 | − normalizedPath: normalizePathForLookup(row.path), | |
| 1021 | − operationPath: row.operationPath ?? null, | |
| 1022 | − name: row.name, | |
| 1023 | − description: row.description, | |
| 1024 | − searchText: row.searchText, | |
| 1025 | − operationHash: row.operationHash, | |
| 1026 | − approvalMode: row.approvalMode, | |
| 1027 | − status: row.status, | |
| 1028 | − refHintTableJson: args.refHintTableJson ?? null, | |
| 1029 | − updatedAt: now, | |
| 1030 | − }; | |
| 1031 | − | |
| 1032 | − if (existing) { | |
| 1033 | − await ctx.db.patch(existing._id, nextRow); | |
| 1034 | − updatedCount += 1; | |
| 1035 | − } else { | |
| 1036 | − await ctx.db.insert("workspaceToolIndex", nextRow); | |
| 1037 | − insertedCount += 1; | |
| 1038 | − } | |
| 1039 | − } | |
| 1040 | − | |
| 1041 | − return { insertedCount, updatedCount }; | |
| 1042 | − }, | |
| 1043 | −}); | |
| 1044 | − | |
| 1045 | −export const removeSourceBindingsAndIndex = internalMutation({ | |
| 1046 | − args: { | |
| 1047 | − workspaceId: v.string(), | |
| 1048 | − sourceId: v.string(), | |
| 1049 | − }, | |
| 1050 | − handler: async (ctx, args) => { | |
| 1051 | − const binding = await ctx.db | |
| 1052 | − .query("sourceArtifactBindings") | |
| 1053 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 1054 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 1055 | − ) | |
| 1056 | − .unique(); | |
| 1057 | − | |
| 1058 | − if (binding) { | |
| 1059 | − await ctx.db.delete(binding._id); | |
| 1060 | − } | |
| 1061 | − | |
| 1062 | − const indexRows = await ctx.db | |
| 1063 | − .query("workspaceToolIndex") | |
| 1064 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 1065 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 1066 | − ) | |
| 1067 | − .collect(); | |
| 1068 | − | |
| 1069 | − for (const row of indexRows) { | |
| 1070 | − await ctx.db.delete(row._id); | |
| 1071 | − } | |
| 1072 | − | |
| 1073 | − return { | |
| 1074 | − removedBinding: Boolean(binding), | |
| 1075 | − removedIndexRows: indexRows.length, | |
| 1076 | − }; | |
| 1077 | − }, | |
| 1078 | −}); | |
| 1079 | − | |
| 1080 | −export const getSourceArtifactBinding = internalQuery({ | |
| 1081 | − args: { | |
| 1082 | − workspaceId: v.string(), | |
| 1083 | − sourceId: v.string(), | |
| 1084 | − }, | |
| 1085 | − handler: async (ctx, args) => { | |
| 1086 | − const binding = await ctx.db | |
| 1087 | − .query("sourceArtifactBindings") | |
| 1088 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 1089 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 1090 | − ) | |
| 1091 | − .unique(); | |
| 1092 | − | |
| 1093 | − return binding ? stripConvexSystemFields(binding as unknown as Record<string, unknown>) : null; | |
| 1094 | − }, | |
| 1095 | −}); | |
| 1096 | − | |
| 1097 | −export const listWorkspaceSourceArtifactBindings = internalQuery({ | |
| 1098 | − args: { | |
| 1099 | − workspaceId: v.string(), | |
| 1100 | − }, | |
| 1101 | − handler: async (ctx, args) => { | |
| 1102 | − const rows = await ctx.db | |
| 1103 | − .query("sourceArtifactBindings") | |
| 1104 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 1105 | − .collect(); | |
| 1106 | − | |
| 1107 | − return rows.map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>)); | |
| 1108 | − }, | |
| 1109 | −}); | |
| 1110 | − | |
| 1111 | −export const getArtifactById = internalQuery({ | |
| 1112 | − args: { | |
| 1113 | − artifactId: v.string(), | |
| 1114 | − }, | |
| 1115 | − handler: async (ctx, args) => { | |
| 1116 | − const artifact = await ctx.db | |
| 1117 | − .query("artifacts") | |
| 1118 | − .withIndex("by_domainId", (q) => q.eq("id", args.artifactId)) | |
| 1119 | − .unique(); | |
| 1120 | − | |
| 1121 | − return artifact ? stripConvexSystemFields(artifact as unknown as Record<string, unknown>) : null; | |
| 1122 | − }, | |
| 1123 | −}); | |
| 1124 | − | |
| 1125 | −export const listArtifactTools = internalQuery({ | |
| 1126 | − args: { | |
| 1127 | − artifactId: v.string(), | |
| 1128 | − }, | |
| 1129 | − handler: async (ctx, args) => { | |
| 1130 | − const rows = await ctx.db | |
| 1131 | − .query("artifactTools") | |
| 1132 | − .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId)) | |
| 1133 | − .collect(); | |
| 1134 | − | |
| 1135 | − return rows | |
| 1136 | − .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>)) | |
| 1137 | − .sort((left, right) => String(left.toolId).localeCompare(String(right.toolId))); | |
| 1138 | − }, | |
| 1139 | −}); | |
| 1140 | − | |
| 1141 | −export const listArtifactToolsPage = internalQuery({ | |
| 1142 | − args: { | |
| 1143 | − artifactId: v.string(), | |
| 1144 | − cursor: v.optional(v.union(v.string(), v.null())), | |
| 1145 | − pageSize: v.optional(v.number()), | |
| 1146 | − }, | |
| 1147 | − handler: async (ctx, args) => { | |
| 1148 | − const pageSize = Math.max(1, Math.min(500, Math.floor(args.pageSize ?? 200))); | |
| 1149 | − const page = await ctx.db | |
| 1150 | − .query("artifactTools") | |
| 1151 | − .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId)) | |
| 1152 | − .paginate({ | |
| 1153 | − cursor: args.cursor ?? null, | |
| 1154 | − numItems: pageSize, | |
| 1155 | − }); | |
| 1156 | − | |
| 1157 | − const rows = page.page.map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>)); | |
| 1158 | − | |
| 1159 | − return { | |
| 1160 | − rows, | |
| 1161 | − continueCursor: page.continueCursor, | |
| 1162 | − isDone: page.isDone, | |
| 1163 | − }; | |
| 1164 | − }, | |
| 1165 | −}); | |
| 1166 | − | |
| 1167 | −export const hasWorkspaceSourceToolIndexRows = internalQuery({ | |
| 1168 | − args: { | |
| 1169 | − workspaceId: v.string(), | |
| 1170 | − sourceId: v.string(), | |
| 1171 | − }, | |
| 1172 | − handler: async (ctx, args) => { | |
| 1173 | − const page = await ctx.db | |
| 1174 | − .query("workspaceToolIndex") | |
| 1175 | − .withIndex("by_workspaceId_sourceId", (q) => | |
| 1176 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 1177 | − ) | |
| 1178 | − .paginate({ | |
| 1179 | − cursor: null, | |
| 1180 | − numItems: 1, | |
| 1181 | − }); | |
| 1182 | − | |
| 1183 | − return page.page.length > 0; | |
| 1184 | − }, | |
| 1185 | −}); | |
| 1186 | − | |
| 1187 | −export const ensureWorkspaceToolIndexCoverage = internalAction({ | |
| 1188 | − args: { | |
| 1189 | − workspaceId: v.string(), | |
| 1190 | − sourceId: v.optional(v.string()), | |
| 1191 | − }, | |
| 1192 | − handler: async (ctx, args) => { | |
| 1193 | − const sourceRows = await ctx.runQuery(runtimeInternal.control_plane.sources.listSources, { | |
| 1194 | − workspaceId: args.workspaceId, | |
| 1195 | − }); | |
| 1196 | − const sourceById = new Map<string, Record<string, unknown>>( | |
| 1197 | − (sourceRows as Array<Record<string, unknown>>).map((row) => [String(row.id), row] as const), | |
| 1198 | − ); | |
| 1199 | − | |
| 1200 | − const bindingRows = await ctx.runQuery( | |
| 1201 | − runtimeInternal.control_plane.tool_registry.listWorkspaceSourceArtifactBindings, | |
| 1202 | − { | |
| 1203 | − workspaceId: args.workspaceId, | |
| 1204 | − }, | |
| 1205 | − ); | |
| 1206 | − | |
| 1207 | − let rebuiltCount = 0; | |
| 1208 | − | |
| 1209 | − for (const binding of bindingRows) { | |
| 1210 | − const sourceId = String(binding.sourceId ?? ""); | |
| 1211 | − if (sourceId.length === 0) { | |
| 1212 | − continue; | |
| 1213 | − } | |
| 1214 | − | |
| 1215 | − if (args.sourceId && sourceId !== args.sourceId) { | |
| 1216 | − continue; | |
| 1217 | − } | |
| 1218 | − | |
| 1219 | − const source = sourceById.get(sourceId); | |
| 1220 | − if (!source) { | |
| 1221 | − continue; | |
| 1222 | − } | |
| 1223 | − | |
| 1224 | − const hasRows = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.hasWorkspaceSourceToolIndexRows, { | |
| 1225 | − workspaceId: args.workspaceId, | |
| 1226 | − sourceId, | |
| 1227 | − }); | |
| 1228 | − if (hasRows) { | |
| 1229 | − continue; | |
| 1230 | − } | |
| 1231 | − | |
| 1232 | − const sourceName = String(source.name ?? ""); | |
| 1233 | − const sourceKind = String(source.kind ?? "openapi"); | |
| 1234 | − const sourceEndpoint = String(source.endpoint ?? ""); | |
| 1235 | − const sourceEnabled = source.enabled === true; | |
| 1236 | − const artifactId = String(binding.artifactId ?? ""); | |
| 1237 | − if (artifactId.length === 0) { | |
| 1238 | − continue; | |
| 1239 | − } | |
| 1240 | − | |
| 1241 | − const artifact = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.getArtifactById, { | |
| 1242 | − artifactId, | |
| 1243 | − }); | |
| 1244 | − const namespace = sourceNamespace({ id: sourceId, name: sourceName }); | |
| 1245 | − | |
| 1246 | − await ctx.runMutation(runtimeInternal.control_plane.tool_registry.clearWorkspaceSourceToolIndex, { | |
| 1247 | − workspaceId: args.workspaceId, | |
| 1248 | − sourceId, | |
| 1249 | − }); | |
| 1250 | − | |
| 1251 | − let cursor: string | null = null; | |
| 1252 | − do { | |
| 1253 | − const page: { | |
| 1254 | − rows: Array<Record<string, unknown>>; | |
| 1255 | − continueCursor: string | null; | |
| 1256 | − isDone: boolean; | |
| 1257 | − } = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listArtifactToolsPage, { | |
| 1258 | − artifactId, | |
| 1259 | − cursor, | |
| 1260 | − pageSize: 200, | |
| 1261 | − }); | |
| 1262 | − | |
| 1263 | − if (page.rows.length > 0) { | |
| 1264 | − await ctx.runMutation(runtimeInternal.control_plane.tool_registry.appendWorkspaceSourceToolIndexChunk, { | |
| 1265 | − workspaceId: args.workspaceId, | |
| 1266 | − sourceId, | |
| 1267 | − sourceName, | |
| 1268 | − sourceKind, | |
| 1269 | − artifactId, | |
| 1270 | − namespace, | |
| 1271 | − refHintTableJson: typeof artifact?.refHintTableJson === "string" ? artifact.refHintTableJson : null, | |
| 1272 | − insertOnly: true, | |
| 1273 | − rows: page.rows.map((row) => { | |
| 1274 | − const toolId = String(row.toolId ?? ""); | |
| 1275 | − const protocol = String(row.protocol ?? "openapi"); | |
| 1276 | − const canonicalPath = | |
| 1277 | − typeof row.canonicalPath === "string" && row.canonicalPath.trim().length > 0 | |
| 1278 | − ? row.canonicalPath.trim() | |
| 1279 | − : null; | |
| 1280 | − const method = toWorkspaceToolMethod( | |
| 1281 | − protocol, | |
| 1282 | − typeof row.metadataJson === "string" ? row.metadataJson : null, | |
| 1283 | − canonicalPath, | |
| 1284 | − ); | |
| 1285 | − const path = `${namespace}.${toolId}`; | |
| 1286 | − const description = typeof row.description === "string" ? row.description : null; | |
| 1287 | − const name = String(row.name ?? toolId); | |
| 1288 | − | |
| 1289 | − return { | |
| 1290 | − toolId, | |
| 1291 | − protocol, | |
| 1292 | − method, | |
| 1293 | − path, | |
| 1294 | − operationPath: canonicalPath, | |
| 1295 | − name, | |
| 1296 | − description, | |
| 1297 | − searchText: normalizedSearchText( | |
| 1298 | − sourceName, | |
| 1299 | − sourceEndpoint, | |
| 1300 | − namespace, | |
| 1301 | − toolId, | |
| 1302 | − name, | |
| 1303 | − description, | |
| 1304 | − ...metadataSearchTerms(typeof row.metadataJson === "string" ? row.metadataJson : null), | |
| 1305 | − ), | |
| 1306 | − operationHash: String(row.operationHash ?? ""), | |
| 1307 | − approvalMode: "auto", | |
| 1308 | − status: sourceEnabled ? "active" : "disabled", | |
| 1309 | − }; | |
| 1310 | − }), | |
| 1311 | − }); | |
| 1312 | − } | |
| 1313 | − | |
| 1314 | − cursor = page.isDone ? null : page.continueCursor; | |
| 1315 | − } while (cursor); | |
| 1316 | − | |
| 1317 | − rebuiltCount += 1; | |
| 1318 | − } | |
| 1319 | − | |
| 1320 | − return { rebuiltCount }; | |
| 1321 | − }, | |
| 1322 | −}); | |
| 1323 | − | |
| 1324 | −export const listArtifactToolIndexSlice = internalQuery({ | |
| 1325 | − args: { | |
| 1326 | − artifactId: v.string(), | |
| 1327 | − offset: v.optional(v.number()), | |
| 1328 | − limit: v.optional(v.number()), | |
| 1329 | − }, | |
| 1330 | − handler: async (ctx, args) => { | |
| 1331 | − const rows = await ctx.db | |
| 1332 | − .query("artifactTools") | |
| 1333 | − .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId)) | |
| 1334 | − .collect(); | |
| 1335 | − | |
| 1336 | − const ordered = rows | |
| 1337 | − .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>)) | |
| 1338 | − .sort((left, right) => String(left.toolId).localeCompare(String(right.toolId))); | |
| 1339 | − | |
| 1340 | − const offset = Math.max(0, Math.floor(args.offset ?? 0)); | |
| 1341 | − const limit = normalizeLimit(args.limit); | |
| 1342 | − | |
| 1343 | − return ordered.slice(offset, offset + limit).map((row) => ({ | |
| 1344 | − toolId: String(row.toolId), | |
| 1345 | − protocol: String(row.protocol), | |
| 1346 | − name: String(row.name), | |
| 1347 | − description: typeof row.description === "string" ? row.description : null, | |
| 1348 | − canonicalPath: typeof row.canonicalPath === "string" ? row.canonicalPath : null, | |
| 1349 | − operationHash: String(row.operationHash), | |
| 1350 | − metadataJson: typeof row.metadataJson === "string" ? row.metadataJson : null, | |
| 1351 | − })); | |
| 1352 | − }, | |
| 1353 | −}); | |
| 1354 | − | |
| 1355 | −export const getArtifactTool = internalQuery({ | |
| 1356 | − args: { | |
| 1357 | − artifactId: v.string(), | |
| 1358 | − toolId: v.string(), | |
| 1359 | − }, | |
| 1360 | − handler: async (ctx, args) => { | |
| 1361 | − const row = await ctx.db | |
| 1362 | − .query("artifactTools") | |
| 1363 | − .withIndex("by_artifactId_toolId", (q) => | |
| 1364 | − q.eq("artifactId", args.artifactId).eq("toolId", args.toolId), | |
| 1365 | − ) | |
| 1366 | − .unique(); | |
| 1367 | − | |
| 1368 | − return row ? stripConvexSystemFields(row as unknown as Record<string, unknown>) : null; | |
| 1369 | − }, | |
| 1370 | −}); | |
| 1371 | − | |
| 1372 | −export const searchWorkspaceTools = internalQuery({ | |
| 1373 | − args: { | |
| 1374 | − workspaceId: v.string(), | |
| 1375 | − query: v.string(), | |
| 1376 | − limit: v.optional(v.number()), | |
| 1377 | − sourceId: v.optional(v.string()), | |
| 1378 | − namespace: v.optional(v.string()), | |
| 1379 | − includeDisabled: v.optional(v.boolean()), | |
| 1380 | − }, | |
| 1381 | − handler: async (ctx, args) => { | |
| 1382 | − const trimmedQuery = args.query.trim(); | |
| 1383 | − const limit = normalizeLimit(args.limit); | |
| 1384 | − if (trimmedQuery.length === 0) { | |
| 1385 | − return []; | |
| 1386 | − } | |
| 1387 | − | |
| 1388 | − const searchQuery: any = ctx.db | |
| 1389 | − .query("workspaceToolIndex") | |
| 1390 | − .withSearchIndex("search_text", (q: any) => { | |
| 1391 | − let scoped = q.search("searchText", trimmedQuery).eq("workspaceId", args.workspaceId); | |
| 1392 | − if (args.sourceId) { | |
| 1393 | − scoped = scoped.eq("sourceId", args.sourceId); | |
| 1394 | − } | |
| 1395 | − if (args.namespace) { | |
| 1396 | − scoped = scoped.eq("namespace", args.namespace); | |
| 1397 | − } | |
| 1398 | − if (args.includeDisabled !== true) { | |
| 1399 | − scoped = scoped.eq("status", "active"); | |
| 1400 | − } | |
| 1401 | − return scoped; | |
| 1402 | − }); | |
| 1403 | − const searchFetchLimit = Math.max(limit, Math.min(maxSearchLimit, Math.max(limit * 4, 200))); | |
| 1404 | − const rows = (await searchQuery.take(searchFetchLimit)).map((row: Record<string, unknown>) => | |
| 1405 | − stripConvexSystemFields(row), | |
| 1406 | − ); | |
| 1407 | − | |
| 1408 | − const queryLower = trimmedQuery.toLowerCase(); | |
| 1409 | − const ranked = rows | |
| 1410 | − .map((row: Record<string, unknown>) => ({ | |
| 1411 | − row, | |
| 1412 | − score: scoreSearchRow(row, queryLower), | |
| 1413 | − })) | |
| 1414 | − .filter((entry: { row: Record<string, unknown>; score: number }) => entry.score > 0) | |
| 1415 | − .sort((left: { row: Record<string, unknown>; score: number }, right: { row: Record<string, unknown>; score: number }) => { | |
| 1416 | − if (left.score !== right.score) { | |
| 1417 | − return right.score - left.score; | |
| 1418 | − } | |
| 1419 | − | |
| 1420 | − return String(left.row.path).localeCompare(String(right.row.path)); | |
| 1421 | − }); | |
| 1422 | − | |
| 1423 | − return ranked.slice(0, limit).map((entry: { row: Record<string, unknown> }) => entry.row); | |
| 1424 | − }, | |
| 1425 | −}); | |
| 1426 | − | |
| 1427 | −export const listWorkspaceTools = internalQuery({ | |
| 1428 | − args: { | |
| 1429 | − workspaceId: v.string(), | |
| 1430 | − limit: v.optional(v.number()), | |
| 1431 | − sourceId: v.optional(v.string()), | |
| 1432 | − namespace: v.optional(v.string()), | |
| 1433 | − includeDisabled: v.optional(v.boolean()), | |
| 1434 | − }, | |
| 1435 | − handler: async (ctx, args) => { | |
| 1436 | − const rows = await listWorkspaceToolIndexRows(ctx, { | |
| 1437 | − workspaceId: args.workspaceId, | |
| 1438 | − limit: normalizeLimit(args.limit), | |
| 1439 | − sourceId: args.sourceId, | |
| 1440 | − namespace: args.namespace, | |
| 1441 | − includeDisabled: args.includeDisabled, | |
| 1442 | − }); | |
| 1443 | − | |
| 1444 | − return rows; | |
| 1445 | − }, | |
| 1446 | −}); | |
| 1447 | − | |
| 1448 | −export const getWorkspaceToolByPath = internalQuery({ | |
| 1449 | − args: { | |
| 1450 | − workspaceId: v.string(), | |
| 1451 | − pathLower: v.string(), | |
| 1452 | − }, | |
| 1453 | − handler: async (ctx, args) => { | |
| 1454 | − const row = await ctx.db | |
| 1455 | − .query("workspaceToolIndex") | |
| 1456 | − .withIndex("by_workspaceId_pathLower", (q) => | |
| 1457 | − q.eq("workspaceId", args.workspaceId).eq("pathLower", args.pathLower), | |
| 1458 | − ) | |
| 1459 | − .first(); | |
| 1460 | − | |
| 1461 | − return row ? stripConvexSystemFields(row as unknown as Record<string, unknown>) : null; | |
| 1462 | − }, | |
| 1463 | −}); | |
| 1464 | − | |
| 1465 | −export const listWorkspaceToolsByNormalizedPath = internalQuery({ | |
| 1466 | − args: { | |
| 1467 | − workspaceId: v.string(), | |
| 1468 | − normalizedPath: v.string(), | |
| 1469 | − limit: v.optional(v.number()), | |
| 1470 | − }, | |
| 1471 | − handler: async (ctx, args) => { | |
| 1472 | − const limit = normalizeLimit(args.limit); | |
| 1473 | − const page = await ctx.db | |
| 1474 | − .query("workspaceToolIndex") | |
| 1475 | − .withIndex("by_workspaceId_normalizedPath", (q) => | |
| 1476 | − q.eq("workspaceId", args.workspaceId).eq("normalizedPath", args.normalizedPath), | |
| 1477 | − ) | |
| 1478 | − .paginate({ | |
| 1479 | − cursor: null, | |
| 1480 | − numItems: limit, | |
| 1481 | − }); | |
| 1482 | − | |
| 1483 | − return page.page | |
| 1484 | − .filter((row) => row.status === "active") | |
| 1485 | − .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>)); | |
| 1486 | − }, | |
| 1487 | −}); | |
| 1488 | − | |
| 1489 | −export const listWorkspaceToolIndexPage = internalQuery({ | |
| 1490 | − args: { | |
| 1491 | − workspaceId: v.string(), | |
| 1492 | − cursor: v.optional(v.union(v.string(), v.null())), | |
| 1493 | − pageSize: v.optional(v.number()), | |
| 1494 | − }, | |
| 1495 | − handler: async (ctx, args) => { | |
| 1496 | − const pageSize = Math.max(1, Math.min(500, Math.floor(args.pageSize ?? 200))); | |
| 1497 | − const page = await ctx.db | |
| 1498 | − .query("workspaceToolIndex") | |
| 1499 | − .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 1500 | − .paginate({ | |
| 1501 | − cursor: args.cursor ?? null, | |
| 1502 | − numItems: pageSize, | |
| 1503 | − }); | |
| 1504 | − | |
| 1505 | − const rows = page.page | |
| 1506 | − .filter((row) => row.status === "active") | |
| 1507 | − .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>)); | |
| 1508 | − | |
| 1509 | − return { | |
| 1510 | − rows, | |
| 1511 | − continueCursor: page.continueCursor, | |
| 1512 | − isDone: page.isDone, | |
| 1513 | − }; | |
| 1514 | − }, | |
| 1515 | −}); | |
| 1516 | − | |
| 1517 | −export const listWorkspaceNamespacesUncached = internalAction({ | |
| 1518 | − args: { | |
| 1519 | − workspaceId: v.string(), | |
| 1520 | − limit: v.optional(v.number()), | |
| 1521 | − }, | |
| 1522 | − handler: async (ctx, args) => { | |
| 1523 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 1524 | − workspaceId: args.workspaceId, | |
| 1525 | − }); | |
| 1526 | − | |
| 1527 | − const grouped = new Map< | |
| 1528 | − string, | |
| 1529 | − { | |
| 1530 | − sourceName: string; | |
| 1531 | − sourceId: string; | |
| 1532 | − sourceKind: string; | |
| 1533 | − toolCount: number; | |
| 1534 | − paths: Array<string>; | |
| 1535 | − } | |
| 1536 | − >(); | |
| 1537 | − | |
| 1538 | − let cursor: string | null = null; | |
| 1539 | − do { | |
| 1540 | − const page: { | |
| 1541 | − rows: Array<Record<string, unknown>>; | |
| 1542 | − continueCursor: string | null; | |
| 1543 | − isDone: boolean; | |
| 1544 | − } = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listWorkspaceToolIndexPage, { | |
| 1545 | − workspaceId: args.workspaceId, | |
| 1546 | − cursor, | |
| 1547 | − pageSize: 250, | |
| 1548 | − }); | |
| 1549 | − | |
| 1550 | − for (const row of page.rows) { | |
| 1551 | − const namespace = String(row.namespace); | |
| 1552 | − const path = String(row.path); | |
| 1553 | − const existing = grouped.get(namespace); | |
| 1554 | − if (existing) { | |
| 1555 | − existing.toolCount += 1; | |
| 1556 | − | |
| 1557 | − if (existing.paths.length < 3) { | |
| 1558 | − existing.paths.push(path); | |
| 1559 | − existing.paths.sort((left, right) => left.localeCompare(right)); | |
| 1560 | − } else if (path.localeCompare(existing.paths[existing.paths.length - 1] ?? path) < 0) { | |
| 1561 | − existing.paths[existing.paths.length - 1] = path; | |
| 1562 | − existing.paths.sort((left, right) => left.localeCompare(right)); | |
| 1563 | − } | |
| 1564 | − | |
| 1565 | − continue; | |
| 1566 | − } | |
| 1567 | − | |
| 1568 | − grouped.set(namespace, { | |
| 1569 | − sourceName: String(row.sourceName), | |
| 1570 | − sourceId: String(row.sourceId), | |
| 1571 | − sourceKind: String(row.sourceKind), | |
| 1572 | − toolCount: 1, | |
| 1573 | − paths: [path], | |
| 1574 | − }); | |
| 1575 | − } | |
| 1576 | − | |
| 1577 | − cursor = page.isDone ? null : (page.continueCursor as string | null); | |
| 1578 | − } while (cursor); | |
| 1579 | − | |
| 1580 | − const limit = normalizeLimit(args.limit); | |
| 1581 | − | |
| 1582 | − if (limit <= 0 || grouped.size === 0) { | |
| 1583 | − return []; | |
| 1584 | − } | |
| 1585 | − | |
| 1586 | − const namespaces = [...grouped.entries()] | |
| 1587 | − .map(([namespace, value]) => ({ | |
| 1588 | − namespace, | |
| 1589 | − source: value.sourceName, | |
| 1590 | − sourceId: value.sourceId, | |
| 1591 | − sourceKind: value.sourceKind, | |
| 1592 | − toolCount: value.toolCount, | |
| 1593 | − samplePaths: value.paths, | |
| 1594 | − })) | |
| 1595 | − .sort((left, right) => left.namespace.localeCompare(right.namespace)); | |
| 1596 | − | |
| 1597 | − return namespaces.slice(0, limit); | |
| 1598 | − }, | |
| 1599 | −}); | |
| 1600 | − | |
| 1601 | −export const listWorkspaceNamespaces = internalAction({ | |
| 1602 | − args: { | |
| 1603 | − workspaceId: v.string(), | |
| 1604 | − limit: v.optional(v.number()), | |
| 1605 | − }, | |
| 1606 | − handler: async (ctx, args) => | |
| 1607 | − listWorkspaceNamespacesActionCache.fetch(ctx, { | |
| 1608 | − workspaceId: args.workspaceId, | |
| 1609 | − limit: args.limit, | |
| 1610 | − }), | |
| 1611 | −}); | |
| @@ -1,431 +0,0 @@ | ||
| 1 | −import { type SourceToolDetail, type SourceToolSummary } from "@executor-v2/management-api"; | |
| 2 | −import { | |
| 3 | − SourceSchema, | |
| 4 | − type OpenApiHttpMethod, | |
| 5 | − type Source, | |
| 6 | −} from "@executor-v2/schema"; | |
| 7 | −import { v } from "convex/values"; | |
| 8 | −import * as Schema from "effect/Schema"; | |
| 9 | −import { internal } from "../_generated/api"; | |
| 10 | − | |
| 11 | −import { internalAction, internalQuery } from "../_generated/server"; | |
| 12 | − | |
| 13 | −const decodeSource = Schema.decodeUnknownSync(SourceSchema); | |
| 14 | −const runtimeInternal = internal as any; | |
| 15 | − | |
| 16 | −const stripConvexSystemFields = ( | |
| 17 | − value: Record<string, unknown>, | |
| 18 | −): Record<string, unknown> => { | |
| 19 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 20 | − return rest; | |
| 21 | −}; | |
| 22 | − | |
| 23 | −const toSource = (document: Record<string, unknown>): Source => | |
| 24 | − decodeSource(stripConvexSystemFields(document)); | |
| 25 | − | |
| 26 | −const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { | |
| 27 | − if (!value) { | |
| 28 | − return {}; | |
| 29 | − } | |
| 30 | − | |
| 31 | − try { | |
| 32 | − const parsed = JSON.parse(value) as unknown; | |
| 33 | − return parsed && typeof parsed === "object" && !Array.isArray(parsed) | |
| 34 | − ? (parsed as Record<string, unknown>) | |
| 35 | − : {}; | |
| 36 | − } catch { | |
| 37 | − return {}; | |
| 38 | − } | |
| 39 | −}; | |
| 40 | − | |
| 41 | −const parseJsonValue = (value: string | null | undefined): unknown | null => { | |
| 42 | − if (!value) { | |
| 43 | − return null; | |
| 44 | − } | |
| 45 | − | |
| 46 | − try { | |
| 47 | − return JSON.parse(value) as unknown; | |
| 48 | − } catch { | |
| 49 | − return null; | |
| 50 | − } | |
| 51 | −}; | |
| 52 | − | |
| 53 | −const asNullableString = (value: unknown): string | null => | |
| 54 | − typeof value === "string" && value.trim().length > 0 ? value : null; | |
| 55 | − | |
| 56 | −const normalizeNamespacePart = (value: string): string => { | |
| 57 | − const normalized = value | |
| 58 | − .trim() | |
| 59 | − .toLowerCase() | |
| 60 | − .replace(/[^a-z0-9]+/g, "_") | |
| 61 | − .replace(/^_+|_+$/g, ""); | |
| 62 | − | |
| 63 | − return normalized.length > 0 ? normalized : "source"; | |
| 64 | −}; | |
| 65 | − | |
| 66 | −const sourceNamespace = (source: Source): string => { | |
| 67 | − const sourceIdSuffix = source.id.slice(-6).toLowerCase(); | |
| 68 | − return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`; | |
| 69 | −}; | |
| 70 | − | |
| 71 | −const isMethod = ( | |
| 72 | − value: string | null, | |
| 73 | −): value is OpenApiHttpMethod | "post" => | |
| 74 | − value === "get" || | |
| 75 | − value === "post" || | |
| 76 | − value === "put" || | |
| 77 | − value === "patch" || | |
| 78 | − value === "delete" || | |
| 79 | − value === "head" || | |
| 80 | − value === "options" || | |
| 81 | − value === "trace"; | |
| 82 | − | |
| 83 | −const methodFromCanonicalPath = (canonicalPath: string | null | undefined): OpenApiHttpMethod | "post" | null => { | |
| 84 | − if (!canonicalPath) { | |
| 85 | − return null; | |
| 86 | − } | |
| 87 | − | |
| 88 | − const candidate = canonicalPath.trim().split(/\s+/)[0]?.toLowerCase() ?? null; | |
| 89 | − return isMethod(candidate) ? candidate : null; | |
| 90 | −}; | |
| 91 | − | |
| 92 | −const toMethod = (protocol: string, metadataJson: string | null | undefined): OpenApiHttpMethod | "post" => { | |
| 93 | − if (protocol !== "openapi") { | |
| 94 | − return "post"; | |
| 95 | − } | |
| 96 | − | |
| 97 | − const metadata = parseJsonObject(metadataJson); | |
| 98 | − const method = asNullableString(metadata.method)?.toLowerCase(); | |
| 99 | − | |
| 100 | − if ( | |
| 101 | − method === "get" || | |
| 102 | − method === "post" || | |
| 103 | − method === "put" || | |
| 104 | − method === "patch" || | |
| 105 | − method === "delete" || | |
| 106 | − method === "head" || | |
| 107 | − method === "options" || | |
| 108 | − method === "trace" | |
| 109 | − ) { | |
| 110 | − return method; | |
| 111 | − } | |
| 112 | − | |
| 113 | − return "get"; | |
| 114 | −}; | |
| 115 | − | |
| 116 | −const collectRefKeys = (value: unknown, refs: Set<string>): void => { | |
| 117 | − if (Array.isArray(value)) { | |
| 118 | − for (const item of value) { | |
| 119 | − collectRefKeys(item, refs); | |
| 120 | − } | |
| 121 | − return; | |
| 122 | − } | |
| 123 | − | |
| 124 | − if (!value || typeof value !== "object") { | |
| 125 | − return; | |
| 126 | − } | |
| 127 | − | |
| 128 | − const record = value as Record<string, unknown>; | |
| 129 | − const refValue = record.$ref; | |
| 130 | − if (typeof refValue === "string" && refValue.startsWith("#/")) { | |
| 131 | − refs.add(refValue); | |
| 132 | − } | |
| 133 | − | |
| 134 | − for (const nestedValue of Object.values(record)) { | |
| 135 | − collectRefKeys(nestedValue, refs); | |
| 136 | − } | |
| 137 | −}; | |
| 138 | − | |
| 139 | −const resolveToolRefHintTableJson = async ( | |
| 140 | − ctx: any, | |
| 141 | − artifactId: string, | |
| 142 | − inputSchemaJson: string | null | undefined, | |
| 143 | − outputSchemaJson: string | null | undefined, | |
| 144 | −): Promise<string | null> => { | |
| 145 | − const initialRefs = new Set<string>(); | |
| 146 | − collectRefKeys(parseJsonValue(inputSchemaJson), initialRefs); | |
| 147 | − collectRefKeys(parseJsonValue(outputSchemaJson), initialRefs); | |
| 148 | − | |
| 149 | − if (initialRefs.size === 0) { | |
| 150 | − return null; | |
| 151 | − } | |
| 152 | − | |
| 153 | − const queue = [...initialRefs]; | |
| 154 | − const seen = new Set<string>(); | |
| 155 | − const table: Record<string, string> = {}; | |
| 156 | − const maxRefs = 5_000; | |
| 157 | − | |
| 158 | − while (queue.length > 0 && seen.size < maxRefs) { | |
| 159 | − const refKey = queue.shift(); | |
| 160 | − if (!refKey || seen.has(refKey)) { | |
| 161 | − continue; | |
| 162 | − } | |
| 163 | − | |
| 164 | − seen.add(refKey); | |
| 165 | − | |
| 166 | − const row = await ctx.db | |
| 167 | − .query("artifactSchemaRefs") | |
| 168 | − .withIndex("by_artifactId_refKey", (q: any) => | |
| 169 | − q.eq("artifactId", artifactId).eq("refKey", refKey), | |
| 170 | − ) | |
| 171 | − .unique(); | |
| 172 | − | |
| 173 | − if (!row || typeof row.schemaJson !== "string") { | |
| 174 | − continue; | |
| 175 | − } | |
| 176 | − | |
| 177 | − table[refKey] = row.schemaJson; | |
| 178 | − | |
| 179 | − const nestedRefs = new Set<string>(); | |
| 180 | − collectRefKeys(parseJsonValue(row.schemaJson), nestedRefs); | |
| 181 | − for (const nestedRef of nestedRefs) { | |
| 182 | − if (!seen.has(nestedRef)) { | |
| 183 | − queue.push(nestedRef); | |
| 184 | − } | |
| 185 | − } | |
| 186 | − } | |
| 187 | − | |
| 188 | − if (Object.keys(table).length === 0) { | |
| 189 | − return null; | |
| 190 | − } | |
| 191 | − | |
| 192 | − return JSON.stringify(table); | |
| 193 | −}; | |
| 194 | − | |
| 195 | −const toSummary = ( | |
| 196 | − source: Source, | |
| 197 | − toolRow: Record<string, unknown>, | |
| 198 | − metadataJson: string | null | undefined, | |
| 199 | −): SourceToolSummary => { | |
| 200 | − const protocol = asNullableString(toolRow.protocol) ?? "openapi"; | |
| 201 | − const canonicalPath = asNullableString(toolRow.canonicalPath); | |
| 202 | − const method = methodFromCanonicalPath(canonicalPath) ?? toMethod(protocol, metadataJson); | |
| 203 | − const toolId = asNullableString(toolRow.toolId) ?? "unknown"; | |
| 204 | − const path = `${sourceNamespace(source)}.${toolId}`; | |
| 205 | − | |
| 206 | − return { | |
| 207 | − sourceId: source.id, | |
| 208 | − sourceName: source.name, | |
| 209 | − sourceKind: source.kind, | |
| 210 | − toolId, | |
| 211 | − name: asNullableString(toolRow.name) ?? "Unnamed Tool", | |
| 212 | − description: asNullableString(toolRow.description), | |
| 213 | − method, | |
| 214 | − path, | |
| 215 | − operationPath: canonicalPath, | |
| 216 | − operationHash: asNullableString(toolRow.operationHash) ?? "", | |
| 217 | − }; | |
| 218 | −}; | |
| 219 | − | |
| 220 | −const summaryFromIndexRow = (row: Record<string, unknown>): SourceToolSummary => { | |
| 221 | − const method = asNullableString(row.method)?.toLowerCase() ?? null; | |
| 222 | − const sourceKind = asNullableString(row.sourceKind); | |
| 223 | − const normalizedSourceKind: Source["kind"] = | |
| 224 | − sourceKind === "graphql" || sourceKind === "mcp" || sourceKind === "internal" | |
| 225 | − ? sourceKind | |
| 226 | − : "openapi"; | |
| 227 | − | |
| 228 | − return { | |
| 229 | − sourceId: (asNullableString(row.sourceId) ?? "") as Source["id"], | |
| 230 | − sourceName: asNullableString(row.sourceName) ?? "", | |
| 231 | − sourceKind: normalizedSourceKind, | |
| 232 | − toolId: asNullableString(row.toolId) ?? "unknown", | |
| 233 | − name: asNullableString(row.name) ?? "Unnamed Tool", | |
| 234 | − description: asNullableString(row.description), | |
| 235 | − method: isMethod(method) ? method : "post", | |
| 236 | − path: asNullableString(row.path) ?? "", | |
| 237 | − operationPath: asNullableString(row.operationPath ?? null), | |
| 238 | − operationHash: asNullableString(row.operationHash) ?? "", | |
| 239 | − }; | |
| 240 | −}; | |
| 241 | − | |
| 242 | −const sortTools = (tools: ReadonlyArray<SourceToolSummary>): Array<SourceToolSummary> => | |
| 243 | − [...tools].sort((left, right) => { | |
| 244 | − const leftSource = left.sourceName.toLowerCase(); | |
| 245 | − const rightSource = right.sourceName.toLowerCase(); | |
| 246 | − | |
| 247 | − if (leftSource !== rightSource) { | |
| 248 | − return leftSource.localeCompare(rightSource); | |
| 249 | − } | |
| 250 | − | |
| 251 | − const leftName = left.name.toLowerCase(); | |
| 252 | − const rightName = right.name.toLowerCase(); | |
| 253 | − | |
| 254 | − if (leftName !== rightName) { | |
| 255 | − return leftName.localeCompare(rightName); | |
| 256 | − } | |
| 257 | − | |
| 258 | − return left.toolId.localeCompare(right.toolId); | |
| 259 | − }); | |
| 260 | − | |
| 261 | −const workspaceToolsPageSize = 250; | |
| 262 | − | |
| 263 | −export const listWorkspaceToolSummaryPage = internalQuery({ | |
| 264 | − args: { | |
| 265 | − workspaceId: v.string(), | |
| 266 | − sourceId: v.optional(v.string()), | |
| 267 | − cursor: v.optional(v.union(v.string(), v.null())), | |
| 268 | − pageSize: v.optional(v.number()), | |
| 269 | − }, | |
| 270 | − handler: async (ctx, args) => { | |
| 271 | − const pageSize = Math.max(1, Math.min(500, Math.floor(args.pageSize ?? workspaceToolsPageSize))); | |
| 272 | − const paginationOptions = { | |
| 273 | − cursor: args.cursor ?? null, | |
| 274 | − numItems: pageSize, | |
| 275 | − }; | |
| 276 | − | |
| 277 | − const page = args.sourceId | |
| 278 | − ? await ctx.db | |
| 279 | − .query("workspaceToolIndex") | |
| 280 | − .withIndex("by_workspaceId_sourceId", (q: any) => | |
| 281 | − q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId), | |
| 282 | − ) | |
| 283 | − .paginate(paginationOptions) | |
| 284 | − : await ctx.db | |
| 285 | − .query("workspaceToolIndex") | |
| 286 | − .withIndex("by_workspaceId", (q: any) => q.eq("workspaceId", args.workspaceId)) | |
| 287 | − .paginate(paginationOptions); | |
| 288 | − | |
| 289 | − const summaries = page.page | |
| 290 | − .filter((row) => row.status === "active") | |
| 291 | − .map((row) => summaryFromIndexRow(stripConvexSystemFields(row as unknown as Record<string, unknown>))); | |
| 292 | − | |
| 293 | − return { | |
| 294 | − summaries, | |
| 295 | − continueCursor: page.continueCursor, | |
| 296 | − isDone: page.isDone, | |
| 297 | − }; | |
| 298 | − }, | |
| 299 | −}); | |
| 300 | − | |
| 301 | −export const listWorkspaceTools = internalAction({ | |
| 302 | − args: { | |
| 303 | − workspaceId: v.string(), | |
| 304 | − }, | |
| 305 | − handler: async (ctx, args): Promise<Array<SourceToolSummary>> => { | |
| 306 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 307 | − workspaceId: args.workspaceId, | |
| 308 | − }); | |
| 309 | − | |
| 310 | − const summaries: Array<SourceToolSummary> = []; | |
| 311 | − let cursor: string | null = null; | |
| 312 | − | |
| 313 | − do { | |
| 314 | − const page: { | |
| 315 | − summaries: Array<SourceToolSummary>; | |
| 316 | − continueCursor: string | null; | |
| 317 | − isDone: boolean; | |
| 318 | − } = await ctx.runQuery(runtimeInternal.control_plane.tools.listWorkspaceToolSummaryPage, { | |
| 319 | − workspaceId: args.workspaceId, | |
| 320 | − cursor, | |
| 321 | − pageSize: workspaceToolsPageSize, | |
| 322 | − }); | |
| 323 | − | |
| 324 | − summaries.push(...page.summaries); | |
| 325 | − cursor = page.isDone ? null : page.continueCursor; | |
| 326 | − } while (cursor); | |
| 327 | − | |
| 328 | − return sortTools(summaries); | |
| 329 | − }, | |
| 330 | −}); | |
| 331 | − | |
| 332 | −export const listSourceTools = internalAction({ | |
| 333 | − args: { | |
| 334 | − workspaceId: v.string(), | |
| 335 | − sourceId: v.string(), | |
| 336 | − }, | |
| 337 | − handler: async (ctx, args): Promise<Array<SourceToolSummary>> => { | |
| 338 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 339 | − workspaceId: args.workspaceId, | |
| 340 | − sourceId: args.sourceId, | |
| 341 | − }); | |
| 342 | − | |
| 343 | − const summaries: Array<SourceToolSummary> = []; | |
| 344 | − let cursor: string | null = null; | |
| 345 | − | |
| 346 | − do { | |
| 347 | − const page: { | |
| 348 | − summaries: Array<SourceToolSummary>; | |
| 349 | − continueCursor: string | null; | |
| 350 | − isDone: boolean; | |
| 351 | − } = await ctx.runQuery(runtimeInternal.control_plane.tools.listWorkspaceToolSummaryPage, { | |
| 352 | − workspaceId: args.workspaceId, | |
| 353 | − sourceId: args.sourceId, | |
| 354 | − cursor, | |
| 355 | − pageSize: workspaceToolsPageSize, | |
| 356 | − }); | |
| 357 | − | |
| 358 | − summaries.push(...page.summaries); | |
| 359 | − cursor = page.isDone ? null : page.continueCursor; | |
| 360 | − } while (cursor); | |
| 361 | − | |
| 362 | − return sortTools(summaries); | |
| 363 | − }, | |
| 364 | −}); | |
| 365 | − | |
| 366 | −export const getToolDetail = internalQuery({ | |
| 367 | − args: { | |
| 368 | − workspaceId: v.string(), | |
| 369 | − sourceId: v.string(), | |
| 370 | − operationHash: v.string(), | |
| 371 | − }, | |
| 372 | − handler: async (ctx, args): Promise<SourceToolDetail | null> => { | |
| 373 | − const sourceRow = await ctx.db | |
| 374 | − .query("sources") | |
| 375 | − .withIndex("by_domainId", (q) => q.eq("id", args.sourceId)) | |
| 376 | − .unique(); | |
| 377 | − | |
| 378 | − if (!sourceRow || sourceRow.workspaceId !== args.workspaceId) { | |
| 379 | − return null; | |
| 380 | − } | |
| 381 | − | |
| 382 | − const source = toSource(sourceRow as unknown as Record<string, unknown>); | |
| 383 | − const indexedRows = await ctx.db | |
| 384 | − .query("workspaceToolIndex") | |
| 385 | − .withIndex("by_workspaceId_operationHash", (q: any) => | |
| 386 | − q.eq("workspaceId", args.workspaceId).eq("operationHash", args.operationHash), | |
| 387 | − ) | |
| 388 | − .collect(); | |
| 389 | − const indexedRow = indexedRows.find((row) => row.sourceId === args.sourceId) ?? null; | |
| 390 | − if (!indexedRow) { | |
| 391 | − return null; | |
| 392 | − } | |
| 393 | − | |
| 394 | − const artifactTool = await ctx.db | |
| 395 | − .query("artifactTools") | |
| 396 | − .withIndex("by_artifactId_toolId", (q: any) => | |
| 397 | − q.eq("artifactId", indexedRow.artifactId).eq("toolId", indexedRow.toolId), | |
| 398 | − ) | |
| 399 | − .unique(); | |
| 400 | − if (!artifactTool) { | |
| 401 | − return null; | |
| 402 | − } | |
| 403 | − | |
| 404 | − const artifact = await ctx.db | |
| 405 | − .query("artifacts") | |
| 406 | − .withIndex("by_domainId", (q) => q.eq("id", indexedRow.artifactId)) | |
| 407 | − .unique(); | |
| 408 | − | |
| 409 | − const resolvedRefHintTableJson = source.kind === "openapi" | |
| 410 | − ? await resolveToolRefHintTableJson( | |
| 411 | − ctx, | |
| 412 | − indexedRow.artifactId, | |
| 413 | − artifactTool.inputSchemaJson ?? null, | |
| 414 | − artifactTool.outputSchemaJson ?? null, | |
| 415 | − ) | |
| 416 | − : null; | |
| 417 | − | |
| 418 | − const summary = summaryFromIndexRow( | |
| 419 | − stripConvexSystemFields(indexedRow as unknown as Record<string, unknown>), | |
| 420 | − ); | |
| 421 | − | |
| 422 | − return { | |
| 423 | − ...summary, | |
| 424 | − inputSchemaJson: artifactTool.inputSchemaJson ?? null, | |
| 425 | − outputSchemaJson: artifactTool.outputSchemaJson ?? null, | |
| 426 | − refHintTableJson: resolvedRefHintTableJson | |
| 427 | − ?? artifact?.refHintTableJson | |
| 428 | − ?? null, | |
| 429 | − }; | |
| 430 | − }, | |
| 431 | −}); | |
| @@ -1,78 +0,0 @@ | ||
| 1 | −import { type UpsertWorkspacePayload } from "@executor-v2/management-api"; | |
| 2 | −import { WorkspaceSchema, type Workspace } from "@executor-v2/schema"; | |
| 3 | −import { v } from "convex/values"; | |
| 4 | −import * as Schema from "effect/Schema"; | |
| 5 | − | |
| 6 | −import { internalMutation, internalQuery } from "../_generated/server"; | |
| 7 | − | |
| 8 | −const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); | |
| 9 | − | |
| 10 | −const stripConvexSystemFields = ( | |
| 11 | − value: Record<string, unknown>, | |
| 12 | −): Record<string, unknown> => { | |
| 13 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 14 | − return rest; | |
| 15 | −}; | |
| 16 | − | |
| 17 | −const toWorkspace = (document: Record<string, unknown>): Workspace => | |
| 18 | − decodeWorkspace(stripConvexSystemFields(document)); | |
| 19 | − | |
| 20 | −const sortWorkspaces = (workspaces: ReadonlyArray<Workspace>): Array<Workspace> => | |
| 21 | − [...workspaces].sort((left, right) => { | |
| 22 | − const leftName = left.name.toLowerCase(); | |
| 23 | − const rightName = right.name.toLowerCase(); | |
| 24 | − | |
| 25 | − if (leftName === rightName) { | |
| 26 | − return left.id.localeCompare(right.id); | |
| 27 | − } | |
| 28 | − | |
| 29 | − return leftName.localeCompare(rightName); | |
| 30 | − }); | |
| 31 | − | |
| 32 | −export const listWorkspaces = internalQuery({ | |
| 33 | − args: {}, | |
| 34 | − handler: async (ctx): Promise<Array<Workspace>> => { | |
| 35 | − const rows = await ctx.db.query("workspaces").collect(); | |
| 36 | − | |
| 37 | − return sortWorkspaces( | |
| 38 | − rows.map((row) => toWorkspace(row as unknown as Record<string, unknown>)), | |
| 39 | − ); | |
| 40 | − }, | |
| 41 | −}); | |
| 42 | − | |
| 43 | −export const upsertWorkspace = internalMutation({ | |
| 44 | − args: { | |
| 45 | − payload: v.object({ | |
| 46 | − id: v.optional(v.string()), | |
| 47 | − organizationId: v.string(), | |
| 48 | − name: v.string(), | |
| 49 | − }), | |
| 50 | − }, | |
| 51 | − handler: async (ctx, args): Promise<Workspace> => { | |
| 52 | − const payload = args.payload as UpsertWorkspacePayload; | |
| 53 | − const now = Date.now(); | |
| 54 | − const workspaceId = payload.id ?? `ws_${crypto.randomUUID()}`; | |
| 55 | − | |
| 56 | − const existing = await ctx.db | |
| 57 | − .query("workspaces") | |
| 58 | − .withIndex("by_domainId", (q) => q.eq("id", workspaceId)) | |
| 59 | − .unique(); | |
| 60 | − | |
| 61 | − const nextWorkspace = decodeWorkspace({ | |
| 62 | − id: workspaceId, | |
| 63 | − organizationId: payload.organizationId, | |
| 64 | − name: payload.name, | |
| 65 | − createdByAccountId: existing?.createdByAccountId ?? null, | |
| 66 | − createdAt: existing?.createdAt ?? now, | |
| 67 | − updatedAt: now, | |
| 68 | − }); | |
| 69 | − | |
| 70 | − if (existing) { | |
| 71 | − await ctx.db.patch(existing._id, nextWorkspace); | |
| 72 | − } else { | |
| 73 | − await ctx.db.insert("workspaces", nextWorkspace); | |
| 74 | − } | |
| 75 | − | |
| 76 | − return nextWorkspace; | |
| 77 | − }, | |
| 78 | −}); | |
| @@ -1,9 +0,0 @@ | ||
| 1 | −import actionCache from "@convex-dev/action-cache/convex.config"; | |
| 2 | −import workflow from "@convex-dev/workflow/convex.config.js"; | |
| 3 | −import { defineApp } from "convex/server"; | |
| 4 | − | |
| 5 | −const app = defineApp(); | |
| 6 | −app.use(actionCache); | |
| 7 | −app.use(workflow); | |
| 8 | − | |
| 9 | −export default app; | |
| @@ -1,192 +0,0 @@ | ||
| 1 | −const encryptedPrefix = "enc:v1:"; | |
| 2 | − | |
| 3 | −const encoder = new TextEncoder(); | |
| 4 | −const decoder = new TextDecoder(); | |
| 5 | − | |
| 6 | −type EncryptionKeyEntry = { | |
| 7 | − keyVersion: string; | |
| 8 | − passphrase: string; | |
| 9 | −}; | |
| 10 | − | |
| 11 | −const bytesToBase64 = (bytes: Uint8Array): string => { | |
| 12 | − let binary = ""; | |
| 13 | − for (const byte of bytes) { | |
| 14 | − binary += String.fromCharCode(byte); | |
| 15 | − } | |
| 16 | − return btoa(binary); | |
| 17 | −}; | |
| 18 | − | |
| 19 | −const base64ToBytes = (base64: string): Uint8Array => { | |
| 20 | − const binary = atob(base64); | |
| 21 | − const bytes = new Uint8Array(binary.length); | |
| 22 | − for (let i = 0; i < binary.length; i += 1) { | |
| 23 | − bytes[i] = binary.charCodeAt(i); | |
| 24 | − } | |
| 25 | − return bytes; | |
| 26 | −}; | |
| 27 | − | |
| 28 | −const normalizeKeyVersion = (value: string): string => { | |
| 29 | − const trimmed = value.trim(); | |
| 30 | − return trimmed.length > 0 ? trimmed : "v1"; | |
| 31 | −}; | |
| 32 | − | |
| 33 | −const parseEncryptionKeyEntries = (): Array<EncryptionKeyEntry> => { | |
| 34 | − const configured = process.env.CREDENTIAL_ENCRYPTION_KEYS?.trim() ?? ""; | |
| 35 | − if (configured.length > 0) { | |
| 36 | − const entries = configured | |
| 37 | − .split(",") | |
| 38 | − .map((part) => part.trim()) | |
| 39 | − .filter((part) => part.length > 0) | |
| 40 | − .flatMap((part) => { | |
| 41 | − const separatorIndex = part.indexOf("=") >= 0 | |
| 42 | − ? part.indexOf("=") | |
| 43 | − : part.indexOf(":"); | |
| 44 | − if (separatorIndex <= 0) { | |
| 45 | − return []; | |
| 46 | − } | |
| 47 | − | |
| 48 | − const keyVersion = normalizeKeyVersion(part.slice(0, separatorIndex)); | |
| 49 | − const passphrase = part.slice(separatorIndex + 1).trim(); | |
| 50 | − if (passphrase.length === 0) { | |
| 51 | − return []; | |
| 52 | − } | |
| 53 | − | |
| 54 | − return [{ keyVersion, passphrase }]; | |
| 55 | − }); | |
| 56 | − | |
| 57 | − if (entries.length > 0) { | |
| 58 | − return entries; | |
| 59 | − } | |
| 60 | − } | |
| 61 | − | |
| 62 | − const fallback = process.env.CREDENTIAL_ENCRYPTION_KEY?.trim() ?? ""; | |
| 63 | − if (fallback.length === 0) { | |
| 64 | − throw new Error( | |
| 65 | − "Encrypted credential storage requires CREDENTIAL_ENCRYPTION_KEY or CREDENTIAL_ENCRYPTION_KEYS", | |
| 66 | − ); | |
| 67 | − } | |
| 68 | − | |
| 69 | − return [{ keyVersion: "v1", passphrase: fallback }]; | |
| 70 | −}; | |
| 71 | − | |
| 72 | −const resolveEncryptionKeyEntries = (): Array<EncryptionKeyEntry> => | |
| 73 | − parseEncryptionKeyEntries(); | |
| 74 | − | |
| 75 | −const keyCache = new Map<string, Promise<CryptoKey>>(); | |
| 76 | − | |
| 77 | −const deriveAesKey = async (passphrase: string): Promise<CryptoKey> => { | |
| 78 | − const cacheKey = passphrase; | |
| 79 | − const existing = keyCache.get(cacheKey); | |
| 80 | − if (existing) { | |
| 81 | − return await existing; | |
| 82 | − } | |
| 83 | − | |
| 84 | − const keyPromise = (async () => { | |
| 85 | − const passphraseBytes = encoder.encode(passphrase); | |
| 86 | − const hashBuffer = await crypto.subtle.digest("SHA-256", passphraseBytes); | |
| 87 | − return await crypto.subtle.importKey( | |
| 88 | − "raw", | |
| 89 | − hashBuffer, | |
| 90 | − { | |
| 91 | − name: "AES-GCM", | |
| 92 | − }, | |
| 93 | − false, | |
| 94 | − ["encrypt", "decrypt"], | |
| 95 | − ); | |
| 96 | − })(); | |
| 97 | − | |
| 98 | − keyCache.set(cacheKey, keyPromise); | |
| 99 | − return await keyPromise; | |
| 100 | −}; | |
| 101 | − | |
| 102 | −const parseEncryptedValue = ( | |
| 103 | − value: string, | |
| 104 | −): { keyVersion: string | null; iv: Uint8Array; cipher: Uint8Array } | null => { | |
| 105 | − if (!value.startsWith(encryptedPrefix)) { | |
| 106 | − return null; | |
| 107 | − } | |
| 108 | − | |
| 109 | − const payload = value.slice(encryptedPrefix.length); | |
| 110 | − const parts = payload.split(":"); | |
| 111 | − | |
| 112 | − try { | |
| 113 | − if (parts.length === 2) { | |
| 114 | − return { | |
| 115 | − keyVersion: null, | |
| 116 | − iv: base64ToBytes(parts[0] ?? ""), | |
| 117 | − cipher: base64ToBytes(parts[1] ?? ""), | |
| 118 | − }; | |
| 119 | − } | |
| 120 | − | |
| 121 | − if (parts.length === 3) { | |
| 122 | − return { | |
| 123 | − keyVersion: normalizeKeyVersion(parts[0] ?? ""), | |
| 124 | − iv: base64ToBytes(parts[1] ?? ""), | |
| 125 | − cipher: base64ToBytes(parts[2] ?? ""), | |
| 126 | − }; | |
| 127 | − } | |
| 128 | − | |
| 129 | − return null; | |
| 130 | − } catch { | |
| 131 | − return null; | |
| 132 | − } | |
| 133 | −}; | |
| 134 | − | |
| 135 | −const resolveKeyEntryForDecrypt = ( | |
| 136 | − keyVersion: string | null, | |
| 137 | −): EncryptionKeyEntry => { | |
| 138 | − const entries = resolveEncryptionKeyEntries(); | |
| 139 | − | |
| 140 | − if (!keyVersion) { | |
| 141 | − return entries[0]!; | |
| 142 | − } | |
| 143 | − | |
| 144 | − const matched = entries.find((entry) => entry.keyVersion === keyVersion); | |
| 145 | − if (matched) { | |
| 146 | − return matched; | |
| 147 | − } | |
| 148 | − | |
| 149 | − throw new Error(`Credential encryption key version is not configured: ${keyVersion}`); | |
| 150 | −}; | |
| 151 | − | |
| 152 | −export const currentCredentialEncryptionKeyVersion = (): string => | |
| 153 | − resolveEncryptionKeyEntries()[0]!.keyVersion; | |
| 154 | − | |
| 155 | −export const isEncryptedSecret = (value: string): boolean => | |
| 156 | − value.startsWith(encryptedPrefix); | |
| 157 | − | |
| 158 | −export const encryptSecretValue = async (plainText: string): Promise<string> => { | |
| 159 | − const activeKey = resolveEncryptionKeyEntries()[0]!; | |
| 160 | − const key = await deriveAesKey(activeKey.passphrase); | |
| 161 | − const iv = crypto.getRandomValues(new Uint8Array(12)); | |
| 162 | − const encrypted = await crypto.subtle.encrypt( | |
| 163 | − { | |
| 164 | − name: "AES-GCM", | |
| 165 | − iv, | |
| 166 | − }, | |
| 167 | − key, | |
| 168 | − encoder.encode(plainText), | |
| 169 | − ); | |
| 170 | − | |
| 171 | − return `${encryptedPrefix}${activeKey.keyVersion}:${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`; | |
| 172 | −}; | |
| 173 | − | |
| 174 | −export const decryptSecretValue = async (storedValue: string): Promise<string> => { | |
| 175 | − const parsed = parseEncryptedValue(storedValue); | |
| 176 | − if (parsed === null) { | |
| 177 | − throw new Error("Credential secret is not encrypted"); | |
| 178 | − } | |
| 179 | − | |
| 180 | − const keyEntry = resolveKeyEntryForDecrypt(parsed.keyVersion); | |
| 181 | − const key = await deriveAesKey(keyEntry.passphrase); | |
| 182 | − const decrypted = await crypto.subtle.decrypt( | |
| 183 | − { | |
| 184 | − name: "AES-GCM", | |
| 185 | − iv: parsed.iv as unknown as BufferSource, | |
| 186 | − }, | |
| 187 | − key, | |
| 188 | − parsed.cipher as unknown as BufferSource, | |
| 189 | − ); | |
| 190 | − | |
| 191 | − return decoder.decode(decrypted); | |
| 192 | −}; | |
| @@ -1,1782 +0,0 @@ | ||
| 1 | −import { describe, expect, it } from "@effect/vitest"; | |
| 2 | −import { extractOpenApiManifest } from "@executor-v2/management-api"; | |
| 3 | −import { convexTest } from "convex-test"; | |
| 4 | −import * as Effect from "effect/Effect"; | |
| 5 | − | |
| 6 | −import { internal } from "./_generated/api"; | |
| 7 | −import { executeRunImpl } from "./executor"; | |
| 8 | −import schema from "./schema"; | |
| 9 | − | |
| 10 | −const runtimeInternal = internal as any; | |
| 11 | −const api = { | |
| 12 | − controlPlane: runtimeInternal.controlPlane ?? runtimeInternal.control_plane, | |
| 13 | −} as any; | |
| 14 | − | |
| 15 | −const setup = () => | |
| 16 | − convexTest(schema, { | |
| 17 | − "./http.ts": () => import("./http"), | |
| 18 | − "./mcp.ts": () => import("./mcp"), | |
| 19 | − "./executor.ts": () => import("./executor"), | |
| 20 | − "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"), | |
| 21 | − "./source_tool_registry.ts": () => import("./source_tool_registry"), | |
| 22 | − "./task_runs.ts": () => import("./task_runs"), | |
| 23 | − "./control_plane/storage.ts": () => import("./control_plane/storage"), | |
| 24 | − "./control_plane/credentials.ts": () => import("./control_plane/credentials"), | |
| 25 | − "./control_plane/sources.ts": () => import("./control_plane/sources"), | |
| 26 | − "./control_plane/tool_registry.ts": () => import("./control_plane/tool_registry"), | |
| 27 | − "./control_plane/tools.ts": () => import("./control_plane/tools"), | |
| 28 | − "./control_plane/openapi_ingest.ts": () => import("./control_plane/openapi_ingest"), | |
| 29 | − "./workflow.ts": () => import("./workflow"), | |
| 30 | − "./controlPlane.ts": () => import("./controlPlane"), | |
| 31 | − "./_generated/api.js": () => import("./_generated/api.js"), | |
| 32 | − }); | |
| 33 | − | |
| 34 | −const ensureWorkspace = async ( | |
| 35 | − t: ReturnType<typeof setup>, | |
| 36 | − workspaceId: string, | |
| 37 | − organizationId: string, | |
| 38 | −): Promise<void> => { | |
| 39 | − await t.mutation(api.controlPlane.upsertWorkspace, { | |
| 40 | − payload: { | |
| 41 | − id: workspaceId, | |
| 42 | − organizationId, | |
| 43 | − name: workspaceId, | |
| 44 | − }, | |
| 45 | − }); | |
| 46 | −}; | |
| 47 | − | |
| 48 | −describe("Convex executor and control-plane", () => { | |
| 49 | − it.effect("executes code via executeRunImpl", () => | |
| 50 | − Effect.gen(function* () { | |
| 51 | − const originalEnv = { | |
| 52 | − runUrl: process.env.CLOUDFLARE_SANDBOX_RUN_URL, | |
| 53 | − authToken: process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN, | |
| 54 | − callbackUrl: process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL, | |
| 55 | − callbackSecret: process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET, | |
| 56 | − }; | |
| 57 | − const originalFetch = globalThis.fetch; | |
| 58 | − | |
| 59 | − try { | |
| 60 | − process.env.CLOUDFLARE_SANDBOX_RUN_URL = "https://sandbox.local/run"; | |
| 61 | − process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = "sandbox-token"; | |
| 62 | − process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = "https://convex.local/callback"; | |
| 63 | − process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = "sandbox-callback-secret"; | |
| 64 | − | |
| 65 | − globalThis.fetch = (async ( | |
| 66 | − input: RequestInfo | URL, | |
| 67 | − init?: RequestInit, | |
| 68 | − ) => { | |
| 69 | − const url = typeof input === "string" ? input : input.toString(); | |
| 70 | − if (url !== "https://sandbox.local/run") { | |
| 71 | − throw new Error(`Unexpected URL: ${url}`); | |
| 72 | − } | |
| 73 | − | |
| 74 | − expect(init?.method).toBe("POST"); | |
| 75 | − const headers = new Headers(init?.headers as HeadersInit | undefined); | |
| 76 | − expect(headers.get("authorization")).toBe( | |
| 77 | − "Bearer sandbox-token", | |
| 78 | − ); | |
| 79 | − | |
| 80 | − return new Response( | |
| 81 | − JSON.stringify({ | |
| 82 | − status: "completed", | |
| 83 | − result: 42, | |
| 84 | − }), | |
| 85 | − { | |
| 86 | − status: 200, | |
| 87 | − headers: { | |
| 88 | − "content-type": "application/json", | |
| 89 | − }, | |
| 90 | − }, | |
| 91 | − ); | |
| 92 | − }) as unknown as typeof fetch; | |
| 93 | − | |
| 94 | − const result = yield* executeRunImpl({ | |
| 95 | − code: "return 6 * 7;", | |
| 96 | − }); | |
| 97 | − | |
| 98 | − expect(result.status).toBe("completed"); | |
| 99 | − expect(result.result).toBe(42); | |
| 100 | − } finally { | |
| 101 | − globalThis.fetch = originalFetch; | |
| 102 | − | |
| 103 | − if (originalEnv.runUrl === undefined) { | |
| 104 | − delete process.env.CLOUDFLARE_SANDBOX_RUN_URL; | |
| 105 | − } else { | |
| 106 | − process.env.CLOUDFLARE_SANDBOX_RUN_URL = originalEnv.runUrl; | |
| 107 | − } | |
| 108 | − | |
| 109 | − if (originalEnv.authToken === undefined) { | |
| 110 | − delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN; | |
| 111 | − } else { | |
| 112 | − process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = originalEnv.authToken; | |
| 113 | − } | |
| 114 | − | |
| 115 | − if (originalEnv.callbackUrl === undefined) { | |
| 116 | − delete process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL; | |
| 117 | − } else { | |
| 118 | − process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = originalEnv.callbackUrl; | |
| 119 | − } | |
| 120 | − | |
| 121 | − if (originalEnv.callbackSecret === undefined) { | |
| 122 | − delete process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET; | |
| 123 | − } else { | |
| 124 | − process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = originalEnv.callbackSecret; | |
| 125 | − } | |
| 126 | − } | |
| 127 | − }), | |
| 128 | − ); | |
| 129 | − | |
| 130 | − it.effect("upserts, lists, and removes sources", () => | |
| 131 | − Effect.gen(function* () { | |
| 132 | − const t = setup(); | |
| 133 | − | |
| 134 | − const added = (yield* Effect.tryPromise(() => | |
| 135 | − t.action(api.controlPlane.upsertSource as any, { | |
| 136 | − workspaceId: "ws_1", | |
| 137 | − payload: { | |
| 138 | − id: "src_1", | |
| 139 | − name: "Weather", | |
| 140 | − kind: "openapi", | |
| 141 | − endpoint: "https://example.com/openapi.json", | |
| 142 | − enabled: true, | |
| 143 | − configJson: "{\"baseUrl\":\"https://example.com\"}", | |
| 144 | − status: "draft", | |
| 145 | − sourceHash: null, | |
| 146 | − lastError: null, | |
| 147 | − }, | |
| 148 | − }), | |
| 149 | − )) as { | |
| 150 | − id: string; | |
| 151 | − workspaceId: string; | |
| 152 | − name: string; | |
| 153 | − }; | |
| 154 | − | |
| 155 | − yield* Effect.tryPromise(() => t.finishAllScheduledFunctions(() => {})); | |
| 156 | − | |
| 157 | − expect(added.id).toBe("src_1"); | |
| 158 | − expect(added.workspaceId).toBe("ws_1"); | |
| 159 | − expect(added.name).toBe("Weather"); | |
| 160 | − | |
| 161 | − const listed = (yield* Effect.tryPromise(() => | |
| 162 | − t.query(api.controlPlane.listSources, { | |
| 163 | − workspaceId: "ws_1", | |
| 164 | − }), | |
| 165 | − )) as Array<{ | |
| 166 | − id: string; | |
| 167 | − }>; | |
| 168 | − | |
| 169 | − expect(listed).toHaveLength(1); | |
| 170 | − expect(listed[0]?.id).toBe("src_1"); | |
| 171 | − | |
| 172 | − const removed = (yield* Effect.tryPromise(() => | |
| 173 | − t.mutation(api.controlPlane.removeSource, { | |
| 174 | − workspaceId: "ws_1", | |
| 175 | − sourceId: "src_1", | |
| 176 | − }), | |
| 177 | − )) as { | |
| 178 | − removed: boolean; | |
| 179 | − }; | |
| 180 | − | |
| 181 | − expect(removed.removed).toBe(true); | |
| 182 | − | |
| 183 | − const listedAfterRemove = (yield* Effect.tryPromise(() => | |
| 184 | − t.query(api.controlPlane.listSources, { | |
| 185 | − workspaceId: "ws_1", | |
| 186 | − }), | |
| 187 | − )) as Array<unknown>; | |
| 188 | − | |
| 189 | − expect(listedAfterRemove).toHaveLength(0); | |
| 190 | − }), | |
| 191 | − ); | |
| 192 | − | |
| 193 | − it.effect("upserts, lists, and removes credentials, policies, and storage", () => | |
| 194 | − Effect.gen(function* () { | |
| 195 | − const t = setup(); | |
| 196 | − | |
| 197 | − yield* Effect.tryPromise(() => ensureWorkspace(t, "ws_1", "org_1")); | |
| 198 | − | |
| 199 | − const addedCredential = (yield* Effect.tryPromise(() => | |
| 200 | − t.action(api.controlPlane.upsertCredentialBinding, { | |
| 201 | − workspaceId: "ws_1", | |
| 202 | − payload: { | |
| 203 | − id: "auth_binding_1", | |
| 204 | − credentialId: "conn_1", | |
| 205 | − scopeType: "workspace", | |
| 206 | − sourceKey: "source:github", | |
| 207 | − provider: "bearer", | |
| 208 | − secretRef: "secret://github/token", | |
| 209 | − accountId: null, | |
| 210 | − additionalHeadersJson: null, | |
| 211 | − boundAuthFingerprint: null, | |
| 212 | − }, | |
| 213 | − }), | |
| 214 | − )) as { | |
| 215 | − id: string; | |
| 216 | − workspaceId: string | null; | |
| 217 | − }; | |
| 218 | − | |
| 219 | − expect(addedCredential.id).toBe("auth_binding_1"); | |
| 220 | − expect(addedCredential.workspaceId).toBe("ws_1"); | |
| 221 | − | |
| 222 | − const listedCredentials = (yield* Effect.tryPromise(() => | |
| 223 | − t.query(api.controlPlane.listCredentialBindings, { | |
| 224 | − workspaceId: "ws_1", | |
| 225 | − }), | |
| 226 | − )) as Array<{ | |
| 227 | − id: string; | |
| 228 | − }>; | |
| 229 | − | |
| 230 | − expect(listedCredentials).toHaveLength(1); | |
| 231 | − expect(listedCredentials[0]?.id).toBe("auth_binding_1"); | |
| 232 | − | |
| 233 | − const removedCredential = (yield* Effect.tryPromise(() => | |
| 234 | − t.mutation(api.controlPlane.removeCredentialBinding, { | |
| 235 | − workspaceId: "ws_1", | |
| 236 | − credentialBindingId: "auth_binding_1", | |
| 237 | − }), | |
| 238 | − )) as { | |
| 239 | − removed: boolean; | |
| 240 | − }; | |
| 241 | − | |
| 242 | − expect(removedCredential.removed).toBe(true); | |
| 243 | − | |
| 244 | − const listedCredentialsAfterRemove = (yield* Effect.tryPromise(() => | |
| 245 | − t.query(api.controlPlane.listCredentialBindings, { | |
| 246 | − workspaceId: "ws_1", | |
| 247 | − }), | |
| 248 | − )) as Array<unknown>; | |
| 249 | − | |
| 250 | − expect(listedCredentialsAfterRemove).toHaveLength(0); | |
| 251 | − | |
| 252 | − const addedPolicy = (yield* Effect.tryPromise(() => | |
| 253 | − t.mutation(api.controlPlane.upsertPolicy, { | |
| 254 | − workspaceId: "ws_1", | |
| 255 | − payload: { | |
| 256 | − id: "pol_1", | |
| 257 | − toolPathPattern: "github.repos.*", | |
| 258 | − decision: "require_approval", | |
| 259 | − }, | |
| 260 | − }), | |
| 261 | − )) as { | |
| 262 | − id: string; | |
| 263 | − workspaceId: string; | |
| 264 | − }; | |
| 265 | − | |
| 266 | − expect(addedPolicy.id).toBe("pol_1"); | |
| 267 | − expect(addedPolicy.workspaceId).toBe("ws_1"); | |
| 268 | − | |
| 269 | − const listedPolicies = (yield* Effect.tryPromise(() => | |
| 270 | − t.query(api.controlPlane.listPolicies, { | |
| 271 | − workspaceId: "ws_1", | |
| 272 | − }), | |
| 273 | − )) as Array<{ | |
| 274 | − id: string; | |
| 275 | − }>; | |
| 276 | − | |
| 277 | − expect(listedPolicies).toHaveLength(1); | |
| 278 | − expect(listedPolicies[0]?.id).toBe("pol_1"); | |
| 279 | − | |
| 280 | − const removedPolicy = (yield* Effect.tryPromise(() => | |
| 281 | − t.mutation(api.controlPlane.removePolicy, { | |
| 282 | − workspaceId: "ws_1", | |
| 283 | − policyId: "pol_1", | |
| 284 | − }), | |
| 285 | − )) as { | |
| 286 | − removed: boolean; | |
| 287 | − }; | |
| 288 | − | |
| 289 | − expect(removedPolicy.removed).toBe(true); | |
| 290 | − | |
| 291 | − const listedPoliciesAfterRemove = (yield* Effect.tryPromise(() => | |
| 292 | − t.query(api.controlPlane.listPolicies, { | |
| 293 | − workspaceId: "ws_1", | |
| 294 | − }), | |
| 295 | − )) as Array<unknown>; | |
| 296 | − | |
| 297 | − expect(listedPoliciesAfterRemove).toHaveLength(0); | |
| 298 | − | |
| 299 | − const openedStorage = (yield* Effect.tryPromise(() => | |
| 300 | − t.mutation(api.controlPlane.openStorageInstance, { | |
| 301 | − workspaceId: "ws_1", | |
| 302 | − payload: { | |
| 303 | − scopeType: "workspace", | |
| 304 | − durability: "ephemeral", | |
| 305 | − provider: "agentfs-local", | |
| 306 | − purpose: "test storage", | |
| 307 | − ttlHours: 1, | |
| 308 | − sessionId: "session_1", | |
| 309 | − }, | |
| 310 | − }), | |
| 311 | − )) as { | |
| 312 | − id: string; | |
| 313 | − status: string; | |
| 314 | − }; | |
| 315 | − | |
| 316 | − expect(openedStorage.status).toBe("active"); | |
| 317 | − | |
| 318 | − const listedStorage = (yield* Effect.tryPromise(() => | |
| 319 | − t.query(api.controlPlane.listStorageInstances, { | |
| 320 | − workspaceId: "ws_1", | |
| 321 | − }), | |
| 322 | − )) as Array<{ | |
| 323 | − id: string; | |
| 324 | − }>; | |
| 325 | − | |
| 326 | − expect(listedStorage).toHaveLength(1); | |
| 327 | − expect(listedStorage[0]?.id).toBe(openedStorage.id); | |
| 328 | − | |
| 329 | − yield* Effect.tryPromise(() => | |
| 330 | − t.mutation(runtimeInternal.control_plane.storage.upsertStorageFileEntry, { | |
| 331 | − workspaceId: "ws_1", | |
| 332 | − storageInstanceId: openedStorage.id, | |
| 333 | − payload: { | |
| 334 | − path: "/notes/today.txt", | |
| 335 | − content: "hello convex storage", | |
| 336 | − }, | |
| 337 | − }), | |
| 338 | − ); | |
| 339 | − | |
| 340 | − const listedDirectory = (yield* Effect.tryPromise(() => | |
| 341 | − t.query(api.controlPlane.listStorageDirectory, { | |
| 342 | − workspaceId: "ws_1", | |
| 343 | − storageInstanceId: openedStorage.id, | |
| 344 | − payload: { | |
| 345 | − path: "/", | |
| 346 | − }, | |
| 347 | − }), | |
| 348 | − )) as { | |
| 349 | − path: string; | |
| 350 | − entries: ReadonlyArray<{ | |
| 351 | − name: string; | |
| 352 | − path: string; | |
| 353 | − kind: "file" | "directory"; | |
| 354 | − }>; | |
| 355 | − }; | |
| 356 | − | |
| 357 | − expect(listedDirectory.path).toBe("/"); | |
| 358 | − expect(listedDirectory.entries).toHaveLength(1); | |
| 359 | − expect(listedDirectory.entries[0]?.name).toBe("notes"); | |
| 360 | − expect(listedDirectory.entries[0]?.kind).toBe("directory"); | |
| 361 | − | |
| 362 | − const listedNestedDirectory = (yield* Effect.tryPromise(() => | |
| 363 | − t.query(api.controlPlane.listStorageDirectory, { | |
| 364 | − workspaceId: "ws_1", | |
| 365 | − storageInstanceId: openedStorage.id, | |
| 366 | − payload: { | |
| 367 | − path: "/notes", | |
| 368 | − }, | |
| 369 | − }), | |
| 370 | − )) as { | |
| 371 | − path: string; | |
| 372 | − entries: ReadonlyArray<{ | |
| 373 | − name: string; | |
| 374 | − path: string; | |
| 375 | − kind: "file" | "directory"; | |
| 376 | − }>; | |
| 377 | − }; | |
| 378 | − | |
| 379 | − expect(listedNestedDirectory.path).toBe("/notes"); | |
| 380 | − expect(listedNestedDirectory.entries).toHaveLength(1); | |
| 381 | − expect(listedNestedDirectory.entries[0]?.name).toBe("today.txt"); | |
| 382 | − expect(listedNestedDirectory.entries[0]?.kind).toBe("file"); | |
| 383 | − | |
| 384 | − const readStorageFile = (yield* Effect.tryPromise(() => | |
| 385 | − t.query(api.controlPlane.readStorageFile, { | |
| 386 | − workspaceId: "ws_1", | |
| 387 | − storageInstanceId: openedStorage.id, | |
| 388 | − payload: { | |
| 389 | − path: "/notes/today.txt", | |
| 390 | − encoding: "utf8", | |
| 391 | − }, | |
| 392 | − }), | |
| 393 | − )) as { | |
| 394 | − content: string; | |
| 395 | − }; | |
| 396 | − | |
| 397 | − expect(readStorageFile.content).toBe("hello convex storage"); | |
| 398 | − | |
| 399 | − yield* Effect.tryPromise(() => | |
| 400 | − t.mutation(runtimeInternal.control_plane.storage.upsertStorageKvEntry, { | |
| 401 | − workspaceId: "ws_1", | |
| 402 | − storageInstanceId: openedStorage.id, | |
| 403 | − payload: { | |
| 404 | − key: "feature.enabled", | |
| 405 | − valueJson: "true", | |
| 406 | − }, | |
| 407 | − }), | |
| 408 | − ); | |
| 409 | − | |
| 410 | − const listedKv = (yield* Effect.tryPromise(() => | |
| 411 | − t.query(api.controlPlane.listStorageKv, { | |
| 412 | − workspaceId: "ws_1", | |
| 413 | − storageInstanceId: openedStorage.id, | |
| 414 | − payload: { | |
| 415 | − prefix: "feature.", | |
| 416 | − }, | |
| 417 | − }), | |
| 418 | − )) as { | |
| 419 | − items: Array<{ | |
| 420 | − key: string; | |
| 421 | − value: unknown; | |
| 422 | − }>; | |
| 423 | − }; | |
| 424 | − | |
| 425 | − expect(listedKv.items).toHaveLength(1); | |
| 426 | − expect(listedKv.items[0]?.key).toBe("feature.enabled"); | |
| 427 | − expect(listedKv.items[0]?.value).toBe(true); | |
| 428 | − | |
| 429 | − yield* Effect.tryPromise(() => | |
| 430 | − t.mutation(api.controlPlane.queryStorageSql, { | |
| 431 | − workspaceId: "ws_1", | |
| 432 | − storageInstanceId: openedStorage.id, | |
| 433 | − payload: { | |
| 434 | − sql: "CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);", | |
| 435 | − }, | |
| 436 | − }), | |
| 437 | − ); | |
| 438 | − | |
| 439 | − yield* Effect.tryPromise(() => | |
| 440 | − t.mutation(api.controlPlane.queryStorageSql, { | |
| 441 | − workspaceId: "ws_1", | |
| 442 | − storageInstanceId: openedStorage.id, | |
| 443 | − payload: { | |
| 444 | − sql: "INSERT INTO kv_store (key, value) VALUES ('hello', 'world');", | |
| 445 | − }, | |
| 446 | − }), | |
| 447 | − ); | |
| 448 | − | |
| 449 | − const queriedSql = (yield* Effect.tryPromise(() => | |
| 450 | − t.mutation(api.controlPlane.queryStorageSql, { | |
| 451 | − workspaceId: "ws_1", | |
| 452 | − storageInstanceId: openedStorage.id, | |
| 453 | − payload: { | |
| 454 | − sql: "SELECT key, value FROM kv_store LIMIT 10;", | |
| 455 | − }, | |
| 456 | − }), | |
| 457 | − )) as { | |
| 458 | − rowCount: number; | |
| 459 | − columns: Array<string>; | |
| 460 | − rows: Array<Record<string, unknown>>; | |
| 461 | − }; | |
| 462 | − | |
| 463 | − expect(queriedSql.rowCount).toBe(1); | |
| 464 | − expect(queriedSql.columns).toEqual(["key", "value"]); | |
| 465 | − expect(queriedSql.rows[0]).toEqual({ key: "hello", value: "world" }); | |
| 466 | − | |
| 467 | − const closedStorage = (yield* Effect.tryPromise(() => | |
| 468 | − t.mutation(api.controlPlane.closeStorageInstance, { | |
| 469 | − workspaceId: "ws_1", | |
| 470 | − storageInstanceId: openedStorage.id, | |
| 471 | − }), | |
| 472 | − )) as { | |
| 473 | − id: string; | |
| 474 | − status: string; | |
| 475 | − }; | |
| 476 | − | |
| 477 | − expect(closedStorage.id).toBe(openedStorage.id); | |
| 478 | − expect(closedStorage.status).toBe("closed"); | |
| 479 | − | |
| 480 | − const removedStorage = (yield* Effect.tryPromise(() => | |
| 481 | − t.mutation(api.controlPlane.removeStorageInstance, { | |
| 482 | − workspaceId: "ws_1", | |
| 483 | − storageInstanceId: openedStorage.id, | |
| 484 | − }), | |
| 485 | − )) as { | |
| 486 | − removed: boolean; | |
| 487 | − }; | |
| 488 | − | |
| 489 | − expect(removedStorage.removed).toBe(true); | |
| 490 | − | |
| 491 | − const listedStorageAfterRemove = (yield* Effect.tryPromise(() => | |
| 492 | − t.query(api.controlPlane.listStorageInstances, { | |
| 493 | − workspaceId: "ws_1", | |
| 494 | − }), | |
| 495 | − )) as Array<unknown>; | |
| 496 | − | |
| 497 | − expect(listedStorageAfterRemove).toHaveLength(0); | |
| 498 | − }), | |
| 499 | − ); | |
| 500 | − | |
| 501 | − it.effect("upserts and lists organizations/workspaces and tool views", () => | |
| 502 | − Effect.gen(function* () { | |
| 503 | − const t = setup(); | |
| 504 | − | |
| 505 | − const organization = (yield* Effect.tryPromise(() => | |
| 506 | − t.mutation(api.controlPlane.upsertOrganization, { | |
| 507 | − payload: { | |
| 508 | − id: "org_1", | |
| 509 | − slug: "acme", | |
| 510 | − name: "Acme Inc", | |
| 511 | − status: "active", | |
| 512 | − }, | |
| 513 | − }), | |
| 514 | − )) as { | |
| 515 | − id: string; | |
| 516 | − name: string; | |
| 517 | − }; | |
| 518 | − | |
| 519 | − expect(organization.id).toBe("org_1"); | |
| 520 | − expect(organization.name).toBe("Acme Inc"); | |
| 521 | − | |
| 522 | − const organizations = (yield* Effect.tryPromise(() => | |
| 523 | − t.query(api.controlPlane.listOrganizations, {}), | |
| 524 | − )) as Array<{ | |
| 525 | − id: string; | |
| 526 | − }>; | |
| 527 | − | |
| 528 | − expect(organizations).toHaveLength(1); | |
| 529 | − expect(organizations[0]?.id).toBe("org_1"); | |
| 530 | − | |
| 531 | − const workspace = (yield* Effect.tryPromise(() => | |
| 532 | − t.mutation(api.controlPlane.upsertWorkspace, { | |
| 533 | − payload: { | |
| 534 | − id: "ws_1", | |
| 535 | − organizationId: "org_1", | |
| 536 | − name: "Primary Workspace", | |
| 537 | − }, | |
| 538 | − }), | |
| 539 | − )) as { | |
| 540 | − id: string; | |
| 541 | − organizationId: string; | |
| 542 | − }; | |
| 543 | − | |
| 544 | − expect(workspace.id).toBe("ws_1"); | |
| 545 | − expect(workspace.organizationId).toBe("org_1"); | |
| 546 | − | |
| 547 | − const workspaces = (yield* Effect.tryPromise(() => | |
| 548 | − t.query(api.controlPlane.listWorkspaces, {}), | |
| 549 | − )) as Array<{ | |
| 550 | − id: string; | |
| 551 | − }>; | |
| 552 | − | |
| 553 | − expect(workspaces).toHaveLength(1); | |
| 554 | − expect(workspaces[0]?.id).toBe("ws_1"); | |
| 555 | − | |
| 556 | − const nowForOpenApiSource = Date.now(); | |
| 557 | − yield* Effect.tryPromise(() => | |
| 558 | − t.run(async (ctx) => { | |
| 559 | − await ctx.db.insert("sources", { | |
| 560 | − id: "src_1", | |
| 561 | − workspaceId: "ws_1", | |
| 562 | − name: "Weather API", | |
| 563 | − kind: "openapi", | |
| 564 | − endpoint: "https://example.com/openapi.json", | |
| 565 | − enabled: true, | |
| 566 | − configJson: "{\"baseUrl\":\"https://example.com\"}", | |
| 567 | − status: "draft", | |
| 568 | − sourceHash: null, | |
| 569 | − lastError: null, | |
| 570 | − createdAt: nowForOpenApiSource, | |
| 571 | − updatedAt: nowForOpenApiSource, | |
| 572 | − }); | |
| 573 | − }), | |
| 574 | − ); | |
| 575 | − | |
| 576 | − const manifest = yield* extractOpenApiManifest("Weather API", { | |
| 577 | − openapi: "3.0.0", | |
| 578 | − info: { | |
| 579 | − title: "Weather API", | |
| 580 | − version: "1.0.0", | |
| 581 | − }, | |
| 582 | − paths: { | |
| 583 | − "/weather": { | |
| 584 | − get: { | |
| 585 | − operationId: "getWeather", | |
| 586 | − responses: { | |
| 587 | − "200": { | |
| 588 | − description: "ok", | |
| 589 | − }, | |
| 590 | − }, | |
| 591 | − }, | |
| 592 | − }, | |
| 593 | − }, | |
| 594 | − }); | |
| 595 | − | |
| 596 | − const now = Date.now(); | |
| 597 | − const openApiArtifactMeta = (yield* Effect.tryPromise(() => | |
| 598 | − t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, { | |
| 599 | − protocol: "openapi", | |
| 600 | − contentHash: manifest.sourceHash, | |
| 601 | − extractorVersion: "openapi_v2", | |
| 602 | − toolCount: manifest.tools.length, | |
| 603 | − refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null, | |
| 604 | − }), | |
| 605 | − )) as { | |
| 606 | − artifactId: string; | |
| 607 | − created: boolean; | |
| 608 | − }; | |
| 609 | − | |
| 610 | − yield* Effect.tryPromise(() => | |
| 611 | − t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | |
| 612 | − artifactId: openApiArtifactMeta.artifactId, | |
| 613 | − protocol: "openapi", | |
| 614 | − insertOnly: true, | |
| 615 | − tools: manifest.tools.map((tool) => ({ | |
| 616 | − toolId: tool.toolId, | |
| 617 | − name: tool.name, | |
| 618 | − description: tool.description, | |
| 619 | − canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`, | |
| 620 | − operationHash: tool.operationHash, | |
| 621 | − invocationJson: JSON.stringify(tool.invocation), | |
| 622 | − inputSchemaJson: tool.typing?.inputSchemaJson ?? null, | |
| 623 | − outputSchemaJson: tool.typing?.outputSchemaJson ?? null, | |
| 624 | − metadataJson: JSON.stringify({ | |
| 625 | − method: tool.method, | |
| 626 | − path: tool.path, | |
| 627 | − }), | |
| 628 | − })), | |
| 629 | − }), | |
| 630 | − ); | |
| 631 | − | |
| 632 | − yield* Effect.tryPromise(() => | |
| 633 | − t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | |
| 634 | − workspaceId: "ws_1", | |
| 635 | − sourceId: "src_1", | |
| 636 | − artifactId: openApiArtifactMeta.artifactId, | |
| 637 | − }), | |
| 638 | − ); | |
| 639 | − | |
| 640 | − yield* Effect.tryPromise(() => | |
| 641 | − t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, { | |
| 642 | − workspaceId: "ws_1", | |
| 643 | − sourceId: "src_1", | |
| 644 | − sourceName: "Weather API", | |
| 645 | − sourceKind: "openapi", | |
| 646 | − artifactId: openApiArtifactMeta.artifactId, | |
| 647 | − namespace: "weather_api_src_1", | |
| 648 | − refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null, | |
| 649 | − rows: manifest.tools.map((tool) => ({ | |
| 650 | − toolId: tool.toolId, | |
| 651 | − protocol: "openapi", | |
| 652 | − method: tool.method, | |
| 653 | − path: `weather_api_src_1.${tool.toolId}`, | |
| 654 | − name: tool.name, | |
| 655 | − description: tool.description, | |
| 656 | − searchText: `weather api ${tool.name.toLowerCase()} ${tool.path.toLowerCase()} ${tool.method.toLowerCase()}`, | |
| 657 | − operationHash: tool.operationHash, | |
| 658 | − approvalMode: "auto", | |
| 659 | − status: "active", | |
| 660 | − })), | |
| 661 | − }), | |
| 662 | − ); | |
| 663 | − | |
| 664 | − const workspaceTools = (yield* Effect.tryPromise(() => | |
| 665 | − t.action(api.controlPlane.listWorkspaceTools, { | |
| 666 | − workspaceId: "ws_1", | |
| 667 | − }), | |
| 668 | − )) as Array<{ | |
| 669 | − sourceId: string; | |
| 670 | − }>; | |
| 671 | − | |
| 672 | − expect(workspaceTools).toHaveLength(1); | |
| 673 | − expect(workspaceTools[0]?.sourceId).toBe("src_1"); | |
| 674 | − | |
| 675 | − const sourceTools = (yield* Effect.tryPromise(() => | |
| 676 | − t.action(api.controlPlane.listSourceTools, { | |
| 677 | − workspaceId: "ws_1", | |
| 678 | − sourceId: "src_1", | |
| 679 | − }), | |
| 680 | − )) as Array<{ | |
| 681 | − sourceId: string; | |
| 682 | − }>; | |
| 683 | − | |
| 684 | − expect(sourceTools).toHaveLength(1); | |
| 685 | − expect(sourceTools[0]?.sourceId).toBe("src_1"); | |
| 686 | − | |
| 687 | − const nowForGraphqlSource = Date.now(); | |
| 688 | − yield* Effect.tryPromise(() => | |
| 689 | − t.run(async (ctx) => { | |
| 690 | − await ctx.db.insert("sources", { | |
| 691 | − id: "src_graphql_1", | |
| 692 | − workspaceId: "ws_1", | |
| 693 | − name: "Linear API", | |
| 694 | − kind: "graphql", | |
| 695 | − endpoint: "https://api.linear.app/graphql", | |
| 696 | − enabled: true, | |
| 697 | − configJson: "{}", | |
| 698 | − status: "draft", | |
| 699 | − sourceHash: null, | |
| 700 | − lastError: null, | |
| 701 | − createdAt: nowForGraphqlSource, | |
| 702 | − updatedAt: nowForGraphqlSource, | |
| 703 | − }); | |
| 704 | − }), | |
| 705 | − ); | |
| 706 | − | |
| 707 | − const graphqlArtifactMeta = (yield* Effect.tryPromise(() => | |
| 708 | − t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, { | |
| 709 | − protocol: "graphql", | |
| 710 | − contentHash: "graphql_schema_hash_1", | |
| 711 | − extractorVersion: "graphql_v1", | |
| 712 | − toolCount: 2, | |
| 713 | − refHintTableJson: null, | |
| 714 | − }), | |
| 715 | − )) as { | |
| 716 | − artifactId: string; | |
| 717 | − created: boolean; | |
| 718 | − }; | |
| 719 | − | |
| 720 | − expect(graphqlArtifactMeta.created).toBe(true); | |
| 721 | − | |
| 722 | − yield* Effect.tryPromise(() => | |
| 723 | − t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | |
| 724 | − artifactId: graphqlArtifactMeta.artifactId, | |
| 725 | − protocol: "graphql", | |
| 726 | − insertOnly: true, | |
| 727 | − tools: [ | |
| 728 | − { | |
| 729 | − toolId: "linear.graphql", | |
| 730 | − name: "Linear GraphQL", | |
| 731 | − description: "Run raw GraphQL queries against Linear", | |
| 732 | − canonicalPath: "raw.graphql", | |
| 733 | − operationHash: "raw_hash", | |
| 734 | − invocationJson: "{}", | |
| 735 | − metadataJson: JSON.stringify({ | |
| 736 | − operationType: "raw", | |
| 737 | − fieldName: "graphql", | |
| 738 | − }), | |
| 739 | − }, | |
| 740 | − { | |
| 741 | − toolId: "linear.query.viewer", | |
| 742 | − name: "viewer", | |
| 743 | − description: "Current viewer", | |
| 744 | − canonicalPath: "query.viewer", | |
| 745 | − operationHash: "viewer_hash", | |
| 746 | − invocationJson: "{}", | |
| 747 | − metadataJson: JSON.stringify({ | |
| 748 | − operationType: "query", | |
| 749 | − fieldName: "viewer", | |
| 750 | − }), | |
| 751 | − }, | |
| 752 | − ], | |
| 753 | − }), | |
| 754 | − ); | |
| 755 | − | |
| 756 | − yield* Effect.tryPromise(() => | |
| 757 | − t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | |
| 758 | − workspaceId: "ws_1", | |
| 759 | − sourceId: "src_graphql_1", | |
| 760 | − artifactId: graphqlArtifactMeta.artifactId, | |
| 761 | − }), | |
| 762 | − ); | |
| 763 | − | |
| 764 | − yield* Effect.tryPromise(() => | |
| 765 | − t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, { | |
| 766 | − workspaceId: "ws_1", | |
| 767 | − sourceId: "src_graphql_1", | |
| 768 | − sourceName: "Linear API", | |
| 769 | − sourceKind: "graphql", | |
| 770 | − artifactId: graphqlArtifactMeta.artifactId, | |
| 771 | − namespace: "linear_api_phql_1", | |
| 772 | − refHintTableJson: null, | |
| 773 | − rows: [ | |
| 774 | − { | |
| 775 | − toolId: "linear.graphql", | |
| 776 | − protocol: "graphql", | |
| 777 | − method: "post", | |
| 778 | − path: "linear_api_phql_1.linear.graphql", | |
| 779 | − name: "Linear GraphQL", | |
| 780 | − description: "Run raw GraphQL queries against Linear", | |
| 781 | − searchText: "linear api graphql linear.graphql", | |
| 782 | − operationHash: "raw_hash", | |
| 783 | − approvalMode: "auto", | |
| 784 | − status: "active", | |
| 785 | − }, | |
| 786 | − { | |
| 787 | − toolId: "linear.query.viewer", | |
| 788 | − protocol: "graphql", | |
| 789 | − method: "post", | |
| 790 | − path: "linear_api_phql_1.linear.query.viewer", | |
| 791 | − name: "viewer", | |
| 792 | − description: "Current viewer", | |
| 793 | − searchText: "linear api graphql viewer linear.query.viewer", | |
| 794 | − operationHash: "viewer_hash", | |
| 795 | − approvalMode: "auto", | |
| 796 | − status: "active", | |
| 797 | − }, | |
| 798 | − ], | |
| 799 | − }), | |
| 800 | − ); | |
| 801 | − | |
| 802 | − const graphqlSourceTools = (yield* Effect.tryPromise(() => | |
| 803 | − t.action(api.controlPlane.listSourceTools, { | |
| 804 | − workspaceId: "ws_1", | |
| 805 | − sourceId: "src_graphql_1", | |
| 806 | − }), | |
| 807 | − )) as Array<{ | |
| 808 | − sourceId: string; | |
| 809 | − toolId: string; | |
| 810 | − method: string; | |
| 811 | − }>; | |
| 812 | − | |
| 813 | − expect(graphqlSourceTools).toHaveLength(2); | |
| 814 | − expect(graphqlSourceTools[0]?.sourceId).toBe("src_graphql_1"); | |
| 815 | − expect(graphqlSourceTools[0]?.method).toBe("post"); | |
| 816 | − | |
| 817 | − const workspaceToolsWithGraphql = (yield* Effect.tryPromise(() => | |
| 818 | − t.action(api.controlPlane.listWorkspaceTools, { | |
| 819 | − workspaceId: "ws_1", | |
| 820 | − }), | |
| 821 | − )) as Array<{ | |
| 822 | − sourceId: string; | |
| 823 | − }>; | |
| 824 | − | |
| 825 | − expect(workspaceToolsWithGraphql.some((tool) => tool.sourceId === "src_graphql_1")).toBe( | |
| 826 | − true, | |
| 827 | − ); | |
| 828 | − | |
| 829 | − const nowForMcpSource = Date.now(); | |
| 830 | − | |
| 831 | − yield* Effect.tryPromise(() => | |
| 832 | − t.run(async (ctx) => { | |
| 833 | − await ctx.db.insert("sources", { | |
| 834 | − id: "src_mcp_1", | |
| 835 | − workspaceId: "ws_1", | |
| 836 | − name: "Deepwiki", | |
| 837 | − kind: "mcp", | |
| 838 | − endpoint: "https://example.com/mcp", | |
| 839 | − enabled: true, | |
| 840 | − configJson: "{}", | |
| 841 | − status: "draft", | |
| 842 | − sourceHash: null, | |
| 843 | − lastError: null, | |
| 844 | − createdAt: nowForMcpSource, | |
| 845 | − updatedAt: nowForMcpSource, | |
| 846 | − }); | |
| 847 | − }), | |
| 848 | − ); | |
| 849 | − | |
| 850 | − const mcpArtifactMeta = (yield* Effect.tryPromise(() => | |
| 851 | − t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, { | |
| 852 | − protocol: "mcp", | |
| 853 | − contentHash: "mcp_source_hash_1", | |
| 854 | − extractorVersion: "mcp_v1", | |
| 855 | − toolCount: 1, | |
| 856 | − refHintTableJson: null, | |
| 857 | − }), | |
| 858 | − )) as { | |
| 859 | − artifactId: string; | |
| 860 | − created: boolean; | |
| 861 | − }; | |
| 862 | − | |
| 863 | − expect(mcpArtifactMeta.created).toBe(true); | |
| 864 | − | |
| 865 | − yield* Effect.tryPromise(() => | |
| 866 | − t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | |
| 867 | − artifactId: mcpArtifactMeta.artifactId, | |
| 868 | − protocol: "mcp", | |
| 869 | − insertOnly: true, | |
| 870 | − tools: [ | |
| 871 | − { | |
| 872 | − toolId: "deepwiki.mcp.search_docs", | |
| 873 | − name: "search_docs", | |
| 874 | − description: "Search docs", | |
| 875 | − canonicalPath: "search_docs", | |
| 876 | − operationHash: "mcp_hash_1", | |
| 877 | − invocationJson: "{}", | |
| 878 | − metadataJson: JSON.stringify({ | |
| 879 | − toolName: "search_docs", | |
| 880 | − }), | |
| 881 | − }, | |
| 882 | − ], | |
| 883 | − }), | |
| 884 | − ); | |
| 885 | − | |
| 886 | − yield* Effect.tryPromise(() => | |
| 887 | − t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | |
| 888 | − workspaceId: "ws_1", | |
| 889 | − sourceId: "src_mcp_1", | |
| 890 | − artifactId: mcpArtifactMeta.artifactId, | |
| 891 | − }), | |
| 892 | − ); | |
| 893 | − | |
| 894 | − yield* Effect.tryPromise(() => | |
| 895 | − t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, { | |
| 896 | − workspaceId: "ws_1", | |
| 897 | − sourceId: "src_mcp_1", | |
| 898 | − sourceName: "Deepwiki", | |
| 899 | − sourceKind: "mcp", | |
| 900 | − artifactId: mcpArtifactMeta.artifactId, | |
| 901 | − namespace: "deepwiki_mcp_1", | |
| 902 | − refHintTableJson: null, | |
| 903 | − rows: [ | |
| 904 | − { | |
| 905 | − toolId: "deepwiki.mcp.search_docs", | |
| 906 | − protocol: "mcp", | |
| 907 | − method: "post", | |
| 908 | − path: "deepwiki_mcp_1.deepwiki.mcp.search_docs", | |
| 909 | − name: "search_docs", | |
| 910 | − description: "Search docs", | |
| 911 | − searchText: "deepwiki mcp search docs", | |
| 912 | − operationHash: "mcp_hash_1", | |
| 913 | − approvalMode: "auto", | |
| 914 | − status: "active", | |
| 915 | − }, | |
| 916 | − ], | |
| 917 | − }), | |
| 918 | − ); | |
| 919 | − | |
| 920 | − const mcpSourceTools = (yield* Effect.tryPromise(() => | |
| 921 | − t.action(api.controlPlane.listSourceTools, { | |
| 922 | − workspaceId: "ws_1", | |
| 923 | − sourceId: "src_mcp_1", | |
| 924 | − }), | |
| 925 | − )) as Array<{ | |
| 926 | − sourceId: string; | |
| 927 | − path: string; | |
| 928 | − }>; | |
| 929 | − | |
| 930 | − expect(mcpSourceTools).toHaveLength(1); | |
| 931 | − expect(mcpSourceTools[0]?.sourceId).toBe("src_mcp_1"); | |
| 932 | − expect(mcpSourceTools[0]?.path).toContain("deepwiki.mcp.search_docs"); | |
| 933 | − | |
| 934 | − const workspaceToolsWithMcp = (yield* Effect.tryPromise(() => | |
| 935 | − t.action(api.controlPlane.listWorkspaceTools, { | |
| 936 | − workspaceId: "ws_1", | |
| 937 | − }), | |
| 938 | − )) as Array<{ | |
| 939 | − sourceId: string; | |
| 940 | − }>; | |
| 941 | − | |
| 942 | − expect(workspaceToolsWithMcp.some((tool) => tool.sourceId === "src_mcp_1")).toBe(true); | |
| 943 | − | |
| 944 | − const sourceToolsFromWrongWorkspace = (yield* Effect.tryPromise(() => | |
| 945 | − t.action(api.controlPlane.listSourceTools, { | |
| 946 | − workspaceId: "ws_2", | |
| 947 | − sourceId: "src_1", | |
| 948 | − }), | |
| 949 | − )) as Array<unknown>; | |
| 950 | − | |
| 951 | − expect(sourceToolsFromWrongWorkspace).toHaveLength(0); | |
| 952 | − }), | |
| 953 | − ); | |
| 954 | − | |
| 955 | − it.effect("marks graphql sources auth_required when introspection is unauthorized", () => | |
| 956 | − Effect.gen(function* () { | |
| 957 | − const t = setup(); | |
| 958 | − const originalFetch = globalThis.fetch; | |
| 959 | − | |
| 960 | − yield* Effect.tryPromise(() => ensureWorkspace(t, "ws_auth", "org_auth")); | |
| 961 | − | |
| 962 | − globalThis.fetch = (async () => | |
| 963 | − new Response( | |
| 964 | − JSON.stringify({ | |
| 965 | − errors: [ | |
| 966 | − { | |
| 967 | − message: "Unauthorized", | |
| 968 | − }, | |
| 969 | − ], | |
| 970 | − }), | |
| 971 | − { | |
| 972 | − status: 401, | |
| 973 | − headers: { | |
| 974 | − "content-type": "application/json", | |
| 975 | − }, | |
| 976 | − }, | |
| 977 | − )) as unknown as typeof fetch; | |
| 978 | − | |
| 979 | − try { | |
| 980 | − const now = Date.now(); | |
| 981 | − | |
| 982 | − yield* Effect.tryPromise(() => | |
| 983 | − t.run(async (ctx) => { | |
| 984 | − await ctx.db.insert("sources", { | |
| 985 | − id: "src_graphql_auth_1", | |
| 986 | − workspaceId: "ws_auth", | |
| 987 | − name: "Linear API", | |
| 988 | − kind: "graphql", | |
| 989 | − endpoint: "https://api.linear.app/graphql", | |
| 990 | − enabled: true, | |
| 991 | − configJson: "{}", | |
| 992 | − status: "draft", | |
| 993 | − sourceHash: null, | |
| 994 | − lastError: null, | |
| 995 | − createdAt: now, | |
| 996 | − updatedAt: now, | |
| 997 | − }); | |
| 998 | − }), | |
| 999 | − ); | |
| 1000 | − | |
| 1001 | − yield* Effect.tryPromise(() => | |
| 1002 | − t.action(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, { | |
| 1003 | − workspaceId: "ws_auth", | |
| 1004 | − sourceId: "src_graphql_auth_1", | |
| 1005 | − }), | |
| 1006 | − ); | |
| 1007 | − | |
| 1008 | − const sources = (yield* Effect.tryPromise(() => | |
| 1009 | − t.query(api.controlPlane.listSources, { | |
| 1010 | − workspaceId: "ws_auth", | |
| 1011 | − }), | |
| 1012 | − )) as Array<{ | |
| 1013 | − status: string; | |
| 1014 | − lastError: string | null; | |
| 1015 | − }>; | |
| 1016 | − | |
| 1017 | − expect(sources).toHaveLength(1); | |
| 1018 | − expect(sources[0]?.status).toBe("auth_required"); | |
| 1019 | − expect(sources[0]?.lastError?.toLowerCase()).toContain("unauthor"); | |
| 1020 | − } finally { | |
| 1021 | − globalThis.fetch = originalFetch; | |
| 1022 | − } | |
| 1023 | − }), | |
| 1024 | − ); | |
| 1025 | − | |
| 1026 | − it.effect("uses credential bindings when ingesting graphql sources", () => | |
| 1027 | − Effect.gen(function* () { | |
| 1028 | − const t = setup(); | |
| 1029 | − const originalFetch = globalThis.fetch; | |
| 1030 | − | |
| 1031 | − yield* Effect.tryPromise(() => ensureWorkspace(t, "ws_cred", "org_cred")); | |
| 1032 | − | |
| 1033 | − globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { | |
| 1034 | − const authHeader = new Headers(init?.headers).get("authorization"); | |
| 1035 | − if (authHeader !== "Bearer linear_token") { | |
| 1036 | − return new Response( | |
| 1037 | − JSON.stringify({ | |
| 1038 | − errors: [ | |
| 1039 | − { | |
| 1040 | − message: "Unauthorized", | |
| 1041 | − }, | |
| 1042 | − ], | |
| 1043 | − }), | |
| 1044 | − { | |
| 1045 | − status: 401, | |
| 1046 | − headers: { | |
| 1047 | − "content-type": "application/json", | |
| 1048 | − }, | |
| 1049 | − }, | |
| 1050 | − ); | |
| 1051 | − } | |
| 1052 | − | |
| 1053 | − const bodyText = typeof init?.body === "string" ? init.body : "{}"; | |
| 1054 | − const payload = JSON.parse(bodyText) as { | |
| 1055 | − query?: string; | |
| 1056 | − variables?: { | |
| 1057 | − name?: string; | |
| 1058 | − }; | |
| 1059 | − }; | |
| 1060 | − | |
| 1061 | − if (payload.query?.includes("SchemaRoots")) { | |
| 1062 | − return new Response( | |
| 1063 | − JSON.stringify({ | |
| 1064 | − data: { | |
| 1065 | − __schema: { | |
| 1066 | − queryType: { name: "Query" }, | |
| 1067 | − mutationType: null, | |
| 1068 | − }, | |
| 1069 | − }, | |
| 1070 | − }), | |
| 1071 | − { | |
| 1072 | − status: 200, | |
| 1073 | − headers: { | |
| 1074 | − "content-type": "application/json", | |
| 1075 | − }, | |
| 1076 | − }, | |
| 1077 | − ); | |
| 1078 | − } | |
| 1079 | − | |
| 1080 | − if (payload.query?.includes("RootFields") && payload.variables?.name === "Query") { | |
| 1081 | − return new Response( | |
| 1082 | − JSON.stringify({ | |
| 1083 | − data: { | |
| 1084 | − __type: { | |
| 1085 | − name: "Query", | |
| 1086 | − fields: [ | |
| 1087 | − { | |
| 1088 | − name: "viewer", | |
| 1089 | − description: "Current viewer", | |
| 1090 | − args: [], | |
| 1091 | − }, | |
| 1092 | − ], | |
| 1093 | − }, | |
| 1094 | − }, | |
| 1095 | − }), | |
| 1096 | − { | |
| 1097 | − status: 200, | |
| 1098 | − headers: { | |
| 1099 | − "content-type": "application/json", | |
| 1100 | − }, | |
| 1101 | − }, | |
| 1102 | − ); | |
| 1103 | − } | |
| 1104 | − | |
| 1105 | − return new Response( | |
| 1106 | − JSON.stringify({ | |
| 1107 | − data: { | |
| 1108 | − __type: { | |
| 1109 | − name: payload.variables?.name ?? "Unknown", | |
| 1110 | − fields: [], | |
| 1111 | − }, | |
| 1112 | − }, | |
| 1113 | − }), | |
| 1114 | − { | |
| 1115 | − status: 200, | |
| 1116 | − headers: { | |
| 1117 | − "content-type": "application/json", | |
| 1118 | − }, | |
| 1119 | − }, | |
| 1120 | − ); | |
| 1121 | − }) as unknown as typeof fetch; | |
| 1122 | − | |
| 1123 | − try { | |
| 1124 | − const now = Date.now(); | |
| 1125 | − | |
| 1126 | − yield* Effect.tryPromise(() => | |
| 1127 | − t.action(api.controlPlane.upsertCredentialBinding, { | |
| 1128 | − workspaceId: "ws_cred", | |
| 1129 | − payload: { | |
| 1130 | − id: "auth_binding_linear", | |
| 1131 | − credentialId: "conn_linear", | |
| 1132 | − scopeType: "workspace", | |
| 1133 | − sourceKey: "source:src_graphql_cred_1", | |
| 1134 | − provider: "bearer", | |
| 1135 | − secretRef: "linear_token", | |
| 1136 | − accountId: null, | |
| 1137 | − additionalHeadersJson: null, | |
| 1138 | − boundAuthFingerprint: null, | |
| 1139 | − }, | |
| 1140 | − }), | |
| 1141 | − ); | |
| 1142 | − | |
| 1143 | − yield* Effect.tryPromise(() => | |
| 1144 | − t.run(async (ctx) => { | |
| 1145 | − await ctx.db.insert("sources", { | |
| 1146 | − id: "src_graphql_cred_1", | |
| 1147 | − workspaceId: "ws_cred", | |
| 1148 | − name: "Linear API", | |
| 1149 | − kind: "graphql", | |
| 1150 | − endpoint: "https://api.linear.app/graphql", | |
| 1151 | − enabled: true, | |
| 1152 | − configJson: "{}", | |
| 1153 | − status: "draft", | |
| 1154 | − sourceHash: null, | |
| 1155 | − lastError: null, | |
| 1156 | − createdAt: now, | |
| 1157 | − updatedAt: now, | |
| 1158 | − }); | |
| 1159 | − }), | |
| 1160 | − ); | |
| 1161 | − | |
| 1162 | − yield* Effect.tryPromise(() => | |
| 1163 | − t.action(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, { | |
| 1164 | − workspaceId: "ws_cred", | |
| 1165 | − sourceId: "src_graphql_cred_1", | |
| 1166 | − }), | |
| 1167 | − ); | |
| 1168 | − | |
| 1169 | − const sources = (yield* Effect.tryPromise(() => | |
| 1170 | − t.query(api.controlPlane.listSources, { | |
| 1171 | − workspaceId: "ws_cred", | |
| 1172 | − }), | |
| 1173 | − )) as Array<{ | |
| 1174 | − status: string; | |
| 1175 | − sourceHash: string | null; | |
| 1176 | − }>; | |
| 1177 | − | |
| 1178 | − expect(sources).toHaveLength(1); | |
| 1179 | − expect(sources[0]?.status).toBe("connected"); | |
| 1180 | − expect(sources[0]?.sourceHash).toBeTypeOf("string"); | |
| 1181 | − | |
| 1182 | − const sourceTools = (yield* Effect.tryPromise(() => | |
| 1183 | − t.action(api.controlPlane.listSourceTools, { | |
| 1184 | − workspaceId: "ws_cred", | |
| 1185 | − sourceId: "src_graphql_cred_1", | |
| 1186 | − }), | |
| 1187 | − )) as Array<{ | |
| 1188 | − toolId: string; | |
| 1189 | − }>; | |
| 1190 | − | |
| 1191 | − expect(sourceTools.length).toBeGreaterThan(0); | |
| 1192 | − } finally { | |
| 1193 | − globalThis.fetch = originalFetch; | |
| 1194 | − } | |
| 1195 | − }), | |
| 1196 | − ); | |
| 1197 | − | |
| 1198 | − it.effect("ingests mcp sources with credential headers", () => | |
| 1199 | − Effect.gen(function* () { | |
| 1200 | − const t = setup(); | |
| 1201 | − const originalFetch = globalThis.fetch; | |
| 1202 | − | |
| 1203 | − yield* Effect.tryPromise(() => | |
| 1204 | − ensureWorkspace(t, "ws_mcp_ingest", "org_mcp_ingest") | |
| 1205 | − ); | |
| 1206 | − | |
| 1207 | − globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { | |
| 1208 | − const authHeader = new Headers(init?.headers).get("authorization"); | |
| 1209 | − if (authHeader !== "Bearer mcp_token") { | |
| 1210 | − return new Response( | |
| 1211 | − JSON.stringify({ | |
| 1212 | − error: { | |
| 1213 | − code: 401, | |
| 1214 | − message: "Unauthorized", | |
| 1215 | − }, | |
| 1216 | − }), | |
| 1217 | − { | |
| 1218 | − status: 401, | |
| 1219 | − headers: { | |
| 1220 | − "content-type": "application/json", | |
| 1221 | − }, | |
| 1222 | − }, | |
| 1223 | − ); | |
| 1224 | − } | |
| 1225 | − | |
| 1226 | − const bodyText = typeof init?.body === "string" ? init.body : "{}"; | |
| 1227 | − const payload = JSON.parse(bodyText) as { | |
| 1228 | − id?: string | number; | |
| 1229 | − method?: string; | |
| 1230 | − }; | |
| 1231 | − | |
| 1232 | − if (payload.method === "initialize") { | |
| 1233 | − return new Response( | |
| 1234 | − JSON.stringify({ | |
| 1235 | − jsonrpc: "2.0", | |
| 1236 | − id: payload.id, | |
| 1237 | − result: { | |
| 1238 | − protocolVersion: "2025-03-26", | |
| 1239 | − capabilities: { | |
| 1240 | − tools: {}, | |
| 1241 | − }, | |
| 1242 | − serverInfo: { | |
| 1243 | − name: "fake-mcp", | |
| 1244 | − version: "0.1.0", | |
| 1245 | − }, | |
| 1246 | − }, | |
| 1247 | − }), | |
| 1248 | − { | |
| 1249 | − status: 200, | |
| 1250 | − headers: { | |
| 1251 | − "content-type": "application/json", | |
| 1252 | − }, | |
| 1253 | − }, | |
| 1254 | − ); | |
| 1255 | − } | |
| 1256 | − | |
| 1257 | − if (payload.method === "notifications/initialized") { | |
| 1258 | − return new Response(null, { | |
| 1259 | − status: 204, | |
| 1260 | − }); | |
| 1261 | − } | |
| 1262 | − | |
| 1263 | − if (payload.method === "tools/list") { | |
| 1264 | − return new Response( | |
| 1265 | − JSON.stringify({ | |
| 1266 | − jsonrpc: "2.0", | |
| 1267 | − id: payload.id, | |
| 1268 | − result: { | |
| 1269 | − tools: [ | |
| 1270 | − { | |
| 1271 | − name: "search_docs", | |
| 1272 | − description: "Search docs", | |
| 1273 | − inputSchema: { | |
| 1274 | − type: "object", | |
| 1275 | − properties: {}, | |
| 1276 | − }, | |
| 1277 | − outputSchema: { | |
| 1278 | − type: "object", | |
| 1279 | − properties: { | |
| 1280 | − results: { | |
| 1281 | − type: "array", | |
| 1282 | − }, | |
| 1283 | − }, | |
| 1284 | − }, | |
| 1285 | − }, | |
| 1286 | − ], | |
| 1287 | − }, | |
| 1288 | − }), | |
| 1289 | − { | |
| 1290 | − status: 200, | |
| 1291 | − headers: { | |
| 1292 | − "content-type": "application/json", | |
| 1293 | − }, | |
| 1294 | − }, | |
| 1295 | − ); | |
| 1296 | − } | |
| 1297 | − | |
| 1298 | − return new Response( | |
| 1299 | − JSON.stringify({ | |
| 1300 | − jsonrpc: "2.0", | |
| 1301 | − id: payload.id, | |
| 1302 | − error: { | |
| 1303 | − code: -32601, | |
| 1304 | − message: "Method not found", | |
| 1305 | − }, | |
| 1306 | − }), | |
| 1307 | − { | |
| 1308 | − status: 200, | |
| 1309 | − headers: { | |
| 1310 | − "content-type": "application/json", | |
| 1311 | − }, | |
| 1312 | − }, | |
| 1313 | − ); | |
| 1314 | − }) as unknown as typeof fetch; | |
| 1315 | − | |
| 1316 | − try { | |
| 1317 | − const now = Date.now(); | |
| 1318 | − | |
| 1319 | − yield* Effect.tryPromise(() => | |
| 1320 | − t.action(api.controlPlane.upsertCredentialBinding, { | |
| 1321 | − workspaceId: "ws_mcp_ingest", | |
| 1322 | − payload: { | |
| 1323 | − id: "auth_binding_mcp", | |
| 1324 | − credentialId: "conn_mcp", | |
| 1325 | − scopeType: "workspace", | |
| 1326 | − sourceKey: "source:src_mcp_ingest_1", | |
| 1327 | − provider: "bearer", | |
| 1328 | − secretRef: "mcp_token", | |
| 1329 | − accountId: null, | |
| 1330 | − additionalHeadersJson: null, | |
| 1331 | − boundAuthFingerprint: null, | |
| 1332 | − }, | |
| 1333 | − }), | |
| 1334 | − ); | |
| 1335 | − | |
| 1336 | − yield* Effect.tryPromise(() => | |
| 1337 | − t.run(async (ctx) => { | |
| 1338 | − await ctx.db.insert("sources", { | |
| 1339 | − id: "src_mcp_ingest_1", | |
| 1340 | − workspaceId: "ws_mcp_ingest", | |
| 1341 | − name: "Deepwiki", | |
| 1342 | − kind: "mcp", | |
| 1343 | − endpoint: "https://example.com/mcp", | |
| 1344 | − enabled: true, | |
| 1345 | − configJson: "{}", | |
| 1346 | − status: "draft", | |
| 1347 | − sourceHash: null, | |
| 1348 | − lastError: null, | |
| 1349 | − createdAt: now, | |
| 1350 | − updatedAt: now, | |
| 1351 | − }); | |
| 1352 | − }), | |
| 1353 | − ); | |
| 1354 | − | |
| 1355 | − yield* Effect.tryPromise(() => | |
| 1356 | − t.action(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, { | |
| 1357 | − workspaceId: "ws_mcp_ingest", | |
| 1358 | − sourceId: "src_mcp_ingest_1", | |
| 1359 | − }), | |
| 1360 | − ); | |
| 1361 | − | |
| 1362 | − const sources = (yield* Effect.tryPromise(() => | |
| 1363 | − t.query(api.controlPlane.listSources, { | |
| 1364 | − workspaceId: "ws_mcp_ingest", | |
| 1365 | − }), | |
| 1366 | − )) as Array<{ | |
| 1367 | − status: string; | |
| 1368 | − sourceHash: string | null; | |
| 1369 | − }>; | |
| 1370 | − | |
| 1371 | − expect(sources).toHaveLength(1); | |
| 1372 | − expect(sources[0]?.status).toBe("connected"); | |
| 1373 | − expect(sources[0]?.sourceHash).toBeTypeOf("string"); | |
| 1374 | − | |
| 1375 | − const sourceTools = (yield* Effect.tryPromise(() => | |
| 1376 | − t.action(api.controlPlane.listSourceTools, { | |
| 1377 | − workspaceId: "ws_mcp_ingest", | |
| 1378 | − sourceId: "src_mcp_ingest_1", | |
| 1379 | − }), | |
| 1380 | − )) as Array<{ | |
| 1381 | − sourceId: string; | |
| 1382 | − path: string; | |
| 1383 | − }>; | |
| 1384 | − | |
| 1385 | − expect(sourceTools).toHaveLength(1); | |
| 1386 | − expect(sourceTools[0]?.sourceId).toBe("src_mcp_ingest_1"); | |
| 1387 | − expect(sourceTools[0]?.path).toContain("deepwiki.mcp.search_docs"); | |
| 1388 | − } finally { | |
| 1389 | − globalThis.fetch = originalFetch; | |
| 1390 | − } | |
| 1391 | − }), | |
| 1392 | − ); | |
| 1393 | − | |
| 1394 | − it.effect("retries OpenAPI invocation once after oauth invalid_token", () => | |
| 1395 | − Effect.gen(function* () { | |
| 1396 | − const t = setup(); | |
| 1397 | − const originalFetch = globalThis.fetch; | |
| 1398 | − | |
| 1399 | − yield* Effect.tryPromise(() => | |
| 1400 | − ensureWorkspace(t, "ws_oauth_runtime", "org_oauth_runtime") | |
| 1401 | − ); | |
| 1402 | − | |
| 1403 | − const observedAuthorizationHeaders: Array<string | null> = []; | |
| 1404 | − let refreshCallCount = 0; | |
| 1405 | − | |
| 1406 | − globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 1407 | − const url = | |
| 1408 | − typeof input === "string" | |
| 1409 | − ? input | |
| 1410 | − : input instanceof URL | |
| 1411 | − ? input.toString() | |
| 1412 | − : input.url; | |
| 1413 | − | |
| 1414 | − if (url === "https://oauth.example.com/token") { | |
| 1415 | − refreshCallCount += 1; | |
| 1416 | − const bodyText = typeof init?.body === "string" ? init.body : ""; | |
| 1417 | − if (!bodyText.includes("grant_type=refresh_token")) { | |
| 1418 | − return new Response( | |
| 1419 | − JSON.stringify({ error: "invalid_request" }), | |
| 1420 | − { | |
| 1421 | − status: 400, | |
| 1422 | − headers: { | |
| 1423 | − "content-type": "application/json", | |
| 1424 | − }, | |
| 1425 | − }, | |
| 1426 | − ); | |
| 1427 | − } | |
| 1428 | − | |
| 1429 | − return new Response( | |
| 1430 | − JSON.stringify({ | |
| 1431 | − access_token: "fresh_token", | |
| 1432 | − token_type: "Bearer", | |
| 1433 | − expires_in: 3600, | |
| 1434 | − }), | |
| 1435 | − { | |
| 1436 | − status: 200, | |
| 1437 | − headers: { | |
| 1438 | − "content-type": "application/json", | |
| 1439 | − }, | |
| 1440 | − }, | |
| 1441 | − ); | |
| 1442 | − } | |
| 1443 | − | |
| 1444 | − if (url === "https://api.example.com/weather") { | |
| 1445 | − const authHeader = new Headers(init?.headers).get("authorization"); | |
| 1446 | − observedAuthorizationHeaders.push(authHeader); | |
| 1447 | − | |
| 1448 | − if (authHeader === "Bearer stale_token") { | |
| 1449 | − return new Response( | |
| 1450 | − JSON.stringify({ | |
| 1451 | − error: "invalid_token", | |
| 1452 | − }), | |
| 1453 | − { | |
| 1454 | − status: 401, | |
| 1455 | − headers: { | |
| 1456 | − "content-type": "application/json", | |
| 1457 | − "www-authenticate": 'Bearer error="invalid_token"', | |
| 1458 | − }, | |
| 1459 | − }, | |
| 1460 | − ); | |
| 1461 | − } | |
| 1462 | − | |
| 1463 | − if (authHeader === "Bearer fresh_token") { | |
| 1464 | − return new Response( | |
| 1465 | − JSON.stringify({ | |
| 1466 | − temperature: 72, | |
| 1467 | − }), | |
| 1468 | − { | |
| 1469 | − status: 200, | |
| 1470 | − headers: { | |
| 1471 | − "content-type": "application/json", | |
| 1472 | − }, | |
| 1473 | − }, | |
| 1474 | − ); | |
| 1475 | − } | |
| 1476 | − | |
| 1477 | − return new Response( | |
| 1478 | − JSON.stringify({ | |
| 1479 | − error: "unauthorized", | |
| 1480 | − }), | |
| 1481 | − { | |
| 1482 | − status: 401, | |
| 1483 | − headers: { | |
| 1484 | − "content-type": "application/json", | |
| 1485 | − }, | |
| 1486 | − }, | |
| 1487 | − ); | |
| 1488 | − } | |
| 1489 | − | |
| 1490 | − throw new Error(`Unexpected URL: ${url}`); | |
| 1491 | − }) as unknown as typeof fetch; | |
| 1492 | − | |
| 1493 | − try { | |
| 1494 | − const sourceId = "src_openapi_oauth_runtime_1"; | |
| 1495 | − const workspaceId = "ws_oauth_runtime"; | |
| 1496 | − const accountId = "acct_oauth_runtime"; | |
| 1497 | − const runId = "run_oauth_runtime_1"; | |
| 1498 | − const callId = "call_oauth_runtime_1"; | |
| 1499 | − const now = Date.now(); | |
| 1500 | − | |
| 1501 | − yield* Effect.tryPromise(() => | |
| 1502 | − t.run(async (ctx) => { | |
| 1503 | − await ctx.db.insert("sources", { | |
| 1504 | − id: sourceId, | |
| 1505 | − workspaceId, | |
| 1506 | − name: "Weather API", | |
| 1507 | − kind: "openapi", | |
| 1508 | − endpoint: "https://api.example.com/openapi.json", | |
| 1509 | − enabled: true, | |
| 1510 | − configJson: JSON.stringify({ | |
| 1511 | − baseUrl: "https://api.example.com", | |
| 1512 | − }), | |
| 1513 | − status: "draft", | |
| 1514 | − sourceHash: null, | |
| 1515 | − lastError: null, | |
| 1516 | − createdAt: now, | |
| 1517 | − updatedAt: now, | |
| 1518 | − }); | |
| 1519 | − }), | |
| 1520 | − ); | |
| 1521 | − | |
| 1522 | − const manifest = yield* extractOpenApiManifest("Weather API", { | |
| 1523 | − openapi: "3.0.0", | |
| 1524 | − info: { | |
| 1525 | − title: "Weather API", | |
| 1526 | − version: "1.0.0", | |
| 1527 | − }, | |
| 1528 | − paths: { | |
| 1529 | − "/weather": { | |
| 1530 | − get: { | |
| 1531 | − operationId: "getWeather", | |
| 1532 | − responses: { | |
| 1533 | − "200": { | |
| 1534 | − description: "ok", | |
| 1535 | − }, | |
| 1536 | − }, | |
| 1537 | − }, | |
| 1538 | − }, | |
| 1539 | − }, | |
| 1540 | − }); | |
| 1541 | − | |
| 1542 | − const artifactMeta = (yield* Effect.tryPromise(() => | |
| 1543 | − t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, { | |
| 1544 | − protocol: "openapi", | |
| 1545 | − contentHash: manifest.sourceHash, | |
| 1546 | − extractorVersion: "openapi_v2", | |
| 1547 | − toolCount: manifest.tools.length, | |
| 1548 | − refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null, | |
| 1549 | − }), | |
| 1550 | − )) as { | |
| 1551 | − artifactId: string; | |
| 1552 | − created: boolean; | |
| 1553 | − }; | |
| 1554 | − | |
| 1555 | − yield* Effect.tryPromise(() => | |
| 1556 | − t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, { | |
| 1557 | − artifactId: artifactMeta.artifactId, | |
| 1558 | − protocol: "openapi", | |
| 1559 | − insertOnly: true, | |
| 1560 | − tools: manifest.tools.map((tool) => ({ | |
| 1561 | − toolId: tool.toolId, | |
| 1562 | − name: tool.name, | |
| 1563 | − description: tool.description, | |
| 1564 | − canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`, | |
| 1565 | − operationHash: tool.operationHash, | |
| 1566 | − invocationJson: JSON.stringify(tool.invocation), | |
| 1567 | − inputSchemaJson: tool.typing?.inputSchemaJson ?? null, | |
| 1568 | − outputSchemaJson: tool.typing?.outputSchemaJson ?? null, | |
| 1569 | − metadataJson: JSON.stringify({ | |
| 1570 | − method: tool.method, | |
| 1571 | − path: tool.path, | |
| 1572 | − }), | |
| 1573 | − })), | |
| 1574 | − }), | |
| 1575 | − ); | |
| 1576 | − | |
| 1577 | − yield* Effect.tryPromise(() => | |
| 1578 | − t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, { | |
| 1579 | − workspaceId, | |
| 1580 | − sourceId, | |
| 1581 | − artifactId: artifactMeta.artifactId, | |
| 1582 | − }), | |
| 1583 | − ); | |
| 1584 | − | |
| 1585 | − const namespace = "weather_api_oauth_runtime_1"; | |
| 1586 | − | |
| 1587 | − yield* Effect.tryPromise(() => | |
| 1588 | − t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, { | |
| 1589 | − workspaceId, | |
| 1590 | − sourceId, | |
| 1591 | − sourceName: "Weather API", | |
| 1592 | − sourceKind: "openapi", | |
| 1593 | − artifactId: artifactMeta.artifactId, | |
| 1594 | − namespace, | |
| 1595 | − refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null, | |
| 1596 | − rows: manifest.tools.map((tool) => ({ | |
| 1597 | − toolId: tool.toolId, | |
| 1598 | − protocol: "openapi", | |
| 1599 | − method: tool.method, | |
| 1600 | − path: `${namespace}.${tool.toolId}`, | |
| 1601 | − name: tool.name, | |
| 1602 | − description: tool.description, | |
| 1603 | − searchText: `weather api ${tool.name.toLowerCase()} ${tool.path.toLowerCase()} ${tool.method.toLowerCase()}`, | |
| 1604 | − operationHash: tool.operationHash, | |
| 1605 | − approvalMode: "auto", | |
| 1606 | − status: "active", | |
| 1607 | − })), | |
| 1608 | − }), | |
| 1609 | − ); | |
| 1610 | − | |
| 1611 | − yield* Effect.tryPromise(() => | |
| 1612 | − t.action(api.controlPlane.upsertCredentialBinding, { | |
| 1613 | − workspaceId, | |
| 1614 | − payload: { | |
| 1615 | − id: "auth_binding_oauth_runtime_1", | |
| 1616 | − credentialId: "conn_oauth_runtime_1", | |
| 1617 | − scopeType: "workspace", | |
| 1618 | − sourceKey: `source:${sourceId}`, | |
| 1619 | − provider: "oauth2", | |
| 1620 | − secretRef: "stale_token", | |
| 1621 | − oauthRefreshToken: "refresh_token_1", | |
| 1622 | − oauthTokenEndpoint: "https://oauth.example.com/token", | |
| 1623 | − oauthClientId: "client_oauth_runtime_1", | |
| 1624 | − accountId: null, | |
| 1625 | − additionalHeadersJson: null, | |
| 1626 | − boundAuthFingerprint: null, | |
| 1627 | − }, | |
| 1628 | − }), | |
| 1629 | − ); | |
| 1630 | − | |
| 1631 | − yield* Effect.tryPromise(() => | |
| 1632 | − t.mutation(runtimeInternal.task_runs.startTaskRun, { | |
| 1633 | − workspaceId, | |
| 1634 | − runId, | |
| 1635 | − accountId, | |
| 1636 | − }), | |
| 1637 | − ); | |
| 1638 | − | |
| 1639 | − const callResult = (yield* Effect.tryPromise(() => | |
| 1640 | − t.action(runtimeInternal.source_tool_registry.invokeWorkspaceTool, { | |
| 1641 | − workspaceId, | |
| 1642 | − accountId, | |
| 1643 | − runId, | |
| 1644 | − callId, | |
| 1645 | − toolPath: `${namespace}.${manifest.tools[0]!.toolId}`, | |
| 1646 | − input: {}, | |
| 1647 | − }), | |
| 1648 | − )) as { | |
| 1649 | − ok: boolean; | |
| 1650 | − value?: { | |
| 1651 | − status?: number; | |
| 1652 | − body?: unknown; | |
| 1653 | − }; | |
| 1654 | − error?: string; | |
| 1655 | − }; | |
| 1656 | − | |
| 1657 | − expect(callResult.ok).toBe(true); | |
| 1658 | − expect(callResult.value?.status).toBe(200); | |
| 1659 | − expect(callResult.value?.body).toEqual({ | |
| 1660 | − temperature: 72, | |
| 1661 | − }); | |
| 1662 | − | |
| 1663 | − expect(refreshCallCount).toBe(1); | |
| 1664 | − expect(observedAuthorizationHeaders).toEqual([ | |
| 1665 | − "Bearer stale_token", | |
| 1666 | − "Bearer fresh_token", | |
| 1667 | − ]); | |
| 1668 | − } finally { | |
| 1669 | − globalThis.fetch = originalFetch; | |
| 1670 | − } | |
| 1671 | − }), | |
| 1672 | − ); | |
| 1673 | − | |
| 1674 | − it.effect("persists approval state for runtime tool calls", () => | |
| 1675 | − Effect.gen(function* () { | |
| 1676 | − const t = setup(); | |
| 1677 | − | |
| 1678 | − const missingRunDecision = (yield* Effect.tryPromise(() => | |
| 1679 | − t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, { | |
| 1680 | − workspaceId: "ws_1", | |
| 1681 | − runId: "run_approval_1", | |
| 1682 | − callId: "call_approval_1", | |
| 1683 | − toolPath: "github.repos.delete", | |
| 1684 | − inputPreviewJson: "{}", | |
| 1685 | − defaultMode: "auto", | |
| 1686 | − requireApprovals: true, | |
| 1687 | − retryAfterMs: 333, | |
| 1688 | − }), | |
| 1689 | − )) as { | |
| 1690 | − kind: "approved" | "pending" | "denied"; | |
| 1691 | − error?: string; | |
| 1692 | − }; | |
| 1693 | − | |
| 1694 | − expect(missingRunDecision.kind).toBe("denied"); | |
| 1695 | − expect(missingRunDecision.error).toContain("Unknown run for approval request"); | |
| 1696 | − | |
| 1697 | − yield* Effect.tryPromise(() => | |
| 1698 | − t.mutation(runtimeInternal.task_runs.startTaskRun, { | |
| 1699 | − workspaceId: "ws_1", | |
| 1700 | − runId: "run_approval_1", | |
| 1701 | − accountId: "acct_1", | |
| 1702 | − }), | |
| 1703 | − ); | |
| 1704 | − | |
| 1705 | − // First evaluation writes a pending approval row when this runId/callId is unseen. | |
| 1706 | − const firstDecision = (yield* Effect.tryPromise(() => | |
| 1707 | − t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, { | |
| 1708 | − workspaceId: "ws_1", | |
| 1709 | − runId: "run_approval_1", | |
| 1710 | − callId: "call_approval_1", | |
| 1711 | − toolPath: "github.repos.delete", | |
| 1712 | − inputPreviewJson: "{}", | |
| 1713 | − defaultMode: "auto", | |
| 1714 | − requireApprovals: true, | |
| 1715 | − retryAfterMs: 333, | |
| 1716 | − }), | |
| 1717 | − )) as { | |
| 1718 | − kind: "approved" | "pending" | "denied"; | |
| 1719 | − approvalId?: string; | |
| 1720 | − retryAfterMs?: number; | |
| 1721 | − }; | |
| 1722 | − | |
| 1723 | − expect(firstDecision.kind).toBe("pending"); | |
| 1724 | − expect(firstDecision.retryAfterMs).toBe(333); | |
| 1725 | − expect(firstDecision.approvalId).toBeTypeOf("string"); | |
| 1726 | − | |
| 1727 | − const secondDecision = (yield* Effect.tryPromise(() => | |
| 1728 | − t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, { | |
| 1729 | − workspaceId: "ws_1", | |
| 1730 | − runId: "run_approval_1", | |
| 1731 | − callId: "call_approval_1", | |
| 1732 | − toolPath: "github.repos.delete", | |
| 1733 | − inputPreviewJson: "{}", | |
| 1734 | − defaultMode: "auto", | |
| 1735 | − requireApprovals: true, | |
| 1736 | − retryAfterMs: 333, | |
| 1737 | − }), | |
| 1738 | − )) as { | |
| 1739 | − kind: "approved" | "pending" | "denied"; | |
| 1740 | − approvalId?: string; | |
| 1741 | − }; | |
| 1742 | − | |
| 1743 | − expect(secondDecision.kind).toBe("pending"); | |
| 1744 | − expect(secondDecision.approvalId).toBe(firstDecision.approvalId); | |
| 1745 | − | |
| 1746 | − const approvalId = firstDecision.approvalId; | |
| 1747 | − if (!approvalId) { | |
| 1748 | − throw new Error("expected approval id"); | |
| 1749 | − } | |
| 1750 | − | |
| 1751 | − yield* Effect.tryPromise(() => | |
| 1752 | − t.mutation(api.controlPlane.resolveApproval, { | |
| 1753 | − workspaceId: "ws_1", | |
| 1754 | − approvalId, | |
| 1755 | − payload: { | |
| 1756 | − status: "approved", | |
| 1757 | − reason: "approved by test", | |
| 1758 | − }, | |
| 1759 | − }), | |
| 1760 | − ); | |
| 1761 | − | |
| 1762 | − const resolvedDecision = (yield* Effect.tryPromise(() => | |
| 1763 | − t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, { | |
| 1764 | − workspaceId: "ws_1", | |
| 1765 | − runId: "run_approval_1", | |
| 1766 | − callId: "call_approval_1", | |
| 1767 | − toolPath: "github.repos.delete", | |
| 1768 | − inputPreviewJson: "{}", | |
| 1769 | − defaultMode: "auto", | |
| 1770 | − requireApprovals: true, | |
| 1771 | − retryAfterMs: 333, | |
| 1772 | − }), | |
| 1773 | − )) as { | |
| 1774 | − kind: "approved" | "pending" | "denied"; | |
| 1775 | − }; | |
| 1776 | − | |
| 1777 | − expect(resolvedDecision).toEqual({ | |
| 1778 | − kind: "approved", | |
| 1779 | − }); | |
| 1780 | − }), | |
| 1781 | − ); | |
| 1782 | −}); | |
| @@ -1,29 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − createRunExecutor, | |
| 3 | − type ToolRegistry, | |
| 4 | −} from "@executor-v2/engine"; | |
| 5 | −import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk"; | |
| 6 | −import * as Effect from "effect/Effect"; | |
| 7 | − | |
| 8 | −import { createExecuteRuntimeRunInConvex } from "./runtime_execution_port"; | |
| 9 | − | |
| 10 | −export type ExecuteRunImplOptions = { | |
| 11 | − toolRegistry?: ToolRegistry; | |
| 12 | − makeRunId?: () => string; | |
| 13 | −}; | |
| 14 | − | |
| 15 | −export const executeRunImpl = ( | |
| 16 | − input: ExecuteRunInput, | |
| 17 | − options: ExecuteRunImplOptions = {}, | |
| 18 | −): Effect.Effect<ExecuteRunResult> => { | |
| 19 | − const runExecutor = createRunExecutor( | |
| 20 | − createExecuteRuntimeRunInConvex({ | |
| 21 | − toolRegistry: options.toolRegistry, | |
| 22 | − }), | |
| 23 | − { | |
| 24 | − makeRunId: options.makeRunId, | |
| 25 | − }, | |
| 26 | − ); | |
| 27 | − | |
| 28 | − return runExecutor.executeRun(input); | |
| 29 | −}; | |
| @@ -1,62 +0,0 @@ | ||
| 1 | −import { httpRouter } from "convex/server"; | |
| 2 | − | |
| 3 | −import { controlPlaneHttpHandler } from "./controlPlane"; | |
| 4 | −import { mcpHandler } from "./mcp"; | |
| 5 | −import { | |
| 6 | − oauthAuthorizationServerHandler, | |
| 7 | − oauthProtectedResourceHandler, | |
| 8 | −} from "./mcp_auth"; | |
| 9 | −import { handleToolCallHttp } from "./runtimeCallbacks"; | |
| 10 | − | |
| 11 | −const http = httpRouter(); | |
| 12 | − | |
| 13 | −http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); | |
| 14 | −http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); | |
| 15 | −http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); | |
| 16 | −http.route({ | |
| 17 | − path: "/.well-known/oauth-protected-resource", | |
| 18 | − method: "GET", | |
| 19 | − handler: oauthProtectedResourceHandler, | |
| 20 | −}); | |
| 21 | −http.route({ | |
| 22 | − path: "/.well-known/oauth-authorization-server", | |
| 23 | − method: "GET", | |
| 24 | − handler: oauthAuthorizationServerHandler, | |
| 25 | −}); | |
| 26 | −http.route({ | |
| 27 | − path: "/v1/runtime/tool-call", | |
| 28 | − method: "POST", | |
| 29 | − handler: handleToolCallHttp, | |
| 30 | −}); | |
| 31 | −http.route({ | |
| 32 | − pathPrefix: "/v1/", | |
| 33 | − method: "GET", | |
| 34 | − handler: controlPlaneHttpHandler, | |
| 35 | −}); | |
| 36 | −http.route({ | |
| 37 | − pathPrefix: "/v1/", | |
| 38 | − method: "POST", | |
| 39 | − handler: controlPlaneHttpHandler, | |
| 40 | −}); | |
| 41 | −http.route({ | |
| 42 | − pathPrefix: "/v1/", | |
| 43 | − method: "PUT", | |
| 44 | − handler: controlPlaneHttpHandler, | |
| 45 | −}); | |
| 46 | −http.route({ | |
| 47 | − pathPrefix: "/v1/", | |
| 48 | − method: "PATCH", | |
| 49 | − handler: controlPlaneHttpHandler, | |
| 50 | −}); | |
| 51 | −http.route({ | |
| 52 | − pathPrefix: "/v1/", | |
| 53 | − method: "DELETE", | |
| 54 | − handler: controlPlaneHttpHandler, | |
| 55 | −}); | |
| 56 | −http.route({ | |
| 57 | − pathPrefix: "/v1/", | |
| 58 | − method: "OPTIONS", | |
| 59 | − handler: controlPlaneHttpHandler, | |
| 60 | −}); | |
| 61 | − | |
| 62 | −export default http; | |
| @@ -1,69 +0,0 @@ | ||
| 1 | −type RequestDenyOptions = { | |
| 2 | − status: number; | |
| 3 | − message: string; | |
| 4 | −}; | |
| 5 | − | |
| 6 | −type InternalSecretOptions = { | |
| 7 | − context: string; | |
| 8 | − envVarNames: ReadonlyArray<string>; | |
| 9 | − headerName?: string; | |
| 10 | −}; | |
| 11 | − | |
| 12 | −const trim = (value: string | undefined): string | undefined => { | |
| 13 | − const candidate = value?.trim(); | |
| 14 | − return candidate && candidate.length > 0 ? candidate : undefined; | |
| 15 | −}; | |
| 16 | − | |
| 17 | −const denyRequest = (options: RequestDenyOptions): Response => | |
| 18 | − Response.json( | |
| 19 | − { | |
| 20 | − error: options.message, | |
| 21 | − }, | |
| 22 | − { status: options.status }, | |
| 23 | − ); | |
| 24 | − | |
| 25 | −const readEnvValue = (name: string): string | undefined => trim(process.env[name]); | |
| 26 | − | |
| 27 | −const readConfiguredSecret = (envVarNames: ReadonlyArray<string>): string | null => { | |
| 28 | − for (const name of envVarNames) { | |
| 29 | − const value = readEnvValue(name); | |
| 30 | − if (value) { | |
| 31 | − return value; | |
| 32 | − } | |
| 33 | − } | |
| 34 | − | |
| 35 | − return null; | |
| 36 | −}; | |
| 37 | − | |
| 38 | −const readRequestHeader = (request: Request, name: string): string | null => | |
| 39 | − trim(request.headers.get(name) ?? undefined) ?? null; | |
| 40 | − | |
| 41 | −export const enforceInternalSecret = ( | |
| 42 | − request: Request, | |
| 43 | − options: InternalSecretOptions, | |
| 44 | −): Response | null => { | |
| 45 | − const expectedSecret = readConfiguredSecret(options.envVarNames); | |
| 46 | − if (expectedSecret === null) { | |
| 47 | − return denyRequest({ | |
| 48 | − status: 503, | |
| 49 | − message: `${options.context} authentication secret is not configured`, | |
| 50 | − }); | |
| 51 | − } | |
| 52 | − | |
| 53 | − const providedSecret = readRequestHeader(request, options.headerName ?? "x-internal-secret"); | |
| 54 | − if (providedSecret === null) { | |
| 55 | − return denyRequest({ | |
| 56 | − status: 401, | |
| 57 | − message: `${options.context} authentication is required`, | |
| 58 | − }); | |
| 59 | − } | |
| 60 | − | |
| 61 | − if (providedSecret !== expectedSecret) { | |
| 62 | − return denyRequest({ | |
| 63 | − status: 403, | |
| 64 | − message: `${options.context} authentication failed`, | |
| 65 | − }); | |
| 66 | − } | |
| 67 | − | |
| 68 | − return null; | |
| 69 | −}; | |
| @@ -1,192 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − buildExecuteToolDescription, | |
| 3 | − defaultExecuteToolDescription, | |
| 4 | − defaultExecuteToolExposureMode, | |
| 5 | − parseExecuteToolExposureMode, | |
| 6 | − type ExecuteToolExposureMode, | |
| 7 | −} from "@executor-v2/engine"; | |
| 8 | −import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway"; | |
| 9 | −import { createExecutorRunClient } from "@executor-v2/sdk"; | |
| 10 | −import * as Effect from "effect/Effect"; | |
| 11 | − | |
| 12 | −import { internal } from "./_generated/api"; | |
| 13 | −import { httpAction, type ActionCtx } from "./_generated/server"; | |
| 14 | −import { executeRunImpl } from "./executor"; | |
| 15 | −import { | |
| 16 | − getMcpAuthConfig, | |
| 17 | − unauthorizedMcpResponse, | |
| 18 | − verifyMcpToken, | |
| 19 | −} from "./mcp_auth"; | |
| 20 | −import { createConvexSourceToolRegistry } from "./source_tool_registry"; | |
| 21 | − | |
| 22 | −const readWorkspaceIdFromRequest = (request: Request): string | null => { | |
| 23 | − const value = new URL(request.url).searchParams.get("workspaceId")?.trim(); | |
| 24 | − return value && value.length > 0 ? value : null; | |
| 25 | −}; | |
| 26 | − | |
| 27 | −const readToolExposureModeFromRequest = ( | |
| 28 | − request: Request, | |
| 29 | − fallbackMode: ExecuteToolExposureMode, | |
| 30 | −): ExecuteToolExposureMode => { | |
| 31 | − const url = new URL(request.url); | |
| 32 | − const rawMode = | |
| 33 | − url.searchParams.get("toolExposureMode") ?? | |
| 34 | − url.searchParams.get("toolContextMode") ?? | |
| 35 | − undefined; | |
| 36 | − | |
| 37 | − return parseExecuteToolExposureMode(rawMode ?? undefined) ?? fallbackMode; | |
| 38 | −}; | |
| 39 | − | |
| 40 | −const resolveExecuteToolDescription = async ( | |
| 41 | − mode: ExecuteToolExposureMode, | |
| 42 | − toolRegistry: ReturnType<typeof createConvexSourceToolRegistry>, | |
| 43 | −): Promise<string> => { | |
| 44 | − try { | |
| 45 | − return await Effect.runPromise( | |
| 46 | − buildExecuteToolDescription({ | |
| 47 | − toolRegistry, | |
| 48 | − mode, | |
| 49 | − }), | |
| 50 | − ); | |
| 51 | − } catch { | |
| 52 | − return defaultExecuteToolDescription; | |
| 53 | − } | |
| 54 | −}; | |
| 55 | − | |
| 56 | −const hasWorkspaceAccess = async ( | |
| 57 | − ctx: ActionCtx, | |
| 58 | − workspaceId: string, | |
| 59 | − accountId: string, | |
| 60 | −): Promise<boolean> => { | |
| 61 | − try { | |
| 62 | − const workspace = (await ctx.runQuery( | |
| 63 | − runtimeInternal.control_plane.actor.getWorkspaceForActor, | |
| 64 | − { workspaceId }, | |
| 65 | − )) as { | |
| 66 | − organizationId: string; | |
| 67 | − createdByAccountId: string | null; | |
| 68 | − } | null; | |
| 69 | − | |
| 70 | − if (!workspace) { | |
| 71 | − return false; | |
| 72 | − } | |
| 73 | − | |
| 74 | − if (workspace.createdByAccountId === accountId) { | |
| 75 | − return true; | |
| 76 | − } | |
| 77 | − | |
| 78 | − const memberships = (await ctx.runQuery( | |
| 79 | − runtimeInternal.control_plane.actor.listOrganizationMembershipsForActor, | |
| 80 | − { accountId }, | |
| 81 | − )) as Array<{ | |
| 82 | − organizationId: string; | |
| 83 | − }>; | |
| 84 | − | |
| 85 | − return memberships.some((membership) => membership.organizationId === workspace.organizationId); | |
| 86 | − } catch { | |
| 87 | − return false; | |
| 88 | − } | |
| 89 | −}; | |
| 90 | − | |
| 91 | −const defaultToolExposureMode = | |
| 92 | − parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ?? | |
| 93 | − defaultExecuteToolExposureMode; | |
| 94 | −const runtimeInternal = internal as any; | |
| 95 | − | |
| 96 | −export const mcpHandler = httpAction(async (ctx, request) => { | |
| 97 | − const mcpAuthConfig = getMcpAuthConfig(); | |
| 98 | − const workspaceId = readWorkspaceIdFromRequest(request); | |
| 99 | − | |
| 100 | − if (!mcpAuthConfig.enabled) { | |
| 101 | − return Response.json( | |
| 102 | − { | |
| 103 | − error: "MCP OAuth must be configured", | |
| 104 | − }, | |
| 105 | − { status: 503 }, | |
| 106 | − ); | |
| 107 | − } | |
| 108 | − | |
| 109 | − const auth = await verifyMcpToken(request, mcpAuthConfig); | |
| 110 | − if (!auth) { | |
| 111 | − return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 112 | − } | |
| 113 | − | |
| 114 | − if (!workspaceId) { | |
| 115 | − return Response.json( | |
| 116 | − { | |
| 117 | − error: "workspaceId query parameter is required", | |
| 118 | − }, | |
| 119 | − { status: 400 }, | |
| 120 | − ); | |
| 121 | − } | |
| 122 | − | |
| 123 | − const accountId = auth.subject; | |
| 124 | − | |
| 125 | − const authorized = await hasWorkspaceAccess(ctx, workspaceId, accountId); | |
| 126 | − if (!authorized) { | |
| 127 | − return Response.json( | |
| 128 | − { | |
| 129 | − error: "Workspace authorization failed", | |
| 130 | − }, | |
| 131 | − { status: 403 }, | |
| 132 | − ); | |
| 133 | − } | |
| 134 | − | |
| 135 | − const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId, { | |
| 136 | − accountId, | |
| 137 | − }); | |
| 138 | − const toolExposureMode = readToolExposureModeFromRequest( | |
| 139 | − request, | |
| 140 | − defaultToolExposureMode, | |
| 141 | − ); | |
| 142 | − const executeToolDescription = await resolveExecuteToolDescription( | |
| 143 | − toolExposureMode, | |
| 144 | − toolRegistry, | |
| 145 | − ); | |
| 146 | − | |
| 147 | − const runClient = createExecutorRunClient(async (input) => { | |
| 148 | − const runId = `run_${crypto.randomUUID()}`; | |
| 149 | − | |
| 150 | − await ctx.runMutation(runtimeInternal.task_runs.startTaskRun, { | |
| 151 | − workspaceId, | |
| 152 | − runId, | |
| 153 | − accountId, | |
| 154 | − sessionId: "session_mcp", | |
| 155 | − runtimeId: "runtime_local_inproc", | |
| 156 | − codeHash: `code_length_${input.code.length}`, | |
| 157 | − }); | |
| 158 | − | |
| 159 | − try { | |
| 160 | − const result = await Effect.runPromise( | |
| 161 | − executeRunImpl(input, { | |
| 162 | − toolRegistry, | |
| 163 | − makeRunId: () => runId, | |
| 164 | − }), | |
| 165 | − ); | |
| 166 | − | |
| 167 | − await ctx.runMutation(runtimeInternal.task_runs.finishTaskRun, { | |
| 168 | − workspaceId, | |
| 169 | − runId, | |
| 170 | − status: result.status, | |
| 171 | − error: result.error ?? null, | |
| 172 | − }); | |
| 173 | − | |
| 174 | − return result; | |
| 175 | − } catch (cause) { | |
| 176 | − await ctx.runMutation(runtimeInternal.task_runs.finishTaskRun, { | |
| 177 | − workspaceId, | |
| 178 | − runId, | |
| 179 | − status: "failed", | |
| 180 | − error: String(cause), | |
| 181 | − }); | |
| 182 | − throw cause; | |
| 183 | − } | |
| 184 | − }); | |
| 185 | − | |
| 186 | − return handleMcpHttpRequest(request, { | |
| 187 | − serverName: "executor-v2-convex", | |
| 188 | − serverVersion: "0.0.0", | |
| 189 | − runClient, | |
| 190 | − executeToolDescription, | |
| 191 | − }); | |
| 192 | −}); | |
| @@ -1,229 +0,0 @@ | ||
| 1 | −import { createRemoteJWKSet, jwtVerify } from "jose"; | |
| 2 | − | |
| 3 | −import { httpAction } from "./_generated/server"; | |
| 4 | − | |
| 5 | −export const MCP_PATH = "/v1/mcp"; | |
| 6 | − | |
| 7 | −type McpAuthConfig = { | |
| 8 | − required: boolean; | |
| 9 | − enabled: boolean; | |
| 10 | − authorizationServer: string | null; | |
| 11 | − jwks: ReturnType<typeof createRemoteJWKSet> | null; | |
| 12 | −}; | |
| 13 | − | |
| 14 | −type VerifiedMcpToken = { | |
| 15 | − provider: "workos"; | |
| 16 | − subject: string; | |
| 17 | −}; | |
| 18 | − | |
| 19 | −const isTruthyEnvValue = (value: string | undefined): boolean => { | |
| 20 | − const normalized = value?.trim().toLowerCase(); | |
| 21 | − return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; | |
| 22 | −}; | |
| 23 | − | |
| 24 | −const parseOptionalBooleanEnv = ( | |
| 25 | − value: string | undefined, | |
| 26 | −): boolean | null => { | |
| 27 | − const normalized = value?.trim().toLowerCase(); | |
| 28 | − if (!normalized) { | |
| 29 | − return null; | |
| 30 | − } | |
| 31 | − | |
| 32 | − if (["1", "true", "yes", "on"].includes(normalized)) { | |
| 33 | − return true; | |
| 34 | − } | |
| 35 | − | |
| 36 | − if (["0", "false", "no", "off"].includes(normalized)) { | |
| 37 | − return false; | |
| 38 | − } | |
| 39 | − | |
| 40 | − return null; | |
| 41 | −}; | |
| 42 | − | |
| 43 | −const mcpAuthRequired = (): boolean => { | |
| 44 | − if (isTruthyEnvValue(process.env.EXECUTOR_ALLOW_UNAUTHENTICATED_MCP)) { | |
| 45 | − return false; | |
| 46 | − } | |
| 47 | − | |
| 48 | − const explicitEnforcement = parseOptionalBooleanEnv( | |
| 49 | − process.env.EXECUTOR_ENFORCE_MCP_AUTH, | |
| 50 | − ); | |
| 51 | − | |
| 52 | − if (explicitEnforcement !== null) { | |
| 53 | − return explicitEnforcement; | |
| 54 | − } | |
| 55 | − | |
| 56 | − return true; | |
| 57 | −}; | |
| 58 | − | |
| 59 | −const getMcpAuthorizationServer = (): string | null => | |
| 60 | − process.env.MCP_AUTHORIZATION_SERVER | |
| 61 | − ?? process.env.MCP_AUTHORIZATION_SERVER_URL | |
| 62 | − ?? process.env.WORKOS_AUTHKIT_ISSUER | |
| 63 | − ?? process.env.WORKOS_AUTHKIT_DOMAIN | |
| 64 | − ?? null; | |
| 65 | − | |
| 66 | −export const getMcpAuthConfig = (): McpAuthConfig => { | |
| 67 | − const required = mcpAuthRequired(); | |
| 68 | − const authorizationServer = getMcpAuthorizationServer(); | |
| 69 | − | |
| 70 | − if (!authorizationServer) { | |
| 71 | − return { | |
| 72 | − required, | |
| 73 | − enabled: false, | |
| 74 | − authorizationServer: null, | |
| 75 | − jwks: null, | |
| 76 | − }; | |
| 77 | − } | |
| 78 | − | |
| 79 | − return { | |
| 80 | − required, | |
| 81 | − enabled: true, | |
| 82 | − authorizationServer, | |
| 83 | − jwks: createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)), | |
| 84 | − }; | |
| 85 | −}; | |
| 86 | − | |
| 87 | −const parseBearerToken = (request: Request): string | null => { | |
| 88 | − const header = request.headers.get("authorization"); | |
| 89 | − if (!header || !header.startsWith("Bearer ")) { | |
| 90 | − return null; | |
| 91 | − } | |
| 92 | − | |
| 93 | − const token = header.slice("Bearer ".length).trim(); | |
| 94 | − return token.length > 0 ? token : null; | |
| 95 | −}; | |
| 96 | − | |
| 97 | −const resourceMetadataUrl = (request: Request): string => { | |
| 98 | − const url = new URL(request.url); | |
| 99 | − const metadata = new URL("/.well-known/oauth-protected-resource", url.origin); | |
| 100 | − metadata.search = url.search; | |
| 101 | − | |
| 102 | − const resource = new URL(url.pathname, url.origin); | |
| 103 | − resource.search = url.search; | |
| 104 | − metadata.searchParams.set("resource", resource.toString()); | |
| 105 | − | |
| 106 | − return metadata.toString(); | |
| 107 | −}; | |
| 108 | − | |
| 109 | −export const unauthorizedMcpResponse = (request: Request, message: string): Response => { | |
| 110 | − const challenge = [ | |
| 111 | − 'Bearer error="unauthorized"', | |
| 112 | − 'error_description="Authorization needed"', | |
| 113 | − `resource_metadata="${resourceMetadataUrl(request)}"`, | |
| 114 | − ].join(", "); | |
| 115 | − | |
| 116 | − return Response.json( | |
| 117 | − { error: message }, | |
| 118 | − { | |
| 119 | − status: 401, | |
| 120 | − headers: { | |
| 121 | − "WWW-Authenticate": challenge, | |
| 122 | − }, | |
| 123 | − }, | |
| 124 | − ); | |
| 125 | −}; | |
| 126 | − | |
| 127 | −export const verifyMcpToken = async ( | |
| 128 | − request: Request, | |
| 129 | − config: McpAuthConfig, | |
| 130 | −): Promise<VerifiedMcpToken | null> => { | |
| 131 | − if (!config.enabled || !config.jwks || !config.authorizationServer) { | |
| 132 | − return null; | |
| 133 | − } | |
| 134 | − | |
| 135 | − const token = parseBearerToken(request); | |
| 136 | − if (!token) { | |
| 137 | − return null; | |
| 138 | − } | |
| 139 | − | |
| 140 | − try { | |
| 141 | − const { payload } = await jwtVerify(token, config.jwks, { | |
| 142 | − issuer: config.authorizationServer, | |
| 143 | − }); | |
| 144 | − | |
| 145 | − if (typeof payload.sub !== "string" || payload.sub.length === 0) { | |
| 146 | − return null; | |
| 147 | − } | |
| 148 | − | |
| 149 | − const providerClaim = typeof payload.provider === "string" ? payload.provider : undefined; | |
| 150 | − if (providerClaim === "anonymous") { | |
| 151 | − return null; | |
| 152 | − } | |
| 153 | − | |
| 154 | − return { | |
| 155 | − provider: "workos", | |
| 156 | − subject: payload.sub, | |
| 157 | − }; | |
| 158 | − } catch (error) { | |
| 159 | − console.error("MCP token verification failed:", error instanceof Error ? error.message : String(error)); | |
| 160 | − return null; | |
| 161 | − } | |
| 162 | −}; | |
| 163 | − | |
| 164 | −const errorResponseForMissingConfig = (config: McpAuthConfig): Response => | |
| 165 | − Response.json( | |
| 166 | − { | |
| 167 | − error: config.required | |
| 168 | − ? "MCP OAuth must be configured for cloud deployments" | |
| 169 | − : "MCP OAuth is not configured", | |
| 170 | − }, | |
| 171 | − { status: config.required ? 503 : 404 }, | |
| 172 | − ); | |
| 173 | − | |
| 174 | −export const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => { | |
| 175 | − const config = getMcpAuthConfig(); | |
| 176 | − if (!config.enabled || !config.authorizationServer) { | |
| 177 | − return errorResponseForMissingConfig(config); | |
| 178 | − } | |
| 179 | − | |
| 180 | − const url = new URL(request.url); | |
| 181 | − let resource = new URL(MCP_PATH, url.origin); | |
| 182 | − resource.search = url.search; | |
| 183 | − | |
| 184 | − const resourceHint = url.searchParams.get("resource"); | |
| 185 | − if (resourceHint) { | |
| 186 | − try { | |
| 187 | − const parsed = new URL(resourceHint); | |
| 188 | − if (parsed.origin !== url.origin) { | |
| 189 | − return Response.json({ error: "resource hint origin must match this server" }, { status: 400 }); | |
| 190 | − } | |
| 191 | − | |
| 192 | − if (parsed.pathname === MCP_PATH) { | |
| 193 | − resource = new URL(MCP_PATH, url.origin); | |
| 194 | − resource.search = parsed.search; | |
| 195 | − } | |
| 196 | − } catch { | |
| 197 | − return Response.json({ error: "Invalid resource hint" }, { status: 400 }); | |
| 198 | − } | |
| 199 | − } | |
| 200 | − | |
| 201 | − return Response.json({ | |
| 202 | − resource: resource.toString(), | |
| 203 | − authorization_servers: [config.authorizationServer], | |
| 204 | − bearer_methods_supported: ["header"], | |
| 205 | − }); | |
| 206 | −}); | |
| 207 | − | |
| 208 | −export const oauthAuthorizationServerHandler = httpAction(async (_ctx, _request) => { | |
| 209 | − const config = getMcpAuthConfig(); | |
| 210 | − if (!config.enabled || !config.authorizationServer) { | |
| 211 | − return errorResponseForMissingConfig(config); | |
| 212 | − } | |
| 213 | − | |
| 214 | − const upstream = new URL("/.well-known/oauth-authorization-server", config.authorizationServer); | |
| 215 | − const response = await fetch(upstream.toString(), { | |
| 216 | − headers: { | |
| 217 | − accept: "application/json", | |
| 218 | − }, | |
| 219 | − }); | |
| 220 | − | |
| 221 | − const body = await response.text(); | |
| 222 | − | |
| 223 | − return new Response(body, { | |
| 224 | − status: response.status, | |
| 225 | − headers: { | |
| 226 | − "content-type": response.headers.get("content-type") ?? "application/json", | |
| 227 | − }, | |
| 228 | − }); | |
| 229 | −}); | |
| @@ -1,5 +0,0 @@ | ||
| 1 | −import { type ExecuteRunInput } from "@executor-v2/sdk"; | |
| 2 | − | |
| 3 | −import { executeRunImpl } from "./executor"; | |
| 4 | − | |
| 5 | −export const executeRun = (input: ExecuteRunInput) => executeRunImpl(input); | |
| @@ -1,165 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − RuntimeAdapterError, | |
| 3 | − invokeRuntimeToolCallResult, | |
| 4 | −} from "@executor-v2/engine"; | |
| 5 | −import type { | |
| 6 | − RuntimeToolCallRequest, | |
| 7 | − RuntimeToolCallResult, | |
| 8 | −} from "@executor-v2/sdk"; | |
| 9 | −import * as Data from "effect/Data"; | |
| 10 | −import * as Effect from "effect/Effect"; | |
| 11 | −import * as ParseResult from "effect/ParseResult"; | |
| 12 | −import * as Schema from "effect/Schema"; | |
| 13 | − | |
| 14 | −import { internal } from "./_generated/api"; | |
| 15 | −import { httpAction, type ActionCtx } from "./_generated/server"; | |
| 16 | −import { enforceInternalSecret } from "./http_security"; | |
| 17 | −import { createConvexSourceToolRegistry } from "./source_tool_registry"; | |
| 18 | − | |
| 19 | −class RuntimeToolCallBadRequestError extends Data.TaggedError( | |
| 20 | − "RuntimeToolCallBadRequestError", | |
| 21 | −)<{ | |
| 22 | − message: string; | |
| 23 | − details: string; | |
| 24 | −}> {} | |
| 25 | − | |
| 26 | −const RuntimeToolCallCredentialContextSchema = Schema.Struct({ | |
| 27 | − workspaceId: Schema.String, | |
| 28 | − sourceKey: Schema.String, | |
| 29 | − organizationId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 30 | − accountId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 31 | −}); | |
| 32 | − | |
| 33 | −const RuntimeToolCallRequestSchema = Schema.Struct({ | |
| 34 | − runId: Schema.String, | |
| 35 | − callId: Schema.String, | |
| 36 | − toolPath: Schema.String, | |
| 37 | − input: Schema.optional( | |
| 38 | − Schema.Record({ | |
| 39 | − key: Schema.String, | |
| 40 | − value: Schema.Unknown, | |
| 41 | − }), | |
| 42 | − ), | |
| 43 | − credentialContext: Schema.optional(RuntimeToolCallCredentialContextSchema), | |
| 44 | −}); | |
| 45 | − | |
| 46 | −const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema); | |
| 47 | − | |
| 48 | −const badRequest = (message: string): Response => | |
| 49 | − Response.json( | |
| 50 | − { | |
| 51 | − ok: false, | |
| 52 | − kind: "failed", | |
| 53 | − error: message, | |
| 54 | − } satisfies RuntimeToolCallResult, | |
| 55 | − { status: 400 }, | |
| 56 | − ); | |
| 57 | − | |
| 58 | −const formatBadRequestMessage = (error: RuntimeToolCallBadRequestError): string => | |
| 59 | − error.details.length > 0 ? `${error.message}: ${error.details}` : error.message; | |
| 60 | − | |
| 61 | −const formatRuntimeAdapterError = (error: RuntimeAdapterError): string => | |
| 62 | − error.details ? `${error.message}: ${error.details}` : error.message; | |
| 63 | − | |
| 64 | −const formatUnknownDetails = (cause: unknown): string => String(cause); | |
| 65 | − | |
| 66 | −const decodeToolCallRequest = (request: Request): Effect.Effect<RuntimeToolCallRequest, RuntimeToolCallBadRequestError> => | |
| 67 | − Effect.gen(function* () { | |
| 68 | − const body = yield* Effect.tryPromise({ | |
| 69 | − try: () => request.json(), | |
| 70 | − catch: (cause) => | |
| 71 | − new RuntimeToolCallBadRequestError({ | |
| 72 | − message: "Invalid runtime callback request body", | |
| 73 | − details: formatUnknownDetails(cause), | |
| 74 | − }), | |
| 75 | − }); | |
| 76 | − | |
| 77 | − return yield* decodeRuntimeToolCallRequest(body).pipe( | |
| 78 | − Effect.mapError( | |
| 79 | − (cause) => | |
| 80 | − new RuntimeToolCallBadRequestError({ | |
| 81 | − message: "Runtime callback request body is invalid", | |
| 82 | − details: ParseResult.TreeFormatter.formatErrorSync(cause), | |
| 83 | − }), | |
| 84 | − ), | |
| 85 | − ); | |
| 86 | − }); | |
| 87 | − | |
| 88 | −const resolveWorkspaceIdForToolCall = ( | |
| 89 | − ctx: ActionCtx, | |
| 90 | − input: RuntimeToolCallRequest, | |
| 91 | −): Effect.Effect<{ workspaceId: string; accountId: string }, RuntimeToolCallBadRequestError> => | |
| 92 | − Effect.gen(function* () { | |
| 93 | − const runContext = yield* Effect.tryPromise({ | |
| 94 | − try: () => | |
| 95 | − ctx.runQuery(internal.task_runs.getTaskRunContext, { | |
| 96 | − runId: input.runId, | |
| 97 | − }), | |
| 98 | − catch: (cause) => | |
| 99 | − new RuntimeToolCallBadRequestError({ | |
| 100 | − message: "Unable to resolve runtime callback workspace", | |
| 101 | − details: formatUnknownDetails(cause), | |
| 102 | − }), | |
| 103 | − }); | |
| 104 | − | |
| 105 | − if (runContext && runContext.workspaceId.trim().length > 0) { | |
| 106 | − const accountId = runContext.accountId.trim(); | |
| 107 | − if (accountId.length === 0) { | |
| 108 | − return yield* new RuntimeToolCallBadRequestError({ | |
| 109 | − message: "Runtime callback run is missing account identity", | |
| 110 | − details: `runId=${input.runId}`, | |
| 111 | − }); | |
| 112 | − } | |
| 113 | − | |
| 114 | − return { | |
| 115 | − workspaceId: runContext.workspaceId.trim(), | |
| 116 | − accountId, | |
| 117 | − }; | |
| 118 | − } | |
| 119 | − | |
| 120 | − return yield* new RuntimeToolCallBadRequestError({ | |
| 121 | − message: "Runtime callback run is not associated with a workspace", | |
| 122 | − details: `runId=${input.runId}`, | |
| 123 | − }); | |
| 124 | − }); | |
| 125 | − | |
| 126 | −const handleToolCallHttpEffect = ( | |
| 127 | − ctx: ActionCtx, | |
| 128 | − request: Request, | |
| 129 | −): Effect.Effect<Response, never> => | |
| 130 | − Effect.gen(function* () { | |
| 131 | − const blockedBySecret = enforceInternalSecret(request, { | |
| 132 | − context: "Runtime callback", | |
| 133 | − envVarNames: ["CLOUDFLARE_SANDBOX_CALLBACK_SECRET"], | |
| 134 | − }); | |
| 135 | − if (blockedBySecret) { | |
| 136 | − return blockedBySecret; | |
| 137 | − } | |
| 138 | − | |
| 139 | − const input = yield* decodeToolCallRequest(request); | |
| 140 | − const runContext = yield* resolveWorkspaceIdForToolCall(ctx, input); | |
| 141 | − | |
| 142 | − const toolRegistry = createConvexSourceToolRegistry(ctx, runContext.workspaceId, { | |
| 143 | − accountId: runContext.accountId, | |
| 144 | − }); | |
| 145 | − | |
| 146 | − const result = yield* invokeRuntimeToolCallResult(toolRegistry, input).pipe( | |
| 147 | − Effect.catchTag("RuntimeAdapterError", (error) => | |
| 148 | − Effect.succeed<RuntimeToolCallResult>({ | |
| 149 | − ok: false, | |
| 150 | − kind: "failed", | |
| 151 | − error: formatRuntimeAdapterError(error), | |
| 152 | − }), | |
| 153 | − ), | |
| 154 | − ); | |
| 155 | − | |
| 156 | − return Response.json(result, { status: 200 }); | |
| 157 | − }).pipe( | |
| 158 | − Effect.catchTag("RuntimeToolCallBadRequestError", (error) => | |
| 159 | − Effect.succeed(badRequest(formatBadRequestMessage(error))), | |
| 160 | − ), | |
| 161 | − ); | |
| 162 | − | |
| 163 | −export const handleToolCallHttp = httpAction((ctx, request) => | |
| 164 | − Effect.runPromise(handleToolCallHttpEffect(ctx, request)), | |
| 165 | −); | |
| @@ -1,10 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − type RuntimeAdapter, | |
| 3 | − type RuntimeExecuteError, | |
| 4 | − type RuntimeExecuteInput, | |
| 5 | −} from "@executor-v2/engine"; | |
| 6 | −import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; | |
| 7 | − | |
| 8 | −export type { RuntimeAdapter, RuntimeExecuteError, RuntimeExecuteInput }; | |
| 9 | − | |
| 10 | −export { makeCloudflareWorkerLoaderRuntimeAdapter }; | |
| @@ -1,74 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − RuntimeExecutionPortError, | |
| 3 | − createRuntimeToolCallService, | |
| 4 | − createStaticToolRegistry, | |
| 5 | − type ExecuteRuntimeRun, | |
| 6 | − type ExecuteRuntimeRunInput, | |
| 7 | − type ToolRegistry, | |
| 8 | − makeRuntimeAdapterRegistry, | |
| 9 | − type RuntimeExecuteError, | |
| 10 | −} from "@executor-v2/engine"; | |
| 11 | −import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; | |
| 12 | −import * as Effect from "effect/Effect"; | |
| 13 | − | |
| 14 | −export type ConvexRuntimeExecutionPortOptions = { | |
| 15 | − toolRegistry?: ToolRegistry; | |
| 16 | −}; | |
| 17 | − | |
| 18 | −export const createExecuteRuntimeRunInConvex = ( | |
| 19 | − options: ConvexRuntimeExecutionPortOptions = {}, | |
| 20 | −): ExecuteRuntimeRun => { | |
| 21 | − const convexRuntimeAdapter = makeCloudflareWorkerLoaderRuntimeAdapter(); | |
| 22 | − const runtimeAdapters = makeRuntimeAdapterRegistry([convexRuntimeAdapter]); | |
| 23 | − | |
| 24 | − const toolRegistry = | |
| 25 | − options.toolRegistry ?? | |
| 26 | − createStaticToolRegistry({ | |
| 27 | − tools: {}, | |
| 28 | − }); | |
| 29 | − const toolCallService = createRuntimeToolCallService(toolRegistry); | |
| 30 | − | |
| 31 | − return (input: ExecuteRuntimeRunInput) => | |
| 32 | − Effect.gen(function* () { | |
| 33 | − const runtimeAdapter = yield* runtimeAdapters.get(convexRuntimeAdapter.kind).pipe( | |
| 34 | − Effect.mapError( | |
| 35 | − (error) => | |
| 36 | − new RuntimeExecutionPortError({ | |
| 37 | − operation: "resolve_runtime_adapter", | |
| 38 | − message: error.message, | |
| 39 | − details: null, | |
| 40 | − }), | |
| 41 | − ), | |
| 42 | − ); | |
| 43 | − | |
| 44 | − const isAvailable = yield* runtimeAdapter.isAvailable(); | |
| 45 | − if (!isAvailable) { | |
| 46 | − return yield* new RuntimeExecutionPortError({ | |
| 47 | − operation: "runtime_available", | |
| 48 | − message: `Runtime '${convexRuntimeAdapter.kind}' is not configured in this convex process.`, | |
| 49 | − details: | |
| 50 | − "Set CLOUDFLARE_SANDBOX_RUN_URL, CLOUDFLARE_SANDBOX_AUTH_TOKEN, CLOUDFLARE_SANDBOX_CALLBACK_URL, and CLOUDFLARE_SANDBOX_CALLBACK_SECRET.", | |
| 51 | − }); | |
| 52 | − } | |
| 53 | − | |
| 54 | − return yield* runtimeAdapter | |
| 55 | − .execute({ | |
| 56 | − runId: input.runId, | |
| 57 | − code: input.code, | |
| 58 | − timeoutMs: input.timeoutMs, | |
| 59 | − toolCallService, | |
| 60 | − }) | |
| 61 | − .pipe( | |
| 62 | − Effect.mapError( | |
| 63 | − (error: RuntimeExecuteError) => | |
| 64 | − new RuntimeExecutionPortError({ | |
| 65 | − operation: "runtime_execute", | |
| 66 | − message: error.message, | |
| 67 | − details: error.details, | |
| 68 | − }), | |
| 69 | − ), | |
| 70 | − ); | |
| 71 | − }); | |
| 72 | −}; | |
| 73 | − | |
| 74 | −export const executeRuntimeRunInConvex = createExecuteRuntimeRunInConvex(); | |
| @@ -1,253 +0,0 @@ | ||
| 1 | −import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect"; | |
| 2 | −import { | |
| 3 | − ApprovalSchema, | |
| 4 | − AuthAuditEventSchema, | |
| 5 | − AuthConnectionSchema, | |
| 6 | − AuthMaterialSchema, | |
| 7 | − EventEnvelopeSchema, | |
| 8 | − OAuthStateSchema, | |
| 9 | − OrganizationMembershipSchema, | |
| 10 | − OrganizationSchema, | |
| 11 | − PolicySchema, | |
| 12 | − ProfileSchema, | |
| 13 | − SourceAuthBindingSchema, | |
| 14 | − SourceSchema, | |
| 15 | − StorageInstanceSchema, | |
| 16 | − SyncStateSchema, | |
| 17 | − TaskRunSchema, | |
| 18 | − WorkspaceSchema, | |
| 19 | −} from "@executor-v2/schema"; | |
| 20 | −import * as Schema from "effect/Schema"; | |
| 21 | − | |
| 22 | −const StorageFileEntrySchema = Schema.Struct({ | |
| 23 | − id: Schema.String, | |
| 24 | − storageInstanceId: Schema.String, | |
| 25 | − path: Schema.String, | |
| 26 | − contentBase64: Schema.String, | |
| 27 | − sizeBytes: Schema.Number, | |
| 28 | − updatedAt: Schema.Number, | |
| 29 | −}); | |
| 30 | − | |
| 31 | −const StorageKvEntrySchema = Schema.Struct({ | |
| 32 | − id: Schema.String, | |
| 33 | − storageInstanceId: Schema.String, | |
| 34 | − key: Schema.String, | |
| 35 | − valueJson: Schema.String, | |
| 36 | − updatedAt: Schema.Number, | |
| 37 | −}); | |
| 38 | − | |
| 39 | −const StorageSqlKvEntrySchema = Schema.Struct({ | |
| 40 | − id: Schema.String, | |
| 41 | − storageInstanceId: Schema.String, | |
| 42 | − key: Schema.String, | |
| 43 | − value: Schema.String, | |
| 44 | − updatedAt: Schema.Number, | |
| 45 | −}); | |
| 46 | − | |
| 47 | −const ArtifactSchema = Schema.Struct({ | |
| 48 | − id: Schema.String, | |
| 49 | − protocol: Schema.String, | |
| 50 | − contentHash: Schema.String, | |
| 51 | − extractorVersion: Schema.String, | |
| 52 | − toolCount: Schema.Number, | |
| 53 | − refHintTableJson: Schema.optional(Schema.NullOr(Schema.String)), | |
| 54 | − createdAt: Schema.Number, | |
| 55 | − updatedAt: Schema.Number, | |
| 56 | −}); | |
| 57 | − | |
| 58 | −const ArtifactToolSchema = Schema.Struct({ | |
| 59 | − id: Schema.String, | |
| 60 | − artifactId: Schema.String, | |
| 61 | − protocol: Schema.String, | |
| 62 | − toolId: Schema.String, | |
| 63 | − name: Schema.String, | |
| 64 | − description: Schema.NullOr(Schema.String), | |
| 65 | − canonicalPath: Schema.String, | |
| 66 | − operationHash: Schema.String, | |
| 67 | − invocationJson: Schema.String, | |
| 68 | − inputSchemaJson: Schema.optional(Schema.NullOr(Schema.String)), | |
| 69 | − outputSchemaJson: Schema.optional(Schema.NullOr(Schema.String)), | |
| 70 | − metadataJson: Schema.optional(Schema.NullOr(Schema.String)), | |
| 71 | − createdAt: Schema.Number, | |
| 72 | − updatedAt: Schema.Number, | |
| 73 | −}); | |
| 74 | − | |
| 75 | −const ArtifactSchemaRefSchema = Schema.Struct({ | |
| 76 | − id: Schema.String, | |
| 77 | − artifactId: Schema.String, | |
| 78 | − refKey: Schema.String, | |
| 79 | − schemaJson: Schema.String, | |
| 80 | − createdAt: Schema.Number, | |
| 81 | − updatedAt: Schema.Number, | |
| 82 | −}); | |
| 83 | − | |
| 84 | −const SourceArtifactBindingSchema = Schema.Struct({ | |
| 85 | − id: Schema.String, | |
| 86 | − workspaceId: Schema.String, | |
| 87 | − sourceId: Schema.String, | |
| 88 | − artifactId: Schema.String, | |
| 89 | − updatedAt: Schema.Number, | |
| 90 | −}); | |
| 91 | − | |
| 92 | −const SourceIngestArtifactBatchSchema = Schema.Struct({ | |
| 93 | − id: Schema.String, | |
| 94 | − workspaceId: Schema.String, | |
| 95 | − sourceId: Schema.String, | |
| 96 | − artifactId: Schema.String, | |
| 97 | − protocol: Schema.String, | |
| 98 | − batchIndex: Schema.Number, | |
| 99 | − toolsJson: Schema.String, | |
| 100 | − updatedAt: Schema.Number, | |
| 101 | −}); | |
| 102 | − | |
| 103 | −const WorkspaceToolIndexSchema = Schema.Struct({ | |
| 104 | − id: Schema.String, | |
| 105 | − workspaceId: Schema.String, | |
| 106 | − sourceId: Schema.String, | |
| 107 | − sourceName: Schema.String, | |
| 108 | − sourceKind: Schema.String, | |
| 109 | − artifactId: Schema.String, | |
| 110 | − toolId: Schema.String, | |
| 111 | − protocol: Schema.String, | |
| 112 | − method: Schema.String, | |
| 113 | − namespace: Schema.String, | |
| 114 | − path: Schema.String, | |
| 115 | − pathLower: Schema.String, | |
| 116 | − normalizedPath: Schema.String, | |
| 117 | − operationPath: Schema.optional(Schema.NullOr(Schema.String)), | |
| 118 | − name: Schema.String, | |
| 119 | − description: Schema.NullOr(Schema.String), | |
| 120 | − searchText: Schema.String, | |
| 121 | − operationHash: Schema.String, | |
| 122 | − approvalMode: Schema.String, | |
| 123 | − status: Schema.String, | |
| 124 | − refHintTableJson: Schema.optional(Schema.NullOr(Schema.String)), | |
| 125 | − updatedAt: Schema.Number, | |
| 126 | −}); | |
| 127 | − | |
| 128 | −export const executorConfectSchema = defineSchema({ | |
| 129 | − profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]), | |
| 130 | − organizations: defineTable(OrganizationSchema) | |
| 131 | − .index("by_domainId", ["id"]) | |
| 132 | − .index("by_slug", ["slug"]), | |
| 133 | − organizationMemberships: defineTable(OrganizationMembershipSchema) | |
| 134 | − .index("by_domainId", ["id"]) | |
| 135 | − .index("by_organizationId", ["organizationId"]) | |
| 136 | − .index("by_accountId", ["accountId"]) | |
| 137 | − .index("by_organizationId_accountId", ["organizationId", "accountId"]), | |
| 138 | − workspaces: defineTable(WorkspaceSchema) | |
| 139 | − .index("by_domainId", ["id"]) | |
| 140 | − .index("by_organizationId", ["organizationId"]) | |
| 141 | − .index("by_createdByAccountId", ["createdByAccountId"]), | |
| 142 | − sources: defineTable(SourceSchema) | |
| 143 | − .index("by_domainId", ["id"]) | |
| 144 | − .index("by_workspaceId", ["workspaceId"]), | |
| 145 | − artifacts: defineTable(ArtifactSchema) | |
| 146 | − .index("by_domainId", ["id"]) | |
| 147 | − .index("by_protocol_contentHash_extractorVersion", [ | |
| 148 | − "protocol", | |
| 149 | − "contentHash", | |
| 150 | − "extractorVersion", | |
| 151 | − ]), | |
| 152 | − artifactTools: defineTable(ArtifactToolSchema) | |
| 153 | − .index("by_domainId", ["id"]) | |
| 154 | − .index("by_artifactId", ["artifactId"]) | |
| 155 | − .index("by_artifactId_toolId", ["artifactId", "toolId"]), | |
| 156 | − artifactSchemaRefs: defineTable(ArtifactSchemaRefSchema) | |
| 157 | − .index("by_domainId", ["id"]) | |
| 158 | − .index("by_artifactId", ["artifactId"]) | |
| 159 | − .index("by_artifactId_refKey", ["artifactId", "refKey"]), | |
| 160 | − sourceArtifactBindings: defineTable(SourceArtifactBindingSchema) | |
| 161 | − .index("by_domainId", ["id"]) | |
| 162 | − .index("by_workspaceId", ["workspaceId"]) | |
| 163 | − .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"]) | |
| 164 | − .index("by_artifactId", ["artifactId"]), | |
| 165 | − sourceIngestArtifactBatches: defineTable(SourceIngestArtifactBatchSchema) | |
| 166 | − .index("by_domainId", ["id"]) | |
| 167 | − .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"]) | |
| 168 | − .index("by_workspaceId_sourceId_artifactId_batchIndex", [ | |
| 169 | − "workspaceId", | |
| 170 | − "sourceId", | |
| 171 | − "artifactId", | |
| 172 | − "batchIndex", | |
| 173 | − ]) | |
| 174 | − .index("by_artifactId", ["artifactId"]), | |
| 175 | − workspaceToolIndex: defineTable(WorkspaceToolIndexSchema) | |
| 176 | − .index("by_domainId", ["id"]) | |
| 177 | − .index("by_workspaceId", ["workspaceId"]) | |
| 178 | − .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"]) | |
| 179 | − .index("by_workspaceId_namespace", ["workspaceId", "namespace"]) | |
| 180 | − .index("by_workspaceId_pathLower", ["workspaceId", "pathLower"]) | |
| 181 | − .index("by_workspaceId_normalizedPath", ["workspaceId", "normalizedPath"]) | |
| 182 | − .index("by_workspaceId_operationHash", ["workspaceId", "operationHash"]) | |
| 183 | − .searchIndex("search_text", { | |
| 184 | − searchField: "searchText", | |
| 185 | − filterFields: ["workspaceId", "sourceId", "status", "namespace"], | |
| 186 | − }), | |
| 187 | − authConnections: defineTable(AuthConnectionSchema) | |
| 188 | − .index("by_domainId", ["id"]) | |
| 189 | − .index("by_organizationId", ["organizationId"]) | |
| 190 | − .index("by_workspaceId", ["workspaceId"]) | |
| 191 | − .index("by_accountId", ["accountId"]) | |
| 192 | − .index("by_status", ["status"]) | |
| 193 | − .index("by_strategy", ["strategy"]), | |
| 194 | − sourceAuthBindings: defineTable(SourceAuthBindingSchema) | |
| 195 | − .index("by_domainId", ["id"]) | |
| 196 | − .index("by_sourceId", ["sourceId"]) | |
| 197 | − .index("by_connectionId", ["connectionId"]) | |
| 198 | − .index("by_workspaceId", ["workspaceId"]) | |
| 199 | − .index("by_organizationId", ["organizationId"]) | |
| 200 | − .index("by_accountId", ["accountId"]), | |
| 201 | − authMaterials: defineTable(AuthMaterialSchema) | |
| 202 | − .index("by_domainId", ["id"]) | |
| 203 | − .index("by_connectionId", ["connectionId"]), | |
| 204 | − oauthState: defineTable(OAuthStateSchema) | |
| 205 | − .index("by_domainId", ["id"]) | |
| 206 | − .index("by_connectionId", ["connectionId"]) | |
| 207 | − .index("by_expiresAt", ["expiresAt"]) | |
| 208 | − .index("by_reauthRequiredAt", ["reauthRequiredAt"]), | |
| 209 | − authAuditEvents: defineTable(AuthAuditEventSchema) | |
| 210 | − .index("by_domainId", ["id"]) | |
| 211 | − .index("by_organizationId", ["organizationId"]) | |
| 212 | − .index("by_connectionId", ["connectionId"]) | |
| 213 | − .index("by_sourceId", ["sourceId"]), | |
| 214 | − policies: defineTable(PolicySchema) | |
| 215 | − .index("by_domainId", ["id"]) | |
| 216 | − .index("by_workspaceId", ["workspaceId"]), | |
| 217 | − storageInstances: defineTable(StorageInstanceSchema) | |
| 218 | − .index("by_domainId", ["id"]) | |
| 219 | − .index("by_workspaceId", ["workspaceId"]) | |
| 220 | − .index("by_organizationId", ["organizationId"]), | |
| 221 | − storageFileEntries: defineTable(StorageFileEntrySchema) | |
| 222 | − .index("by_domainId", ["id"]) | |
| 223 | − .index("by_storageInstanceId", ["storageInstanceId"]) | |
| 224 | − .index("by_storageInstanceId_path", ["storageInstanceId", "path"]), | |
| 225 | − storageKvEntries: defineTable(StorageKvEntrySchema) | |
| 226 | − .index("by_domainId", ["id"]) | |
| 227 | − .index("by_storageInstanceId", ["storageInstanceId"]) | |
| 228 | − .index("by_storageInstanceId_key", ["storageInstanceId", "key"]), | |
| 229 | − storageSqlKvEntries: defineTable(StorageSqlKvEntrySchema) | |
| 230 | − .index("by_domainId", ["id"]) | |
| 231 | − .index("by_storageInstanceId", ["storageInstanceId"]) | |
| 232 | − .index("by_storageInstanceId_key", ["storageInstanceId", "key"]), | |
| 233 | − approvals: defineTable(ApprovalSchema) | |
| 234 | − .index("by_domainId", ["id"]) | |
| 235 | − .index("by_workspaceId", ["workspaceId"]) | |
| 236 | − .index("by_taskRunId", ["taskRunId"]) | |
| 237 | − .index("by_taskRunId_callId", ["taskRunId", "callId"]), | |
| 238 | − taskRuns: defineTable(TaskRunSchema) | |
| 239 | − .index("by_domainId", ["id"]) | |
| 240 | − .index("by_workspaceId", ["workspaceId"]) | |
| 241 | − .index("by_sessionId", ["sessionId"]), | |
| 242 | − syncStates: defineTable(SyncStateSchema) | |
| 243 | − .index("by_domainId", ["id"]) | |
| 244 | − .index("by_workspaceId", ["workspaceId"]), | |
| 245 | − events: defineTable(EventEnvelopeSchema) | |
| 246 | − .index("by_domainId", ["id"]) | |
| 247 | − .index("by_workspaceId", ["workspaceId"]) | |
| 248 | − .index("by_workspaceId_sequence", ["workspaceId", "sequence"]), | |
| 249 | −}); | |
| 250 | − | |
| 251 | −export type ExecutorConfectTables = TablesFromSchemaDefinition<typeof executorConfectSchema>; | |
| 252 | − | |
| 253 | −export default executorConfectSchema.convexSchemaDefinition; | |
| @@ -1,2128 +0,0 @@ | ||
| 1 | −import { | |
| 2 | − PersistentToolApprovalPolicyStoreError, | |
| 3 | − RuntimeAdapterError, | |
| 4 | − ToolProviderError, | |
| 5 | − createPersistentToolApprovalPolicy, | |
| 6 | − makeOpenApiToolProvider, | |
| 7 | − makeToolProviderRegistry, | |
| 8 | − type PersistentToolApprovalRecord, | |
| 9 | − type PersistentToolApprovalStore, | |
| 10 | − type ToolApprovalPolicy, | |
| 11 | − type ToolRegistry, | |
| 12 | − type ToolRegistryCatalogNamespacesInput, | |
| 13 | − type ToolRegistryCatalogNamespacesOutput, | |
| 14 | − type ToolRegistryCatalogToolsInput, | |
| 15 | − type ToolRegistryCatalogToolsOutput, | |
| 16 | − type ToolRegistryDiscoverDepth, | |
| 17 | − type ToolRegistryDiscoverInput, | |
| 18 | − type ToolRegistryDiscoverOutput, | |
| 19 | − type ToolRegistryDiscoverQueryResult, | |
| 20 | − type ToolRegistryToolSummary, | |
| 21 | −} from "@executor-v2/engine"; | |
| 22 | −import { | |
| 23 | − ApprovalSchema, | |
| 24 | − OpenApiInvocationPayloadSchema, | |
| 25 | − type Approval, | |
| 26 | − type CanonicalToolDescriptor, | |
| 27 | − type Source, | |
| 28 | −} from "@executor-v2/schema"; | |
| 29 | −import type { RuntimeToolCallResult } from "@executor-v2/sdk"; | |
| 30 | −import { v } from "convex/values"; | |
| 31 | −import * as Effect from "effect/Effect"; | |
| 32 | −import * as ParseResult from "effect/ParseResult"; | |
| 33 | −import * as Schema from "effect/Schema"; | |
| 34 | − | |
| 35 | −import { internal } from "./_generated/api"; | |
| 36 | −import { internalAction, internalMutation, type ActionCtx } from "./_generated/server"; | |
| 37 | − | |
| 38 | −const runtimeInternal = internal as any; | |
| 39 | − | |
| 40 | −const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema); | |
| 41 | −const decodeOpenApiInvocationPayload = Schema.decodeUnknownSync(OpenApiInvocationPayloadSchema); | |
| 42 | − | |
| 43 | −const sourceToolRegistryRuntimeKind = "source-tool-registry"; | |
| 44 | −const defaultPendingRetryAfterMs = 1_000; | |
| 45 | −const maxCatalogNamespacesLimit = 5_000; | |
| 46 | −const maxCatalogToolsLimit = 50_000; | |
| 47 | −const maxUnknownToolSuggestions = 3; | |
| 48 | − | |
| 49 | −const readBooleanFlag = (value: string | undefined): boolean => { | |
| 50 | − const normalized = value?.trim().toLowerCase(); | |
| 51 | − return normalized === "1" || normalized === "true" || normalized === "yes"; | |
| 52 | −}; | |
| 53 | − | |
| 54 | −const requireToolApprovalsByDefault = readBooleanFlag( | |
| 55 | − process.env.CONVEX_REQUIRE_TOOL_APPROVALS, | |
| 56 | −); | |
| 57 | − | |
| 58 | −const stripConvexSystemFields = ( | |
| 59 | − value: Record<string, unknown>, | |
| 60 | −): Record<string, unknown> => { | |
| 61 | − const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value; | |
| 62 | − return rest; | |
| 63 | −}; | |
| 64 | − | |
| 65 | −const toRuntimeAdapterError = ( | |
| 66 | − operation: string, | |
| 67 | − message: string, | |
| 68 | − details: string | null, | |
| 69 | −): RuntimeAdapterError => | |
| 70 | − new RuntimeAdapterError({ | |
| 71 | − operation, | |
| 72 | − runtimeKind: sourceToolRegistryRuntimeKind, | |
| 73 | − message, | |
| 74 | − details, | |
| 75 | − }); | |
| 76 | − | |
| 77 | −const normalizePendingRetryAfterMs = (value: number | undefined): number => { | |
| 78 | − if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { | |
| 79 | − return defaultPendingRetryAfterMs; | |
| 80 | − } | |
| 81 | − | |
| 82 | − return Math.round(value); | |
| 83 | −}; | |
| 84 | − | |
| 85 | −const normalizeDeniedError = (value: string | undefined, toolPath: string): string => { | |
| 86 | − const normalized = value?.trim(); | |
| 87 | − if (normalized && normalized.length > 0) { | |
| 88 | − return normalized; | |
| 89 | − } | |
| 90 | − | |
| 91 | − return `Tool call denied: ${toolPath}`; | |
| 92 | −}; | |
| 93 | − | |
| 94 | −const toToolCallResultFromDecision = ( | |
| 95 | − decision: | |
| 96 | − | { kind: "approved" } | |
| 97 | − | { kind: "pending"; approvalId: string; retryAfterMs?: number; error?: string } | |
| 98 | − | { kind: "denied"; error: string }, | |
| 99 | − toolPath: string, | |
| 100 | −): RuntimeToolCallResult => { | |
| 101 | − if (decision.kind === "approved") { | |
| 102 | − return { | |
| 103 | − ok: true, | |
| 104 | − value: undefined, | |
| 105 | − }; | |
| 106 | − } | |
| 107 | − | |
| 108 | − if (decision.kind === "pending") { | |
| 109 | − return { | |
| 110 | − ok: false, | |
| 111 | − kind: "pending", | |
| 112 | − approvalId: decision.approvalId, | |
| 113 | − retryAfterMs: normalizePendingRetryAfterMs(decision.retryAfterMs), | |
| 114 | − error: decision.error, | |
| 115 | − }; | |
| 116 | − } | |
| 117 | − | |
| 118 | − return { | |
| 119 | − ok: false, | |
| 120 | − kind: "denied", | |
| 121 | − error: normalizeDeniedError(decision.error, toolPath), | |
| 122 | − }; | |
| 123 | −}; | |
| 124 | − | |
| 125 | −const serializeInputPreview = (input: Record<string, unknown> | undefined): string => { | |
| 126 | − try { | |
| 127 | − return JSON.stringify(input ?? {}); | |
| 128 | − } catch { | |
| 129 | − return "{}"; | |
| 130 | − } | |
| 131 | −}; | |
| 132 | − | |
| 133 | −const toPersistentApprovalStoreError = ( | |
| 134 | − operation: string, | |
| 135 | − message: string, | |
| 136 | − details: string | null, | |
| 137 | −): PersistentToolApprovalPolicyStoreError => | |
| 138 | − new PersistentToolApprovalPolicyStoreError({ | |
| 139 | − operation, | |
| 140 | − message, | |
| 141 | − details, | |
| 142 | − }); | |
| 143 | − | |
| 144 | −const toPersistentApprovalRecord = ( | |
| 145 | − approval: Approval, | |
| 146 | −): PersistentToolApprovalRecord => ({ | |
| 147 | − approvalId: approval.id, | |
| 148 | − workspaceId: approval.workspaceId, | |
| 149 | − runId: approval.taskRunId, | |
| 150 | − callId: approval.callId, | |
| 151 | − toolPath: approval.toolPath, | |
| 152 | − status: approval.status, | |
| 153 | − reason: approval.reason, | |
| 154 | −}); | |
| 155 | − | |
| 156 | −const normalizeToolPathForLookup = (path: string): string => | |
| 157 | − path | |
| 158 | − .trim() | |
| 159 | − .toLowerCase() | |
| 160 | − .replace(/[^a-z0-9]+/g, ".") | |
| 161 | − .replace(/\.+/g, ".") | |
| 162 | − .replace(/^\.|\.$/g, ""); | |
| 163 | − | |
| 164 | −const safeJsonParse = (value: string): unknown => { | |
| 165 | − try { | |
| 166 | − return JSON.parse(value); | |
| 167 | − } catch { | |
| 168 | − return null; | |
| 169 | − } | |
| 170 | −}; | |
| 171 | − | |
| 172 | −const jsonObjectFromUnknown = (value: unknown): Record<string, unknown> => { | |
| 173 | − if (!value || typeof value !== "object" || Array.isArray(value)) { | |
| 174 | − return {}; | |
| 175 | − } | |
| 176 | − | |
| 177 | − return value as Record<string, unknown>; | |
| 178 | −}; | |
| 179 | − | |
| 180 | −const normalizeHeaderString = (value: unknown): string | null => { | |
| 181 | − if (typeof value !== "string") { | |
| 182 | − return null; | |
| 183 | − } | |
| 184 | − | |
| 185 | − const trimmed = value.trim(); | |
| 186 | − return trimmed.length > 0 ? trimmed : null; | |
| 187 | −}; | |
| 188 | − | |
| 189 | −const toStringRecord = (value: Record<string, unknown>): Record<string, string> => { | |
| 190 | − const normalized: Record<string, string> = {}; | |
| 191 | − | |
| 192 | − for (const [rawKey, rawValue] of Object.entries(value)) { | |
| 193 | − const key = normalizeHeaderString(rawKey); | |
| 194 | − const headerValue = normalizeHeaderString(rawValue); | |
| 195 | − if (key && headerValue) { | |
| 196 | − normalized[key] = headerValue; | |
| 197 | − } | |
| 198 | − } | |
| 199 | − | |
| 200 | − return normalized; | |
| 201 | −}; | |
| 202 | − | |
| 203 | −const parseSourceConfig = (source: Source): Record<string, unknown> => | |
| 204 | − jsonObjectFromUnknown(safeJsonParse(source.configJson)); | |
| 205 | − | |
| 206 | −const collectConfiguredHeadersFromSourceConfig = ( | |
| 207 | − config: Record<string, unknown>, | |
| 208 | −): Record<string, string> => { | |
| 209 | − const headers = jsonObjectFromUnknown(config.headers); | |
| 210 | − const nextHeaders: Record<string, string> = {}; | |
| 211 | − | |
| 212 | − for (const [rawKey, rawValue] of Object.entries(headers)) { | |
| 213 | − const key = normalizeHeaderString(rawKey); | |
| 214 | − const value = normalizeHeaderString(rawValue); | |
| 215 | − if (key && value) { | |
| 216 | − nextHeaders[key] = value; | |
| 217 | − } | |
| 218 | − } | |
| 219 | − | |
| 220 | − return nextHeaders; | |
| 221 | −}; | |
| 222 | − | |
| 223 | −const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | |
| 224 | − const merged: Record<string, string> = {}; | |
| 225 | − const keyByLower = new Map<string, string>(); | |
| 226 | − | |
| 227 | − for (const headers of sets) { | |
| 228 | − for (const [rawKey, rawValue] of Object.entries(headers)) { | |
| 229 | − const key = rawKey.trim(); | |
| 230 | − const value = rawValue.trim(); | |
| 231 | − if (key.length === 0 || value.length === 0) { | |
| 232 | − continue; | |
| 233 | − } | |
| 234 | − | |
| 235 | − const normalizedKey = key.toLowerCase(); | |
| 236 | − const existingKey = keyByLower.get(normalizedKey); | |
| 237 | − if (existingKey && existingKey !== key) { | |
| 238 | − delete merged[existingKey]; | |
| 239 | − } | |
| 240 | − | |
| 241 | − keyByLower.set(normalizedKey, key); | |
| 242 | − merged[key] = value; | |
| 243 | − } | |
| 244 | − } | |
| 245 | − | |
| 246 | − return merged; | |
| 247 | −}; | |
| 248 | − | |
| 249 | −const resolveSourceCredentialHeadersForInvocation = async ( | |
| 250 | − ctx: ActionCtx, | |
| 251 | − source: Source, | |
| 252 | − accountId: string, | |
| 253 | − options?: { | |
| 254 | − forceRefresh?: boolean; | |
| 255 | − }, | |
| 256 | −): Promise<{ | |
| 257 | − headers: Record<string, string>; | |
| 258 | − oauthConnectionId: string | null; | |
| 259 | −}> => { | |
| 260 | − try { | |
| 261 | − const resolved = (await ctx.runAction( | |
| 262 | − runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, | |
| 263 | − { | |
| 264 | − workspaceId: source.workspaceId, | |
| 265 | − sourceId: source.id, | |
| 266 | − accountId, | |
| 267 | − ...(options?.forceRefresh === true ? { forceRefresh: true } : {}), | |
| 268 | − }, | |
| 269 | − )) as { headers?: unknown; oauthConnectionId?: unknown }; | |
| 270 | − | |
| 271 | − const headers = jsonObjectFromUnknown(resolved.headers); | |
| 272 | − const normalized: Record<string, string> = {}; | |
| 273 | − | |
| 274 | − for (const [rawKey, rawValue] of Object.entries(headers)) { | |
| 275 | − const key = normalizeHeaderString(rawKey); | |
| 276 | − const value = normalizeHeaderString(rawValue); | |
| 277 | − if (key && value) { | |
| 278 | − normalized[key] = value; | |
| 279 | − } | |
| 280 | − } | |
| 281 | − | |
| 282 | − return { | |
| 283 | − headers: normalized, | |
| 284 | − oauthConnectionId: | |
| 285 | − typeof resolved.oauthConnectionId === "string" && resolved.oauthConnectionId.trim().length > 0 | |
| 286 | − ? resolved.oauthConnectionId | |
| 287 | − : null, | |
| 288 | − }; | |
| 289 | − } catch { | |
| 290 | − return { | |
| 291 | − headers: {}, | |
| 292 | − oauthConnectionId: null, | |
| 293 | − }; | |
| 294 | − } | |
| 295 | −}; | |
| 296 | − | |
| 297 | −const resolveSourceHeadersForInvocation = async ( | |
| 298 | − ctx: ActionCtx, | |
| 299 | − source: Source, | |
| 300 | − accountId: string, | |
| 301 | − options?: { | |
| 302 | − forceRefresh?: boolean; | |
| 303 | − }, | |
| 304 | −): Promise<{ | |
| 305 | − headers: Record<string, string>; | |
| 306 | − oauthConnectionId: string | null; | |
| 307 | −}> => { | |
| 308 | − const config = parseSourceConfig(source); | |
| 309 | − const credentialResolved = await resolveSourceCredentialHeadersForInvocation( | |
| 310 | − ctx, | |
| 311 | − source, | |
| 312 | − accountId, | |
| 313 | − options, | |
| 314 | − ); | |
| 315 | − | |
| 316 | − return { | |
| 317 | − headers: mergeHeaders( | |
| 318 | − collectConfiguredHeadersFromSourceConfig(config), | |
| 319 | − credentialResolved.headers, | |
| 320 | − ), | |
| 321 | − oauthConnectionId: credentialResolved.oauthConnectionId, | |
| 322 | − }; | |
| 323 | −}; | |
| 324 | − | |
| 325 | −const sourceWithInvocationHeaders = ( | |
| 326 | − source: Source, | |
| 327 | − invocationHeaders: Record<string, string>, | |
| 328 | −): Source => { | |
| 329 | − const config = parseSourceConfig(source); | |
| 330 | − const mergedHeaders = mergeHeaders( | |
| 331 | − collectConfiguredHeadersFromSourceConfig(config), | |
| 332 | − invocationHeaders, | |
| 333 | − ); | |
| 334 | − | |
| 335 | − const nextConfig: Record<string, unknown> = { | |
| 336 | − ...config, | |
| 337 | − }; | |
| 338 | − | |
| 339 | − if (Object.keys(mergedHeaders).length > 0) { | |
| 340 | − nextConfig.headers = mergedHeaders; | |
| 341 | − } else { | |
| 342 | − delete nextConfig.headers; | |
| 343 | − } | |
| 344 | − | |
| 345 | − return { | |
| 346 | − ...source, | |
| 347 | − configJson: JSON.stringify(nextConfig), | |
| 348 | − }; | |
| 349 | −}; | |
| 350 | − | |
| 351 | −const readHeaderCaseInsensitive = ( | |
| 352 | − headers: Record<string, string>, | |
| 353 | − name: string, | |
| 354 | −): string | null => { | |
| 355 | − const target = name.toLowerCase(); | |
| 356 | − | |
| 357 | − for (const [key, value] of Object.entries(headers)) { | |
| 358 | − if (key.toLowerCase() === target) { | |
| 359 | − return value; | |
| 360 | − } | |
| 361 | − } | |
| 362 | − | |
| 363 | − return null; | |
| 364 | −}; | |
| 365 | − | |
| 366 | −const textHasInvalidTokenMarker = (value: string): boolean => { | |
| 367 | − const normalized = value.toLowerCase(); | |
| 368 | − return normalized.includes("invalid_token") || normalized.includes("invalid token"); | |
| 369 | −}; | |
| 370 | − | |
| 371 | −const valueHasInvalidTokenMarker = (value: unknown): boolean => { | |
| 372 | − if (typeof value === "string") { | |
| 373 | − return textHasInvalidTokenMarker(value); | |
| 374 | − } | |
| 375 | − | |
| 376 | − if (Array.isArray(value)) { | |
| 377 | − return value.some((entry) => valueHasInvalidTokenMarker(entry)); | |
| 378 | − } | |
| 379 | − | |
| 380 | − if (!value || typeof value !== "object") { | |
| 381 | − return false; | |
| 382 | − } | |
| 383 | − | |
| 384 | − const record = value as Record<string, unknown>; | |
| 385 | − if (valueHasInvalidTokenMarker(record.error)) { | |
| 386 | − return true; | |
| 387 | − } | |
| 388 | − | |
| 389 | − if (valueHasInvalidTokenMarker(record.error_description)) { | |
| 390 | − return true; | |
| 391 | − } | |
| 392 | − | |
| 393 | − if (valueHasInvalidTokenMarker(record.message)) { | |
| 394 | − return true; | |
| 395 | − } | |
| 396 | − | |
| 397 | − if (valueHasInvalidTokenMarker(record.code)) { | |
| 398 | − return true; | |
| 399 | − } | |
| 400 | − | |
| 401 | − if (Array.isArray(record.errors) && record.errors.some((entry) => valueHasInvalidTokenMarker(entry))) { | |
| 402 | − return true; | |
| 403 | − } | |
| 404 | − | |
| 405 | − return Object.values(record).some((entry) => valueHasInvalidTokenMarker(entry)); | |
| 406 | −}; | |
| 407 | − | |
| 408 | −type HttpInvocationEnvelope = { | |
| 409 | − status: number | null; | |
| 410 | − headers: Record<string, string>; | |
| 411 | − body: unknown; | |
| 412 | −}; | |
| 413 | − | |
| 414 | −const toHttpInvocationEnvelope = (output: unknown): HttpInvocationEnvelope => { | |
| 415 | − if (!output || typeof output !== "object" || Array.isArray(output)) { | |
| 416 | − return { | |
| 417 | − status: null, | |
| 418 | − headers: {}, | |
| 419 | − body: null, | |
| 420 | − }; | |
| 421 | − } | |
| 422 | − | |
| 423 | − const record = output as Record<string, unknown>; | |
| 424 | − const rawStatus = record.status; | |
| 425 | − const rawHeaders = record.headers; | |
| 426 | − | |
| 427 | − return { | |
| 428 | − status: | |
| 429 | − typeof rawStatus === "number" && Number.isFinite(rawStatus) | |
| 430 | − ? Math.floor(rawStatus) | |
| 431 | − : null, | |
| 432 | − headers: | |
| 433 | − rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders) | |
| 434 | − ? toStringRecord(rawHeaders as Record<string, unknown>) | |
| 435 | − : {}, | |
| 436 | − body: record.body, | |
| 437 | − }; | |
| 438 | −}; | |
| 439 | − | |
| 440 | −const responseHasInvalidTokenSignal = (envelope: HttpInvocationEnvelope): boolean => { | |
| 441 | − if (envelope.status !== 401) { | |
| 442 | − return false; | |
| 443 | − } | |
| 444 | − | |
| 445 | − const wwwAuthenticate = readHeaderCaseInsensitive(envelope.headers, "www-authenticate"); | |
| 446 | − if (wwwAuthenticate && textHasInvalidTokenMarker(wwwAuthenticate)) { | |
| 447 | − return true; | |
| 448 | − } | |
| 449 | − | |
| 450 | − return valueHasInvalidTokenMarker(envelope.body); | |
| 451 | −}; | |
| 452 | − | |
| 453 | −const shouldRetryOAuthAfterUnauthorized = ( | |
| 454 | − oauthConnectionId: string | null, | |
| 455 | − output: unknown, | |
| 456 | −): boolean => { | |
| 457 | − if (!oauthConnectionId) { | |
| 458 | − return false; | |
| 459 | − } | |
| 460 | − | |
| 461 | − return responseHasInvalidTokenSignal(toHttpInvocationEnvelope(output)); | |
| 462 | −}; | |
| 463 | − | |
| 464 | −const headersToRecord = (headers: Headers): Record<string, string> => { | |
| 465 | − const record: Record<string, string> = {}; | |
| 466 | − headers.forEach((value, key) => { | |
| 467 | − record[key] = value; | |
| 468 | − }); | |
| 469 | − return record; | |
| 470 | −}; | |
| 471 | − | |
| 472 | −const GraphqlInvocationArgSchema = Schema.Struct({ | |
| 473 | − name: Schema.String, | |
| 474 | − description: Schema.NullOr(Schema.String), | |
| 475 | − required: Schema.Boolean, | |
| 476 | − type: Schema.String, | |
| 477 | − defaultValue: Schema.NullOr(Schema.String), | |
| 478 | −}); | |
| 479 | − | |
| 480 | −const GraphqlRawInvocationSchema = Schema.Struct({ | |
| 481 | − kind: Schema.Literal("graphql_raw"), | |
| 482 | − endpoint: Schema.String, | |
| 483 | −}); | |
| 484 | − | |
| 485 | −const GraphqlFieldInvocationSchema = Schema.Struct({ | |
| 486 | − kind: Schema.Literal("graphql_field"), | |
| 487 | − endpoint: Schema.String, | |
| 488 | − operationType: Schema.Literal("query", "mutation"), | |
| 489 | − fieldName: Schema.String, | |
| 490 | − args: Schema.Array(GraphqlInvocationArgSchema), | |
| 491 | −}); | |
| 492 | − | |
| 493 | −const GraphqlInvocationPayloadSchema = Schema.Union( | |
| 494 | − GraphqlRawInvocationSchema, | |
| 495 | − GraphqlFieldInvocationSchema, | |
| 496 | −); | |
| 497 | − | |
| 498 | −type GraphqlInvocationPayload = typeof GraphqlInvocationPayloadSchema.Type; | |
| 499 | − | |
| 500 | −const decodeGraphqlInvocationPayload = Schema.decodeUnknownSync(GraphqlInvocationPayloadSchema); | |
| 501 | − | |
| 502 | −const McpInvocationPayloadSchema = Schema.Struct({ | |
| 503 | − kind: Schema.Literal("mcp_tool"), | |
| 504 | − endpoint: Schema.String, | |
| 505 | − transport: Schema.Literal("streamable-http", "sse"), | |
| 506 | − queryParams: Schema.Record({ | |
| 507 | − key: Schema.String, | |
| 508 | − value: Schema.String, | |
| 509 | − }), | |
| 510 | − toolName: Schema.String, | |
| 511 | − inputSchema: Schema.optional(Schema.Unknown), | |
| 512 | − outputSchema: Schema.optional(Schema.Unknown), | |
| 513 | −}); | |
| 514 | − | |
| 515 | −type McpInvocationPayload = typeof McpInvocationPayloadSchema.Type; | |
| 516 | − | |
| 517 | −const decodeMcpInvocationPayload = Schema.decodeUnknownSync(McpInvocationPayloadSchema); | |
| 518 | − | |
| 519 | −const asOptionalString = (value: unknown): string | null => | |
| 520 | − typeof value === "string" && value.trim().length > 0 ? value : null; | |
| 521 | − | |
| 522 | −const normalizeInvokeInput = (input: unknown): Record<string, unknown> => { | |
| 523 | − if (!input || typeof input !== "object" || Array.isArray(input)) { | |
| 524 | − return {}; | |
| 525 | − } | |
| 526 | − | |
| 527 | − return input as Record<string, unknown>; | |
| 528 | −}; | |
| 529 | − | |
| 530 | −const createGraphqlOperationQuery = ( | |
| 531 | − payload: Extract<GraphqlInvocationPayload, { kind: "graphql_field" }>, | |
| 532 | − args: Record<string, unknown>, | |
| 533 | −): { | |
| 534 | − query: string; | |
| 535 | − variables: Record<string, unknown>; | |
| 536 | −} => { | |
| 537 | − const definitions: Array<string> = []; | |
| 538 | − const callArgs: Array<string> = []; | |
| 539 | − const variables: Record<string, unknown> = {}; | |
| 540 | − | |
| 541 | − for (const arg of payload.args) { | |
| 542 | − const value = args[arg.name]; | |
| 543 | − | |
| 544 | − if (value === undefined || value === null) { | |
| 545 | − if (arg.required) { | |
| 546 | − throw new Error(`Missing required GraphQL argument: ${arg.name}`); | |
| 547 | − } | |
| 548 | − | |
| 549 | − continue; | |
| 550 | − } | |
| 551 | − | |
| 552 | − const varType = arg.type.trim().length > 0 ? arg.type : "String"; | |
| 553 | − definitions.push(`$${arg.name}: ${varType}`); | |
| 554 | − callArgs.push(`${arg.name}: $${arg.name}`); | |
| 555 | − variables[arg.name] = value; | |
| 556 | − } | |
| 557 | − | |
| 558 | − const variableDefs = definitions.length > 0 ? `(${definitions.join(", ")})` : ""; | |
| 559 | − const fieldArgs = callArgs.length > 0 ? `(${callArgs.join(", ")})` : ""; | |
| 560 | − const selectionSet = asOptionalString(args.selectionSet); | |
| 561 | − const fieldSelection = | |
| 562 | − selectionSet && selectionSet.length > 0 | |
| 563 | − ? `${payload.fieldName}${fieldArgs} { ${selectionSet} }` | |
| 564 | − : `${payload.fieldName}${fieldArgs}`; | |
| 565 | − const query = `${payload.operationType}${variableDefs} { ${fieldSelection} }`; | |
| 566 | − | |
| 567 | − return { query, variables }; | |
| 568 | −}; | |
| 569 | − | |
| 570 | −const graphqlResponseBody = async (response: Response): Promise<unknown> => { | |
| 571 | − const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | |
| 572 | − if (contentType.includes("application/json")) { | |
| 573 | − return await response.json(); | |
| 574 | − } | |
| 575 | − | |
| 576 | − return await response.text(); | |
| 577 | −}; | |
| 578 | − | |
| 579 | −const createMcpEndpointUrl = (payload: McpInvocationPayload): URL => { | |
| 580 | − const url = new URL(payload.endpoint); | |
| 581 | − | |
| 582 | − for (const [key, value] of Object.entries(payload.queryParams)) { | |
| 583 | − url.searchParams.set(key, value); | |
| 584 | − } | |
| 585 | − | |
| 586 | − return url; | |
| 587 | −}; | |
| 588 | − | |
| 589 | −const postMcpJsonRpc = async ( | |
| 590 | − endpoint: URL, | |
| 591 | − body: unknown, | |
| 592 | − sessionId: string | null, | |
| 593 | − sourceHeaders: Record<string, string>, | |
| 594 | −): Promise<Response> => { | |
| 595 | − const headers = new Headers({ | |
| 596 | − "content-type": "application/json", | |
| 597 | − accept: "application/json, text/event-stream", | |
| 598 | − }); | |
| 599 | − | |
| 600 | − for (const [key, value] of Object.entries(sourceHeaders)) { | |
| 601 | − headers.set(key, value); | |
| 602 | − } | |
| 603 | − | |
| 604 | − if (sessionId && sessionId.trim().length > 0) { | |
| 605 | − headers.set("mcp-session-id", sessionId); | |
| 606 | − } | |
| 607 | − | |
| 608 | − return await fetch(endpoint, { | |
| 609 | − method: "POST", | |
| 610 | − headers, | |
| 611 | − body: JSON.stringify(body), | |
| 612 | − }); | |
| 613 | −}; | |
| 614 | − | |
| 615 | −const decodeMcpJsonResponse = async (response: Response): Promise<Record<string, unknown>> => { | |
| 616 | − const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | |
| 617 | − | |
| 618 | − if (!contentType.includes("application/json")) { | |
| 619 | − const text = await response.text(); | |
| 620 | − return { raw: text }; | |
| 621 | − } | |
| 622 | − | |
| 623 | − return jsonObjectFromUnknown(await response.json()); | |
| 624 | −}; | |
| 625 | − | |
| 626 | −const describeOutput = (value: unknown): string => { | |
| 627 | − if (typeof value === "string") { | |
| 628 | − return value; | |
| 629 | − } | |
| 630 | − | |
| 631 | − try { | |
| 632 | − return JSON.stringify(value); | |
| 633 | − } catch { | |
| 634 | − return String(value); | |
| 635 | − } | |
| 636 | −}; | |
| 637 | − | |
| 638 | −const deriveHintFromSchemaJson = ( | |
| 639 | − schemaJson: string | null | undefined, | |
| 640 | − fallback: string, | |
| 641 | −): string => { | |
| 642 | − if (!schemaJson) { | |
| 643 | − return fallback; | |
| 644 | − } | |
| 645 | − | |
| 646 | − const schema = safeJsonParse(schemaJson); | |
| 647 | − if (!schema || typeof schema !== "object" || Array.isArray(schema)) { | |
| 648 | − return fallback; | |
| 649 | − } | |
| 650 | − | |
| 651 | − const schemaRecord = schema as Record<string, unknown>; | |
| 652 | − const title = schemaRecord.title; | |
| 653 | − if (typeof title === "string" && title.trim().length > 0) { | |
| 654 | − return title.trim(); | |
| 655 | − } | |
| 656 | − | |
| 657 | − const type = schemaRecord.type; | |
| 658 | − if (type === "object") { | |
| 659 | − const properties = schemaRecord.properties; | |
| 660 | − if (properties && typeof properties === "object" && !Array.isArray(properties)) { | |
| 661 | − const keys = Object.keys(properties); | |
| 662 | − if (keys.length > 0) { | |
| 663 | − const shown = keys.slice(0, 3).join(", "); | |
| 664 | − return keys.length <= 3 ? `object { ${shown} }` : `object { ${shown}, ... }`; | |
| 665 | − } | |
| 666 | − } | |
| 667 | − | |
| 668 | − return "object"; | |
| 669 | − } | |
| 670 | − | |
| 671 | − if (type === "array") { | |
| 672 | − return "array"; | |
| 673 | − } | |
| 674 | − | |
| 675 | − if (typeof type === "string") { | |
| 676 | − return type; | |
| 677 | − } | |
| 678 | − | |
| 679 | − return fallback; | |
| 680 | −}; | |
| 681 | − | |
| 682 | −type WorkspaceToolIndexRow = { | |
| 683 | − workspaceId: string; | |
| 684 | − sourceId: string; | |
| 685 | − sourceName: string; | |
| 686 | − sourceKind: string; | |
| 687 | − artifactId: string; | |
| 688 | − toolId: string; | |
| 689 | − protocol: string; | |
| 690 | − namespace: string; | |
| 691 | − path: string; | |
| 692 | − pathLower: string; | |
| 693 | − normalizedPath: string; | |
| 694 | − name: string; | |
| 695 | − description: string | null; | |
| 696 | − operationHash: string; | |
| 697 | − approvalMode: string; | |
| 698 | − status: string; | |
| 699 | − refHintTableJson?: string | null; | |
| 700 | −}; | |
| 701 | − | |
| 702 | −type ArtifactToolRow = { | |
| 703 | − artifactId: string; | |
| 704 | − protocol: string; | |
| 705 | − toolId: string; | |
| 706 | − name: string; | |
| 707 | − description: string | null; | |
| 708 | − canonicalPath: string; | |
| 709 | − operationHash: string; | |
| 710 | − invocationJson: string; | |
| 711 | − inputSchemaJson?: string | null; | |
| 712 | − outputSchemaJson?: string | null; | |
| 713 | − metadataJson?: string | null; | |
| 714 | −}; | |
| 715 | − | |
| 716 | −const toToolProviderDetails = (cause: unknown): string => | |
| 717 | − ParseResult.isParseError(cause) | |
| 718 | − ? ParseResult.TreeFormatter.formatErrorSync(cause) | |
| 719 | − : String(cause); | |
| 720 | − | |
| 721 | −const toToolProviderError = ( | |
| 722 | − providerKind: "openapi" | "graphql" | "mcp", | |
| 723 | − operation: string, | |
| 724 | − message: string, | |
| 725 | − cause: unknown, | |
| 726 | −): ToolProviderError => | |
| 727 | − new ToolProviderError({ | |
| 728 | − providerKind, | |
| 729 | − operation, | |
| 730 | − message, | |
| 731 | − details: toToolProviderDetails(cause), | |
| 732 | − }); | |
| 733 | − | |
| 734 | −const tokenizeSearchQuery = (query: string): Array<string> => | |
| 735 | − Array.from( | |
| 736 | − new Set( | |
| 737 | − query | |
| 738 | − .toLowerCase() | |
| 739 | − .split(/[\s._:/-]+/) | |
| 740 | − .map((part) => part.trim()) | |
| 741 | − .filter((part) => part.length > 0), | |
| 742 | − ), | |
| 743 | − ); | |
| 744 | − | |
| 745 | −const normalizeDiscoverDepth = (value: unknown): ToolRegistryDiscoverDepth => { | |
| 746 | − if (typeof value !== "number" || !Number.isFinite(value)) { | |
| 747 | − return 1; | |
| 748 | − } | |
| 749 | − | |
| 750 | − if (value <= 0) { | |
| 751 | − return 0; | |
| 752 | − } | |
| 753 | − | |
| 754 | − if (value >= 2) { | |
| 755 | − return 2; | |
| 756 | − } | |
| 757 | − | |
| 758 | − return 1; | |
| 759 | −}; | |
| 760 | − | |
| 761 | −type NormalizedDiscoverQuery = { | |
| 762 | − text: string; | |
| 763 | − lowerText: string; | |
| 764 | − depth: ToolRegistryDiscoverDepth; | |
| 765 | −}; | |
| 766 | − | |
| 767 | −const normalizeDiscoverQueries = ( | |
| 768 | − input: ToolRegistryDiscoverInput, | |
| 769 | −): Array<NormalizedDiscoverQuery> => { | |
| 770 | − const normalized = (input.queries ?? []).map((query) => { | |
| 771 | − const text = query.text.trim(); | |
| 772 | − return { | |
| 773 | − text, | |
| 774 | − lowerText: text.toLowerCase(), | |
| 775 | − depth: normalizeDiscoverDepth(query.depth), | |
| 776 | − }; | |
| 777 | − }); | |
| 778 | − | |
| 779 | − if (normalized.length > 0) { | |
| 780 | − return normalized; | |
| 781 | − } | |
| 782 | − | |
| 783 | − const fallbackText = (input.query ?? "").trim(); | |
| 784 | − return [ | |
| 785 | − { | |
| 786 | − text: fallbackText, | |
| 787 | − lowerText: fallbackText.toLowerCase(), | |
| 788 | − depth: 1, | |
| 789 | − }, | |
| 790 | − ]; | |
| 791 | −}; | |
| 792 | − | |
| 793 | −const scoreSummary = ( | |
| 794 | − summary: ToolRegistryToolSummary, | |
| 795 | − query: string, | |
| 796 | − depth: ToolRegistryDiscoverDepth = 1, | |
| 797 | −): number => { | |
| 798 | − if (query.length === 0) { | |
| 799 | − return 1; | |
| 800 | − } | |
| 801 | − | |
| 802 | − const lowerQuery = query.toLowerCase(); | |
| 803 | − const lowerPath = summary.path.toLowerCase(); | |
| 804 | − const lowerSource = (summary.source ?? "").toLowerCase(); | |
| 805 | − const lowerDescription = (summary.description ?? "").toLowerCase(); | |
| 806 | − | |
| 807 | − if (lowerPath === lowerQuery) { | |
| 808 | − return 100; | |
| 809 | − } | |
| 810 | − | |
| 811 | − if (lowerPath.startsWith(lowerQuery)) { | |
| 812 | − return 80; | |
| 813 | − } | |
| 814 | − | |
| 815 | − if (lowerPath.includes(lowerQuery)) { | |
| 816 | − return 60; | |
| 817 | − } | |
| 818 | − | |
| 819 | − if (lowerSource.includes(lowerQuery)) { | |
| 820 | − return 40; | |
| 821 | − } | |
| 822 | − | |
| 823 | − if (lowerDescription.includes(lowerQuery)) { | |
| 824 | − return 30; | |
| 825 | − } | |
| 826 | − | |
| 827 | − if (depth === 0) { | |
| 828 | − return 0; | |
| 829 | − } | |
| 830 | − | |
| 831 | − const tokens = tokenizeSearchQuery(lowerQuery); | |
| 832 | − if (tokens.length === 0) { | |
| 833 | − return 0; | |
| 834 | − } | |
| 835 | − | |
| 836 | − let pathMatches = 0; | |
| 837 | − let sourceMatches = 0; | |
| 838 | − let descriptionMatches = 0; | |
| 839 | − | |
| 840 | − for (const token of tokens) { | |
| 841 | − if (lowerPath.includes(token)) { | |
| 842 | − pathMatches += 1; | |
| 843 | − continue; | |
| 844 | − } | |
| 845 | − | |
| 846 | − if (lowerSource.includes(token)) { | |
| 847 | − sourceMatches += 1; | |
| 848 | − continue; | |
| 849 | − } | |
| 850 | − | |
| 851 | − if (depth >= 2 && lowerDescription.includes(token)) { | |
| 852 | − descriptionMatches += 1; | |
| 853 | − } | |
| 854 | − } | |
| 855 | − | |
| 856 | − const matchedTokens = pathMatches + sourceMatches + descriptionMatches; | |
| 857 | − if (matchedTokens === 0) { | |
| 858 | − return 0; | |
| 859 | − } | |
| 860 | − | |
| 861 | − let score = | |
| 862 | − pathMatches * 18 + | |
| 863 | − sourceMatches * 12 + | |
| 864 | − descriptionMatches * 8; | |
| 865 | − | |
| 866 | − score += matchedTokens === tokens.length ? 20 : 5; | |
| 867 | − | |
| 868 | − return score; | |
| 869 | −}; | |
| 870 | − | |
| 871 | −const levenshteinDistance = (left: string, right: string): number => { | |
| 872 | − if (left === right) { | |
| 873 | − return 0; | |
| 874 | − } | |
| 875 | − | |
| 876 | − if (left.length === 0) { | |
| 877 | − return right.length; | |
| 878 | − } | |
| 879 | − | |
| 880 | − if (right.length === 0) { | |
| 881 | − return left.length; | |
| 882 | − } | |
| 883 | − | |
| 884 | − const previous = new Array<number>(right.length + 1); | |
| 885 | − const current = new Array<number>(right.length + 1); | |
| 886 | − | |
| 887 | − for (let column = 0; column <= right.length; column += 1) { | |
| 888 | − previous[column] = column; | |
| 889 | − } | |
| 890 | − | |
| 891 | − for (let row = 1; row <= left.length; row += 1) { | |
| 892 | − current[0] = row; | |
| 893 | − | |
| 894 | − for (let column = 1; column <= right.length; column += 1) { | |
| 895 | − const substitutionCost = left[row - 1] === right[column - 1] ? 0 : 1; | |
| 896 | − current[column] = Math.min( | |
| 897 | − previous[column] + 1, | |
| 898 | − current[column - 1] + 1, | |
| 899 | − previous[column - 1] + substitutionCost, | |
| 900 | − ); | |
| 901 | − } | |
| 902 | − | |
| 903 | − for (let column = 0; column <= right.length; column += 1) { | |
| 904 | − previous[column] = current[column]; | |
| 905 | − } | |
| 906 | − } | |
| 907 | − | |
| 908 | − return previous[right.length] ?? right.length; | |
| 909 | −}; | |
| 910 | − | |
| 911 | −const uniquePaths = (paths: ReadonlyArray<string>): Array<string> => { | |
| 912 | − const seen = new Set<string>(); | |
| 913 | − const ordered: Array<string> = []; | |
| 914 | − | |
| 915 | − for (const path of paths) { | |
| 916 | − if (!seen.has(path)) { | |
| 917 | − seen.add(path); | |
| 918 | − ordered.push(path); | |
| 919 | − } | |
| 920 | − | |
| 921 | − if (ordered.length >= maxUnknownToolSuggestions) { | |
| 922 | − break; | |
| 923 | − } | |
| 924 | − } | |
| 925 | − | |
| 926 | − return ordered; | |
| 927 | −}; | |
| 928 | − | |
| 929 | −const toToolSummary = ( | |
| 930 | − row: WorkspaceToolIndexRow, | |
| 931 | − options: { | |
| 932 | − includeSchemas: boolean; | |
| 933 | − compact: boolean; | |
| 934 | − artifactTool?: ArtifactToolRow | null; | |
| 935 | − }, | |
| 936 | −): ToolRegistryToolSummary => { | |
| 937 | − const inputSchemaJson = options.artifactTool?.inputSchemaJson ?? undefined; | |
| 938 | − const outputSchemaJson = options.artifactTool?.outputSchemaJson ?? undefined; | |
| 939 | − | |
| 940 | − return { | |
| 941 | − path: row.path, | |
| 942 | − source: row.sourceName, | |
| 943 | − approval: row.approvalMode === "required" ? "required" : "auto", | |
| 944 | − description: options.compact ? undefined : row.description ?? undefined, | |
| 945 | − inputHint: options.compact | |
| 946 | − ? undefined | |
| 947 | − : options.artifactTool | |
| 948 | − ? deriveHintFromSchemaJson(inputSchemaJson ?? null, "input") | |
| 949 | − : undefined, | |
| 950 | − outputHint: options.compact | |
| 951 | − ? undefined | |
| 952 | − : options.artifactTool | |
| 953 | − ? deriveHintFromSchemaJson(outputSchemaJson ?? null, "output") | |
| 954 | − : undefined, | |
| 955 | − typing: | |
| 956 | − options.includeSchemas && (inputSchemaJson || outputSchemaJson) | |
| 957 | − ? { | |
| 958 | − inputSchemaJson, | |
| 959 | − outputSchemaJson, | |
| 960 | − refHintKeys: [], | |
| 961 | − } | |
| 962 | − : undefined, | |
| 963 | − }; | |
| 964 | −}; | |
| 965 | − | |
| 966 | −const toSearchScoringSummary = (row: WorkspaceToolIndexRow): ToolRegistryToolSummary => ({ | |
| 967 | − path: row.path, | |
| 968 | − source: row.sourceName, | |
| 969 | − approval: row.approvalMode === "required" ? "required" : "auto", | |
| 970 | − description: row.description ?? undefined, | |
| 971 | −}); | |
| 972 | − | |
| 973 | −const hydrateRowsWithArtifactTools = ( | |
| 974 | − ctx: ActionCtx, | |
| 975 | − rows: ReadonlyArray<WorkspaceToolIndexRow>, | |
| 976 | −): Effect.Effect<Array<{ row: WorkspaceToolIndexRow; artifactTool: ArtifactToolRow | null }>, RuntimeAdapterError> => | |
| 977 | − Effect.forEach( | |
| 978 | − rows, | |
| 979 | − (row) => | |
| 980 | − getArtifactToolEffect(ctx, row.artifactId, row.toolId).pipe( | |
| 981 | − Effect.map((artifactTool) => ({ | |
| 982 | − row, | |
| 983 | − artifactTool, | |
| 984 | − })), | |
| 985 | − ), | |
| 986 | − { concurrency: 8 }, | |
| 987 | − ); | |
| 988 | − | |
| 989 | −const parseRefHintTableJson = (value: string | null | undefined): Record<string, string> | null => { | |
| 990 | − if (!value) { | |
| 991 | − return null; | |
| 992 | − } | |
| 993 | − | |
| 994 | − const parsed = safeJsonParse(value); | |
| 995 | − if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 996 | − return null; | |
| 997 | − } | |
| 998 | − | |
| 999 | − const next: Record<string, string> = {}; | |
| 1000 | − for (const [key, raw] of Object.entries(parsed as Record<string, unknown>)) { | |
| 1001 | − if (typeof raw === "string") { | |
| 1002 | − next[key] = raw; | |
| 1003 | − } | |
| 1004 | − } | |
| 1005 | − | |
| 1006 | − return Object.keys(next).length > 0 ? next : null; | |
| 1007 | −}; | |
| 1008 | − | |
| 1009 | −const mergeRefHintTables = ( | |
| 1010 | − rows: ReadonlyArray<WorkspaceToolIndexRow>, | |
| 1011 | − includeSchemas: boolean, | |
| 1012 | −): Record<string, string> | undefined => { | |
| 1013 | − if (!includeSchemas) { | |
| 1014 | − return undefined; | |
| 1015 | − } | |
| 1016 | − | |
| 1017 | − const merged: Record<string, string> = {}; | |
| 1018 | − for (const row of rows) { | |
| 1019 | − const table = parseRefHintTableJson(row.refHintTableJson ?? null); | |
| 1020 | − if (!table) { | |
| 1021 | − continue; | |
| 1022 | − } | |
| 1023 | − | |
| 1024 | − Object.assign(merged, table); | |
| 1025 | − } | |
| 1026 | − | |
| 1027 | − return Object.keys(merged).length > 0 ? merged : undefined; | |
| 1028 | −}; | |
| 1029 | − | |
| 1030 | −const listWorkspaceToolsEffect = ( | |
| 1031 | − ctx: ActionCtx, | |
| 1032 | − workspaceId: string, | |
| 1033 | − options: { | |
| 1034 | − limit: number; | |
| 1035 | − sourceId?: string; | |
| 1036 | − namespace?: string; | |
| 1037 | − }, | |
| 1038 | −): Effect.Effect<Array<WorkspaceToolIndexRow>, RuntimeAdapterError> => | |
| 1039 | − Effect.tryPromise({ | |
| 1040 | − try: async () => { | |
| 1041 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 1042 | − workspaceId, | |
| 1043 | − ...(options.sourceId ? { sourceId: options.sourceId } : {}), | |
| 1044 | − }); | |
| 1045 | − | |
| 1046 | − return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listWorkspaceTools, { | |
| 1047 | − workspaceId, | |
| 1048 | − limit: options.limit, | |
| 1049 | − sourceId: options.sourceId, | |
| 1050 | − namespace: options.namespace, | |
| 1051 | − includeDisabled: false, | |
| 1052 | − })) as Array<WorkspaceToolIndexRow>; | |
| 1053 | − }, | |
| 1054 | − catch: (cause) => | |
| 1055 | − toRuntimeAdapterError( | |
| 1056 | − "list_workspace_tools", | |
| 1057 | − "Failed to list indexed workspace tools", | |
| 1058 | − String(cause), | |
| 1059 | − ), | |
| 1060 | − }); | |
| 1061 | − | |
| 1062 | −const searchWorkspaceToolsEffect = ( | |
| 1063 | − ctx: ActionCtx, | |
| 1064 | − workspaceId: string, | |
| 1065 | − options: { | |
| 1066 | − query: string; | |
| 1067 | − limit: number; | |
| 1068 | − sourceId?: string; | |
| 1069 | − namespace?: string; | |
| 1070 | − }, | |
| 1071 | −): Effect.Effect<Array<WorkspaceToolIndexRow>, RuntimeAdapterError> => | |
| 1072 | − Effect.tryPromise({ | |
| 1073 | − try: async () => { | |
| 1074 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 1075 | − workspaceId, | |
| 1076 | − ...(options.sourceId ? { sourceId: options.sourceId } : {}), | |
| 1077 | − }); | |
| 1078 | − | |
| 1079 | − return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.searchWorkspaceTools, { | |
| 1080 | − workspaceId, | |
| 1081 | − query: options.query, | |
| 1082 | − limit: options.limit, | |
| 1083 | − sourceId: options.sourceId, | |
| 1084 | − namespace: options.namespace, | |
| 1085 | − includeDisabled: false, | |
| 1086 | − })) as Array<WorkspaceToolIndexRow>; | |
| 1087 | − }, | |
| 1088 | − catch: (cause) => | |
| 1089 | − toRuntimeAdapterError( | |
| 1090 | − "search_workspace_tools", | |
| 1091 | − "Failed to search indexed workspace tools", | |
| 1092 | − String(cause), | |
| 1093 | − ), | |
| 1094 | − }); | |
| 1095 | − | |
| 1096 | −const getWorkspaceToolByPathEffect = ( | |
| 1097 | − ctx: ActionCtx, | |
| 1098 | − workspaceId: string, | |
| 1099 | − toolPath: string, | |
| 1100 | −): Effect.Effect<WorkspaceToolIndexRow | null, RuntimeAdapterError> => | |
| 1101 | − Effect.tryPromise({ | |
| 1102 | − try: async () => { | |
| 1103 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 1104 | − workspaceId, | |
| 1105 | − }); | |
| 1106 | − | |
| 1107 | − return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.getWorkspaceToolByPath, { | |
| 1108 | − workspaceId, | |
| 1109 | − pathLower: toolPath.toLowerCase(), | |
| 1110 | − })) as WorkspaceToolIndexRow | null; | |
| 1111 | − }, | |
| 1112 | − catch: (cause) => | |
| 1113 | − toRuntimeAdapterError( | |
| 1114 | − "resolve_tool_path", | |
| 1115 | − "Failed to resolve tool path", | |
| 1116 | − String(cause), | |
| 1117 | − ), | |
| 1118 | − }); | |
| 1119 | − | |
| 1120 | −const listWorkspaceToolsByNormalizedPathEffect = ( | |
| 1121 | − ctx: ActionCtx, | |
| 1122 | − workspaceId: string, | |
| 1123 | − normalizedPath: string, | |
| 1124 | −): Effect.Effect<Array<WorkspaceToolIndexRow>, RuntimeAdapterError> => | |
| 1125 | − Effect.tryPromise({ | |
| 1126 | − try: async () => { | |
| 1127 | − await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, { | |
| 1128 | − workspaceId, | |
| 1129 | − }); | |
| 1130 | − | |
| 1131 | − return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listWorkspaceToolsByNormalizedPath, { | |
| 1132 | − workspaceId, | |
| 1133 | − normalizedPath, | |
| 1134 | − limit: 25, | |
| 1135 | − })) as Array<WorkspaceToolIndexRow>; | |
| 1136 | − }, | |
| 1137 | − catch: (cause) => | |
| 1138 | − toRuntimeAdapterError( | |
| 1139 | − "resolve_tool_path", | |
| 1140 | − "Failed to resolve normalized tool path", | |
| 1141 | − String(cause), | |
| 1142 | − ), | |
| 1143 | − }); | |
| 1144 | − | |
| 1145 | −const getArtifactToolEffect = ( | |
| 1146 | − ctx: ActionCtx, | |
| 1147 | − artifactId: string, | |
| 1148 | − toolId: string, | |
| 1149 | −): Effect.Effect<ArtifactToolRow | null, RuntimeAdapterError> => | |
| 1150 | − Effect.tryPromise({ | |
| 1151 | − try: async () => | |
| 1152 | − (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.getArtifactTool, { | |
| 1153 | − artifactId, | |
| 1154 | − toolId, | |
| 1155 | − })) as ArtifactToolRow | null, | |
| 1156 | − catch: (cause) => | |
| 1157 | − toRuntimeAdapterError( | |
| 1158 | − "load_artifact_tool", | |
| 1159 | − `Failed to load artifact tool: ${artifactId}:${toolId}`, | |
| 1160 | − String(cause), | |
| 1161 | − ), | |
| 1162 | − }); | |
| 1163 | − | |
| 1164 | −const getSourceForInvocationEffect = ( | |
| 1165 | − ctx: ActionCtx, | |
| 1166 | − workspaceId: string, | |
| 1167 | − sourceId: string, | |
| 1168 | −): Effect.Effect<Source | null, RuntimeAdapterError> => | |
| 1169 | − Effect.tryPromise({ | |
| 1170 | − try: async () => { | |
| 1171 | − const source = (await ctx.runQuery(runtimeInternal.control_plane.sources.getSourceForIngest, { | |
| 1172 | − sourceId, | |
| 1173 | − })) as Source | null; | |
| 1174 | − | |
| 1175 | − if (!source || source.workspaceId !== workspaceId || !source.enabled) { | |
| 1176 | − return null; | |
| 1177 | − } | |
| 1178 | − | |
| 1179 | − return source; | |
| 1180 | − }, | |
| 1181 | − catch: (cause) => | |
| 1182 | − toRuntimeAdapterError( | |
| 1183 | − "load_source", | |
| 1184 | − `Failed to load source for invocation: ${sourceId}`, | |
| 1185 | − String(cause), | |
| 1186 | − ), | |
| 1187 | − }); | |
| 1188 | − | |
| 1189 | −const invokeToolWithRegistry = ( | |
| 1190 | − ctx: ActionCtx, | |
| 1191 | − source: Source, | |
| 1192 | − artifactTool: ArtifactToolRow, | |
| 1193 | − args: Record<string, unknown>, | |
| 1194 | − accountId: string, | |
| 1195 | −): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> => | |
| 1196 | − Effect.gen(function* () { | |
| 1197 | − const baseOpenApiProvider = makeOpenApiToolProvider(); | |
| 1198 | − | |
| 1199 | − const openApiProvider = { | |
| 1200 | − kind: "openapi" as const, | |
| 1201 | − invoke: (input: { source: Source | null; tool: CanonicalToolDescriptor; args: unknown }) => | |
| 1202 | − Effect.gen(function* () { | |
| 1203 | − const sourceForInvoke = input.source; | |
| 1204 | − if (!sourceForInvoke) { | |
| 1205 | − return yield* toToolProviderError( | |
| 1206 | − "openapi", | |
| 1207 | − "invoke_tool", | |
| 1208 | − `OpenAPI invocation failed for tool: ${input.tool.toolId}`, | |
| 1209 | − "OpenAPI provider requires a source", | |
| 1210 | − ); | |
| 1211 | − } | |
| 1212 | − | |
| 1213 | − const resolveHeaders = (forceRefresh: boolean) => | |
| 1214 | − Effect.tryPromise({ | |
| 1215 | − try: () => | |
| 1216 | − resolveSourceHeadersForInvocation( | |
| 1217 | − ctx, | |
| 1218 | − sourceForInvoke, | |
| 1219 | − accountId, | |
| 1220 | − forceRefresh ? { forceRefresh: true } : undefined, | |
| 1221 | − ), | |
| 1222 | − catch: (cause) => | |
| 1223 | − toToolProviderError( | |
| 1224 | − "openapi", | |
| 1225 | − "resolve_credentials", | |
| 1226 | − `OpenAPI credential resolution failed for tool: ${input.tool.toolId}`, | |
| 1227 | − cause, | |
| 1228 | − ), | |
| 1229 | − }); | |
| 1230 | − | |
| 1231 | − const invokeOpenApi = (resolvedHeaders: Record<string, string>) => | |
| 1232 | − baseOpenApiProvider.invoke({ | |
| 1233 | − source: sourceWithInvocationHeaders(sourceForInvoke, resolvedHeaders), | |
| 1234 | − tool: input.tool, | |
| 1235 | − args: input.args, | |
| 1236 | − }); | |
| 1237 | − | |
| 1238 | − const sourceHeaders = yield* resolveHeaders(false); | |
| 1239 | − let result = yield* invokeOpenApi(sourceHeaders.headers); | |
| 1240 | − | |
| 1241 | − if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { | |
| 1242 | − const refreshedHeaders = yield* resolveHeaders(true); | |
| 1243 | − result = yield* invokeOpenApi(refreshedHeaders.headers); | |
| 1244 | − } | |
| 1245 | − | |
| 1246 | − return result; | |
| 1247 | − }), | |
| 1248 | − }; | |
| 1249 | − | |
| 1250 | − const graphqlProvider = { | |
| 1251 | − kind: "graphql" as const, | |
| 1252 | − invoke: (input: { source: Source | null; tool: CanonicalToolDescriptor; args: unknown }) => | |
| 1253 | − Effect.tryPromise({ | |
| 1254 | − try: async () => { | |
| 1255 | − if (!input.source) { | |
| 1256 | − throw new Error("GraphQL provider requires a source"); | |
| 1257 | − } | |
| 1258 | − | |
| 1259 | − const payload = decodeGraphqlInvocationPayload(input.tool.providerPayload); | |
| 1260 | − const invokeArgs = normalizeInvokeInput(input.args); | |
| 1261 | − | |
| 1262 | − let query: string; | |
| 1263 | − let variables: Record<string, unknown> | undefined; | |
| 1264 | − let operationName: string | undefined; | |
| 1265 | − | |
| 1266 | − if (payload.kind === "graphql_raw") { | |
| 1267 | − const rawQuery = asOptionalString(invokeArgs.query); | |
| 1268 | − if (!rawQuery) { | |
| 1269 | − throw new Error("Missing required GraphQL query string at args.query"); | |
| 1270 | − } | |
| 1271 | − | |
| 1272 | − query = rawQuery; | |
| 1273 | − variables = jsonObjectFromUnknown(invokeArgs.variables); | |
| 1274 | − operationName = asOptionalString(invokeArgs.operationName) ?? undefined; | |
| 1275 | − } else { | |
| 1276 | − const built = createGraphqlOperationQuery(payload, invokeArgs); | |
| 1277 | − query = built.query; | |
| 1278 | − variables = built.variables; | |
| 1279 | − operationName = payload.fieldName; | |
| 1280 | − } | |
| 1281 | − | |
| 1282 | − const invokeGraphql = async ( | |
| 1283 | − resolvedHeaders: Record<string, string>, | |
| 1284 | − ): Promise<{ output: unknown; isError: boolean }> => { | |
| 1285 | − const response = await fetch(payload.endpoint, { | |
| 1286 | − method: "POST", | |
| 1287 | − headers: mergeHeaders( | |
| 1288 | − { | |
| 1289 | − "content-type": "application/json", | |
| 1290 | − }, | |
| 1291 | − resolvedHeaders, | |
| 1292 | − ), | |
| 1293 | − body: JSON.stringify({ | |
| 1294 | − query, | |
| 1295 | − variables, | |
| 1296 | − operationName, | |
| 1297 | − }), | |
| 1298 | − }); | |
| 1299 | − | |
| 1300 | − const body = await graphqlResponseBody(response); | |
| 1301 | − const bodyRecord = jsonObjectFromUnknown(body); | |
| 1302 | − const hasErrors = Array.isArray(bodyRecord.errors); | |
| 1303 | − | |
| 1304 | − return { | |
| 1305 | − output: { | |
| 1306 | − status: response.status, | |
| 1307 | − headers: headersToRecord(response.headers), | |
| 1308 | − body, | |
| 1309 | − }, | |
| 1310 | − isError: response.status >= 400 || hasErrors, | |
| 1311 | − }; | |
| 1312 | − }; | |
| 1313 | − | |
| 1314 | − const sourceHeaders = await resolveSourceHeadersForInvocation( | |
| 1315 | − ctx, | |
| 1316 | − input.source, | |
| 1317 | − accountId, | |
| 1318 | − ); | |
| 1319 | − | |
| 1320 | − let result = await invokeGraphql(sourceHeaders.headers); | |
| 1321 | − | |
| 1322 | − if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { | |
| 1323 | − const refreshedHeaders = await resolveSourceHeadersForInvocation( | |
| 1324 | − ctx, | |
| 1325 | − input.source, | |
| 1326 | − accountId, | |
| 1327 | − { forceRefresh: true }, | |
| 1328 | − ); | |
| 1329 | − | |
| 1330 | − result = await invokeGraphql(refreshedHeaders.headers); | |
| 1331 | − } | |
| 1332 | − | |
| 1333 | − return result; | |
| 1334 | − }, | |
| 1335 | − catch: (cause) => | |
| 1336 | − toToolProviderError( | |
| 1337 | − "graphql", | |
| 1338 | − "invoke_tool", | |
| 1339 | − `GraphQL invocation failed for tool: ${input.tool.toolId}`, | |
| 1340 | − cause, | |
| 1341 | − ), | |
| 1342 | − }), | |
| 1343 | − }; | |
| 1344 | − | |
| 1345 | − const mcpProvider = { | |
| 1346 | − kind: "mcp" as const, | |
| 1347 | − invoke: (input: { source: Source | null; tool: CanonicalToolDescriptor; args: unknown }) => | |
| 1348 | − Effect.tryPromise({ | |
| 1349 | − try: async () => { | |
| 1350 | − if (!input.source) { | |
| 1351 | − throw new Error("MCP provider requires a source"); | |
| 1352 | − } | |
| 1353 | − | |
| 1354 | − const payload = decodeMcpInvocationPayload(input.tool.providerPayload); | |
| 1355 | − const invokeArgs = normalizeInvokeInput(input.args); | |
| 1356 | − | |
| 1357 | − if (payload.transport !== "streamable-http") { | |
| 1358 | − throw new Error( | |
| 1359 | − `Unsupported MCP transport for runtime invocation: ${payload.transport}`, | |
| 1360 | − ); | |
| 1361 | − } | |
| 1362 | − | |
| 1363 | − const endpoint = createMcpEndpointUrl(payload); | |
| 1364 | − const invokeMcp = async ( | |
| 1365 | − resolvedHeaders: Record<string, string>, | |
| 1366 | − ): Promise<{ output: unknown; isError: boolean }> => { | |
| 1367 | − const initializeResponse = await postMcpJsonRpc( | |
| 1368 | − endpoint, | |
| 1369 | − { | |
| 1370 | − jsonrpc: "2.0", | |
| 1371 | − id: `init_${crypto.randomUUID()}`, | |
| 1372 | − method: "initialize", | |
| 1373 | − params: { | |
| 1374 | − protocolVersion: "2024-11-05", | |
| 1375 | − capabilities: {}, | |
| 1376 | − clientInfo: { | |
| 1377 | − name: "executor-v2-runtime", | |
| 1378 | − version: "0.1.0", | |
| 1379 | − }, | |
| 1380 | − }, | |
| 1381 | − }, | |
| 1382 | − null, | |
| 1383 | − resolvedHeaders, | |
| 1384 | − ); | |
| 1385 | − const initializeBody = await decodeMcpJsonResponse(initializeResponse); | |
| 1386 | − | |
| 1387 | − const sessionId = initializeResponse.headers.get("mcp-session-id"); | |
| 1388 | − | |
| 1389 | − if (initializeResponse.status >= 400 || initializeBody.error !== undefined) { | |
| 1390 | − return { | |
| 1391 | − output: { | |
| 1392 | − status: initializeResponse.status, | |
| 1393 | − headers: headersToRecord(initializeResponse.headers), | |
| 1394 | − body: initializeBody, | |
| 1395 | − }, | |
| 1396 | − isError: true, | |
| 1397 | − }; | |
| 1398 | − } | |
| 1399 | − | |
| 1400 | − await postMcpJsonRpc( | |
| 1401 | − endpoint, | |
| 1402 | − { | |
| 1403 | − jsonrpc: "2.0", | |
| 1404 | − method: "notifications/initialized", | |
| 1405 | − params: {}, | |
| 1406 | − }, | |
| 1407 | − sessionId, | |
| 1408 | − resolvedHeaders, | |
| 1409 | − ); | |
| 1410 | − | |
| 1411 | − const callResponse = await postMcpJsonRpc( | |
| 1412 | − endpoint, | |
| 1413 | − { | |
| 1414 | − jsonrpc: "2.0", | |
| 1415 | − id: `call_${crypto.randomUUID()}`, | |
| 1416 | − method: "tools/call", | |
| 1417 | − params: { | |
| 1418 | − name: payload.toolName, | |
| 1419 | − arguments: invokeArgs, | |
| 1420 | − }, | |
| 1421 | − }, | |
| 1422 | − sessionId, | |
| 1423 | − resolvedHeaders, | |
| 1424 | − ); | |
| 1425 | − const callBody = await decodeMcpJsonResponse(callResponse); | |
| 1426 | − | |
| 1427 | − return { | |
| 1428 | − output: { | |
| 1429 | − status: callResponse.status, | |
| 1430 | − headers: headersToRecord(callResponse.headers), | |
| 1431 | − body: callBody, | |
| 1432 | − }, | |
| 1433 | − isError: callResponse.status >= 400 || callBody.error !== undefined, | |
| 1434 | − }; | |
| 1435 | − }; | |
| 1436 | − | |
| 1437 | − const sourceHeaders = await resolveSourceHeadersForInvocation( | |
| 1438 | − ctx, | |
| 1439 | − input.source, | |
| 1440 | − accountId, | |
| 1441 | − ); | |
| 1442 | − | |
| 1443 | − let result = await invokeMcp(sourceHeaders.headers); | |
| 1444 | − | |
| 1445 | − if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) { | |
| 1446 | − const refreshedHeaders = await resolveSourceHeadersForInvocation( | |
| 1447 | − ctx, | |
| 1448 | − input.source, | |
| 1449 | − accountId, | |
| 1450 | − { forceRefresh: true }, | |
| 1451 | − ); | |
| 1452 | − | |
| 1453 | − result = await invokeMcp(refreshedHeaders.headers); | |
| 1454 | − } | |
| 1455 | − | |
| 1456 | − return result; | |
| 1457 | − }, | |
| 1458 | − catch: (cause) => | |
| 1459 | − toToolProviderError( | |
| 1460 | − "mcp", | |
| 1461 | − "invoke_tool", | |
| 1462 | − `MCP invocation failed for tool: ${input.tool.toolId}`, | |
| 1463 | − cause, | |
| 1464 | − ), | |
| 1465 | − }), | |
| 1466 | − }; | |
| 1467 | − | |
| 1468 | − const registry = makeToolProviderRegistry([ | |
| 1469 | − openApiProvider, | |
| 1470 | − graphqlProvider, | |
| 1471 | − mcpProvider, | |
| 1472 | − ]); | |
| 1473 | − | |
| 1474 | − const providerKind = artifactTool.protocol; | |
| 1475 | − const invocationPayload = safeJsonParse(artifactTool.invocationJson); | |
| 1476 | − | |
| 1477 | − const descriptor = { | |
| 1478 | − providerKind: providerKind as any, | |
| 1479 | − sourceId: source.id, | |
| 1480 | − workspaceId: source.workspaceId, | |
| 1481 | − toolId: artifactTool.toolId, | |
| 1482 | − name: artifactTool.name, | |
| 1483 | − description: artifactTool.description, | |
| 1484 | − invocationMode: | |
| 1485 | − providerKind === "graphql" | |
| 1486 | − ? "graphql" | |
| 1487 | − : providerKind === "mcp" | |
| 1488 | − ? "mcp" | |
| 1489 | − : "http", | |
| 1490 | − availability: "remote_capable", | |
| 1491 | − providerPayload: | |
| 1492 | − providerKind === "openapi" | |
| 1493 | − ? decodeOpenApiInvocationPayload(invocationPayload) | |
| 1494 | − : providerKind === "graphql" | |
| 1495 | − ? decodeGraphqlInvocationPayload(invocationPayload) | |
| 1496 | − : decodeMcpInvocationPayload(invocationPayload), | |
| 1497 | − } as CanonicalToolDescriptor; | |
| 1498 | − | |
| 1499 | − const invocation = yield* registry | |
| 1500 | − .invoke({ | |
| 1501 | − source, | |
| 1502 | − tool: descriptor, | |
| 1503 | − args, | |
| 1504 | − }) | |
| 1505 | − .pipe( | |
| 1506 | − Effect.mapError((cause) => | |
| 1507 | − cause._tag === "ToolProviderError" | |
| 1508 | − ? toRuntimeAdapterError("invoke_tool", cause.message, cause.details) | |
| 1509 | − : toRuntimeAdapterError("invoke_tool", cause.message, null), | |
| 1510 | − ), | |
| 1511 | − ); | |
| 1512 | − | |
| 1513 | − return invocation; | |
| 1514 | − }); | |
| 1515 | − | |
| 1516 | −const unknownToolPathErrorMessage = ( | |
| 1517 | − requestedPath: string, | |
| 1518 | − suggestions: ReadonlyArray<string>, | |
| 1519 | −): string => { | |
| 1520 | − const hintQuery = requestedPath.trim().length > 0 ? requestedPath.trim() : "tool"; | |
| 1521 | − const suggestionText = | |
| 1522 | − suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}.` : ""; | |
| 1523 | − | |
| 1524 | − return `Unknown tool path: ${requestedPath}.${suggestionText} Use tools.discover({ queries: [{ text: ${JSON.stringify( | |
| 1525 | − hintQuery, | |
| 1526 | − )}, depth: 1 }] }) or tools.catalog.tools({ query: ${JSON.stringify( | |
| 1527 | − hintQuery, | |
| 1528 | − )} }) to find available tool paths.`; | |
| 1529 | −}; | |
| 1530 | − | |
| 1531 | −const resolveToolPath = ( | |
| 1532 | − ctx: ActionCtx, | |
| 1533 | − workspaceId: string, | |
| 1534 | − requestedPath: string, | |
| 1535 | −): Effect.Effect< | |
| 1536 | − { row: WorkspaceToolIndexRow | null; suggestions: Array<string> }, | |
| 1537 | − RuntimeAdapterError | |
| 1538 | −> => | |
| 1539 | − Effect.gen(function* () { | |
| 1540 | − const trimmedPath = requestedPath.trim(); | |
| 1541 | − if (trimmedPath.length === 0) { | |
| 1542 | − return { | |
| 1543 | − row: null, | |
| 1544 | − suggestions: [], | |
| 1545 | − }; | |
| 1546 | − } | |
| 1547 | − | |
| 1548 | − const exact = yield* getWorkspaceToolByPathEffect(ctx, workspaceId, trimmedPath); | |
| 1549 | − if (exact && exact.status === "active") { | |
| 1550 | − return { | |
| 1551 | − row: exact, | |
| 1552 | − suggestions: [], | |
| 1553 | − }; | |
| 1554 | − } | |
| 1555 | − | |
| 1556 | − const normalizedPath = normalizeToolPathForLookup(trimmedPath); | |
| 1557 | − const normalizedMatches = yield* listWorkspaceToolsByNormalizedPathEffect( | |
| 1558 | − ctx, | |
| 1559 | − workspaceId, | |
| 1560 | − normalizedPath, | |
| 1561 | − ); | |
| 1562 | − | |
| 1563 | − if (normalizedMatches.length === 1) { | |
| 1564 | − return { | |
| 1565 | − row: normalizedMatches[0] ?? null, | |
| 1566 | − suggestions: [], | |
| 1567 | − }; | |
| 1568 | − } | |
| 1569 | − | |
| 1570 | − if (normalizedMatches.length > 1) { | |
| 1571 | − const preferred = [...normalizedMatches].sort((left, right) => { | |
| 1572 | − if (left.path.length !== right.path.length) { | |
| 1573 | − return left.path.length - right.path.length; | |
| 1574 | − } | |
| 1575 | − | |
| 1576 | − return left.path.localeCompare(right.path); | |
| 1577 | − })[0]; | |
| 1578 | − | |
| 1579 | − return { | |
| 1580 | − row: preferred ?? null, | |
| 1581 | − suggestions: [], | |
| 1582 | − }; | |
| 1583 | − } | |
| 1584 | − | |
| 1585 | − const candidates = yield* searchWorkspaceToolsEffect(ctx, workspaceId, { | |
| 1586 | − query: trimmedPath, | |
| 1587 | − limit: 20, | |
| 1588 | − }); | |
| 1589 | − | |
| 1590 | − if (candidates.length === 0) { | |
| 1591 | − return { | |
| 1592 | − row: null, | |
| 1593 | − suggestions: [], | |
| 1594 | − }; | |
| 1595 | − } | |
| 1596 | − | |
| 1597 | − const lowerRequested = trimmedPath.toLowerCase(); | |
| 1598 | − const closest = [...candidates] | |
| 1599 | − .map((candidate) => ({ | |
| 1600 | − candidate, | |
| 1601 | − distance: levenshteinDistance(candidate.path.toLowerCase(), lowerRequested), | |
| 1602 | − })) | |
| 1603 | − .sort((left, right) => { | |
| 1604 | − if (left.distance !== right.distance) { | |
| 1605 | − return left.distance - right.distance; | |
| 1606 | − } | |
| 1607 | − | |
| 1608 | − return left.candidate.path.localeCompare(right.candidate.path); | |
| 1609 | − }); | |
| 1610 | − | |
| 1611 | − const best = closest[0]; | |
| 1612 | − const maxDistance = Math.max(2, Math.floor(trimmedPath.length * 0.2)); | |
| 1613 | − | |
| 1614 | − if (best && best.distance <= maxDistance) { | |
| 1615 | − return { | |
| 1616 | − row: best.candidate, | |
| 1617 | − suggestions: [], | |
| 1618 | − }; | |
| 1619 | − } | |
| 1620 | − | |
| 1621 | − return { | |
| 1622 | − row: null, | |
| 1623 | − suggestions: uniquePaths(closest.map((item) => item.candidate.path)), | |
| 1624 | − }; | |
| 1625 | − }); | |
| 1626 | − | |
| 1627 | −const approvalModeValidator = v.union(v.literal("auto"), v.literal("required")); | |
| 1628 | − | |
| 1629 | −export const evaluateToolApproval = internalMutation({ | |
| 1630 | − args: { | |
| 1631 | − workspaceId: v.string(), | |
| 1632 | − runId: v.string(), | |
| 1633 | − callId: v.string(), | |
| 1634 | − toolPath: v.string(), | |
| 1635 | − inputPreviewJson: v.string(), | |
| 1636 | − defaultMode: approvalModeValidator, | |
| 1637 | − requireApprovals: v.optional(v.boolean()), | |
| 1638 | − retryAfterMs: v.optional(v.number()), | |
| 1639 | − }, | |
| 1640 | − handler: async (ctx, args) => { | |
| 1641 | − const taskRunRow = await ctx.db | |
| 1642 | − .query("taskRuns") | |
| 1643 | − .withIndex("by_domainId", (q) => q.eq("id", args.runId)) | |
| 1644 | − .unique(); | |
| 1645 | − | |
| 1646 | − if (!taskRunRow || taskRunRow.workspaceId !== args.workspaceId) { | |
| 1647 | − return { | |
| 1648 | − kind: "denied" as const, | |
| 1649 | − error: `Unknown run for approval request: ${args.runId}`, | |
| 1650 | − }; | |
| 1651 | − } | |
| 1652 | − | |
| 1653 | − const store: PersistentToolApprovalStore = { | |
| 1654 | − findByRunAndCall: (input) => | |
| 1655 | − Effect.tryPromise({ | |
| 1656 | − try: async () => { | |
| 1657 | − const rows = await ctx.db | |
| 1658 | − .query("approvals") | |
| 1659 | − .withIndex("by_taskRunId_callId", (q) => | |
| 1660 | − q.eq("taskRunId", input.runId).eq("callId", input.callId), | |
| 1661 | − ) | |
| 1662 | − .collect(); | |
| 1663 | − | |
| 1664 | − const row = rows.find((candidate) => candidate.workspaceId === input.workspaceId) ?? null; | |
| 1665 | − if (!row) { | |
| 1666 | − return null; | |
| 1667 | − } | |
| 1668 | − | |
| 1669 | − const approval = decodeApproval( | |
| 1670 | − stripConvexSystemFields(row as unknown as Record<string, unknown>), | |
| 1671 | − ); | |
| 1672 | − | |
| 1673 | − return toPersistentApprovalRecord(approval); | |
| 1674 | − }, | |
| 1675 | − catch: (cause) => | |
| 1676 | − toPersistentApprovalStoreError( | |
| 1677 | − "approvals.find", | |
| 1678 | − "Failed to query approval", | |
| 1679 | − String(cause), | |
| 1680 | − ), | |
| 1681 | − }), | |
| 1682 | − | |
| 1683 | − createPending: (input) => | |
| 1684 | − Effect.tryPromise({ | |
| 1685 | − try: async () => { | |
| 1686 | − const existingRows = await ctx.db | |
| 1687 | − .query("approvals") | |
| 1688 | − .withIndex("by_taskRunId_callId", (q) => | |
| 1689 | − q.eq("taskRunId", input.runId).eq("callId", input.callId), | |
| 1690 | − ) | |
| 1691 | − .collect(); | |
| 1692 | − | |
| 1693 | − const existingRow = | |
| 1694 | − existingRows.find((candidate) => candidate.workspaceId === input.workspaceId) ?? null; | |
| 1695 | − if (existingRow) { | |
| 1696 | − return toPersistentApprovalRecord( | |
| 1697 | − decodeApproval( | |
| 1698 | − stripConvexSystemFields(existingRow as unknown as Record<string, unknown>), | |
| 1699 | − ), | |
| 1700 | − ); | |
| 1701 | − } | |
| 1702 | − | |
| 1703 | − const approval = { | |
| 1704 | − id: `apr_${crypto.randomUUID()}`, | |
| 1705 | − workspaceId: input.workspaceId, | |
| 1706 | − taskRunId: input.runId, | |
| 1707 | − callId: input.callId, | |
| 1708 | − toolPath: input.toolPath, | |
| 1709 | − status: "pending", | |
| 1710 | − inputPreviewJson: input.inputPreviewJson, | |
| 1711 | − reason: null, | |
| 1712 | − requestedAt: Date.now(), | |
| 1713 | − resolvedAt: null, | |
| 1714 | − } as Approval; | |
| 1715 | − | |
| 1716 | − await ctx.db.insert("approvals", approval); | |
| 1717 | − return toPersistentApprovalRecord(approval); | |
| 1718 | − }, | |
| 1719 | − catch: (cause) => | |
| 1720 | − toPersistentApprovalStoreError( | |
| 1721 | − "approvals.create", | |
| 1722 | − "Failed to create pending approval", | |
| 1723 | − String(cause), | |
| 1724 | − ), | |
| 1725 | − }), | |
| 1726 | − }; | |
| 1727 | − | |
| 1728 | − const policy = createPersistentToolApprovalPolicy({ | |
| 1729 | − store, | |
| 1730 | − requireApprovals: args.requireApprovals === true, | |
| 1731 | − retryAfterMs: args.retryAfterMs, | |
| 1732 | − serializeInputPreview: () => args.inputPreviewJson, | |
| 1733 | − onStoreError: (error) => ({ | |
| 1734 | − kind: "denied", | |
| 1735 | − error: | |
| 1736 | − error.details && error.details.length > 0 | |
| 1737 | − ? `${error.message}: ${error.details}` | |
| 1738 | − : error.message, | |
| 1739 | − }), | |
| 1740 | − }); | |
| 1741 | − | |
| 1742 | − return await policy.evaluate({ | |
| 1743 | − workspaceId: args.workspaceId, | |
| 1744 | − runId: args.runId, | |
| 1745 | − callId: args.callId, | |
| 1746 | − toolPath: args.toolPath, | |
| 1747 | − defaultMode: args.defaultMode, | |
| 1748 | − }); | |
| 1749 | − }, | |
| 1750 | −}); | |
| 1751 | − | |
| 1752 | −const createConvexPersistentToolApprovalPolicy = ( | |
| 1753 | − ctx: ActionCtx, | |
| 1754 | − workspaceId: string, | |
| 1755 | − options: { | |
| 1756 | − requireApprovals: boolean; | |
| 1757 | − retryAfterMs: number; | |
| 1758 | − }, | |
| 1759 | −): ToolApprovalPolicy => ({ | |
| 1760 | − evaluate: (input) => | |
| 1761 | − ctx.runMutation(runtimeInternal.source_tool_registry.evaluateToolApproval, { | |
| 1762 | − workspaceId, | |
| 1763 | − runId: input.runId, | |
| 1764 | − callId: input.callId, | |
| 1765 | − toolPath: input.toolPath, | |
| 1766 | − inputPreviewJson: serializeInputPreview(input.input), | |
| 1767 | − defaultMode: input.defaultMode, | |
| 1768 | − requireApprovals: options.requireApprovals, | |
| 1769 | − retryAfterMs: options.retryAfterMs, | |
| 1770 | − }), | |
| 1771 | −}); | |
| 1772 | − | |
| 1773 | −const evaluateApprovalDecisionEffect = ( | |
| 1774 | − policy: ToolApprovalPolicy, | |
| 1775 | − input: { | |
| 1776 | − workspaceId: string; | |
| 1777 | − runId: string; | |
| 1778 | − callId: string; | |
| 1779 | − toolPath: string; | |
| 1780 | − source: string; | |
| 1781 | − defaultMode: "auto" | "required"; | |
| 1782 | − args: Record<string, unknown>; | |
| 1783 | − }, | |
| 1784 | −): Effect.Effect< | |
| 1785 | − | { kind: "approved" } | |
| 1786 | − | { kind: "pending"; approvalId: string; retryAfterMs?: number; error?: string } | |
| 1787 | − | { kind: "denied"; error: string }, | |
| 1788 | − RuntimeAdapterError | |
| 1789 | −> => | |
| 1790 | − Effect.tryPromise({ | |
| 1791 | − try: () => | |
| 1792 | − Promise.resolve( | |
| 1793 | − policy.evaluate({ | |
| 1794 | − workspaceId: input.workspaceId, | |
| 1795 | − runId: input.runId, | |
| 1796 | − callId: input.callId, | |
| 1797 | − toolPath: input.toolPath, | |
| 1798 | − source: input.source, | |
| 1799 | − input: input.args, | |
| 1800 | − defaultMode: input.defaultMode, | |
| 1801 | − }), | |
| 1802 | − ), | |
| 1803 | − catch: (cause) => | |
| 1804 | − toRuntimeAdapterError( | |
| 1805 | − "evaluate_approval", | |
| 1806 | − `Tool approval evaluation failed: ${input.toolPath}`, | |
| 1807 | − String(cause), | |
| 1808 | − ), | |
| 1809 | − }); | |
| 1810 | − | |
| 1811 | −const describeSource = (row: { | |
| 1812 | − sourceName: string; | |
| 1813 | − sourceKind: string; | |
| 1814 | −}): string => `${row.sourceKind.toUpperCase()} source: ${row.sourceName}`; | |
| 1815 | − | |
| 1816 | −export type ConvexSourceToolRegistryOptions = { | |
| 1817 | − accountId: string; | |
| 1818 | − requireToolApprovals?: boolean; | |
| 1819 | − approvalRetryAfterMs?: number; | |
| 1820 | −}; | |
| 1821 | − | |
| 1822 | −export const createConvexSourceToolRegistry = ( | |
| 1823 | − ctx: ActionCtx, | |
| 1824 | − workspaceId: string, | |
| 1825 | − options: ConvexSourceToolRegistryOptions, | |
| 1826 | −): ToolRegistry => { | |
| 1827 | − const requireApprovals = options.requireToolApprovals ?? requireToolApprovalsByDefault; | |
| 1828 | − const accountId = options.accountId.trim(); | |
| 1829 | − if (accountId.length === 0) { | |
| 1830 | − throw new Error("Tool registry accountId is required"); | |
| 1831 | − } | |
| 1832 | − const approvalRetryAfterMs = | |
| 1833 | − typeof options.approvalRetryAfterMs === "number" && | |
| 1834 | − Number.isFinite(options.approvalRetryAfterMs) && | |
| 1835 | − options.approvalRetryAfterMs >= 0 | |
| 1836 | − ? Math.round(options.approvalRetryAfterMs) | |
| 1837 | − : defaultPendingRetryAfterMs; | |
| 1838 | − | |
| 1839 | − const approvalPolicy = createConvexPersistentToolApprovalPolicy(ctx, workspaceId, { | |
| 1840 | − requireApprovals, | |
| 1841 | − retryAfterMs: approvalRetryAfterMs, | |
| 1842 | − }); | |
| 1843 | − | |
| 1844 | − return { | |
| 1845 | − callTool: (input) => | |
| 1846 | − Effect.gen(function* () { | |
| 1847 | − const resolved = yield* resolveToolPath(ctx, workspaceId, input.toolPath); | |
| 1848 | − if (!resolved.row) { | |
| 1849 | − return { | |
| 1850 | − ok: false, | |
| 1851 | − kind: "failed", | |
| 1852 | − error: unknownToolPathErrorMessage(input.toolPath, resolved.suggestions), | |
| 1853 | − } satisfies RuntimeToolCallResult; | |
| 1854 | − } | |
| 1855 | − | |
| 1856 | − const args = normalizeInvokeInput(input.input); | |
| 1857 | − const toolPath = resolved.row.path; | |
| 1858 | − const approval = yield* evaluateApprovalDecisionEffect(approvalPolicy, { | |
| 1859 | − workspaceId, | |
| 1860 | − runId: input.runId, | |
| 1861 | − callId: input.callId, | |
| 1862 | − toolPath, | |
| 1863 | − source: resolved.row.sourceName, | |
| 1864 | − defaultMode: | |
| 1865 | − resolved.row.approvalMode === "required" | |
| 1866 | − ? "required" | |
| 1867 | − : "auto", | |
| 1868 | − args, | |
| 1869 | − }); | |
| 1870 | − | |
| 1871 | − if (approval.kind !== "approved") { | |
| 1872 | − return toToolCallResultFromDecision(approval, toolPath); | |
| 1873 | − } | |
| 1874 | − | |
| 1875 | − const artifactTool = yield* getArtifactToolEffect( | |
| 1876 | − ctx, | |
| 1877 | − resolved.row.artifactId, | |
| 1878 | − resolved.row.toolId, | |
| 1879 | − ); | |
| 1880 | − | |
| 1881 | − if (!artifactTool) { | |
| 1882 | − return { | |
| 1883 | − ok: false, | |
| 1884 | − kind: "failed", | |
| 1885 | − error: `Missing artifact tool payload for ${resolved.row.path}`, | |
| 1886 | − } satisfies RuntimeToolCallResult; | |
| 1887 | − } | |
| 1888 | − | |
| 1889 | − const source = yield* getSourceForInvocationEffect(ctx, workspaceId, resolved.row.sourceId); | |
| 1890 | − if (!source) { | |
| 1891 | − return { | |
| 1892 | − ok: false, | |
| 1893 | − kind: "failed", | |
| 1894 | − error: `Source unavailable for tool: ${resolved.row.path}`, | |
| 1895 | − } satisfies RuntimeToolCallResult; | |
| 1896 | − } | |
| 1897 | − | |
| 1898 | − const invocation = yield* invokeToolWithRegistry( | |
| 1899 | − ctx, | |
| 1900 | − source, | |
| 1901 | − artifactTool, | |
| 1902 | − args, | |
| 1903 | − accountId, | |
| 1904 | − ); | |
| 1905 | − if (invocation.isError) { | |
| 1906 | − return { | |
| 1907 | − ok: false, | |
| 1908 | − kind: "failed", | |
| 1909 | − error: describeOutput(invocation.output), | |
| 1910 | − } satisfies RuntimeToolCallResult; | |
| 1911 | − } | |
| 1912 | − | |
| 1913 | − return { | |
| 1914 | − ok: true, | |
| 1915 | − value: invocation.output, | |
| 1916 | − } satisfies RuntimeToolCallResult; | |
| 1917 | − }), | |
| 1918 | − | |
| 1919 | − discover: (input: ToolRegistryDiscoverInput) => | |
| 1920 | − Effect.gen(function* () { | |
| 1921 | − const limit = Math.max(1, Math.min(50, input.limit ?? 8)); | |
| 1922 | − const compact = input.compact === true; | |
| 1923 | − const includeSchemas = input.includeSchemas === true; | |
| 1924 | − const discoverQueries = normalizeDiscoverQueries(input); | |
| 1925 | − const fetchLimit = Math.max(limit * 6, 50); | |
| 1926 | − | |
| 1927 | − const perQueryData = yield* Effect.forEach(discoverQueries, (query) => | |
| 1928 | − Effect.gen(function* () { | |
| 1929 | − const rows = | |
| 1930 | − query.text.length > 0 | |
| 1931 | − ? yield* searchWorkspaceToolsEffect(ctx, workspaceId, { | |
| 1932 | − query: query.text, | |
| 1933 | − limit: fetchLimit, | |
| 1934 | − }) | |
| 1935 | − : yield* listWorkspaceToolsEffect(ctx, workspaceId, { | |
| 1936 | − limit: fetchLimit, | |
| 1937 | − }); | |
| 1938 | − | |
| 1939 | − const rankedRows = rows | |
| 1940 | − .map((row) => ({ | |
| 1941 | − row, | |
| 1942 | − score: scoreSummary( | |
| 1943 | − toSearchScoringSummary(row), | |
| 1944 | − query.lowerText, | |
| 1945 | − query.depth, | |
| 1946 | − ), | |
| 1947 | − })) | |
| 1948 | − .filter((item) => item.score > 0) | |
| 1949 | − .sort((left, right) => right.score - left.score) | |
| 1950 | − .slice(0, limit); | |
| 1951 | − | |
| 1952 | − const selectedRows = rankedRows.map((item) => item.row); | |
| 1953 | − const summaries = includeSchemas | |
| 1954 | − ? ( | |
| 1955 | − yield* hydrateRowsWithArtifactTools(ctx, selectedRows).pipe( | |
| 1956 | − Effect.map((entries) => | |
| 1957 | − entries.map(({ row, artifactTool }) => | |
| 1958 | − toToolSummary(row, { | |
| 1959 | − includeSchemas, | |
| 1960 | − compact, | |
| 1961 | − artifactTool, | |
| 1962 | − }), | |
| 1963 | − ), | |
| 1964 | − ), | |
| 1965 | − ) | |
| 1966 | − ) | |
| 1967 | − : selectedRows.map((row) => | |
| 1968 | − toToolSummary(row, { | |
| 1969 | − includeSchemas, | |
| 1970 | − compact, | |
| 1971 | − }), | |
| 1972 | − ); | |
| 1973 | − | |
| 1974 | − return { | |
| 1975 | − rows, | |
| 1976 | − queryResult: { | |
| 1977 | − text: query.text, | |
| 1978 | − depth: query.depth, | |
| 1979 | − bestPath: summaries[0]?.path ?? null, | |
| 1980 | − results: summaries, | |
| 1981 | − total: summaries.length, | |
| 1982 | − } satisfies ToolRegistryDiscoverQueryResult, | |
| 1983 | − }; | |
| 1984 | − }), | |
| 1985 | − ); | |
| 1986 | − | |
| 1987 | − const perQuery = perQueryData.map((entry) => entry.queryResult); | |
| 1988 | − const primary = perQuery[0] ?? { | |
| 1989 | − text: "", | |
| 1990 | − depth: 1 as const, | |
| 1991 | − bestPath: null, | |
| 1992 | − results: [] as Array<ToolRegistryToolSummary>, | |
| 1993 | − total: 0, | |
| 1994 | − }; | |
| 1995 | − const allRows = perQueryData.flatMap((entry) => entry.rows); | |
| 1996 | − | |
| 1997 | − return { | |
| 1998 | − bestPath: primary.bestPath, | |
| 1999 | − results: primary.results, | |
| 2000 | − total: primary.total, | |
| 2001 | − perQuery, | |
| 2002 | − refHintTable: mergeRefHintTables(allRows, includeSchemas), | |
| 2003 | − } satisfies ToolRegistryDiscoverOutput; | |
| 2004 | − }), | |
| 2005 | − | |
| 2006 | − catalogNamespaces: (input: ToolRegistryCatalogNamespacesInput) => | |
| 2007 | − Effect.gen(function* () { | |
| 2008 | − const limit = Math.max(1, Math.min(maxCatalogNamespacesLimit, input.limit ?? 50)); | |
| 2009 | − | |
| 2010 | − const rows = yield* Effect.tryPromise({ | |
| 2011 | − try: async () => | |
| 2012 | − (await ctx.runAction(runtimeInternal.control_plane.tool_registry.listWorkspaceNamespaces, { | |
| 2013 | − workspaceId, | |
| 2014 | − limit, | |
| 2015 | − })) as Array<{ | |
| 2016 | − namespace: string; | |
| 2017 | − source: string; | |
| 2018 | − sourceId: string; | |
| 2019 | − sourceKind: string; | |
| 2020 | − toolCount: number; | |
| 2021 | − samplePaths: Array<string>; | |
| 2022 | − }>, | |
| 2023 | − catch: (cause) => | |
| 2024 | − toRuntimeAdapterError( | |
| 2025 | − "catalog_namespaces", | |
| 2026 | − "Failed to catalog namespaces", | |
| 2027 | − String(cause), | |
| 2028 | − ), | |
| 2029 | − }); | |
| 2030 | − | |
| 2031 | − return { | |
| 2032 | − namespaces: rows.map((row) => ({ | |
| 2033 | − namespace: row.namespace, | |
| 2034 | − toolCount: row.toolCount, | |
| 2035 | − samplePaths: row.samplePaths, | |
| 2036 | − source: row.source, | |
| 2037 | − sourceKey: row.sourceId, | |
| 2038 | − description: describeSource({ sourceName: row.source, sourceKind: row.sourceKind }), | |
| 2039 | − })), | |
| 2040 | − total: rows.length, | |
| 2041 | − } satisfies ToolRegistryCatalogNamespacesOutput; | |
| 2042 | − }), | |
| 2043 | − | |
| 2044 | − catalogTools: (input: ToolRegistryCatalogToolsInput) => | |
| 2045 | − Effect.gen(function* () { | |
| 2046 | − const limit = Math.max(1, Math.min(maxCatalogToolsLimit, input.limit ?? 50)); | |
| 2047 | − const queryText = (input.query ?? "").trim(); | |
| 2048 | − const compact = input.compact === true; | |
| 2049 | − const includeSchemas = input.includeSchemas === true; | |
| 2050 | − const namespace = input.namespace?.trim(); | |
| 2051 | − | |
| 2052 | − const rows = | |
| 2053 | − queryText.length > 0 | |
| 2054 | − ? yield* searchWorkspaceToolsEffect(ctx, workspaceId, { | |
| 2055 | − query: queryText, | |
| 2056 | − namespace, | |
| 2057 | − limit: Math.max(limit * 4, 50), | |
| 2058 | − }) | |
| 2059 | − : yield* listWorkspaceToolsEffect(ctx, workspaceId, { | |
| 2060 | − namespace, | |
| 2061 | − limit: Math.max(limit * 4, 50), | |
| 2062 | − }); | |
| 2063 | − | |
| 2064 | − const rankedRows = rows | |
| 2065 | − .map((row) => ({ | |
| 2066 | − row, | |
| 2067 | − score: scoreSummary(toSearchScoringSummary(row), queryText.toLowerCase()), | |
| 2068 | − })) | |
| 2069 | − .filter((item) => item.score > 0) | |
| 2070 | − .sort((left, right) => right.score - left.score) | |
| 2071 | − .slice(0, limit); | |
| 2072 | − | |
| 2073 | − const selectedRows = rankedRows.map((item) => item.row); | |
| 2074 | − | |
| 2075 | − const summaries = includeSchemas | |
| 2076 | − ? ( | |
| 2077 | − yield* hydrateRowsWithArtifactTools(ctx, selectedRows).pipe( | |
| 2078 | − Effect.map((entries) => | |
| 2079 | − entries.map(({ row, artifactTool }) => | |
| 2080 | − toToolSummary(row, { | |
| 2081 | − includeSchemas, | |
| 2082 | − compact, | |
| 2083 | − artifactTool, | |
| 2084 | − }), | |
| 2085 | − ), | |
| 2086 | − ), | |
| 2087 | − ) | |
| 2088 | − ) | |
| 2089 | − : selectedRows.map((row) => | |
| 2090 | − toToolSummary(row, { | |
| 2091 | − includeSchemas, | |
| 2092 | − compact, | |
| 2093 | − }), | |
| 2094 | − ); | |
| 2095 | − | |
| 2096 | − return { | |
| 2097 | − results: summaries, | |
| 2098 | − total: summaries.length, | |
| 2099 | − refHintTable: mergeRefHintTables(rows, includeSchemas), | |
| 2100 | − } satisfies ToolRegistryCatalogToolsOutput; | |
| 2101 | − }), | |
| 2102 | − } as ToolRegistry; | |
| 2103 | −}; | |
| 2104 | − | |
| 2105 | −export const invokeWorkspaceTool = internalAction({ | |
| 2106 | − args: { | |
| 2107 | − workspaceId: v.string(), | |
| 2108 | − accountId: v.string(), | |
| 2109 | − runId: v.string(), | |
| 2110 | − callId: v.string(), | |
| 2111 | − toolPath: v.string(), | |
| 2112 | − input: v.optional(v.any()), | |
| 2113 | − }, | |
| 2114 | − handler: async (ctx, args): Promise<RuntimeToolCallResult> => { | |
| 2115 | − const toolRegistry = createConvexSourceToolRegistry(ctx, args.workspaceId, { | |
| 2116 | − accountId: args.accountId, | |
| 2117 | − }); | |
| 2118 | − | |
| 2119 | − return await Effect.runPromise( | |
| 2120 | − toolRegistry.callTool({ | |
| 2121 | − runId: args.runId, | |
| 2122 | − callId: args.callId, | |
| 2123 | − toolPath: args.toolPath, | |
| 2124 | − input: normalizeInvokeInput(args.input), | |
| 2125 | − }), | |
| 2126 | − ); | |
| 2127 | − }, | |
| 2128 | −}); | |
| @@ -1,106 +0,0 @@ | ||
| 1 | −import { type TaskRun } from "@executor-v2/schema"; | |
| 2 | −import { v } from "convex/values"; | |
| 3 | − | |
| 4 | −import { internalMutation, internalQuery } from "./_generated/server"; | |
| 5 | − | |
| 6 | −const taskRunTerminalStatusValidator = v.union( | |
| 7 | − v.literal("completed"), | |
| 8 | − v.literal("failed"), | |
| 9 | − v.literal("timed_out"), | |
| 10 | − v.literal("denied"), | |
| 11 | −); | |
| 12 | − | |
| 13 | −export const startTaskRun = internalMutation({ | |
| 14 | − args: { | |
| 15 | − workspaceId: v.string(), | |
| 16 | − runId: v.string(), | |
| 17 | − accountId: v.string(), | |
| 18 | − sessionId: v.optional(v.string()), | |
| 19 | − runtimeId: v.optional(v.string()), | |
| 20 | − codeHash: v.optional(v.string()), | |
| 21 | − }, | |
| 22 | − handler: async (ctx, args): Promise<void> => { | |
| 23 | − const existing = await ctx.db | |
| 24 | − .query("taskRuns") | |
| 25 | − .withIndex("by_domainId", (q) => q.eq("id", args.runId)) | |
| 26 | − .unique(); | |
| 27 | − | |
| 28 | − if (existing) { | |
| 29 | − if (existing.workspaceId !== args.workspaceId) { | |
| 30 | − throw new Error(`Task run workspace mismatch: ${args.runId}`); | |
| 31 | − } | |
| 32 | − | |
| 33 | − if (existing.accountId !== args.accountId) { | |
| 34 | − throw new Error(`Task run account mismatch: ${args.runId}`); | |
| 35 | − } | |
| 36 | − | |
| 37 | − return; | |
| 38 | − } | |
| 39 | − | |
| 40 | − const taskRun = { | |
| 41 | − id: args.runId, | |
| 42 | − workspaceId: args.workspaceId, | |
| 43 | − accountId: args.accountId, | |
| 44 | − sessionId: args.sessionId ?? "session_runtime", | |
| 45 | − runtimeId: args.runtimeId ?? "runtime", | |
| 46 | − codeHash: args.codeHash ?? "runtime", | |
| 47 | − status: "running", | |
| 48 | − startedAt: Date.now(), | |
| 49 | − completedAt: null, | |
| 50 | − exitCode: null, | |
| 51 | − error: null, | |
| 52 | − } as TaskRun; | |
| 53 | − | |
| 54 | − await ctx.db.insert("taskRuns", taskRun); | |
| 55 | − }, | |
| 56 | −}); | |
| 57 | − | |
| 58 | −export const finishTaskRun = internalMutation({ | |
| 59 | − args: { | |
| 60 | − workspaceId: v.string(), | |
| 61 | − runId: v.string(), | |
| 62 | − status: taskRunTerminalStatusValidator, | |
| 63 | − error: v.optional(v.union(v.string(), v.null())), | |
| 64 | − }, | |
| 65 | − handler: async (ctx, args): Promise<void> => { | |
| 66 | − const existing = await ctx.db | |
| 67 | − .query("taskRuns") | |
| 68 | − .withIndex("by_domainId", (q) => q.eq("id", args.runId)) | |
| 69 | − .unique(); | |
| 70 | − | |
| 71 | − if (!existing) { | |
| 72 | − return; | |
| 73 | − } | |
| 74 | − | |
| 75 | − if (existing.workspaceId !== args.workspaceId) { | |
| 76 | − throw new Error(`Task run workspace mismatch: ${args.runId}`); | |
| 77 | − } | |
| 78 | − | |
| 79 | − await ctx.db.patch(existing._id, { | |
| 80 | − status: args.status, | |
| 81 | − completedAt: Date.now(), | |
| 82 | − error: args.error ?? null, | |
| 83 | − }); | |
| 84 | − }, | |
| 85 | −}); | |
| 86 | − | |
| 87 | −export const getTaskRunContext = internalQuery({ | |
| 88 | − args: { | |
| 89 | − runId: v.string(), | |
| 90 | − }, | |
| 91 | − handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string } | null> => { | |
| 92 | − const existing = await ctx.db | |
| 93 | − .query("taskRuns") | |
| 94 | − .withIndex("by_domainId", (q) => q.eq("id", args.runId)) | |
| 95 | − .unique(); | |
| 96 | − | |
| 97 | − if (!existing) { | |
| 98 | − return null; | |
| 99 | − } | |
| 100 | − | |
| 101 | − return { | |
| 102 | − workspaceId: existing.workspaceId, | |
| 103 | − accountId: existing.accountId, | |
| 104 | − }; | |
| 105 | − }, | |
| 106 | −}); | |
| @@ -1,25 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − /* This TypeScript project config describes the environment that | |
| 3 | − * Convex functions run in and is used to typecheck them. | |
| 4 | − * You can modify it, but some settings are required to use Convex. | |
| 5 | − */ | |
| 6 | − "compilerOptions": { | |
| 7 | − /* These settings are not required by Convex and can be modified. */ | |
| 8 | − "allowJs": true, | |
| 9 | − "strict": true, | |
| 10 | − "moduleResolution": "Bundler", | |
| 11 | − "jsx": "react-jsx", | |
| 12 | − "skipLibCheck": true, | |
| 13 | − "allowSyntheticDefaultImports": true, | |
| 14 | − | |
| 15 | − /* These compiler options are required by Convex */ | |
| 16 | − "target": "ESNext", | |
| 17 | − "lib": ["ES2021", "dom"], | |
| 18 | − "forceConsistentCasingInFileNames": true, | |
| 19 | − "module": "ESNext", | |
| 20 | − "isolatedModules": true, | |
| 21 | − "noEmit": true | |
| 22 | − }, | |
| 23 | − "include": ["./**/*"], | |
| 24 | − "exclude": ["./_generated"] | |
| 25 | −} | |
| @@ -1,15 +0,0 @@ | ||
| 1 | −import { WorkflowManager } from "@convex-dev/workflow"; | |
| 2 | − | |
| 3 | −import { components } from "./_generated/api"; | |
| 4 | − | |
| 5 | −export const workflow = new WorkflowManager(components.workflow, { | |
| 6 | − workpoolOptions: { | |
| 7 | − defaultRetryBehavior: { | |
| 8 | − maxAttempts: 3, | |
| 9 | − initialBackoffMs: 250, | |
| 10 | − base: 2, | |
| 11 | − }, | |
| 12 | − retryActionsByDefault: true, | |
| 13 | − maxParallelism: 8, | |
| 14 | − }, | |
| 15 | −}); | |
| @@ -1,33 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "name": "@executor-v2/app-convex", | |
| 3 | − "private": true, | |
| 4 | − "type": "module", | |
| 5 | − "scripts": { | |
| 6 | − "dev": "CREDENTIAL_ENCRYPTION_KEY=dev-credential-encryption-key bunx convex dev", | |
| 7 | − "test": "CREDENTIAL_ENCRYPTION_KEY=test-credential-encryption-key bunx vitest run --no-watch convex/*.test.ts", | |
| 8 | − "typecheck": "bunx tsc --noEmit -p tsconfig.json", | |
| 9 | − "convex:codegen": "bunx convex codegen" | |
| 10 | − }, | |
| 11 | − "dependencies": { | |
| 12 | − "@convex-dev/action-cache": "^0.3.0", | |
| 13 | − "@convex-dev/workflow": "^0.3.4", | |
| 14 | − "@executor-v2/confect": "workspace:*", | |
| 15 | − "@executor-v2/domain": "workspace:*", | |
| 16 | − "@executor-v2/engine": "workspace:*", | |
| 17 | − "@executor-v2/management-api": "workspace:*", | |
| 18 | − "@executor-v2/mcp-gateway": "workspace:*", | |
| 19 | − "@executor-v2/persistence-ports": "workspace:*", | |
| 20 | − "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", | |
| 21 | − "@executor-v2/schema": "workspace:*", | |
| 22 | − "@executor-v2/sdk": "workspace:*", | |
| 23 | − "@modelcontextprotocol/sdk": "^1.27.1", | |
| 24 | − "convex": "catalog:", | |
| 25 | − "effect": "catalog:", | |
| 26 | − "jose": "^6.1.0" | |
| 27 | − }, | |
| 28 | − "devDependencies": { | |
| 29 | − "@effect/vitest": "catalog:", | |
| 30 | − "convex-test": "workspace:*", | |
| 31 | − "vitest": "catalog:" | |
| 32 | − } | |
| 33 | −} | |
| @@ -1 +0,0 @@ | ||
| 1 | −export {}; | |
| @@ -1,4 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "extends": "../../tsconfig.json", | |
| 3 | − "include": ["src", "convex"] | |
| 4 | −} | |
| @@ -15,7 +15,8 @@ · 14 unmodified lines | ||
| 15 | 15 | "@executor-v2/domain": "workspace:*", |
| 16 | 16 | "@executor-v2/engine": "workspace:*", |
| 17 | 17 | "@executor-v2/mcp-gateway": "workspace:*", |
| 18 | − "@executor-v2/persistence-local": "workspace:*", | |
| 18 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 19 | + "@executor-v2/persistence-sql": "workspace:*", | |
| 19 | 20 | "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", |
| 20 | 21 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 21 | 22 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| @@ -8,17 +8,19 @@ import { · 7 unmodified lines | ||
| 8 | 8 | makeActor, |
| 9 | 9 | makeAllowAllActor, |
| 10 | 10 | } from "@executor-v2/domain"; |
| 11 | −import { | |
| 12 | − type LocalStateSnapshot, | |
| 13 | − type LocalStateStore, | |
| 14 | −} from "@executor-v2/persistence-local"; | |
| 11 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 15 | 12 | import { |
| 16 | 13 | type OrganizationMembership, |
| 14 | + type Workspace, | |
| 17 | 15 | type WorkspaceMembership, |
| 18 | 16 | } from "@executor-v2/schema"; |
| 19 | 17 | import * as PlatformHeaders from "@effect/platform/Headers"; |
| 20 | 18 | import * as Effect from "effect/Effect"; |
| 21 | −import * as Option from "effect/Option"; | |
| 19 | + | |
| 20 | +type ActorRows = Pick< | |
| 21 | + SqlControlPlanePersistence["rows"], | |
| 22 | + "organizationMemberships" | "workspaces" | |
| 23 | +>; | |
| 22 | 24 | |
| 23 | 25 | const isTruthy = (value: string | undefined): boolean => { |
| 24 | 26 | const normalized = value?.trim().toLowerCase(); |
| @@ -36,19 +38,19 @@ const localAdminFallbackEnabled = (() => { · 11 unmodified lines | ||
| 36 | 38 | })(); |
| 37 | 39 | |
| 38 | 40 | const organizationMembershipsForAccount = ( |
| 39 | − snapshot: LocalStateSnapshot, | |
| 41 | + memberships: ReadonlyArray<OrganizationMembership>, | |
| 40 | 42 | accountId: OrganizationMembership["accountId"], |
| 41 | 43 | ): ReadonlyArray<OrganizationMembership> => |
| 42 | − snapshot.organizationMemberships.filter( | |
| 44 | + memberships.filter( | |
| 43 | 45 | (membership) => membership.accountId === accountId, |
| 44 | 46 | ); |
| 45 | 47 | |
| 46 | 48 | const workspaceMembershipsForAccount = ( |
| 47 | − snapshot: LocalStateSnapshot, | |
| 49 | + workspaces: ReadonlyArray<Workspace>, | |
| 48 | 50 | accountId: OrganizationMembership["accountId"], |
| 49 | 51 | organizationMemberships: ReadonlyArray<OrganizationMembership>, |
| 50 | 52 | ): ReadonlyArray<WorkspaceMembership> => |
| 51 | − snapshot.workspaces.flatMap((workspace) => | |
| 53 | + workspaces.flatMap((workspace) => | |
| 52 | 54 | deriveWorkspaceMembershipsForPrincipal({ |
| 53 | 55 | principalAccountId: accountId, |
| 54 | 56 | workspaceId: workspace.id, |
| @@ -58,13 +60,16 @@ const workspaceMembershipsForAccount = ( · 3 unmodified lines | ||
| 58 | 60 | ); |
| 59 | 61 | |
| 60 | 62 | const resolveActorFromSnapshot = ( |
| 61 | − localStateStore: LocalStateStore, | |
| 63 | + rows: ActorRows, | |
| 62 | 64 | headers: PlatformHeaders.Headers, |
| 63 | 65 | ) => |
| 64 | 66 | Effect.gen(function* () { |
| 65 | 67 | const principal = yield* requirePrincipalFromHeaders(headers); |
| 66 | 68 | |
| 67 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 69 | + const [memberships, workspaces] = yield* Effect.all([ | |
| 70 | + rows.organizationMemberships.list(), | |
| 71 | + rows.workspaces.list(), | |
| 72 | + ]).pipe( | |
| 68 | 73 | Effect.mapError( |
| 69 | 74 | (error) => |
| 70 | 75 | new ActorUnauthenticatedError({ |
| @@ -73,20 +78,19 @@ const resolveActorFromSnapshot = ( · 2 unmodified lines | ||
| 73 | 78 | ), |
| 74 | 79 | ); |
| 75 | 80 | |
| 76 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 77 | − | |
| 78 | − if (snapshot === null && localAdminFallbackEnabled) { | |
| 81 | + if (memberships.length === 0 && workspaces.length === 0 && localAdminFallbackEnabled) { | |
| 79 | 82 | return makeAllowAllActor(principal); |
| 80 | 83 | } |
| 81 | 84 | |
| 82 | − const organizationMemberships = | |
| 83 | − snapshot === null | |
| 84 | − ? [] | |
| 85 | − : organizationMembershipsForAccount(snapshot, principal.accountId); | |
| 86 | − const workspaceMemberships = | |
| 87 | − snapshot === null | |
| 88 | − ? [] | |
| 89 | − : workspaceMembershipsForAccount(snapshot, principal.accountId, organizationMemberships); | |
| 85 | + const organizationMemberships = organizationMembershipsForAccount( | |
| 86 | + memberships, | |
| 87 | + principal.accountId, | |
| 88 | + ); | |
| 89 | + const workspaceMemberships = workspaceMembershipsForAccount( | |
| 90 | + workspaces, | |
| 91 | + principal.accountId, | |
| 92 | + organizationMemberships, | |
| 93 | + ); | |
| 90 | 94 | |
| 91 | 95 | return yield* makeActor({ |
| 92 | 96 | principal, |
| @@ -95,14 +99,17 @@ const resolveActorFromSnapshot = ( · 2 unmodified lines | ||
| 95 | 99 | }); |
| 96 | 100 | }); |
| 97 | 101 | |
| 98 | −export const PmActorLive = (localStateStore: LocalStateStore) => | |
| 102 | +export const PmActorLive = (rows: ActorRows) => | |
| 99 | 103 | ControlPlaneActorResolverLive({ |
| 100 | − resolveActor: (input) => resolveActorFromSnapshot(localStateStore, input.headers), | |
| 104 | + resolveActor: (input) => resolveActorFromSnapshot(rows, input.headers), | |
| 101 | 105 | resolveWorkspaceActor: (input) => |
| 102 | 106 | Effect.gen(function* () { |
| 103 | 107 | const principal = yield* requirePrincipalFromHeaders(input.headers); |
| 104 | 108 | |
| 105 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 109 | + const [memberships, workspaces] = yield* Effect.all([ | |
| 110 | + rows.organizationMemberships.list(), | |
| 111 | + rows.workspaces.list(), | |
| 112 | + ]).pipe( | |
| 106 | 113 | Effect.mapError( |
| 107 | 114 | (error) => |
| 108 | 115 | new ActorUnauthenticatedError({ |
| @@ -111,21 +118,16 @@ export const PmActorLive = (localStateStore: LocalStateStore) => · 2 unmodified lines | ||
| 111 | 118 | ), |
| 112 | 119 | ); |
| 113 | 120 | |
| 114 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 115 | − | |
| 116 | − if (snapshot === null && localAdminFallbackEnabled) { | |
| 121 | + if (memberships.length === 0 && workspaces.length === 0 && localAdminFallbackEnabled) { | |
| 117 | 122 | return makeAllowAllActor(principal); |
| 118 | 123 | } |
| 119 | 124 | |
| 120 | − const organizationMemberships = | |
| 121 | − snapshot === null | |
| 122 | − ? [] | |
| 123 | − : organizationMembershipsForAccount(snapshot, principal.accountId); | |
| 125 | + const organizationMemberships = organizationMembershipsForAccount( | |
| 126 | + memberships, | |
| 127 | + principal.accountId, | |
| 128 | + ); | |
| 124 | 129 | |
| 125 | − const workspace = | |
| 126 | − snapshot === null | |
| 127 | − ? null | |
| 128 | − : snapshot.workspaces.find((item) => item.id === input.workspaceId) ?? null; | |
| 130 | + const workspace = workspaces.find((item) => item.id === input.workspaceId) ?? null; | |
| 129 | 131 | |
| 130 | 132 | const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({ |
| 131 | 133 | principalAccountId: principal.accountId, |
| @@ -6,18 +6,15 @@ import { · 5 unmodified lines | ||
| 6 | 6 | type ToolApprovalPolicy, |
| 7 | 7 | } from "@executor-v2/engine"; |
| 8 | 8 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 9 | −import { | |
| 10 | − type LocalStateSnapshot, | |
| 11 | − type LocalStateStore, | |
| 12 | − type LocalStateStoreError, | |
| 13 | −} from "@executor-v2/persistence-local"; | |
| 9 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 14 | 10 | import { |
| 15 | 11 | makeControlPlaneApprovalsService, |
| 16 | 12 | type ControlPlaneApprovalsServiceShape, |
| 17 | 13 | } from "@executor-v2/management-api"; |
| 18 | 14 | import { type Approval } from "@executor-v2/schema"; |
| 19 | 15 | import * as Effect from "effect/Effect"; |
| 20 | −import * as Option from "effect/Option"; | |
| 16 | + | |
| 17 | +type ApprovalRows = Pick<SqlControlPlanePersistence["rows"], "approvals">; | |
| 21 | 18 | |
| 22 | 19 | const toSourceStoreError = ( |
| 23 | 20 | operation: string, |
| @@ -26,16 +23,16 @@ const toSourceStoreError = ( · 2 unmodified lines | ||
| 26 | 23 | ): SourceStoreError => |
| 27 | 24 | new SourceStoreError({ |
| 28 | 25 | operation, |
| 29 | − backend: "local-file", | |
| 30 | − location: "snapshot.json", | |
| 26 | + backend: "sql", | |
| 27 | + location: "approvals", | |
| 31 | 28 | message, |
| 32 | 29 | reason: null, |
| 33 | 30 | details, |
| 34 | 31 | }); |
| 35 | 32 | |
| 36 | −const toSourceStoreErrorFromLocalState = ( | |
| 33 | +const toSourceStoreErrorFromRowStore = ( | |
| 37 | 34 | operation: string, |
| 38 | − error: LocalStateStoreError, | |
| 35 | + error: { message: string; details: string | null; reason: string | null }, | |
| 39 | 36 | ): SourceStoreError => |
| 40 | 37 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 41 | 38 | |
| @@ -62,9 +59,9 @@ const toPersistentApprovalStoreError = ( · 20 unmodified lines | ||
| 62 | 59 | details, |
| 63 | 60 | }); |
| 64 | 61 | |
| 65 | −const toPersistentApprovalStoreErrorFromLocalState = ( | |
| 62 | +const toPersistentApprovalStoreErrorFromRowStore = ( | |
| 66 | 63 | operation: string, |
| 67 | − error: LocalStateStoreError, | |
| 64 | + error: { message: string; details: string | null; reason: string | null }, | |
| 68 | 65 | ): PersistentToolApprovalPolicyStoreError => |
| 69 | 66 | toPersistentApprovalStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 70 | 67 | |
| @@ -78,51 +75,24 @@ const toPersistentApprovalRecord = (approval: Approval): PersistentToolApprovalR · 7 unmodified lines | ||
| 78 | 75 | reason: approval.reason, |
| 79 | 76 | }); |
| 80 | 77 | |
| 81 | −const updateApproval = ( | |
| 82 | − snapshot: LocalStateSnapshot, | |
| 83 | − index: number, | |
| 84 | − nextApproval: Approval, | |
| 85 | −): LocalStateSnapshot => { | |
| 86 | − const approvals = [...snapshot.approvals]; | |
| 87 | − approvals[index] = nextApproval; | |
| 88 | − | |
| 89 | − return { | |
| 90 | − ...snapshot, | |
| 91 | − generatedAt: Date.now(), | |
| 92 | − approvals, | |
| 93 | − }; | |
| 94 | −}; | |
| 95 | − | |
| 96 | 78 | export type PmPersistentToolApprovalPolicyOptions = { |
| 97 | 79 | requireApprovals?: boolean; |
| 98 | 80 | retryAfterMs?: number; |
| 99 | 81 | }; |
| 100 | 82 | |
| 101 | 83 | export const createPmPersistentToolApprovalPolicy = ( |
| 102 | − localStateStore: LocalStateStore, | |
| 84 | + rows: ApprovalRows, | |
| 103 | 85 | options: PmPersistentToolApprovalPolicyOptions = {}, |
| 104 | 86 | ): ToolApprovalPolicy => { |
| 105 | − const missingSnapshotError = (operation: string): PersistentToolApprovalPolicyStoreError => | |
| 106 | − toPersistentApprovalStoreError( | |
| 107 | − operation, | |
| 108 | − "Persistent approvals require an initialized local state snapshot", | |
| 109 | − null, | |
| 110 | − ); | |
| 111 | − | |
| 112 | 87 | const store: PersistentToolApprovalStore = { |
| 113 | 88 | findByRunAndCall: (input) => |
| 114 | − localStateStore.getSnapshot().pipe( | |
| 89 | + rows.approvals.list().pipe( | |
| 115 | 90 | Effect.mapError((error) => |
| 116 | − toPersistentApprovalStoreErrorFromLocalState("approvals.read", error), | |
| 91 | + toPersistentApprovalStoreErrorFromRowStore("approvals.read", error), | |
| 117 | 92 | ), |
| 118 | − Effect.flatMap((snapshotOption) => { | |
| 119 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 120 | − if (snapshot === null) { | |
| 121 | − return Effect.fail(missingSnapshotError("approvals.read")); | |
| 122 | − } | |
| 123 | − | |
| 93 | + Effect.flatMap((approvals) => { | |
| 124 | 94 | const approval = |
| 125 | − snapshot.approvals.find( | |
| 95 | + approvals.find( | |
| 126 | 96 | (candidate) => |
| 127 | 97 | candidate.workspaceId === input.workspaceId && |
| 128 | 98 | candidate.taskRunId === input.runId && |
| @@ -134,16 +104,11 @@ export const createPmPersistentToolApprovalPolicy = ( · 5 unmodified lines | ||
| 134 | 104 | ), |
| 135 | 105 | |
| 136 | 106 | createPending: (input) => |
| 137 | − localStateStore.getSnapshot().pipe( | |
| 107 | + rows.approvals.list().pipe( | |
| 138 | 108 | Effect.mapError((error) => |
| 139 | − toPersistentApprovalStoreErrorFromLocalState("approvals.read", error), | |
| 109 | + toPersistentApprovalStoreErrorFromRowStore("approvals.read", error), | |
| 140 | 110 | ), |
| 141 | − Effect.flatMap((snapshotOption) => { | |
| 142 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 143 | − if (snapshot === null) { | |
| 144 | − return Effect.fail(missingSnapshotError("approvals.create")); | |
| 145 | − } | |
| 146 | − | |
| 111 | + Effect.flatMap(() => { | |
| 147 | 112 | const pendingApproval = { |
| 148 | 113 | id: `apr_${crypto.randomUUID()}`, |
| 149 | 114 | workspaceId: input.workspaceId, |
| @@ -157,15 +122,9 @@ export const createPmPersistentToolApprovalPolicy = ( · 7 unmodified lines | ||
| 157 | 122 | resolvedAt: null, |
| 158 | 123 | } as Approval; |
| 159 | 124 | |
| 160 | − const nextSnapshot: LocalStateSnapshot = { | |
| 161 | − ...snapshot, | |
| 162 | − generatedAt: Date.now(), | |
| 163 | − approvals: [...snapshot.approvals, pendingApproval], | |
| 164 | − }; | |
| 165 | − | |
| 166 | − return localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 125 | + return rows.approvals.upsert(pendingApproval).pipe( | |
| 167 | 126 | Effect.mapError((error) => |
| 168 | − toPersistentApprovalStoreErrorFromLocalState("approvals.write", error), | |
| 127 | + toPersistentApprovalStoreErrorFromRowStore("approvals.write", error), | |
| 169 | 128 | ), |
| 170 | 129 | Effect.as(toPersistentApprovalRecord(pendingApproval)), |
| 171 | 130 | ); |
| @@ -181,48 +140,34 @@ export const createPmPersistentToolApprovalPolicy = ( · 9 unmodified lines | ||
| 181 | 140 | }; |
| 182 | 141 | |
| 183 | 142 | export const createPmApprovalsService = ( |
| 184 | − localStateStore: LocalStateStore, | |
| 143 | + rows: ApprovalRows, | |
| 185 | 144 | ): ControlPlaneApprovalsServiceShape => |
| 186 | 145 | makeControlPlaneApprovalsService({ |
| 187 | 146 | listApprovals: (workspaceId) => |
| 188 | 147 | Effect.gen(function* () { |
| 189 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 148 | + const approvals = yield* rows.approvals.list().pipe( | |
| 190 | 149 | Effect.mapError((error) => |
| 191 | − toSourceStoreErrorFromLocalState("approvals.list", error), | |
| 150 | + toSourceStoreErrorFromRowStore("approvals.list", error), | |
| 192 | 151 | ), |
| 193 | 152 | ); |
| 194 | 153 | |
| 195 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 196 | − if (snapshot === null) { | |
| 197 | − return []; | |
| 198 | − } | |
| 199 | − | |
| 200 | − const approvals = snapshot.approvals.filter( | |
| 154 | + const scopedApprovals = approvals.filter( | |
| 201 | 155 | (approval) => approval.workspaceId === workspaceId, |
| 202 | 156 | ); |
| 203 | 157 | |
| 204 | − return sortApprovals(approvals); | |
| 158 | + return sortApprovals(scopedApprovals); | |
| 205 | 159 | }), |
| 206 | 160 | |
| 207 | 161 | resolveApproval: (input) => |
| 208 | 162 | Effect.gen(function* () { |
| 209 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 163 | + const approvals = yield* rows.approvals.list().pipe( | |
| 210 | 164 | Effect.mapError((error) => |
| 211 | − toSourceStoreErrorFromLocalState("approvals.resolve", error), | |
| 165 | + toSourceStoreErrorFromRowStore("approvals.resolve", error), | |
| 212 | 166 | ), |
| 213 | 167 | ); |
| 214 | 168 | |
| 215 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 216 | − if (snapshot === null) { | |
| 217 | − return yield* toSourceStoreError( | |
| 218 | − "approvals.resolve", | |
| 219 | − "Approval snapshot not found", | |
| 220 | − `workspace=${input.workspaceId} approval=${input.approvalId}`, | |
| 221 | − ); | |
| 222 | − } | |
| 223 | − | |
| 224 | 169 | const index = findApprovalIndex( |
| 225 | − snapshot.approvals, | |
| 170 | + approvals, | |
| 226 | 171 | input.workspaceId, |
| 227 | 172 | input.approvalId, |
| 228 | 173 | ); |
| @@ -235,7 +180,7 @@ export const createPmApprovalsService = ( · 6 unmodified lines | ||
| 235 | 180 | ); |
| 236 | 181 | } |
| 237 | 182 | |
| 238 | − const approval = snapshot.approvals[index]; | |
| 183 | + const approval = approvals[index]; | |
| 239 | 184 | if (approval.status !== "pending") { |
| 240 | 185 | return yield* toSourceStoreError( |
| 241 | 186 | "approvals.resolve", |
| @@ -251,11 +196,9 @@ export const createPmApprovalsService = ( · 9 unmodified lines | ||
| 251 | 196 | resolvedAt: Date.now(), |
| 252 | 197 | }; |
| 253 | 198 | |
| 254 | − const nextSnapshot = updateApproval(snapshot, index, resolvedApproval); | |
| 255 | − | |
| 256 | − yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 199 | + yield* rows.approvals.upsert(resolvedApproval).pipe( | |
| 257 | 200 | Effect.mapError((error) => |
| 258 | − toSourceStoreErrorFromLocalState("approvals.resolve_write", error), | |
| 201 | + toSourceStoreErrorFromRowStore("approvals.resolve_write", error), | |
| 259 | 202 | ), |
| 260 | 203 | ); |
| 261 | 204 | |
| @@ -5,9 +5,27 @@ import { · 4 unmodified lines | ||
| 5 | 5 | sourceIdFromSourceKey, |
| 6 | 6 | type ResolveToolCredentials, |
| 7 | 7 | } from "@executor-v2/engine"; |
| 8 | −import { type LocalStateStore } from "@executor-v2/persistence-local"; | |
| 8 | +import { type RowStoreError } from "@executor-v2/persistence-ports"; | |
| 9 | +import { | |
| 10 | + type AuthConnection, | |
| 11 | + type AuthMaterial, | |
| 12 | + type OAuthState, | |
| 13 | + type SourceAuthBinding, | |
| 14 | + type Workspace, | |
| 15 | +} from "@executor-v2/schema"; | |
| 9 | 16 | import * as Effect from "effect/Effect"; |
| 10 | −import * as Option from "effect/Option"; | |
| 17 | + | |
| 18 | +type RowReader<A> = { | |
| 19 | + list: () => Effect.Effect<ReadonlyArray<A>, RowStoreError>; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +type CredentialResolverRows = { | |
| 23 | + workspaces: RowReader<Workspace>; | |
| 24 | + sourceAuthBindings: RowReader<SourceAuthBinding>; | |
| 25 | + authConnections: RowReader<AuthConnection>; | |
| 26 | + authMaterials: RowReader<AuthMaterial>; | |
| 27 | + oauthStates: RowReader<OAuthState>; | |
| 28 | +}; | |
| 11 | 29 | |
| 12 | 30 | const toCredentialResolverError = ( |
| 13 | 31 | operation: string, |
| @@ -151,7 +169,7 @@ const buildSecretHeaders = ( · 137 unmodified lines | ||
| 151 | 169 | }; |
| 152 | 170 | |
| 153 | 171 | export const createPmResolveToolCredentials = ( |
| 154 | − localStateStore: LocalStateStore, | |
| 172 | + rows: CredentialResolverRows, | |
| 155 | 173 | ): ResolveToolCredentials => |
| 156 | 174 | makeCredentialResolver((input) => |
| 157 | 175 | Effect.gen(function* () { |
| @@ -162,23 +180,22 @@ export const createPmResolveToolCredentials = ( · 4 unmodified lines | ||
| 162 | 180 | }; |
| 163 | 181 | } |
| 164 | 182 | |
| 165 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 183 | + const [workspaces, bindings, connections, oauthStates, materials] = yield* Effect.all([ | |
| 184 | + rows.workspaces.list(), | |
| 185 | + rows.sourceAuthBindings.list(), | |
| 186 | + rows.authConnections.list(), | |
| 187 | + rows.oauthStates.list(), | |
| 188 | + rows.authMaterials.list(), | |
| 189 | + ]).pipe( | |
| 166 | 190 | Effect.mapError((error) => |
| 167 | 191 | toCredentialResolverError( |
| 168 | − "read_local_state_snapshot", | |
| 169 | − "Failed reading local snapshot while resolving credentials", | |
| 192 | + "read_credential_rows", | |
| 193 | + "Failed reading credential rows while resolving credentials", | |
| 170 | 194 | error.details ?? error.message, |
| 171 | 195 | ), |
| 172 | 196 | ), |
| 173 | 197 | ); |
| 174 | 198 | |
| 175 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 176 | − if (snapshot === null) { | |
| 177 | − return { | |
| 178 | − headers: {}, | |
| 179 | − }; | |
| 180 | − } | |
| 181 | − | |
| 182 | 199 | const sourceId = sourceIdFromSourceKey(context.sourceKey); |
| 183 | 200 | if (!sourceId) { |
| 184 | 201 | return { |
| @@ -186,12 +203,12 @@ export const createPmResolveToolCredentials = ( · 1 unmodified line | ||
| 186 | 203 | }; |
| 187 | 204 | } |
| 188 | 205 | |
| 189 | − const workspace = snapshot.workspaces.find( | |
| 206 | + const workspace = workspaces.find( | |
| 190 | 207 | (item) => item.id === context.workspaceId, |
| 191 | 208 | ); |
| 192 | 209 | const organizationId = context.organizationId ?? workspace?.organizationId ?? null; |
| 193 | 210 | |
| 194 | − const binding = snapshot.sourceAuthBindings | |
| 211 | + const binding = bindings | |
| 195 | 212 | .filter((candidate) => candidate.sourceId === sourceId) |
| 196 | 213 | .map((candidate) => { |
| 197 | 214 | let score = -1; |
| @@ -230,7 +247,7 @@ export const createPmResolveToolCredentials = ( · 32 unmodified lines | ||
| 230 | 247 | }; |
| 231 | 248 | } |
| 232 | 249 | |
| 233 | − const connection = snapshot.authConnections.find( | |
| 250 | + const connection = connections.find( | |
| 234 | 251 | (candidate) => candidate.id === binding.connectionId, |
| 235 | 252 | ); |
| 236 | 253 | if (!connection || connection.status !== "active") { |
| @@ -244,7 +261,7 @@ export const createPmResolveToolCredentials = ( · 7 unmodified lines | ||
| 244 | 261 | ); |
| 245 | 262 | |
| 246 | 263 | if (connection.strategy === "oauth2") { |
| 247 | − const oauthState = snapshot.oauthStates.find( | |
| 264 | + const oauthState = oauthStates.find( | |
| 248 | 265 | (candidate) => candidate.connectionId === connection.id, |
| 249 | 266 | ); |
| 250 | 267 | |
| @@ -258,7 +275,7 @@ export const createPmResolveToolCredentials = ( · 7 unmodified lines | ||
| 258 | 275 | }; |
| 259 | 276 | } |
| 260 | 277 | |
| 261 | − const material = snapshot.authMaterials.find( | |
| 278 | + const material = materials.find( | |
| 262 | 279 | (candidate) => candidate.connectionId === connection.id, |
| 263 | 280 | ); |
| 264 | 281 | |
| @@ -1,9 +1,5 @@ | ||
| 1 | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | −import { | |
| 3 | − type LocalStateSnapshot, | |
| 4 | − type LocalStateStore, | |
| 5 | − type LocalStateStoreError, | |
| 6 | −} from "@executor-v2/persistence-local"; | |
| 2 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 7 | 3 | import { |
| 8 | 4 | makeControlPlaneCredentialsService, |
| 9 | 5 | type ControlPlaneCredentialsServiceShape, |
| @@ -17,10 +13,19 @@ import { · 7 unmodified lines | ||
| 17 | 13 | type OrganizationId, |
| 18 | 14 | type SourceAuthBinding, |
| 19 | 15 | type SourceCredentialBinding, |
| 16 | + type Workspace, | |
| 20 | 17 | type WorkspaceId, |
| 21 | 18 | } from "@executor-v2/schema"; |
| 22 | 19 | import * as Effect from "effect/Effect"; |
| 23 | −import * as Option from "effect/Option"; | |
| 20 | + | |
| 21 | +type CredentialRows = Pick< | |
| 22 | + SqlControlPlanePersistence["rows"], | |
| 23 | + | "workspaces" | |
| 24 | + | "authConnections" | |
| 25 | + | "sourceAuthBindings" | |
| 26 | + | "authMaterials" | |
| 27 | + | "oauthStates" | |
| 28 | +>; | |
| 24 | 29 | |
| 25 | 30 | const toSourceStoreError = ( |
| 26 | 31 | operation: string, |
| @@ -29,24 +34,24 @@ const toSourceStoreError = ( · 2 unmodified lines | ||
| 29 | 34 | ): SourceStoreError => |
| 30 | 35 | new SourceStoreError({ |
| 31 | 36 | operation, |
| 32 | − backend: "local-file", | |
| 33 | − location: "snapshot.json", | |
| 37 | + backend: "sql", | |
| 38 | + location: "credentials", | |
| 34 | 39 | message, |
| 35 | 40 | reason: null, |
| 36 | 41 | details, |
| 37 | 42 | }); |
| 38 | 43 | |
| 39 | −const toSourceStoreErrorFromLocalState = ( | |
| 44 | +const toSourceStoreErrorFromRowStore = ( | |
| 40 | 45 | operation: string, |
| 41 | − error: LocalStateStoreError, | |
| 46 | + error: { message: string; details: string | null; reason: string | null }, | |
| 42 | 47 | ): SourceStoreError => |
| 43 | 48 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 44 | 49 | |
| 45 | 50 | const resolveWorkspaceOrganizationId = ( |
| 46 | − snapshot: LocalStateSnapshot, | |
| 51 | + workspaces: ReadonlyArray<Workspace>, | |
| 47 | 52 | workspaceId: WorkspaceId, |
| 48 | 53 | ): OrganizationId => { |
| 49 | − const workspace = snapshot.workspaces.find((item) => item.id === workspaceId); | |
| 54 | + const workspace = workspaces.find((item) => item.id === workspaceId); | |
| 50 | 55 | |
| 51 | 56 | if (!workspace) { |
| 52 | 57 | throw new Error(`Workspace not found: ${workspaceId}`); |
| @@ -139,21 +144,6 @@ const sortCredentialBindings = ( · 86 unmodified lines | ||
| 139 | 144 | return leftKey.localeCompare(rightKey); |
| 140 | 145 | }); |
| 141 | 146 | |
| 142 | −const upsertById = <A extends { id: string }>( | |
| 143 | − values: ReadonlyArray<A>, | |
| 144 | − nextValue: A, | |
| 145 | −): Array<A> => { | |
| 146 | − const next = [...values]; | |
| 147 | − const index = next.findIndex((value) => value.id === nextValue.id); | |
| 148 | − if (index >= 0) { | |
| 149 | − next[index] = nextValue; | |
| 150 | − return next; | |
| 151 | − } | |
| 152 | − | |
| 153 | − next.push(nextValue); | |
| 154 | − return next; | |
| 155 | −}; | |
| 156 | − | |
| 157 | 147 | const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { |
| 158 | 148 | if (!value) { |
| 159 | 149 | return {}; |
| @@ -245,88 +235,33 @@ const buildOAuthRefreshConfigFromPayload = ( · 85 unmodified lines | ||
| 245 | 235 | ?? existing.clientInformationJson, |
| 246 | 236 | }); |
| 247 | 237 | |
| 248 | −const removeCredentialBindingForWorkspace = ( | |
| 249 | − snapshot: LocalStateSnapshot, | |
| 250 | − workspaceId: WorkspaceId, | |
| 251 | − credentialBindingId: string, | |
| 252 | −): { snapshot: LocalStateSnapshot; removed: boolean } => { | |
| 253 | − const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | |
| 254 | − const removeIndex = snapshot.sourceAuthBindings.findIndex( | |
| 255 | − (binding) => | |
| 256 | − binding.id === credentialBindingId | |
| 257 | − && ( | |
| 258 | − binding.workspaceId === workspaceId | |
| 259 | − || (binding.workspaceId === null && binding.organizationId === organizationId) | |
| 260 | − ), | |
| 261 | − ); | |
| 262 | − | |
| 263 | − if (removeIndex < 0) { | |
| 264 | − return { | |
| 265 | − snapshot, | |
| 266 | − removed: false, | |
| 267 | − }; | |
| 268 | − } | |
| 269 | − | |
| 270 | − const bindingToRemove = snapshot.sourceAuthBindings[removeIndex]; | |
| 271 | − if (!bindingToRemove) { | |
| 272 | − return { | |
| 273 | − snapshot, | |
| 274 | − removed: false, | |
| 275 | − }; | |
| 276 | − } | |
| 277 | − | |
| 278 | − const nextBindings = [...snapshot.sourceAuthBindings]; | |
| 279 | − nextBindings.splice(removeIndex, 1); | |
| 280 | − | |
| 281 | − const hasRemainingBindings = nextBindings.some( | |
| 282 | − (binding) => binding.connectionId === bindingToRemove.connectionId, | |
| 283 | − ); | |
| 284 | − | |
| 285 | − return { | |
| 286 | − removed: true, | |
| 287 | − snapshot: { | |
| 288 | − ...snapshot, | |
| 289 | − generatedAt: Date.now(), | |
| 290 | − sourceAuthBindings: nextBindings, | |
| 291 | − authConnections: hasRemainingBindings | |
| 292 | − ? snapshot.authConnections | |
| 293 | − : snapshot.authConnections.filter( | |
| 294 | − (connection) => connection.id !== bindingToRemove.connectionId, | |
| 295 | − ), | |
| 296 | − authMaterials: hasRemainingBindings | |
| 297 | − ? snapshot.authMaterials | |
| 298 | − : snapshot.authMaterials.filter( | |
| 299 | − (material) => material.connectionId !== bindingToRemove.connectionId, | |
| 300 | − ), | |
| 301 | − oauthStates: hasRemainingBindings | |
| 302 | − ? snapshot.oauthStates | |
| 303 | − : snapshot.oauthStates.filter( | |
| 304 | − (state) => state.connectionId !== bindingToRemove.connectionId, | |
| 305 | − ), | |
| 306 | − }, | |
| 307 | − }; | |
| 308 | −}; | |
| 309 | − | |
| 310 | 238 | export const createPmCredentialsService = ( |
| 311 | − localStateStore: LocalStateStore, | |
| 239 | + rows: CredentialRows, | |
| 312 | 240 | ): ControlPlaneCredentialsServiceShape => |
| 313 | 241 | makeControlPlaneCredentialsService({ |
| 314 | 242 | listCredentialBindings: (workspaceId) => |
| 315 | 243 | Effect.gen(function* () { |
| 316 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 317 | − Effect.mapError((error) => | |
| 318 | − toSourceStoreErrorFromLocalState("credentials.list", error), | |
| 244 | + const [bindings, connections, workspaces] = yield* Effect.all([ | |
| 245 | + rows.sourceAuthBindings.list().pipe( | |
| 246 | + Effect.mapError((error) => | |
| 247 | + toSourceStoreErrorFromRowStore("credentials.bindings.list", error), | |
| 248 | + ), | |
| 319 | 249 | ), |
| 320 | − ); | |
| 321 | − | |
| 322 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 323 | − if (snapshot === null) { | |
| 324 | − return []; | |
| 325 | − } | |
| 250 | + rows.authConnections.list().pipe( | |
| 251 | + Effect.mapError((error) => | |
| 252 | + toSourceStoreErrorFromRowStore("credentials.connections.list", error), | |
| 253 | + ), | |
| 254 | + ), | |
| 255 | + rows.workspaces.list().pipe( | |
| 256 | + Effect.mapError((error) => | |
| 257 | + toSourceStoreErrorFromRowStore("credentials.workspaces.list", error), | |
| 258 | + ), | |
| 259 | + ), | |
| 260 | + ]); | |
| 326 | 261 | |
| 327 | − const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | |
| 262 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, workspaceId); | |
| 328 | 263 | |
| 329 | − const scopedBindings = snapshot.sourceAuthBindings.filter( | |
| 264 | + const scopedBindings = bindings.filter( | |
| 330 | 265 | (binding) => |
| 331 | 266 | binding.workspaceId === workspaceId |
| 332 | 267 | || (binding.workspaceId === null && binding.organizationId === organizationId), |
| @@ -335,7 +270,7 @@ export const createPmCredentialsService = ( · 2 unmodified lines | ||
| 335 | 270 | const compatBindings: Array<SourceCredentialBinding> = []; |
| 336 | 271 | |
| 337 | 272 | for (const binding of scopedBindings) { |
| 338 | − const connection = snapshot.authConnections.find( | |
| 273 | + const connection = connections.find( | |
| 339 | 274 | (candidate) => candidate.id === binding.connectionId, |
| 340 | 275 | ); |
| 341 | 276 | |
| @@ -351,20 +286,33 @@ export const createPmCredentialsService = ( · 9 unmodified lines | ||
| 351 | 286 | |
| 352 | 287 | upsertCredentialBinding: (input) => |
| 353 | 288 | Effect.gen(function* () { |
| 354 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 355 | − Effect.mapError((error) => | |
| 356 | − toSourceStoreErrorFromLocalState("credentials.upsert", error), | |
| 289 | + const [bindings, connections, materials, oauthStates, workspaces] = yield* Effect.all([ | |
| 290 | + rows.sourceAuthBindings.list().pipe( | |
| 291 | + Effect.mapError((error) => | |
| 292 | + toSourceStoreErrorFromRowStore("credentials.bindings.list", error), | |
| 293 | + ), | |
| 357 | 294 | ), |
| 358 | − ); | |
| 359 | − | |
| 360 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 361 | − if (snapshot === null) { | |
| 362 | − return yield* toSourceStoreError( | |
| 363 | − "credentials.upsert", | |
| 364 | − "Credential snapshot not found", | |
| 365 | − `workspace=${input.workspaceId}`, | |
| 366 | − ); | |
| 367 | − } | |
| 295 | + rows.authConnections.list().pipe( | |
| 296 | + Effect.mapError((error) => | |
| 297 | + toSourceStoreErrorFromRowStore("credentials.connections.list", error), | |
| 298 | + ), | |
| 299 | + ), | |
| 300 | + rows.authMaterials.list().pipe( | |
| 301 | + Effect.mapError((error) => | |
| 302 | + toSourceStoreErrorFromRowStore("credentials.materials.list", error), | |
| 303 | + ), | |
| 304 | + ), | |
| 305 | + rows.oauthStates.list().pipe( | |
| 306 | + Effect.mapError((error) => | |
| 307 | + toSourceStoreErrorFromRowStore("credentials.oauth_states.list", error), | |
| 308 | + ), | |
| 309 | + ), | |
| 310 | + rows.workspaces.list().pipe( | |
| 311 | + Effect.mapError((error) => | |
| 312 | + toSourceStoreErrorFromRowStore("credentials.workspaces.list", error), | |
| 313 | + ), | |
| 314 | + ), | |
| 315 | + ]); | |
| 368 | 316 | |
| 369 | 317 | if (input.payload.scopeType === "account" && input.payload.accountId === null) { |
| 370 | 318 | return yield* toSourceStoreError( |
| @@ -388,10 +336,10 @@ export const createPmCredentialsService = ( · 17 unmodified lines | ||
| 388 | 336 | const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined; |
| 389 | 337 | |
| 390 | 338 | const existingBinding = requestedBindingId |
| 391 | − ? snapshot.sourceAuthBindings.find((binding) => binding.id === requestedBindingId) ?? null | |
| 339 | + ? bindings.find((binding) => binding.id === requestedBindingId) ?? null | |
| 392 | 340 | : null; |
| 393 | 341 | |
| 394 | − const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | |
| 342 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 395 | 343 | |
| 396 | 344 | const scopeWorkspaceId = |
| 397 | 345 | input.payload.scopeType === "workspace" ? input.workspaceId : null; |
| @@ -410,7 +358,7 @@ export const createPmCredentialsService = ( · 12 unmodified lines | ||
| 410 | 358 | ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"]) |
| 411 | 359 | ) as AuthConnection["id"]; |
| 412 | 360 | |
| 413 | − const existingConnection = snapshot.authConnections.find( | |
| 361 | + const existingConnection = connections.find( | |
| 414 | 362 | (connection) => connection.id === requestedConnectionId, |
| 415 | 363 | ) ?? null; |
| 416 | 364 | |
| @@ -465,15 +413,17 @@ export const createPmCredentialsService = ( · 48 unmodified lines | ||
| 465 | 413 | updatedAt: now, |
| 466 | 414 | }; |
| 467 | 415 | |
| 468 | − let nextSnapshot: LocalStateSnapshot = { | |
| 469 | − ...snapshot, | |
| 470 | − generatedAt: now, | |
| 471 | − authConnections: upsertById(snapshot.authConnections, nextConnection), | |
| 472 | − sourceAuthBindings: upsertById(snapshot.sourceAuthBindings, nextBinding), | |
| 473 | − }; | |
| 416 | + yield* Effect.all([ | |
| 417 | + rows.authConnections.upsert(nextConnection), | |
| 418 | + rows.sourceAuthBindings.upsert(nextBinding), | |
| 419 | + ]).pipe( | |
| 420 | + Effect.mapError((error) => | |
| 421 | + toSourceStoreErrorFromRowStore("credentials.upsert_rows", error), | |
| 422 | + ), | |
| 423 | + ); | |
| 474 | 424 | |
| 475 | 425 | if (nextConnection.strategy === "oauth2") { |
| 476 | − const existingOAuth = snapshot.oauthStates.find( | |
| 426 | + const existingOAuth = oauthStates.find( | |
| 477 | 427 | (state) => state.connectionId === requestedConnectionId, |
| 478 | 428 | ) ?? null; |
| 479 | 429 | const refreshConfig = buildOAuthRefreshConfigFromPayload( |
| @@ -518,15 +468,16 @@ export const createPmCredentialsService = ( · 38 unmodified lines | ||
| 518 | 468 | updatedAt: now, |
| 519 | 469 | }; |
| 520 | 470 | |
| 521 | − nextSnapshot = { | |
| 522 | − ...nextSnapshot, | |
| 523 | − authMaterials: nextSnapshot.authMaterials.filter( | |
| 524 | − (material) => material.connectionId !== requestedConnectionId, | |
| 471 | + yield* Effect.all([ | |
| 472 | + rows.oauthStates.upsert(oauthState), | |
| 473 | + rows.authMaterials.removeByConnectionId(requestedConnectionId), | |
| 474 | + ]).pipe( | |
| 475 | + Effect.mapError((error) => | |
| 476 | + toSourceStoreErrorFromRowStore("credentials.upsert_oauth", error), | |
| 525 | 477 | ), |
| 526 | − oauthStates: upsertById(nextSnapshot.oauthStates, oauthState), | |
| 527 | − }; | |
| 478 | + ); | |
| 528 | 479 | } else { |
| 529 | − const existingMaterial = snapshot.authMaterials.find( | |
| 480 | + const existingMaterial = materials.find( | |
| 530 | 481 | (material) => material.connectionId === requestedConnectionId, |
| 531 | 482 | ) ?? null; |
| 532 | 483 | |
| @@ -541,57 +492,81 @@ export const createPmCredentialsService = ( · 8 unmodified lines | ||
| 541 | 492 | updatedAt: now, |
| 542 | 493 | }; |
| 543 | 494 | |
| 544 | − nextSnapshot = { | |
| 545 | − ...nextSnapshot, | |
| 546 | − authMaterials: upsertById(nextSnapshot.authMaterials, material), | |
| 547 | − oauthStates: nextSnapshot.oauthStates.filter( | |
| 548 | − (state) => state.connectionId !== requestedConnectionId, | |
| 495 | + yield* Effect.all([ | |
| 496 | + rows.authMaterials.upsert(material), | |
| 497 | + rows.oauthStates.removeByConnectionId(requestedConnectionId), | |
| 498 | + ]).pipe( | |
| 499 | + Effect.mapError((error) => | |
| 500 | + toSourceStoreErrorFromRowStore("credentials.upsert_secret", error), | |
| 549 | 501 | ), |
| 550 | − }; | |
| 502 | + ); | |
| 551 | 503 | } |
| 552 | 504 | |
| 553 | − yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 554 | − Effect.mapError((error) => | |
| 555 | − toSourceStoreErrorFromLocalState("credentials.upsert_write", error), | |
| 556 | − ), | |
| 557 | − ); | |
| 558 | − | |
| 559 | 505 | return toCompatSourceCredentialBinding(nextBinding, nextConnection); |
| 560 | 506 | }), |
| 561 | 507 | |
| 562 | 508 | removeCredentialBinding: (input) => |
| 563 | 509 | Effect.gen(function* () { |
| 564 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 565 | − Effect.mapError((error) => | |
| 566 | − toSourceStoreErrorFromLocalState("credentials.remove", error), | |
| 510 | + const [bindings, workspaces] = yield* Effect.all([ | |
| 511 | + rows.sourceAuthBindings.list().pipe( | |
| 512 | + Effect.mapError((error) => | |
| 513 | + toSourceStoreErrorFromRowStore("credentials.bindings.list", error), | |
| 514 | + ), | |
| 515 | + ), | |
| 516 | + rows.workspaces.list().pipe( | |
| 517 | + Effect.mapError((error) => | |
| 518 | + toSourceStoreErrorFromRowStore("credentials.workspaces.list", error), | |
| 519 | + ), | |
| 567 | 520 | ), |
| 521 | + ]); | |
| 522 | + | |
| 523 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 524 | + const binding = bindings.find( | |
| 525 | + (item) => | |
| 526 | + item.id === input.credentialBindingId | |
| 527 | + && ( | |
| 528 | + item.workspaceId === input.workspaceId | |
| 529 | + || (item.workspaceId === null && item.organizationId === organizationId) | |
| 530 | + ), | |
| 568 | 531 | ); |
| 569 | 532 | |
| 570 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 571 | − if (snapshot === null) { | |
| 533 | + if (!binding) { | |
| 572 | 534 | return { |
| 573 | 535 | removed: false, |
| 574 | 536 | }; |
| 575 | 537 | } |
| 576 | 538 | |
| 577 | − const next = removeCredentialBindingForWorkspace( | |
| 578 | − snapshot, | |
| 579 | − input.workspaceId, | |
| 580 | − input.credentialBindingId, | |
| 581 | − ); | |
| 539 | + const removed = yield* rows.sourceAuthBindings | |
| 540 | + .removeById(binding.id) | |
| 541 | + .pipe( | |
| 542 | + Effect.mapError((error) => | |
| 543 | + toSourceStoreErrorFromRowStore("credentials.remove_binding", error), | |
| 544 | + ), | |
| 545 | + ); | |
| 582 | 546 | |
| 583 | − if (!next.removed) { | |
| 547 | + if (!removed) { | |
| 584 | 548 | return { |
| 585 | 549 | removed: false, |
| 586 | 550 | }; |
| 587 | 551 | } |
| 588 | 552 | |
| 589 | − yield* localStateStore.writeSnapshot(next.snapshot).pipe( | |
| 590 | − Effect.mapError((error) => | |
| 591 | − toSourceStoreErrorFromLocalState("credentials.remove_write", error), | |
| 592 | − ), | |
| 553 | + const hasRemainingBindings = bindings.some( | |
| 554 | + (candidate) => | |
| 555 | + candidate.id !== binding.id && candidate.connectionId === binding.connectionId, | |
| 593 | 556 | ); |
| 594 | 557 | |
| 558 | + if (!hasRemainingBindings) { | |
| 559 | + yield* Effect.all([ | |
| 560 | + rows.authConnections.removeById(binding.connectionId), | |
| 561 | + rows.authMaterials.removeByConnectionId(binding.connectionId), | |
| 562 | + rows.oauthStates.removeByConnectionId(binding.connectionId), | |
| 563 | + ]).pipe( | |
| 564 | + Effect.mapError((error) => | |
| 565 | + toSourceStoreErrorFromRowStore("credentials.remove_connection_data", error), | |
| 566 | + ), | |
| 567 | + ); | |
| 568 | + } | |
| 569 | + | |
| 595 | 570 | return { |
| 596 | 571 | removed: true, |
| 597 | 572 | }; |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import * as BunContext from "@effect/platform-bun/BunContext"; | |
| 2 | 1 | import { |
| 3 | 2 | ControlPlaneService, |
| 4 | 3 | makeControlPlaneService, |
| @@ -22,16 +21,16 @@ import { · 17 unmodified lines | ||
| 22 | 21 | parseExecuteToolExposureMode, |
| 23 | 22 | } from "@executor-v2/engine"; |
| 24 | 23 | import { |
| 25 | − makeLocalSourceStore, | |
| 26 | − makeLocalStateStore, | |
| 27 | − makeLocalToolArtifactStore, | |
| 28 | −} from "@executor-v2/persistence-local"; | |
| 24 | + makeSqlControlPlanePersistence, | |
| 25 | + type SqlControlPlanePersistence, | |
| 26 | +} from "@executor-v2/persistence-sql"; | |
| 29 | 27 | import { type RuntimeToolCallResult } from "@executor-v2/sdk"; |
| 30 | 28 | import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; |
| 31 | 29 | import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess"; |
| 32 | 30 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; |
| 33 | 31 | import * as Effect from "effect/Effect"; |
| 34 | 32 | import * as Layer from "effect/Layer"; |
| 33 | +import * as path from "node:path"; | |
| 35 | 34 | |
| 36 | 35 | import { PmActorLive } from "./actor"; |
| 37 | 36 | import { |
| @@ -91,6 +90,75 @@ const defaultToolExposureMode = readConfiguredToolExposureMode( · 53 unmodified lines | ||
| 91 | 90 | process.env.PM_TOOL_EXPOSURE_MODE, |
| 92 | 91 | ); |
| 93 | 92 | |
| 93 | +const ensurePmBootstrap = ( | |
| 94 | + persistence: SqlControlPlanePersistence, | |
| 95 | +) => | |
| 96 | + Effect.gen(function* () { | |
| 97 | + const now = Date.now(); | |
| 98 | + const [organizations, memberships, workspaces, profileOption] = yield* Effect.all([ | |
| 99 | + persistence.rows.organizations.list(), | |
| 100 | + persistence.rows.organizationMemberships.list(), | |
| 101 | + persistence.rows.workspaces.list(), | |
| 102 | + persistence.rows.profile.get(), | |
| 103 | + ]); | |
| 104 | + | |
| 105 | + const organizationId = "org_local"; | |
| 106 | + const accountId = "acct_local"; | |
| 107 | + | |
| 108 | + if (organizations.find((item) => item.id === organizationId) === undefined) { | |
| 109 | + yield* persistence.rows.organizations.upsert({ | |
| 110 | + id: organizationId as any, | |
| 111 | + slug: organizationId, | |
| 112 | + name: "Local Organization", | |
| 113 | + status: "active", | |
| 114 | + createdByAccountId: accountId as any, | |
| 115 | + createdAt: now, | |
| 116 | + updatedAt: now, | |
| 117 | + }); | |
| 118 | + } | |
| 119 | + | |
| 120 | + if ( | |
| 121 | + memberships.find( | |
| 122 | + (item) => item.organizationId === organizationId && item.accountId === accountId, | |
| 123 | + ) === undefined | |
| 124 | + ) { | |
| 125 | + yield* persistence.rows.organizationMemberships.upsert({ | |
| 126 | + id: "org_member_local" as any, | |
| 127 | + organizationId: organizationId as any, | |
| 128 | + accountId: accountId as any, | |
| 129 | + role: "owner", | |
| 130 | + status: "active", | |
| 131 | + billable: false, | |
| 132 | + invitedByAccountId: null, | |
| 133 | + joinedAt: now, | |
| 134 | + createdAt: now, | |
| 135 | + updatedAt: now, | |
| 136 | + }); | |
| 137 | + } | |
| 138 | + | |
| 139 | + if (workspaces.find((item) => item.id === workspaceId) === undefined) { | |
| 140 | + yield* persistence.rows.workspaces.upsert({ | |
| 141 | + id: workspaceId as any, | |
| 142 | + organizationId: organizationId as any, | |
| 143 | + name: "Local Workspace", | |
| 144 | + createdByAccountId: accountId as any, | |
| 145 | + createdAt: now, | |
| 146 | + updatedAt: now, | |
| 147 | + }); | |
| 148 | + } | |
| 149 | + | |
| 150 | + if (profileOption._tag === "None") { | |
| 151 | + yield* persistence.rows.profile.upsert({ | |
| 152 | + id: "profile_local" as any, | |
| 153 | + defaultWorkspaceId: workspaceId as any, | |
| 154 | + displayName: "Local", | |
| 155 | + runtimeMode: "local", | |
| 156 | + createdAt: now, | |
| 157 | + updatedAt: now, | |
| 158 | + }); | |
| 159 | + } | |
| 160 | + }); | |
| 161 | + | |
| 94 | 162 | const pmRuntimeAdapters = [ |
| 95 | 163 | makeLocalInProcessRuntimeAdapter(), |
| 96 | 164 | makeDenoSubprocessRuntimeAdapter(), |
| @@ -101,23 +169,20 @@ const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters); · 4 unmodified lines | ||
| 101 | 169 | const defaultRuntimeKind = |
| 102 | 170 | readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind; |
| 103 | 171 | |
| 104 | −const sourceStore = await Effect.runPromise( | |
| 105 | − makeLocalSourceStore({ | |
| 106 | − rootDir: pmStateRootDir, | |
| 107 | − }).pipe(Effect.provide(BunContext.layer)), | |
| 172 | +const persistence: SqlControlPlanePersistence = await Effect.runPromise( | |
| 173 | + makeSqlControlPlanePersistence({ | |
| 174 | + databaseUrl: process.env.PM_CONTROL_PLANE_DATABASE_URL, | |
| 175 | + sqlitePath: | |
| 176 | + process.env.PM_CONTROL_PLANE_SQLITE_PATH | |
| 177 | + ?? path.resolve(pmStateRootDir, "control-plane.sqlite"), | |
| 178 | + postgresApplicationName: "executor-v2-pm", | |
| 179 | + }), | |
| 108 | 180 | ); |
| 109 | 181 | |
| 110 | −const localStateStore = await Effect.runPromise( | |
| 111 | − makeLocalStateStore({ | |
| 112 | − rootDir: pmStateRootDir, | |
| 113 | − }).pipe(Effect.provide(BunContext.layer)), | |
| 114 | −); | |
| 182 | +const sourceStore = persistence.sourceStore; | |
| 183 | +const toolArtifactStore = persistence.toolArtifactStore; | |
| 115 | 184 | |
| 116 | −const toolArtifactStore = await Effect.runPromise( | |
| 117 | − makeLocalToolArtifactStore({ | |
| 118 | − rootDir: pmStateRootDir, | |
| 119 | − }).pipe(Effect.provide(BunContext.layer)), | |
| 120 | −); | |
| 185 | +await Effect.runPromise(ensurePmBootstrap(persistence)); | |
| 121 | 186 | |
| 122 | 187 | const sourceCatalog = makeSourceCatalogService(sourceStore); |
| 123 | 188 | const sourceManager = makeSourceManagerService(toolArtifactStore); |
| @@ -152,15 +217,15 @@ const sourcesService = { · 28 unmodified lines | ||
| 152 | 217 | }), |
| 153 | 218 | }; |
| 154 | 219 | |
| 155 | −const credentialsService = createPmCredentialsService(localStateStore); | |
| 156 | −const policiesService = createPmPoliciesService(localStateStore); | |
| 157 | −const organizationsService = createPmOrganizationsService(localStateStore); | |
| 158 | −const workspacesService = createPmWorkspacesService(localStateStore); | |
| 220 | +const credentialsService = createPmCredentialsService(persistence.rows); | |
| 221 | +const policiesService = createPmPoliciesService(persistence.rows); | |
| 222 | +const organizationsService = createPmOrganizationsService(persistence.rows); | |
| 223 | +const workspacesService = createPmWorkspacesService(persistence.rows); | |
| 159 | 224 | const toolsService = createPmToolsService(sourceStore, toolArtifactStore); |
| 160 | −const storageService = createPmStorageService(localStateStore, { | |
| 225 | +const storageService = createPmStorageService(persistence.rows, { | |
| 161 | 226 | stateRootDir: pmStateRootDir, |
| 162 | 227 | }); |
| 163 | −const approvalsService = createPmApprovalsService(localStateStore); | |
| 228 | +const approvalsService = createPmApprovalsService(persistence.rows); | |
| 164 | 229 | const controlPlaneService = makeControlPlaneService({ |
| 165 | 230 | sources: sourcesService, |
| 166 | 231 | credentials: credentialsService, |
| @@ -174,7 +239,7 @@ const controlPlaneService = makeControlPlaneService({ · 7 unmodified lines | ||
| 174 | 239 | |
| 175 | 240 | const controlPlaneWebHandler = makeControlPlaneWebHandler( |
| 176 | 241 | Layer.succeed(ControlPlaneService, controlPlaneService), |
| 177 | − PmActorLive(localStateStore), | |
| 242 | + PmActorLive(persistence.rows), | |
| 178 | 243 | ); |
| 179 | 244 | |
| 180 | 245 | const toolProviderRegistry = makeToolProviderRegistry([ |
| @@ -182,7 +247,7 @@ const toolProviderRegistry = makeToolProviderRegistry([ · 1 unmodified line | ||
| 182 | 247 | makeMcpToolProvider(), |
| 183 | 248 | makeGraphqlToolProvider(), |
| 184 | 249 | ]); |
| 185 | −const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(localStateStore, { | |
| 250 | +const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(persistence.rows, { | |
| 186 | 251 | requireApprovals: requireToolApprovals, |
| 187 | 252 | }); |
| 188 | 253 | const toolRegistry = createSourceToolRegistry({ |
| @@ -228,6 +293,7 @@ const server = startPmHttpServer({ · 39 unmodified lines | ||
| 228 | 293 | const shutdown = async () => { |
| 229 | 294 | server.stop(); |
| 230 | 295 | await controlPlaneWebHandler.dispose(); |
| 296 | + await persistence.close(); | |
| 231 | 297 | }; |
| 232 | 298 | |
| 233 | 299 | process.on("SIGINT", () => { |
| @@ -1,19 +1,19 @@ | ||
| 1 | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 2 | 3 | import { |
| 3 | 4 | makeControlPlaneOrganizationsService, |
| 4 | 5 | type ControlPlaneOrganizationsServiceShape, |
| 5 | 6 | } from "@executor-v2/management-api"; |
| 6 | −import { | |
| 7 | − type LocalStateSnapshot, | |
| 8 | − type LocalStateStore, | |
| 9 | − type LocalStateStoreError, | |
| 10 | −} from "@executor-v2/persistence-local"; | |
| 11 | 7 | import { |
| 12 | 8 | type Organization, |
| 13 | 9 | type OrganizationMembership, |
| 14 | 10 | } from "@executor-v2/schema"; |
| 15 | 11 | import * as Effect from "effect/Effect"; |
| 16 | −import * as Option from "effect/Option"; | |
| 12 | + | |
| 13 | +type OrganizationRows = Pick< | |
| 14 | + SqlControlPlanePersistence["rows"], | |
| 15 | + "organizations" | "organizationMemberships" | |
| 16 | +>; | |
| 17 | 17 | |
| 18 | 18 | const toSourceStoreError = ( |
| 19 | 19 | operation: string, |
| @@ -22,16 +22,16 @@ const toSourceStoreError = ( · 2 unmodified lines | ||
| 22 | 22 | ): SourceStoreError => |
| 23 | 23 | new SourceStoreError({ |
| 24 | 24 | operation, |
| 25 | − backend: "local-file", | |
| 26 | − location: "snapshot.json", | |
| 25 | + backend: "sql", | |
| 26 | + location: "organizations", | |
| 27 | 27 | message, |
| 28 | 28 | reason: null, |
| 29 | 29 | details, |
| 30 | 30 | }); |
| 31 | 31 | |
| 32 | −const toSourceStoreErrorFromLocalState = ( | |
| 32 | +const toSourceStoreErrorFromRowStore = ( | |
| 33 | 33 | operation: string, |
| 34 | − error: LocalStateStoreError, | |
| 34 | + error: { message: string; details: string | null; reason: string | null }, | |
| 35 | 35 | ): SourceStoreError => |
| 36 | 36 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 37 | 37 | |
| @@ -49,83 +49,45 @@ const sortOrganizations = ( · 11 unmodified lines | ||
| 49 | 49 | return leftName.localeCompare(rightName); |
| 50 | 50 | }); |
| 51 | 51 | |
| 52 | −const replaceOrganizationAt = ( | |
| 53 | − snapshot: LocalStateSnapshot, | |
| 54 | − index: number, | |
| 55 | − organization: Organization, | |
| 56 | −): LocalStateSnapshot => { | |
| 57 | − const next = [...snapshot.organizations]; | |
| 58 | − next[index] = organization; | |
| 59 | − | |
| 60 | − return { | |
| 61 | − ...snapshot, | |
| 62 | − generatedAt: Date.now(), | |
| 63 | − organizations: next, | |
| 64 | − }; | |
| 65 | −}; | |
| 66 | − | |
| 67 | −const appendOrganization = ( | |
| 68 | − snapshot: LocalStateSnapshot, | |
| 69 | − organization: Organization, | |
| 70 | −): LocalStateSnapshot => ({ | |
| 71 | − ...snapshot, | |
| 72 | − generatedAt: Date.now(), | |
| 73 | − organizations: [...snapshot.organizations, organization], | |
| 74 | −}); | |
| 75 | − | |
| 76 | −const appendOrganizationMembership = ( | |
| 77 | − snapshot: LocalStateSnapshot, | |
| 78 | − membership: OrganizationMembership, | |
| 79 | −): LocalStateSnapshot => ({ | |
| 80 | − ...snapshot, | |
| 81 | − generatedAt: Date.now(), | |
| 82 | − organizationMemberships: [...snapshot.organizationMemberships, membership], | |
| 83 | −}); | |
| 84 | − | |
| 85 | 52 | export const createPmOrganizationsService = ( |
| 86 | − localStateStore: LocalStateStore, | |
| 53 | + rows: OrganizationRows, | |
| 87 | 54 | ): ControlPlaneOrganizationsServiceShape => |
| 88 | 55 | makeControlPlaneOrganizationsService({ |
| 89 | 56 | listOrganizations: () => |
| 90 | 57 | Effect.gen(function* () { |
| 91 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 58 | + const organizations = yield* rows.organizations.list().pipe( | |
| 92 | 59 | Effect.mapError((error) => |
| 93 | − toSourceStoreErrorFromLocalState("organizations.list", error), | |
| 60 | + toSourceStoreErrorFromRowStore("organizations.list", error), | |
| 94 | 61 | ), |
| 95 | 62 | ); |
| 96 | 63 | |
| 97 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 98 | − if (snapshot === null) { | |
| 99 | − return []; | |
| 100 | − } | |
| 101 | − | |
| 102 | − return sortOrganizations(snapshot.organizations); | |
| 64 | + return sortOrganizations(organizations); | |
| 103 | 65 | }), |
| 104 | 66 | |
| 105 | 67 | upsertOrganization: (input) => |
| 106 | 68 | Effect.gen(function* () { |
| 107 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 69 | + const organizations = yield* rows.organizations.list().pipe( | |
| 108 | 70 | Effect.mapError((error) => |
| 109 | − toSourceStoreErrorFromLocalState("organizations.upsert", error), | |
| 71 | + toSourceStoreErrorFromRowStore("organizations.upsert", error), | |
| 110 | 72 | ), |
| 111 | 73 | ); |
| 112 | 74 | |
| 113 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 114 | − if (snapshot === null) { | |
| 115 | − return yield* toSourceStoreError( | |
| 116 | − "organizations.upsert", | |
| 117 | − "Organization snapshot not found", | |
| 118 | − null, | |
| 119 | − ); | |
| 120 | − } | |
| 75 | + const memberships = yield* rows.organizationMemberships.list().pipe( | |
| 76 | + Effect.mapError((error) => | |
| 77 | + toSourceStoreErrorFromRowStore( | |
| 78 | + "organizations.memberships.list", | |
| 79 | + error, | |
| 80 | + ), | |
| 81 | + ), | |
| 82 | + ); | |
| 121 | 83 | |
| 122 | 84 | const now = Date.now(); |
| 123 | 85 | const existingIndex = input.payload.id |
| 124 | − ? snapshot.organizations.findIndex((organization) => organization.id === input.payload.id) | |
| 125 | − : snapshot.organizations.findIndex( | |
| 86 | + ? organizations.findIndex((organization) => organization.id === input.payload.id) | |
| 87 | + : organizations.findIndex( | |
| 126 | 88 | (organization) => organization.slug === input.payload.slug, |
| 127 | 89 | ); |
| 128 | − const existing = existingIndex >= 0 ? snapshot.organizations[existingIndex] : null; | |
| 90 | + const existing = existingIndex >= 0 ? organizations[existingIndex] : null; | |
| 129 | 91 | |
| 130 | 92 | const nextOrganization: Organization = { |
| 131 | 93 | id: |
| @@ -142,19 +104,21 @@ export const createPmOrganizationsService = ( · 10 unmodified lines | ||
| 142 | 104 | updatedAt: now, |
| 143 | 105 | }; |
| 144 | 106 | |
| 145 | − let nextSnapshot = existingIndex >= 0 | |
| 146 | − ? replaceOrganizationAt(snapshot, existingIndex, nextOrganization) | |
| 147 | − : appendOrganization(snapshot, nextOrganization); | |
| 107 | + yield* rows.organizations.upsert(nextOrganization).pipe( | |
| 108 | + Effect.mapError((error) => | |
| 109 | + toSourceStoreErrorFromRowStore("organizations.upsert_write", error), | |
| 110 | + ), | |
| 111 | + ); | |
| 148 | 112 | |
| 149 | 113 | if (existing === null && nextOrganization.createdByAccountId !== null) { |
| 150 | − const existingMembership = nextSnapshot.organizationMemberships.find( | |
| 114 | + const existingMembership = memberships.find( | |
| 151 | 115 | (membership) => |
| 152 | 116 | membership.organizationId === nextOrganization.id |
| 153 | 117 | && membership.accountId === nextOrganization.createdByAccountId, |
| 154 | 118 | ); |
| 155 | 119 | |
| 156 | 120 | if (!existingMembership) { |
| 157 | − nextSnapshot = appendOrganizationMembership(nextSnapshot, { | |
| 121 | + const membership: OrganizationMembership = { | |
| 158 | 122 | id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"], |
| 159 | 123 | organizationId: nextOrganization.id, |
| 160 | 124 | accountId: nextOrganization.createdByAccountId, |
| @@ -165,16 +129,19 @@ export const createPmOrganizationsService = ( · 4 unmodified lines | ||
| 165 | 129 | joinedAt: now, |
| 166 | 130 | createdAt: now, |
| 167 | 131 | updatedAt: now, |
| 168 | − }); | |
| 132 | + }; | |
| 133 | + | |
| 134 | + yield* rows.organizationMemberships.upsert(membership).pipe( | |
| 135 | + Effect.mapError((error) => | |
| 136 | + toSourceStoreErrorFromRowStore( | |
| 137 | + "organizations.membership_upsert_write", | |
| 138 | + error, | |
| 139 | + ), | |
| 140 | + ), | |
| 141 | + ); | |
| 169 | 142 | } |
| 170 | 143 | } |
| 171 | 144 | |
| 172 | − yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 173 | − Effect.mapError((error) => | |
| 174 | − toSourceStoreErrorFromLocalState("organizations.upsert_write", error), | |
| 175 | − ), | |
| 176 | − ); | |
| 177 | − | |
| 178 | 145 | return nextOrganization; |
| 179 | 146 | }), |
| 180 | 147 | }); |
| @@ -1,60 +1,34 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import type { | |
| 3 | − LocalStateSnapshot, | |
| 4 | − LocalStateStore, | |
| 5 | −} from "@executor-v2/persistence-local"; | |
| 6 | 2 | import * as Effect from "effect/Effect"; |
| 7 | −import * as Option from "effect/Option"; | |
| 3 | +import { type Approval } from "@executor-v2/schema"; | |
| 8 | 4 | |
| 9 | 5 | import { |
| 10 | 6 | createPmApprovalsService, |
| 11 | 7 | createPmPersistentToolApprovalPolicy, |
| 12 | 8 | } from "./approvals-service"; |
| 13 | 9 | |
| 14 | −const createTestSnapshot = (): LocalStateSnapshot => | |
| 15 | − ({ | |
| 16 | − schemaVersion: 1, | |
| 17 | − generatedAt: Date.now(), | |
| 18 | − profile: { | |
| 19 | − id: "profile_local", | |
| 20 | − defaultWorkspaceId: "ws_local", | |
| 21 | − displayName: "Local", | |
| 22 | − runtimeMode: "local", | |
| 23 | − createdAt: Date.now(), | |
| 24 | − updatedAt: Date.now(), | |
| 25 | − }, | |
| 26 | − organizations: [], | |
| 27 | − organizationMemberships: [], | |
| 28 | − workspaces: [], | |
| 29 | − sources: [], | |
| 30 | − toolArtifacts: [], | |
| 31 | − authConnections: [], | |
| 32 | − sourceAuthBindings: [], | |
| 33 | − authMaterials: [], | |
| 34 | − oauthStates: [], | |
| 35 | − policies: [], | |
| 36 | − approvals: [], | |
| 37 | − taskRuns: [], | |
| 38 | − storageInstances: [], | |
| 39 | − syncStates: [], | |
| 40 | − }) as unknown as LocalStateSnapshot; | |
| 41 | − | |
| 42 | 10 | describe("PM persistent approval policy", () => { |
| 43 | 11 | it.effect("creates pending approval records and reuses resolved decisions", () => |
| 44 | 12 | Effect.gen(function* () { |
| 45 | − let snapshot: LocalStateSnapshot = createTestSnapshot(); | |
| 13 | + const approvals: Array<Approval> = []; | |
| 46 | 14 | |
| 47 | − const localStateStore: LocalStateStore = { | |
| 48 | − getSnapshot: () => Effect.succeed(Option.some(snapshot)), | |
| 49 | − writeSnapshot: (nextSnapshot) => | |
| 50 | − Effect.sync(() => { | |
| 51 | − snapshot = nextSnapshot; | |
| 52 | − }), | |
| 53 | − readEvents: () => Effect.succeed([]), | |
| 54 | − appendEvents: () => Effect.void, | |
| 15 | + const approvalRows = { | |
| 16 | + approvals: { | |
| 17 | + list: () => Effect.succeed(approvals), | |
| 18 | + upsert: (approval: Approval) => | |
| 19 | + Effect.sync(() => { | |
| 20 | + const index = approvals.findIndex((item) => item.id === approval.id); | |
| 21 | + if (index >= 0) { | |
| 22 | + approvals[index] = approval; | |
| 23 | + return; | |
| 24 | + } | |
| 25 | + | |
| 26 | + approvals.push(approval); | |
| 27 | + }), | |
| 28 | + }, | |
| 55 | 29 | }; |
| 56 | 30 | |
| 57 | − const policy = createPmPersistentToolApprovalPolicy(localStateStore, { | |
| 31 | + const policy = createPmPersistentToolApprovalPolicy(approvalRows, { | |
| 58 | 32 | requireApprovals: true, |
| 59 | 33 | retryAfterMs: 250, |
| 60 | 34 | }); |
| @@ -80,9 +54,9 @@ describe("PM persistent approval policy", () => { · 19 unmodified lines | ||
| 80 | 54 | if (first.kind === "pending") { |
| 81 | 55 | expect(first.retryAfterMs).toBe(250); |
| 82 | 56 | } |
| 83 | − expect(snapshot.approvals).toHaveLength(1); | |
| 84 | − expect(snapshot.approvals[0]?.status).toBe("pending"); | |
| 85 | − expect(snapshot.approvals[0]?.callId).toBe("call_approval_1"); | |
| 57 | + expect(approvals).toHaveLength(1); | |
| 58 | + expect(approvals[0]?.status).toBe("pending"); | |
| 59 | + expect(approvals[0]?.callId).toBe("call_approval_1"); | |
| 86 | 60 | |
| 87 | 61 | const second = yield* Effect.promise(() => |
| 88 | 62 | Promise.resolve( |
| @@ -98,10 +72,10 @@ describe("PM persistent approval policy", () => { · 9 unmodified lines | ||
| 98 | 72 | ); |
| 99 | 73 | |
| 100 | 74 | expect(second).toEqual(first); |
| 101 | − expect(snapshot.approvals).toHaveLength(1); | |
| 75 | + expect(approvals).toHaveLength(1); | |
| 102 | 76 | |
| 103 | − const approvalsService = createPmApprovalsService(localStateStore); | |
| 104 | − const createdApproval = snapshot.approvals[0]; | |
| 77 | + const approvalsService = createPmApprovalsService(approvalRows); | |
| 78 | + const createdApproval = approvals[0]; | |
| 105 | 79 | if (!createdApproval) { |
| 106 | 80 | throw new Error("expected pending approval record"); |
| 107 | 81 | } |
| @@ -1,16 +1,13 @@ | ||
| 1 | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | −import { | |
| 3 | − type LocalStateSnapshot, | |
| 4 | − type LocalStateStore, | |
| 5 | − type LocalStateStoreError, | |
| 6 | −} from "@executor-v2/persistence-local"; | |
| 2 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 7 | 3 | import { |
| 8 | 4 | makeControlPlanePoliciesService, |
| 9 | 5 | type ControlPlanePoliciesServiceShape, |
| 10 | 6 | } from "@executor-v2/management-api"; |
| 11 | 7 | import { type Policy } from "@executor-v2/schema"; |
| 12 | 8 | import * as Effect from "effect/Effect"; |
| 13 | −import * as Option from "effect/Option"; | |
| 9 | + | |
| 10 | +type PolicyRows = Pick<SqlControlPlanePersistence["rows"], "policies">; | |
| 14 | 11 | |
| 15 | 12 | const toSourceStoreError = ( |
| 16 | 13 | operation: string, |
| @@ -19,16 +16,16 @@ const toSourceStoreError = ( · 2 unmodified lines | ||
| 19 | 16 | ): SourceStoreError => |
| 20 | 17 | new SourceStoreError({ |
| 21 | 18 | operation, |
| 22 | − backend: "local-file", | |
| 23 | − location: "snapshot.json", | |
| 19 | + backend: "sql", | |
| 20 | + location: "policies", | |
| 24 | 21 | message, |
| 25 | 22 | reason: null, |
| 26 | 23 | details, |
| 27 | 24 | }); |
| 28 | 25 | |
| 29 | −const toSourceStoreErrorFromLocalState = ( | |
| 26 | +const toSourceStoreErrorFromRowStore = ( | |
| 30 | 27 | operation: string, |
| 31 | − error: LocalStateStoreError, | |
| 28 | + error: { message: string; details: string | null; reason: string | null }, | |
| 32 | 29 | ): SourceStoreError => |
| 33 | 30 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 34 | 31 | |
| @@ -43,109 +40,42 @@ const sortPolicies = (policies: ReadonlyArray<Policy>): Array<Policy> => · 8 unmodified lines | ||
| 43 | 40 | return leftPattern.localeCompare(rightPattern); |
| 44 | 41 | }); |
| 45 | 42 | |
| 46 | −const replacePolicyAt = ( | |
| 47 | − snapshot: LocalStateSnapshot, | |
| 48 | − index: number, | |
| 49 | − policy: Policy, | |
| 50 | −): LocalStateSnapshot => { | |
| 51 | − const next = [...snapshot.policies]; | |
| 52 | − next[index] = policy; | |
| 53 | − | |
| 54 | − return { | |
| 55 | − ...snapshot, | |
| 56 | − generatedAt: Date.now(), | |
| 57 | − policies: next, | |
| 58 | − }; | |
| 59 | −}; | |
| 60 | − | |
| 61 | −const appendPolicy = ( | |
| 62 | − snapshot: LocalStateSnapshot, | |
| 63 | − policy: Policy, | |
| 64 | −): LocalStateSnapshot => ({ | |
| 65 | − ...snapshot, | |
| 66 | − generatedAt: Date.now(), | |
| 67 | − policies: [...snapshot.policies, policy], | |
| 68 | −}); | |
| 69 | − | |
| 70 | −const removePolicyForWorkspace = ( | |
| 71 | − snapshot: LocalStateSnapshot, | |
| 72 | − workspaceId: Policy["workspaceId"], | |
| 73 | − policyId: string, | |
| 74 | −): { snapshot: LocalStateSnapshot; removed: boolean } => { | |
| 75 | − const removeIndex = snapshot.policies.findIndex( | |
| 76 | − (policy) => policy.workspaceId === workspaceId && policy.id === policyId, | |
| 77 | − ); | |
| 78 | − | |
| 79 | − if (removeIndex < 0) { | |
| 80 | − return { | |
| 81 | − snapshot, | |
| 82 | − removed: false, | |
| 83 | − }; | |
| 84 | − } | |
| 85 | − | |
| 86 | − const next = [...snapshot.policies]; | |
| 87 | − next.splice(removeIndex, 1); | |
| 88 | − | |
| 89 | − return { | |
| 90 | − removed: true, | |
| 91 | − snapshot: { | |
| 92 | − ...snapshot, | |
| 93 | − generatedAt: Date.now(), | |
| 94 | − policies: next, | |
| 95 | − }, | |
| 96 | − }; | |
| 97 | −}; | |
| 98 | − | |
| 99 | 43 | export const createPmPoliciesService = ( |
| 100 | − localStateStore: LocalStateStore, | |
| 44 | + rows: PolicyRows, | |
| 101 | 45 | ): ControlPlanePoliciesServiceShape => |
| 102 | 46 | makeControlPlanePoliciesService({ |
| 103 | 47 | listPolicies: (workspaceId) => |
| 104 | 48 | Effect.gen(function* () { |
| 105 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 49 | + const policies = yield* rows.policies.list().pipe( | |
| 106 | 50 | Effect.mapError((error) => |
| 107 | − toSourceStoreErrorFromLocalState("policies.list", error), | |
| 51 | + toSourceStoreErrorFromRowStore("policies.list", error), | |
| 108 | 52 | ), |
| 109 | 53 | ); |
| 110 | 54 | |
| 111 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 112 | − if (snapshot === null) { | |
| 113 | − return []; | |
| 114 | − } | |
| 115 | − | |
| 116 | 55 | return sortPolicies( |
| 117 | − snapshot.policies.filter((policy) => policy.workspaceId === workspaceId), | |
| 56 | + policies.filter((policy) => policy.workspaceId === workspaceId), | |
| 118 | 57 | ); |
| 119 | 58 | }), |
| 120 | 59 | |
| 121 | 60 | upsertPolicy: (input) => |
| 122 | 61 | Effect.gen(function* () { |
| 123 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 62 | + const policies = yield* rows.policies.list().pipe( | |
| 124 | 63 | Effect.mapError((error) => |
| 125 | − toSourceStoreErrorFromLocalState("policies.upsert", error), | |
| 64 | + toSourceStoreErrorFromRowStore("policies.upsert", error), | |
| 126 | 65 | ), |
| 127 | 66 | ); |
| 128 | 67 | |
| 129 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 130 | − if (snapshot === null) { | |
| 131 | − return yield* toSourceStoreError( | |
| 132 | − "policies.upsert", | |
| 133 | − "Policy snapshot not found", | |
| 134 | − `workspace=${input.workspaceId}`, | |
| 135 | − ); | |
| 136 | − } | |
| 137 | − | |
| 138 | 68 | const now = Date.now(); |
| 139 | 69 | const requestedId = input.payload.id; |
| 140 | 70 | |
| 141 | 71 | const existingIndex = requestedId |
| 142 | − ? snapshot.policies.findIndex( | |
| 72 | + ? policies.findIndex( | |
| 143 | 73 | (policy) => |
| 144 | 74 | policy.workspaceId === input.workspaceId && policy.id === requestedId, |
| 145 | 75 | ) |
| 146 | 76 | : -1; |
| 147 | 77 | |
| 148 | − const existing = existingIndex >= 0 ? snapshot.policies[existingIndex] : null; | |
| 78 | + const existing = existingIndex >= 0 ? policies[existingIndex] : null; | |
| 149 | 79 | |
| 150 | 80 | const nextPolicy: Policy = { |
| 151 | 81 | id: existing?.id ?? (requestedId ?? (`pol_${crypto.randomUUID()}` as Policy["id"])), |
| @@ -156,13 +86,9 @@ export const createPmPoliciesService = ( · 4 unmodified lines | ||
| 156 | 86 | updatedAt: now, |
| 157 | 87 | }; |
| 158 | 88 | |
| 159 | − const nextSnapshot = existingIndex >= 0 | |
| 160 | − ? replacePolicyAt(snapshot, existingIndex, nextPolicy) | |
| 161 | − : appendPolicy(snapshot, nextPolicy); | |
| 162 | − | |
| 163 | − yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 89 | + yield* rows.policies.upsert(nextPolicy).pipe( | |
| 164 | 90 | Effect.mapError((error) => |
| 165 | − toSourceStoreErrorFromLocalState("policies.upsert_write", error), | |
| 91 | + toSourceStoreErrorFromRowStore("policies.upsert_write", error), | |
| 166 | 92 | ), |
| 167 | 93 | ); |
| 168 | 94 | |
| @@ -171,38 +97,30 @@ export const createPmPoliciesService = ( · 2 unmodified lines | ||
| 171 | 97 | |
| 172 | 98 | removePolicy: (input) => |
| 173 | 99 | Effect.gen(function* () { |
| 174 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 100 | + const policies = yield* rows.policies.list().pipe( | |
| 175 | 101 | Effect.mapError((error) => |
| 176 | − toSourceStoreErrorFromLocalState("policies.remove", error), | |
| 102 | + toSourceStoreErrorFromRowStore("policies.remove", error), | |
| 177 | 103 | ), |
| 178 | 104 | ); |
| 179 | 105 | |
| 180 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 181 | − if (snapshot === null) { | |
| 182 | − return { | |
| 183 | − removed: false, | |
| 184 | − }; | |
| 185 | − } | |
| 186 | − | |
| 187 | − const next = removePolicyForWorkspace( | |
| 188 | − snapshot, | |
| 189 | − input.workspaceId, | |
| 190 | − input.policyId, | |
| 106 | + const existing = policies.find( | |
| 107 | + (policy) => policy.workspaceId === input.workspaceId && policy.id === input.policyId, | |
| 191 | 108 | ); |
| 192 | − if (!next.removed) { | |
| 109 | + | |
| 110 | + if (!existing) { | |
| 193 | 111 | return { |
| 194 | 112 | removed: false, |
| 195 | 113 | }; |
| 196 | 114 | } |
| 197 | 115 | |
| 198 | − yield* localStateStore.writeSnapshot(next.snapshot).pipe( | |
| 116 | + const removed = yield* rows.policies.removeById(input.policyId).pipe( | |
| 199 | 117 | Effect.mapError((error) => |
| 200 | − toSourceStoreErrorFromLocalState("policies.remove_write", error), | |
| 118 | + toSourceStoreErrorFromRowStore("policies.remove_write", error), | |
| 201 | 119 | ), |
| 202 | 120 | ); |
| 203 | 121 | |
| 204 | 122 | return { |
| 205 | − removed: true, | |
| 123 | + removed, | |
| 206 | 124 | }; |
| 207 | 125 | }), |
| 208 | 126 | }); |
| @@ -1,53 +1,21 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import type { | |
| 3 | − LocalStateSnapshot, | |
| 4 | − LocalStateStore, | |
| 5 | −} from "@executor-v2/persistence-local"; | |
| 6 | −import type { StorageInstance, WorkspaceId } from "@executor-v2/schema"; | |
| 2 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 3 | +import { | |
| 4 | + type OrganizationId, | |
| 5 | + type StorageInstance, | |
| 6 | + type WorkspaceId, | |
| 7 | +} from "@executor-v2/schema"; | |
| 7 | 8 | import * as Effect from "effect/Effect"; |
| 8 | −import * as Option from "effect/Option"; | |
| 9 | 9 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import * as path from "node:path"; |
| 12 | 12 | |
| 13 | 13 | import { createPmStorageService } from "./storage-service"; |
| 14 | 14 | |
| 15 | −const createTestSnapshot = (): LocalStateSnapshot => | |
| 16 | − ({ | |
| 17 | − schemaVersion: 1, | |
| 18 | − generatedAt: Date.now(), | |
| 19 | − profile: { | |
| 20 | − id: "profile_local", | |
| 21 | − defaultWorkspaceId: "ws_local", | |
| 22 | − displayName: "Local", | |
| 23 | − runtimeMode: "local", | |
| 24 | − createdAt: Date.now(), | |
| 25 | − updatedAt: Date.now(), | |
| 26 | − }, | |
| 27 | − organizations: [], | |
| 28 | − organizationMemberships: [], | |
| 29 | − workspaces: [ | |
| 30 | − { | |
| 31 | − id: "ws_local", | |
| 32 | − organizationId: "org_local", | |
| 33 | − name: "Local Workspace", | |
| 34 | − createdByAccountId: null, | |
| 35 | − createdAt: Date.now(), | |
| 36 | − updatedAt: Date.now(), | |
| 37 | − }, | |
| 38 | − ], | |
| 39 | − sources: [], | |
| 40 | − toolArtifacts: [], | |
| 41 | − authConnections: [], | |
| 42 | − sourceAuthBindings: [], | |
| 43 | − authMaterials: [], | |
| 44 | − oauthStates: [], | |
| 45 | − policies: [], | |
| 46 | − approvals: [], | |
| 47 | − taskRuns: [], | |
| 48 | − storageInstances: [], | |
| 49 | − syncStates: [], | |
| 50 | − }) as unknown as LocalStateSnapshot; | |
| 15 | +type StorageRows = Pick< | |
| 16 | + SqlControlPlanePersistence["rows"], | |
| 17 | + "workspaces" | "storageInstances" | |
| 18 | +>; | |
| 51 | 19 | |
| 52 | 20 | describe("PM storage service", () => { |
| 53 | 21 | it.effect("opens, inspects, queries, and removes storage instances", () => |
| @@ -56,24 +24,52 @@ describe("PM storage service", () => { · 2 unmodified lines | ||
| 56 | 24 | mkdtemp(path.join(tmpdir(), "executor-v2-pm-storage-")), |
| 57 | 25 | ); |
| 58 | 26 | |
| 59 | − let snapshot: LocalStateSnapshot = createTestSnapshot(); | |
| 27 | + const workspaceId = "ws_local" as WorkspaceId; | |
| 28 | + const workspace = { | |
| 29 | + id: workspaceId, | |
| 30 | + organizationId: "org_local" as OrganizationId, | |
| 31 | + name: "Local Workspace", | |
| 32 | + createdByAccountId: null, | |
| 33 | + createdAt: Date.now(), | |
| 34 | + updatedAt: Date.now(), | |
| 35 | + }; | |
| 60 | 36 | |
| 61 | − const localStateStore: LocalStateStore = { | |
| 62 | − getSnapshot: () => Effect.succeed(Option.some(snapshot)), | |
| 63 | − writeSnapshot: (nextSnapshot) => | |
| 64 | − Effect.sync(() => { | |
| 65 | − snapshot = nextSnapshot; | |
| 66 | − }), | |
| 67 | − readEvents: () => Effect.succeed([]), | |
| 68 | − appendEvents: () => Effect.void, | |
| 37 | + const storageInstances: Array<StorageInstance> = []; | |
| 38 | + | |
| 39 | + const rows: StorageRows = { | |
| 40 | + workspaces: { | |
| 41 | + list: () => Effect.succeed([workspace]), | |
| 42 | + upsert: () => Effect.void, | |
| 43 | + }, | |
| 44 | + storageInstances: { | |
| 45 | + list: () => Effect.succeed(storageInstances), | |
| 46 | + upsert: (storageInstance) => | |
| 47 | + Effect.sync(() => { | |
| 48 | + const index = storageInstances.findIndex((item) => item.id === storageInstance.id); | |
| 49 | + if (index >= 0) { | |
| 50 | + storageInstances[index] = storageInstance; | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + | |
| 54 | + storageInstances.push(storageInstance); | |
| 55 | + }), | |
| 56 | + removeById: (storageInstanceId) => | |
| 57 | + Effect.sync(() => { | |
| 58 | + const index = storageInstances.findIndex((item) => item.id === storageInstanceId); | |
| 59 | + if (index < 0) { | |
| 60 | + return false; | |
| 61 | + } | |
| 62 | + | |
| 63 | + storageInstances.splice(index, 1); | |
| 64 | + return true; | |
| 65 | + }), | |
| 66 | + }, | |
| 69 | 67 | }; |
| 70 | 68 | |
| 71 | − const service = createPmStorageService(localStateStore, { | |
| 69 | + const service = createPmStorageService(rows, { | |
| 72 | 70 | stateRootDir, |
| 73 | 71 | }); |
| 74 | 72 | |
| 75 | − const workspaceId = "ws_local" as WorkspaceId; | |
| 76 | − | |
| 77 | 73 | const storageInstance = yield* service.openStorageInstance({ |
| 78 | 74 | workspaceId, |
| 79 | 75 | payload: { |
| @@ -3,18 +3,14 @@ import { · 2 unmodified lines | ||
| 3 | 3 | type ControlPlaneStorageServiceShape, |
| 4 | 4 | } from "@executor-v2/management-api"; |
| 5 | 5 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 6 | −import { | |
| 7 | − type LocalStateSnapshot, | |
| 8 | − type LocalStateStore, | |
| 9 | − type LocalStateStoreError, | |
| 10 | −} from "@executor-v2/persistence-local"; | |
| 6 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 11 | 7 | import { |
| 12 | 8 | type OrganizationId, |
| 13 | 9 | type StorageInstance, |
| 10 | + type Workspace, | |
| 14 | 11 | type WorkspaceId, |
| 15 | 12 | } from "@executor-v2/schema"; |
| 16 | 13 | import * as Effect from "effect/Effect"; |
| 17 | −import * as Option from "effect/Option"; | |
| 18 | 14 | import { |
| 19 | 15 | mkdir, |
| 20 | 16 | readdir, |
| @@ -25,6 +21,11 @@ import { · 4 unmodified lines | ||
| 25 | 21 | } from "node:fs/promises"; |
| 26 | 22 | import * as path from "node:path"; |
| 27 | 23 | |
| 24 | +type StorageRows = Pick< | |
| 25 | + SqlControlPlanePersistence["rows"], | |
| 26 | + "workspaces" | "storageInstances" | |
| 27 | +>; | |
| 28 | + | |
| 28 | 29 | const DEFAULT_EPHEMERAL_TTL_HOURS = 24; |
| 29 | 30 | const DEFAULT_KV_LIMIT = 100; |
| 30 | 31 | const MAX_KV_LIMIT = 1000; |
| @@ -68,16 +69,16 @@ const toSourceStoreError = ( · 37 unmodified lines | ||
| 68 | 69 | ): SourceStoreError => |
| 69 | 70 | new SourceStoreError({ |
| 70 | 71 | operation, |
| 71 | − backend: "local-file", | |
| 72 | − location: "snapshot.json", | |
| 72 | + backend: "sql", | |
| 73 | + location: "storage", | |
| 73 | 74 | message, |
| 74 | 75 | reason: null, |
| 75 | 76 | details, |
| 76 | 77 | }); |
| 77 | 78 | |
| 78 | −const toSourceStoreErrorFromLocalState = ( | |
| 79 | +const toSourceStoreErrorFromRowStore = ( | |
| 79 | 80 | operation: string, |
| 80 | − error: LocalStateStoreError, | |
| 81 | + error: { message: string; details: string | null; reason: string | null }, | |
| 81 | 82 | ): SourceStoreError => |
| 82 | 83 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 83 | 84 | |
| @@ -93,10 +94,10 @@ const toSourceStoreErrorFromCause = ( · 9 unmodified lines | ||
| 93 | 94 | ); |
| 94 | 95 | |
| 95 | 96 | const resolveWorkspaceOrganizationId = ( |
| 96 | − snapshot: LocalStateSnapshot, | |
| 97 | + workspaces: ReadonlyArray<Workspace>, | |
| 97 | 98 | workspaceId: WorkspaceId, |
| 98 | 99 | ): OrganizationId => { |
| 99 | − const workspace = snapshot.workspaces.find((item) => item.id === workspaceId); | |
| 100 | + const workspace = workspaces.find((item) => item.id === workspaceId); | |
| 100 | 101 | |
| 101 | 102 | if (!workspace) { |
| 102 | 103 | throw new Error(`Workspace not found: ${workspaceId}`); |
| @@ -124,70 +125,6 @@ const sortStorageInstances = ( · 21 unmodified lines | ||
| 124 | 125 | return left.id.localeCompare(right.id); |
| 125 | 126 | }); |
| 126 | 127 | |
| 127 | −const replaceStorageInstanceAt = ( | |
| 128 | − snapshot: LocalStateSnapshot, | |
| 129 | − index: number, | |
| 130 | − storageInstance: StorageInstance, | |
| 131 | −): LocalStateSnapshot => { | |
| 132 | − const next = [...snapshot.storageInstances]; | |
| 133 | − next[index] = storageInstance; | |
| 134 | − | |
| 135 | − return { | |
| 136 | − ...snapshot, | |
| 137 | − generatedAt: Date.now(), | |
| 138 | − storageInstances: next, | |
| 139 | − }; | |
| 140 | −}; | |
| 141 | − | |
| 142 | −const appendStorageInstance = ( | |
| 143 | − snapshot: LocalStateSnapshot, | |
| 144 | − storageInstance: StorageInstance, | |
| 145 | −): LocalStateSnapshot => ({ | |
| 146 | − ...snapshot, | |
| 147 | − generatedAt: Date.now(), | |
| 148 | − storageInstances: [...snapshot.storageInstances, storageInstance], | |
| 149 | −}); | |
| 150 | − | |
| 151 | −const removeStorageInstanceById = ( | |
| 152 | − snapshot: LocalStateSnapshot, | |
| 153 | − input: { | |
| 154 | − workspaceId: WorkspaceId; | |
| 155 | − organizationId: OrganizationId; | |
| 156 | − storageInstanceId: StorageInstance["id"]; | |
| 157 | − }, | |
| 158 | −): { snapshot: LocalStateSnapshot; removed: boolean } => { | |
| 159 | − let removed = false; | |
| 160 | − | |
| 161 | − const next = snapshot.storageInstances.filter((instance) => { | |
| 162 | − const matches = | |
| 163 | − instance.id === input.storageInstanceId | |
| 164 | − && canAccessStorageInstance(instance, input.workspaceId, input.organizationId); | |
| 165 | − | |
| 166 | − if (matches) { | |
| 167 | − removed = true; | |
| 168 | − return false; | |
| 169 | − } | |
| 170 | − | |
| 171 | − return true; | |
| 172 | − }); | |
| 173 | − | |
| 174 | − if (!removed) { | |
| 175 | − return { | |
| 176 | − snapshot, | |
| 177 | − removed: false, | |
| 178 | − }; | |
| 179 | − } | |
| 180 | − | |
| 181 | − return { | |
| 182 | − removed: true, | |
| 183 | − snapshot: { | |
| 184 | − ...snapshot, | |
| 185 | − generatedAt: Date.now(), | |
| 186 | − storageInstances: next, | |
| 187 | − }, | |
| 188 | − }; | |
| 189 | −}; | |
| 190 | − | |
| 191 | 128 | const storageInstanceRootPath = (stateRootDir: string, storageInstanceId: string): string => |
| 192 | 129 | path.resolve(stateRootDir, STORAGE_ROOT_DIRECTORY, storageInstanceId); |
| 193 | 130 | |
| @@ -292,60 +229,49 @@ const readKvStore = async ( · 98 unmodified lines | ||
| 292 | 229 | }; |
| 293 | 230 | |
| 294 | 231 | const findStorageInstance = ( |
| 295 | − snapshot: LocalStateSnapshot, | |
| 232 | + storageInstances: ReadonlyArray<StorageInstance>, | |
| 233 | + organizationId: OrganizationId, | |
| 296 | 234 | input: { |
| 297 | 235 | workspaceId: WorkspaceId; |
| 298 | 236 | storageInstanceId: StorageInstance["id"]; |
| 299 | 237 | }, |
| 300 | 238 | ): { |
| 301 | 239 | storageInstance: StorageInstance; |
| 302 | − storageInstanceIndex: number; | |
| 303 | 240 | } | null => { |
| 304 | − const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | |
| 305 | − const storageInstanceIndex = snapshot.storageInstances.findIndex((instance) => | |
| 241 | + const storageInstance = storageInstances.find((instance) => | |
| 306 | 242 | instance.id === input.storageInstanceId |
| 307 | 243 | && canAccessStorageInstance(instance, input.workspaceId, organizationId) |
| 308 | 244 | ); |
| 309 | 245 | |
| 310 | − if (storageInstanceIndex < 0) { | |
| 311 | − return null; | |
| 312 | − } | |
| 313 | − | |
| 314 | − const storageInstance = snapshot.storageInstances[storageInstanceIndex]; | |
| 315 | 246 | if (!storageInstance) { |
| 316 | 247 | return null; |
| 317 | 248 | } |
| 318 | 249 | |
| 319 | 250 | return { |
| 320 | 251 | storageInstance, |
| 321 | − storageInstanceIndex, | |
| 322 | 252 | }; |
| 323 | 253 | }; |
| 324 | 254 | |
| 325 | 255 | const touchStorageInstance = ( |
| 326 | − snapshot: LocalStateSnapshot, | |
| 327 | − storageInstanceIndex: number, | |
| 328 | −): { | |
| 329 | − snapshot: LocalStateSnapshot; | |
| 330 | − storageInstance: StorageInstance; | |
| 331 | −} => { | |
| 332 | − const existing = snapshot.storageInstances[storageInstanceIndex]; | |
| 256 | + rows: StorageRows, | |
| 257 | + existing: StorageInstance, | |
| 258 | + operation: string, | |
| 259 | +): Effect.Effect<StorageInstance, SourceStoreError> => { | |
| 333 | 260 | const now = Date.now(); |
| 334 | − | |
| 335 | − const touchedStorageInstance: StorageInstance = { | |
| 261 | + const next: StorageInstance = { | |
| 336 | 262 | ...existing, |
| 337 | 263 | updatedAt: now, |
| 338 | 264 | lastSeenAt: now, |
| 339 | 265 | }; |
| 340 | 266 | |
| 341 | − return { | |
| 342 | − storageInstance: touchedStorageInstance, | |
| 343 | − snapshot: replaceStorageInstanceAt(snapshot, storageInstanceIndex, touchedStorageInstance), | |
| 344 | − }; | |
| 267 | + return rows.storageInstances.upsert(next).pipe( | |
| 268 | + Effect.mapError((error) => toSourceStoreErrorFromRowStore(operation, error)), | |
| 269 | + Effect.as(next), | |
| 270 | + ); | |
| 345 | 271 | }; |
| 346 | 272 | |
| 347 | 273 | export const createPmStorageService = ( |
| 348 | − localStateStore: LocalStateStore, | |
| 274 | + rows: StorageRows, | |
| 349 | 275 | options: { |
| 350 | 276 | stateRootDir: string; |
| 351 | 277 | }, |
| @@ -353,21 +279,23 @@ export const createPmStorageService = ( · 1 unmodified line | ||
| 353 | 279 | makeControlPlaneStorageService({ |
| 354 | 280 | listStorageInstances: (workspaceId) => |
| 355 | 281 | Effect.gen(function* () { |
| 356 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 357 | − Effect.mapError((error) => | |
| 358 | − toSourceStoreErrorFromLocalState("storage.list", error), | |
| 282 | + const [storageInstances, workspaces] = yield* Effect.all([ | |
| 283 | + rows.storageInstances.list().pipe( | |
| 284 | + Effect.mapError((error) => | |
| 285 | + toSourceStoreErrorFromRowStore("storage.list_instances", error), | |
| 286 | + ), | |
| 359 | 287 | ), |
| 360 | − ); | |
| 361 | − | |
| 362 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 363 | − if (snapshot === null) { | |
| 364 | − return []; | |
| 365 | − } | |
| 288 | + rows.workspaces.list().pipe( | |
| 289 | + Effect.mapError((error) => | |
| 290 | + toSourceStoreErrorFromRowStore("storage.list_instances.workspaces", error), | |
| 291 | + ), | |
| 292 | + ), | |
| 293 | + ]); | |
| 366 | 294 | |
| 367 | − const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | |
| 295 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, workspaceId); | |
| 368 | 296 | |
| 369 | 297 | return sortStorageInstances( |
| 370 | − snapshot.storageInstances.filter((instance) => | |
| 298 | + storageInstances.filter((instance) => | |
| 371 | 299 | canAccessStorageInstance(instance, workspaceId, organizationId) |
| 372 | 300 | ), |
| 373 | 301 | ); |
| @@ -375,21 +303,6 @@ export const createPmStorageService = ( · 1 unmodified line | ||
| 375 | 303 | |
| 376 | 304 | openStorageInstance: (input) => |
| 377 | 305 | Effect.gen(function* () { |
| 378 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 379 | − Effect.mapError((error) => | |
| 380 | − toSourceStoreErrorFromLocalState("storage.open", error), | |
| 381 | − ), | |
| 382 | − ); | |
| 383 | − | |
| 384 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 385 | − if (snapshot === null) { | |
| 386 | − return yield* toSourceStoreError( | |
| 387 | − "storage.open", | |
| 388 | − "Storage snapshot not found", | |
| 389 | − `workspace=${input.workspaceId}`, | |
| 390 | − ); | |
| 391 | − } | |
| 392 | − | |
| 393 | 306 | if (input.payload.scopeType === "account" && input.payload.accountId === undefined) { |
| 394 | 307 | return yield* toSourceStoreError( |
| 395 | 308 | "storage.open", |
| @@ -398,8 +311,14 @@ export const createPmStorageService = ( · 2 unmodified lines | ||
| 398 | 311 | ); |
| 399 | 312 | } |
| 400 | 313 | |
| 314 | + const workspaces = yield* rows.workspaces.list().pipe( | |
| 315 | + Effect.mapError((error) => | |
| 316 | + toSourceStoreErrorFromRowStore("storage.open.workspaces", error), | |
| 317 | + ), | |
| 318 | + ); | |
| 319 | + | |
| 401 | 320 | const now = Date.now(); |
| 402 | − const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | |
| 321 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 403 | 322 | const storageInstanceId = |
| 404 | 323 | `storage_${crypto.randomUUID()}` as StorageInstance["id"]; |
| 405 | 324 | const ttlHours = |
| @@ -450,11 +369,9 @@ export const createPmStorageService = ( · 44 unmodified lines | ||
| 450 | 369 | : null, |
| 451 | 370 | }; |
| 452 | 371 | |
| 453 | − const nextSnapshot = appendStorageInstance(snapshot, nextStorageInstance); | |
| 454 | − | |
| 455 | − yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 372 | + yield* rows.storageInstances.upsert(nextStorageInstance).pipe( | |
| 456 | 373 | Effect.mapError((error) => |
| 457 | − toSourceStoreErrorFromLocalState("storage.open_write", error), | |
| 374 | + toSourceStoreErrorFromRowStore("storage.open_write", error), | |
| 458 | 375 | ), |
| 459 | 376 | ); |
| 460 | 377 | |
| @@ -463,22 +380,21 @@ export const createPmStorageService = ( · 2 unmodified lines | ||
| 463 | 380 | |
| 464 | 381 | closeStorageInstance: (input) => |
| 465 | 382 | Effect.gen(function* () { |
| 466 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 467 | − Effect.mapError((error) => | |
| 468 | − toSourceStoreErrorFromLocalState("storage.close", error), | |
| 383 | + const [storageInstances, workspaces] = yield* Effect.all([ | |
| 384 | + rows.storageInstances.list().pipe( | |
| 385 | + Effect.mapError((error) => | |
| 386 | + toSourceStoreErrorFromRowStore("storage.close.instances", error), | |
| 387 | + ), | |
| 469 | 388 | ), |
| 470 | − ); | |
| 471 | − | |
| 472 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 473 | − if (snapshot === null) { | |
| 474 | − return yield* toSourceStoreError( | |
| 475 | − "storage.close", | |
| 476 | − "Storage snapshot not found", | |
| 477 | − `workspace=${input.workspaceId}`, | |
| 478 | − ); | |
| 479 | − } | |
| 389 | + rows.workspaces.list().pipe( | |
| 390 | + Effect.mapError((error) => | |
| 391 | + toSourceStoreErrorFromRowStore("storage.close.workspaces", error), | |
| 392 | + ), | |
| 393 | + ), | |
| 394 | + ]); | |
| 480 | 395 | |
| 481 | − const found = findStorageInstance(snapshot, { | |
| 396 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 397 | + const found = findStorageInstance(storageInstances, organizationId, { | |
| 482 | 398 | workspaceId: input.workspaceId, |
| 483 | 399 | storageInstanceId: input.storageInstanceId, |
| 484 | 400 | }); |
| @@ -501,15 +417,9 @@ export const createPmStorageService = ( · 16 unmodified lines | ||
| 501 | 417 | closedAt: found.storageInstance.closedAt ?? now, |
| 502 | 418 | }; |
| 503 | 419 | |
| 504 | − const nextSnapshot = replaceStorageInstanceAt( | |
| 505 | − snapshot, | |
| 506 | − found.storageInstanceIndex, | |
| 507 | − nextStorageInstance, | |
| 508 | − ); | |
| 509 | − | |
| 510 | − yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 420 | + yield* rows.storageInstances.upsert(nextStorageInstance).pipe( | |
| 511 | 421 | Effect.mapError((error) => |
| 512 | − toSourceStoreErrorFromLocalState("storage.close_write", error), | |
| 422 | + toSourceStoreErrorFromRowStore("storage.close_write", error), | |
| 513 | 423 | ), |
| 514 | 424 | ); |
| 515 | 425 | |
| @@ -518,28 +428,26 @@ export const createPmStorageService = ( · 2 unmodified lines | ||
| 518 | 428 | |
| 519 | 429 | removeStorageInstance: (input) => |
| 520 | 430 | Effect.gen(function* () { |
| 521 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 522 | − Effect.mapError((error) => | |
| 523 | − toSourceStoreErrorFromLocalState("storage.remove", error), | |
| 431 | + const [storageInstances, workspaces] = yield* Effect.all([ | |
| 432 | + rows.storageInstances.list().pipe( | |
| 433 | + Effect.mapError((error) => | |
| 434 | + toSourceStoreErrorFromRowStore("storage.remove.instances", error), | |
| 435 | + ), | |
| 524 | 436 | ), |
| 525 | − ); | |
| 526 | − | |
| 527 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 528 | − if (snapshot === null) { | |
| 529 | − return { | |
| 530 | − removed: false, | |
| 531 | − }; | |
| 532 | − } | |
| 533 | − | |
| 534 | − const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | |
| 437 | + rows.workspaces.list().pipe( | |
| 438 | + Effect.mapError((error) => | |
| 439 | + toSourceStoreErrorFromRowStore("storage.remove.workspaces", error), | |
| 440 | + ), | |
| 441 | + ), | |
| 442 | + ]); | |
| 535 | 443 | |
| 536 | − const next = removeStorageInstanceById(snapshot, { | |
| 444 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 445 | + const found = findStorageInstance(storageInstances, organizationId, { | |
| 537 | 446 | workspaceId: input.workspaceId, |
| 538 | − organizationId, | |
| 539 | 447 | storageInstanceId: input.storageInstanceId, |
| 540 | 448 | }); |
| 541 | 449 | |
| 542 | − if (!next.removed) { | |
| 450 | + if (found === null) { | |
| 543 | 451 | return { |
| 544 | 452 | removed: false, |
| 545 | 453 | }; |
| @@ -547,13 +455,10 @@ export const createPmStorageService = ( · 1 unmodified line | ||
| 547 | 455 | |
| 548 | 456 | yield* Effect.tryPromise({ |
| 549 | 457 | try: () => |
| 550 | − rm( | |
| 551 | − storageInstanceRootPath(options.stateRootDir, input.storageInstanceId), | |
| 552 | − { | |
| 553 | − recursive: true, | |
| 554 | − force: true, | |
| 555 | − }, | |
| 556 | − ), | |
| 458 | + rm(storageInstanceRootPath(options.stateRootDir, input.storageInstanceId), { | |
| 459 | + recursive: true, | |
| 460 | + force: true, | |
| 461 | + }), | |
| 557 | 462 | catch: (cause) => |
| 558 | 463 | toSourceStoreErrorFromCause( |
| 559 | 464 | "storage.remove_files", |
| @@ -562,35 +467,34 @@ export const createPmStorageService = ( · 2 unmodified lines | ||
| 562 | 467 | ), |
| 563 | 468 | }); |
| 564 | 469 | |
| 565 | − yield* localStateStore.writeSnapshot(next.snapshot).pipe( | |
| 470 | + const removed = yield* rows.storageInstances.removeById(input.storageInstanceId).pipe( | |
| 566 | 471 | Effect.mapError((error) => |
| 567 | − toSourceStoreErrorFromLocalState("storage.remove_write", error), | |
| 472 | + toSourceStoreErrorFromRowStore("storage.remove_write", error), | |
| 568 | 473 | ), |
| 569 | 474 | ); |
| 570 | 475 | |
| 571 | 476 | return { |
| 572 | − removed: true, | |
| 477 | + removed, | |
| 573 | 478 | }; |
| 574 | 479 | }), |
| 575 | 480 | |
| 576 | 481 | listStorageDirectory: (input) => |
| 577 | 482 | Effect.gen(function* () { |
| 578 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 579 | − Effect.mapError((error) => | |
| 580 | − toSourceStoreErrorFromLocalState("storage.listDirectory", error), | |
| 483 | + const [storageInstances, workspaces] = yield* Effect.all([ | |
| 484 | + rows.storageInstances.list().pipe( | |
| 485 | + Effect.mapError((error) => | |
| 486 | + toSourceStoreErrorFromRowStore("storage.list_directory.instances", error), | |
| 487 | + ), | |
| 581 | 488 | ), |
| 582 | − ); | |
| 583 | − | |
| 584 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 585 | − if (snapshot === null) { | |
| 586 | − return yield* toSourceStoreError( | |
| 587 | − "storage.listDirectory", | |
| 588 | − "Storage snapshot not found", | |
| 589 | − `workspace=${input.workspaceId}`, | |
| 590 | − ); | |
| 591 | − } | |
| 489 | + rows.workspaces.list().pipe( | |
| 490 | + Effect.mapError((error) => | |
| 491 | + toSourceStoreErrorFromRowStore("storage.list_directory.workspaces", error), | |
| 492 | + ), | |
| 493 | + ), | |
| 494 | + ]); | |
| 592 | 495 | |
| 593 | − const found = findStorageInstance(snapshot, { | |
| 496 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 497 | + const found = findStorageInstance(storageInstances, organizationId, { | |
| 594 | 498 | workspaceId: input.workspaceId, |
| 595 | 499 | storageInstanceId: input.storageInstanceId, |
| 596 | 500 | }); |
| @@ -660,15 +564,10 @@ export const createPmStorageService = ( · 63 unmodified lines | ||
| 660 | 564 | ), |
| 661 | 565 | }); |
| 662 | 566 | |
| 663 | − const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | |
| 664 | − | |
| 665 | − yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | |
| 666 | − Effect.mapError((error) => | |
| 667 | − toSourceStoreErrorFromLocalState( | |
| 668 | − "storage.listDirectory_touch", | |
| 669 | − error, | |
| 670 | − ), | |
| 671 | − ), | |
| 567 | + yield* touchStorageInstance( | |
| 568 | + rows, | |
| 569 | + found.storageInstance, | |
| 570 | + "storage.listDirectory_touch", | |
| 672 | 571 | ); |
| 673 | 572 | |
| 674 | 573 | return result; |
| @@ -676,22 +575,21 @@ export const createPmStorageService = ( · 1 unmodified line | ||
| 676 | 575 | |
| 677 | 576 | readStorageFile: (input) => |
| 678 | 577 | Effect.gen(function* () { |
| 679 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 680 | − Effect.mapError((error) => | |
| 681 | − toSourceStoreErrorFromLocalState("storage.readFile", error), | |
| 578 | + const [storageInstances, workspaces] = yield* Effect.all([ | |
| 579 | + rows.storageInstances.list().pipe( | |
| 580 | + Effect.mapError((error) => | |
| 581 | + toSourceStoreErrorFromRowStore("storage.read_file.instances", error), | |
| 582 | + ), | |
| 682 | 583 | ), |
| 683 | − ); | |
| 684 | − | |
| 685 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 686 | − if (snapshot === null) { | |
| 687 | − return yield* toSourceStoreError( | |
| 688 | − "storage.readFile", | |
| 689 | − "Storage snapshot not found", | |
| 690 | − `workspace=${input.workspaceId}`, | |
| 691 | − ); | |
| 692 | − } | |
| 584 | + rows.workspaces.list().pipe( | |
| 585 | + Effect.mapError((error) => | |
| 586 | + toSourceStoreErrorFromRowStore("storage.read_file.workspaces", error), | |
| 587 | + ), | |
| 588 | + ), | |
| 589 | + ]); | |
| 693 | 590 | |
| 694 | − const found = findStorageInstance(snapshot, { | |
| 591 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 592 | + const found = findStorageInstance(storageInstances, organizationId, { | |
| 695 | 593 | workspaceId: input.workspaceId, |
| 696 | 594 | storageInstanceId: input.storageInstanceId, |
| 697 | 595 | }); |
| @@ -741,35 +639,28 @@ export const createPmStorageService = ( · 43 unmodified lines | ||
| 741 | 639 | ), |
| 742 | 640 | }); |
| 743 | 641 | |
| 744 | − const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | |
| 745 | − | |
| 746 | − yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | |
| 747 | − Effect.mapError((error) => | |
| 748 | − toSourceStoreErrorFromLocalState("storage.readFile_touch", error), | |
| 749 | − ), | |
| 750 | − ); | |
| 642 | + yield* touchStorageInstance(rows, found.storageInstance, "storage.readFile_touch"); | |
| 751 | 643 | |
| 752 | 644 | return result; |
| 753 | 645 | }), |
| 754 | 646 | |
| 755 | 647 | listStorageKv: (input) => |
| 756 | 648 | Effect.gen(function* () { |
| 757 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 758 | − Effect.mapError((error) => | |
| 759 | − toSourceStoreErrorFromLocalState("storage.listKv", error), | |
| 649 | + const [storageInstances, workspaces] = yield* Effect.all([ | |
| 650 | + rows.storageInstances.list().pipe( | |
| 651 | + Effect.mapError((error) => | |
| 652 | + toSourceStoreErrorFromRowStore("storage.list_kv.instances", error), | |
| 653 | + ), | |
| 760 | 654 | ), |
| 761 | − ); | |
| 762 | − | |
| 763 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 764 | − if (snapshot === null) { | |
| 765 | − return yield* toSourceStoreError( | |
| 766 | − "storage.listKv", | |
| 767 | − "Storage snapshot not found", | |
| 768 | − `workspace=${input.workspaceId}`, | |
| 769 | − ); | |
| 770 | − } | |
| 655 | + rows.workspaces.list().pipe( | |
| 656 | + Effect.mapError((error) => | |
| 657 | + toSourceStoreErrorFromRowStore("storage.list_kv.workspaces", error), | |
| 658 | + ), | |
| 659 | + ), | |
| 660 | + ]); | |
| 771 | 661 | |
| 772 | − const found = findStorageInstance(snapshot, { | |
| 662 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 663 | + const found = findStorageInstance(storageInstances, organizationId, { | |
| 773 | 664 | workspaceId: input.workspaceId, |
| 774 | 665 | storageInstanceId: input.storageInstanceId, |
| 775 | 666 | }); |
| @@ -815,35 +706,28 @@ export const createPmStorageService = ( · 39 unmodified lines | ||
| 815 | 706 | ), |
| 816 | 707 | }); |
| 817 | 708 | |
| 818 | − const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | |
| 819 | − | |
| 820 | − yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | |
| 821 | − Effect.mapError((error) => | |
| 822 | − toSourceStoreErrorFromLocalState("storage.listKv_touch", error), | |
| 823 | − ), | |
| 824 | − ); | |
| 709 | + yield* touchStorageInstance(rows, found.storageInstance, "storage.listKv_touch"); | |
| 825 | 710 | |
| 826 | 711 | return result; |
| 827 | 712 | }), |
| 828 | 713 | |
| 829 | 714 | queryStorageSql: (input) => |
| 830 | 715 | Effect.gen(function* () { |
| 831 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 832 | − Effect.mapError((error) => | |
| 833 | − toSourceStoreErrorFromLocalState("storage.querySql", error), | |
| 716 | + const [storageInstances, workspaces] = yield* Effect.all([ | |
| 717 | + rows.storageInstances.list().pipe( | |
| 718 | + Effect.mapError((error) => | |
| 719 | + toSourceStoreErrorFromRowStore("storage.query_sql.instances", error), | |
| 720 | + ), | |
| 834 | 721 | ), |
| 835 | − ); | |
| 836 | − | |
| 837 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 838 | − if (snapshot === null) { | |
| 839 | − return yield* toSourceStoreError( | |
| 840 | − "storage.querySql", | |
| 841 | − "Storage snapshot not found", | |
| 842 | − `workspace=${input.workspaceId}`, | |
| 843 | − ); | |
| 844 | − } | |
| 722 | + rows.workspaces.list().pipe( | |
| 723 | + Effect.mapError((error) => | |
| 724 | + toSourceStoreErrorFromRowStore("storage.query_sql.workspaces", error), | |
| 725 | + ), | |
| 726 | + ), | |
| 727 | + ]); | |
| 845 | 728 | |
| 846 | − const found = findStorageInstance(snapshot, { | |
| 729 | + const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); | |
| 730 | + const found = findStorageInstance(storageInstances, organizationId, { | |
| 847 | 731 | workspaceId: input.workspaceId, |
| 848 | 732 | storageInstanceId: input.storageInstanceId, |
| 849 | 733 | }); |
| @@ -894,16 +778,16 @@ export const createPmStorageService = ( · 44 unmodified lines | ||
| 894 | 778 | try { |
| 895 | 779 | const statement = db.query(sqlText); |
| 896 | 780 | const rawRows = statement.all() as Array<Record<string, unknown>>; |
| 897 | − const rows = rawRows.slice(0, maxRows); | |
| 781 | + const rowsLimited = rawRows.slice(0, maxRows); | |
| 898 | 782 | const columns = |
| 899 | − rows.length > 0 | |
| 900 | − ? Array.from(new Set(rows.flatMap((row) => Object.keys(row)))) | |
| 783 | + rowsLimited.length > 0 | |
| 784 | + ? Array.from(new Set(rowsLimited.flatMap((row) => Object.keys(row)))) | |
| 901 | 785 | : []; |
| 902 | 786 | |
| 903 | 787 | return { |
| 904 | − rows, | |
| 788 | + rows: rowsLimited, | |
| 905 | 789 | columns, |
| 906 | − rowCount: rows.length, | |
| 790 | + rowCount: rowsLimited.length, | |
| 907 | 791 | }; |
| 908 | 792 | } catch { |
| 909 | 793 | db.run(sqlText); |
| @@ -926,13 +810,7 @@ export const createPmStorageService = ( · 16 unmodified lines | ||
| 926 | 810 | ), |
| 927 | 811 | }); |
| 928 | 812 | |
| 929 | − const touched = touchStorageInstance(snapshot, found.storageInstanceIndex); | |
| 930 | − | |
| 931 | − yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | |
| 932 | − Effect.mapError((error) => | |
| 933 | − toSourceStoreErrorFromLocalState("storage.querySql_touch", error), | |
| 934 | − ), | |
| 935 | − ); | |
| 813 | + yield* touchStorageInstance(rows, found.storageInstance, "storage.querySql_touch"); | |
| 936 | 814 | |
| 937 | 815 | return result; |
| 938 | 816 | }), |
| @@ -4,21 +4,31 @@ import { · 3 unmodified lines | ||
| 4 | 4 | } from "@executor-v2/engine"; |
| 5 | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | −import * as Option from "effect/Option"; | |
| 8 | 7 | |
| 9 | 8 | import { createPmResolveToolCredentials } from "./credential-resolver"; |
| 10 | 9 | |
| 11 | −const emptyLocalStateStore = { | |
| 12 | − getSnapshot: () => Effect.succeed(Option.none()), | |
| 13 | − writeSnapshot: () => Effect.void, | |
| 14 | − readEvents: () => Effect.succeed([]), | |
| 15 | − appendEvents: () => Effect.void, | |
| 10 | +const emptyCredentialRows = { | |
| 11 | + workspaces: { | |
| 12 | + list: () => Effect.succeed([]), | |
| 13 | + }, | |
| 14 | + sourceAuthBindings: { | |
| 15 | + list: () => Effect.succeed([]), | |
| 16 | + }, | |
| 17 | + authConnections: { | |
| 18 | + list: () => Effect.succeed([]), | |
| 19 | + }, | |
| 20 | + authMaterials: { | |
| 21 | + list: () => Effect.succeed([]), | |
| 22 | + }, | |
| 23 | + oauthStates: { | |
| 24 | + list: () => Effect.succeed([]), | |
| 25 | + }, | |
| 16 | 26 | }; |
| 17 | 27 | |
| 18 | 28 | describe("PM runtime tool-call handling", () => { |
| 19 | 29 | it.effect("returns failed callback result for unimplemented invoker", () => |
| 20 | 30 | Effect.gen(function* () { |
| 21 | − const resolveCredentials = createPmResolveToolCredentials(emptyLocalStateStore); | |
| 31 | + const resolveCredentials = createPmResolveToolCredentials(emptyCredentialRows); | |
| 22 | 32 | const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("pm"); |
| 23 | 33 | const handleToolCall = createRuntimeToolCallHandler({ |
| 24 | 34 | resolveCredentials, |
| @@ -31,8 +31,8 @@ const toSourceStoreError = ( · 30 unmodified lines | ||
| 31 | 31 | ): SourceStoreError => |
| 32 | 32 | new SourceStoreError({ |
| 33 | 33 | operation, |
| 34 | − backend: "local-file", | |
| 35 | − location: "tool-artifacts.json", | |
| 34 | + backend: "sql", | |
| 35 | + location: "tool_artifacts", | |
| 36 | 36 | message, |
| 37 | 37 | reason: null, |
| 38 | 38 | details, |
| @@ -1,16 +1,13 @@ | ||
| 1 | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | +import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | |
| 2 | 3 | import { |
| 3 | 4 | makeControlPlaneWorkspacesService, |
| 4 | 5 | type ControlPlaneWorkspacesServiceShape, |
| 5 | 6 | } from "@executor-v2/management-api"; |
| 6 | −import { | |
| 7 | − type LocalStateSnapshot, | |
| 8 | − type LocalStateStore, | |
| 9 | − type LocalStateStoreError, | |
| 10 | −} from "@executor-v2/persistence-local"; | |
| 11 | 7 | import { type Workspace } from "@executor-v2/schema"; |
| 12 | 8 | import * as Effect from "effect/Effect"; |
| 13 | −import * as Option from "effect/Option"; | |
| 9 | + | |
| 10 | +type WorkspaceRows = Pick<SqlControlPlanePersistence["rows"], "workspaces">; | |
| 14 | 11 | |
| 15 | 12 | const toSourceStoreError = ( |
| 16 | 13 | operation: string, |
| @@ -19,16 +16,16 @@ const toSourceStoreError = ( · 2 unmodified lines | ||
| 19 | 16 | ): SourceStoreError => |
| 20 | 17 | new SourceStoreError({ |
| 21 | 18 | operation, |
| 22 | − backend: "local-file", | |
| 23 | − location: "snapshot.json", | |
| 19 | + backend: "sql", | |
| 20 | + location: "workspaces", | |
| 24 | 21 | message, |
| 25 | 22 | reason: null, |
| 26 | 23 | details, |
| 27 | 24 | }); |
| 28 | 25 | |
| 29 | −const toSourceStoreErrorFromLocalState = ( | |
| 26 | +const toSourceStoreErrorFromRowStore = ( | |
| 30 | 27 | operation: string, |
| 31 | − error: LocalStateStoreError, | |
| 28 | + error: { message: string; details: string | null; reason: string | null }, | |
| 32 | 29 | ): SourceStoreError => |
| 33 | 30 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 34 | 31 | |
| @@ -44,72 +41,34 @@ const sortWorkspaces = (workspaces: ReadonlyArray<Workspace>): Array<Workspace> · 9 unmodified lines | ||
| 44 | 41 | return leftName.localeCompare(rightName); |
| 45 | 42 | }); |
| 46 | 43 | |
| 47 | −const replaceWorkspaceAt = ( | |
| 48 | − snapshot: LocalStateSnapshot, | |
| 49 | − index: number, | |
| 50 | − workspace: Workspace, | |
| 51 | −): LocalStateSnapshot => { | |
| 52 | − const next = [...snapshot.workspaces]; | |
| 53 | − next[index] = workspace; | |
| 54 | − | |
| 55 | − return { | |
| 56 | − ...snapshot, | |
| 57 | − generatedAt: Date.now(), | |
| 58 | − workspaces: next, | |
| 59 | − }; | |
| 60 | −}; | |
| 61 | − | |
| 62 | −const appendWorkspace = ( | |
| 63 | − snapshot: LocalStateSnapshot, | |
| 64 | − workspace: Workspace, | |
| 65 | −): LocalStateSnapshot => ({ | |
| 66 | − ...snapshot, | |
| 67 | − generatedAt: Date.now(), | |
| 68 | − workspaces: [...snapshot.workspaces, workspace], | |
| 69 | −}); | |
| 70 | − | |
| 71 | 44 | export const createPmWorkspacesService = ( |
| 72 | − localStateStore: LocalStateStore, | |
| 45 | + rows: WorkspaceRows, | |
| 73 | 46 | ): ControlPlaneWorkspacesServiceShape => |
| 74 | 47 | makeControlPlaneWorkspacesService({ |
| 75 | 48 | listWorkspaces: () => |
| 76 | 49 | Effect.gen(function* () { |
| 77 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 50 | + const workspaces = yield* rows.workspaces.list().pipe( | |
| 78 | 51 | Effect.mapError((error) => |
| 79 | − toSourceStoreErrorFromLocalState("workspaces.list", error), | |
| 52 | + toSourceStoreErrorFromRowStore("workspaces.list", error), | |
| 80 | 53 | ), |
| 81 | 54 | ); |
| 82 | 55 | |
| 83 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 84 | − if (snapshot === null) { | |
| 85 | − return []; | |
| 86 | − } | |
| 87 | − | |
| 88 | − return sortWorkspaces(snapshot.workspaces); | |
| 56 | + return sortWorkspaces(workspaces); | |
| 89 | 57 | }), |
| 90 | 58 | |
| 91 | 59 | upsertWorkspace: (input) => |
| 92 | 60 | Effect.gen(function* () { |
| 93 | − const snapshotOption = yield* localStateStore.getSnapshot().pipe( | |
| 61 | + const workspaces = yield* rows.workspaces.list().pipe( | |
| 94 | 62 | Effect.mapError((error) => |
| 95 | − toSourceStoreErrorFromLocalState("workspaces.upsert", error), | |
| 63 | + toSourceStoreErrorFromRowStore("workspaces.upsert", error), | |
| 96 | 64 | ), |
| 97 | 65 | ); |
| 98 | 66 | |
| 99 | − const snapshot = Option.getOrNull(snapshotOption); | |
| 100 | − if (snapshot === null) { | |
| 101 | − return yield* toSourceStoreError( | |
| 102 | − "workspaces.upsert", | |
| 103 | − "Workspace snapshot not found", | |
| 104 | − null, | |
| 105 | − ); | |
| 106 | − } | |
| 107 | − | |
| 108 | 67 | const now = Date.now(); |
| 109 | 68 | const existingIndex = input.payload.id |
| 110 | − ? snapshot.workspaces.findIndex((workspace) => workspace.id === input.payload.id) | |
| 69 | + ? workspaces.findIndex((workspace) => workspace.id === input.payload.id) | |
| 111 | 70 | : -1; |
| 112 | − const existing = existingIndex >= 0 ? snapshot.workspaces[existingIndex] : null; | |
| 71 | + const existing = existingIndex >= 0 ? workspaces[existingIndex] : null; | |
| 113 | 72 | |
| 114 | 73 | const nextWorkspace: Workspace = { |
| 115 | 74 | id: existing?.id ?? (input.payload.id ?? (`ws_${crypto.randomUUID()}` as Workspace["id"])), |
| @@ -120,13 +79,9 @@ export const createPmWorkspacesService = ( · 4 unmodified lines | ||
| 120 | 79 | updatedAt: now, |
| 121 | 80 | }; |
| 122 | 81 | |
| 123 | − const nextSnapshot = existingIndex >= 0 | |
| 124 | − ? replaceWorkspaceAt(snapshot, existingIndex, nextWorkspace) | |
| 125 | − : appendWorkspace(snapshot, nextWorkspace); | |
| 126 | − | |
| 127 | − yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | |
| 82 | + yield* rows.workspaces.upsert(nextWorkspace).pipe( | |
| 128 | 83 | Effect.mapError((error) => |
| 129 | − toSourceStoreErrorFromLocalState("workspaces.upsert_write", error), | |
| 84 | + toSourceStoreErrorFromRowStore("workspaces.upsert_write", error), | |
| 130 | 85 | ), |
| 131 | 86 | ); |
| 132 | 87 | |
| @@ -13,9 +13,18 @@ Run: · 12 unmodified lines | ||
| 13 | 13 | By default, browser control-plane API calls go through the same-origin proxy at |
| 14 | 14 | `/api/control-plane`. |
| 15 | 15 | |
| 16 | −The proxy forwards to the first available upstream value in: | |
| 17 | −`CONTROL_PLANE_SERVER_BASE_URL`, `CONTROL_PLANE_UPSTREAM_URL`, `NEXT_PUBLIC_CONVEX_URL`, `CONVEX_URL`. | |
| 18 | −If none are set, fallback is `http://127.0.0.1:8788`. | |
| 16 | +The API route now runs the control-plane server in-process. | |
| 17 | + | |
| 18 | +Database selection priority: | |
| 19 | + | |
| 20 | +- `CONTROL_PLANE_DATABASE_URL` | |
| 21 | +- `DATABASE_URL` | |
| 22 | +- `POSTGRES_URL` | |
| 23 | + | |
| 24 | +If none are set, fallback is local SQLite at: | |
| 25 | + | |
| 26 | +- `CONTROL_PLANE_SQLITE_PATH` | |
| 27 | +- default: `.executor-v2/web-state/control-plane.sqlite` | |
| 19 | 28 | |
| 20 | 29 | If needed, `NEXT_PUBLIC_CONTROL_PLANE_BASE_URL` can override the browser base URL. |
| 21 | 30 | |
| @@ -23,7 +32,7 @@ MCP install URL generation: · 1 unmodified line | ||
| 23 | 32 | |
| 24 | 33 | - Derives from existing control-plane/frontend config. |
| 25 | 34 | - Prioritizes server-side `CONTROL_PLANE_UPSTREAM_URL` (or server/base control-plane URL) when available. |
| 26 | −- In local dev, defaults to direct Convex MCP URL: `http://127.0.0.1:8788/v1/mcp?...` | |
| 35 | +- In local dev with no DB URL, defaults to local SQLite-backed control-plane in this Next.js app. | |
| 27 | 36 | |
| 28 | 37 | WorkOS auth setup (optional but recommended): |
| 29 | 38 | |
| @@ -5,8 +5,6 @@ const resolveMcpBaseUrl = (): string | null => { · 4 unmodified lines | ||
| 5 | 5 | process.env.CONTROL_PLANE_UPSTREAM_URL, |
| 6 | 6 | process.env.CONTROL_PLANE_SERVER_BASE_URL, |
| 7 | 7 | process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL, |
| 8 | − process.env.NEXT_PUBLIC_CONVEX_URL, | |
| 9 | − process.env.CONVEX_URL, | |
| 10 | 8 | ]; |
| 11 | 9 | |
| 12 | 10 | for (const candidate of candidates) { |
| @@ -1,9 +1,17 @@ | ||
| 1 | 1 | import { withAuth } from "@workos-inc/authkit-nextjs"; |
| 2 | 2 | import type { NextRequest } from "next/server"; |
| 3 | 3 | |
| 4 | +import { | |
| 5 | + applyPrincipalHeaders, | |
| 6 | + createLocalPrincipal, | |
| 7 | + createWorkosPrincipal, | |
| 8 | + getControlPlaneRuntime, | |
| 9 | + provisionPrincipal, | |
| 10 | +} from "../../../../lib/control-plane/server"; | |
| 4 | 11 | import { isWorkosEnabled } from "../../../../lib/workos"; |
| 5 | 12 | |
| 6 | 13 | export const dynamic = "force-dynamic"; |
| 14 | +export const runtime = "nodejs"; | |
| 7 | 15 | |
| 8 | 16 | type RouteContext = { |
| 9 | 17 | params: Promise<{ |
| @@ -11,81 +19,16 @@ type RouteContext = { · 1 unmodified line | ||
| 11 | 19 | }>; |
| 12 | 20 | }; |
| 13 | 21 | |
| 14 | −const trim = (value: string | undefined): string | undefined => { | |
| 15 | − const candidate = value?.trim(); | |
| 16 | − return candidate && candidate.length > 0 ? candidate : undefined; | |
| 17 | −}; | |
| 18 | − | |
| 19 | −const defaultControlPlaneUpstreamUrl = "http://127.0.0.1:8788"; | |
| 20 | − | |
| 21 | −const controlPlaneUpstreamBaseUrl = | |
| 22 | − trim(process.env.CONTROL_PLANE_SERVER_BASE_URL) | |
| 23 | − ?? trim(process.env.CONTROL_PLANE_UPSTREAM_URL) | |
| 24 | − ?? trim(process.env.NEXT_PUBLIC_CONVEX_URL) | |
| 25 | − ?? trim(process.env.CONVEX_URL) | |
| 26 | − ?? defaultControlPlaneUpstreamUrl; | |
| 27 | − | |
| 28 | −const methodAllowsBody = (method: string): boolean => | |
| 29 | − method !== "GET" && method !== "HEAD"; | |
| 30 | − | |
| 31 | −const forwardableRequestHeaders = [ | |
| 32 | − "accept", | |
| 33 | − "content-type", | |
| 34 | − "traceparent", | |
| 35 | − "b3", | |
| 36 | − "x-request-id", | |
| 37 | −] as const; | |
| 38 | − | |
| 39 | 22 | const isCsrfSafeMethod = (method: string): boolean => |
| 40 | 23 | method === "GET" || method === "HEAD" || method === "OPTIONS"; |
| 41 | 24 | |
| 42 | −const toAuthorizationHeader = (token: string): string => | |
| 43 | − token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`; | |
| 44 | − | |
| 45 | −const buildUpstreamHeaders = ( | |
| 46 | − request: NextRequest, | |
| 47 | − accessToken: string, | |
| 48 | −): Headers => { | |
| 49 | − const headers = new Headers(); | |
| 50 | − | |
| 51 | − headers.set("authorization", toAuthorizationHeader(accessToken)); | |
| 52 | − | |
| 53 | − for (const name of forwardableRequestHeaders) { | |
| 54 | − const value = request.headers.get(name); | |
| 55 | − if (value) { | |
| 56 | − headers.set(name, value); | |
| 57 | − } | |
| 58 | − } | |
| 59 | − | |
| 60 | − headers.set("accept-encoding", "identity"); | |
| 61 | − | |
| 62 | − return headers; | |
| 63 | −}; | |
| 64 | − | |
| 65 | −const responseHeadersToStrip = [ | |
| 66 | − "content-encoding", | |
| 67 | − "content-length", | |
| 68 | − "transfer-encoding", | |
| 69 | − "connection", | |
| 70 | − "keep-alive", | |
| 71 | −] as const; | |
| 72 | − | |
| 73 | −const sanitizeUpstreamResponseHeaders = (headers: Headers): Headers => { | |
| 74 | − const sanitized = new Headers(headers); | |
| 75 | − | |
| 76 | − for (const name of responseHeadersToStrip) { | |
| 77 | − sanitized.delete(name); | |
| 78 | − } | |
| 79 | − | |
| 80 | − return sanitized; | |
| 81 | −}; | |
| 82 | − | |
| 83 | −const resolveUpstreamUrl = ( | |
| 25 | +const rewriteControlPlaneRequest = ( | |
| 84 | 26 | request: NextRequest, |
| 85 | − pathSegments: ReadonlyArray<string>, | |
| 86 | −): URL => { | |
| 87 | − const pathname = `/${pathSegments.join("/")}`; | |
| 88 | − return new URL(`${pathname}${request.nextUrl.search}`, controlPlaneUpstreamBaseUrl); | |
| 27 | + path: ReadonlyArray<string>, | |
| 28 | +): Request => { | |
| 29 | + const rewrittenUrl = new URL(request.url); | |
| 30 | + rewrittenUrl.pathname = `/${path.join("/")}`; | |
| 31 | + return new Request(rewrittenUrl, request); | |
| 89 | 32 | }; |
| 90 | 33 | |
| 91 | 34 | const handle = async (request: NextRequest, context: RouteContext): Promise<Response> => { |
| @@ -96,13 +39,6 @@ const handle = async (request: NextRequest, context: RouteContext): Promise<Resp · 4 unmodified lines | ||
| 96 | 39 | return Response.json({ error: "Not found" }, { status: 404 }); |
| 97 | 40 | } |
| 98 | 41 | |
| 99 | − if (!isWorkosEnabled()) { | |
| 100 | − return Response.json( | |
| 101 | − { error: "WorkOS auth is not enabled" }, | |
| 102 | − { status: 503 }, | |
| 103 | − ); | |
| 104 | − } | |
| 105 | − | |
| 106 | 42 | if (!isCsrfSafeMethod(method)) { |
| 107 | 43 | const origin = request.headers.get("origin"); |
| 108 | 44 | if (origin && origin !== request.nextUrl.origin) { |
| @@ -110,36 +46,49 @@ const handle = async (request: NextRequest, context: RouteContext): Promise<Resp · 1 unmodified line | ||
| 110 | 46 | } |
| 111 | 47 | } |
| 112 | 48 | |
| 113 | − let accessToken: string | undefined; | |
| 49 | + const runtime = await getControlPlaneRuntime(); | |
| 50 | + const controlPlaneRequest = rewriteControlPlaneRequest(request, path); | |
| 51 | + | |
| 52 | + if (!isWorkosEnabled()) { | |
| 53 | + const principal = createLocalPrincipal(); | |
| 54 | + await provisionPrincipal(runtime, principal); | |
| 55 | + return runtime.handleControlPlane(applyPrincipalHeaders(controlPlaneRequest, principal)); | |
| 56 | + } | |
| 57 | + | |
| 58 | + let user: | |
| 59 | + | { | |
| 60 | + id: string; | |
| 61 | + email?: string | null; | |
| 62 | + firstName?: string | null; | |
| 63 | + lastName?: string | null; | |
| 64 | + } | |
| 65 | + | null | |
| 66 | + | undefined; | |
| 67 | + | |
| 114 | 68 | try { |
| 115 | − ({ accessToken } = await withAuth()); | |
| 69 | + ({ user } = await withAuth()); | |
| 116 | 70 | } catch { |
| 117 | − return Response.json( | |
| 118 | − { error: "Authentication unavailable" }, | |
| 119 | − { status: 503 }, | |
| 120 | − ); | |
| 71 | + return Response.json({ error: "Authentication unavailable" }, { status: 503 }); | |
| 121 | 72 | } |
| 122 | 73 | |
| 123 | − if (!accessToken) { | |
| 74 | + if (!user) { | |
| 124 | 75 | return Response.json({ error: "Unauthorized" }, { status: 401 }); |
| 125 | 76 | } |
| 126 | 77 | |
| 127 | − const body = methodAllowsBody(method) | |
| 128 | − ? await request.arrayBuffer() | |
| 129 | − : undefined; | |
| 78 | + const displayName = [user.firstName, user.lastName] | |
| 79 | + .filter((part): part is string => typeof part === "string" && part.trim().length > 0) | |
| 80 | + .join(" ") | |
| 81 | + || null; | |
| 130 | 82 | |
| 131 | − const upstreamResponse = await fetch(resolveUpstreamUrl(request, path), { | |
| 132 | − method, | |
| 133 | − headers: buildUpstreamHeaders(request, accessToken), | |
| 134 | − body, | |
| 135 | − redirect: "manual", | |
| 83 | + const principal = createWorkosPrincipal({ | |
| 84 | + subject: user.id, | |
| 85 | + email: user.email ?? null, | |
| 86 | + displayName, | |
| 136 | 87 | }); |
| 137 | 88 | |
| 138 | − return new Response(upstreamResponse.body, { | |
| 139 | − status: upstreamResponse.status, | |
| 140 | − statusText: upstreamResponse.statusText, | |
| 141 | − headers: sanitizeUpstreamResponseHeaders(upstreamResponse.headers), | |
| 142 | − }); | |
| 89 | + await provisionPrincipal(runtime, principal); | |
| 90 | + | |
| 91 | + return runtime.handleControlPlane(applyPrincipalHeaders(controlPlaneRequest, principal)); | |
| 143 | 92 | }; |
| 144 | 93 | |
| 145 | 94 | export const GET = handle; |
| @@ -0,0 +1,321 @@ | ||
| 1 | +import { | |
| 2 | + ControlPlaneAuthHeaders, | |
| 3 | + ControlPlaneService, | |
| 4 | + fetchOpenApiDocument, | |
| 5 | + makeControlPlaneService, | |
| 6 | + makeControlPlaneSourcesService, | |
| 7 | + makeControlPlaneWebHandler, | |
| 8 | + makeSourceCatalogService, | |
| 9 | + makeSourceManagerService, | |
| 10 | +} from "@executor-v2/management-api"; | |
| 11 | +import { | |
| 12 | + makeSqlControlPlanePersistence, | |
| 13 | + type SqlControlPlanePersistence, | |
| 14 | +} from "@executor-v2/persistence-sql"; | |
| 15 | +import { | |
| 16 | + type SourceStore, | |
| 17 | + type ToolArtifactStore, | |
| 18 | +} from "@executor-v2/persistence-ports"; | |
| 19 | +import { | |
| 20 | + type Organization, | |
| 21 | + type OrganizationMembership, | |
| 22 | + type Workspace, | |
| 23 | +} from "@executor-v2/schema"; | |
| 24 | +import * as Effect from "effect/Effect"; | |
| 25 | +import * as Layer from "effect/Layer"; | |
| 26 | + | |
| 27 | +import { PmActorLive } from "../../../pm/src/actor"; | |
| 28 | +import { createPmApprovalsService } from "../../../pm/src/approvals-service"; | |
| 29 | +import { createPmCredentialsService } from "../../../pm/src/credentials-service"; | |
| 30 | +import { createPmOrganizationsService } from "../../../pm/src/organizations-service"; | |
| 31 | +import { createPmPoliciesService } from "../../../pm/src/policies-service"; | |
| 32 | +import { createPmStorageService } from "../../../pm/src/storage-service"; | |
| 33 | +import { createPmToolsService } from "../../../pm/src/tools-service"; | |
| 34 | +import { createPmWorkspacesService } from "../../../pm/src/workspaces-service"; | |
| 35 | + | |
| 36 | +const trim = (value: string | undefined): string | undefined => { | |
| 37 | + const candidate = value?.trim(); | |
| 38 | + return candidate && candidate.length > 0 ? candidate : undefined; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const defaultControlPlaneStateRootDir = ".executor-v2/web-state"; | |
| 42 | +const defaultControlPlaneSqliteFile = `${defaultControlPlaneStateRootDir}/control-plane.sqlite`; | |
| 43 | + | |
| 44 | +type ControlPlaneRuntime = { | |
| 45 | + persistence: SqlControlPlanePersistence; | |
| 46 | + sourceStore: SourceStore; | |
| 47 | + toolArtifactStore: ToolArtifactStore; | |
| 48 | + fetchOpenApiDocument: typeof fetchOpenApiDocument; | |
| 49 | + handleControlPlane: (request: Request) => Promise<Response>; | |
| 50 | + dispose: () => Promise<void>; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +type ControlPlanePrincipal = { | |
| 54 | + accountId: string; | |
| 55 | + provider: "local" | "workos" | "service"; | |
| 56 | + subject: string; | |
| 57 | + email: string | null; | |
| 58 | + displayName: string | null; | |
| 59 | + organizationId: string; | |
| 60 | + workspaceId: string; | |
| 61 | +}; | |
| 62 | + | |
| 63 | +const normalizeIdPart = (value: string): string => | |
| 64 | + value.replace(/[^a-zA-Z0-9_-]/g, "_"); | |
| 65 | + | |
| 66 | +const toAccountScopedIds = (subject: string) => { | |
| 67 | + const normalized = normalizeIdPart(subject); | |
| 68 | + | |
| 69 | + return { | |
| 70 | + accountId: `acct_${normalized}`, | |
| 71 | + organizationId: `org_${normalized}`, | |
| 72 | + workspaceId: `ws_${normalized}`, | |
| 73 | + }; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +const resolveDatabaseUrl = (): string | undefined => { | |
| 77 | + const candidates = [ | |
| 78 | + process.env.CONTROL_PLANE_DATABASE_URL, | |
| 79 | + process.env.DATABASE_URL, | |
| 80 | + process.env.POSTGRES_URL, | |
| 81 | + ]; | |
| 82 | + | |
| 83 | + for (const candidate of candidates) { | |
| 84 | + const value = trim(candidate); | |
| 85 | + if (value) { | |
| 86 | + return value; | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + return undefined; | |
| 91 | +}; | |
| 92 | + | |
| 93 | +const resolveSqlitePath = (): string => | |
| 94 | + trim(process.env.CONTROL_PLANE_SQLITE_PATH) ?? defaultControlPlaneSqliteFile; | |
| 95 | + | |
| 96 | +const resolveStateRootDir = (): string => | |
| 97 | + trim(process.env.CONTROL_PLANE_STATE_ROOT_DIR) ?? defaultControlPlaneStateRootDir; | |
| 98 | + | |
| 99 | +const ensurePrincipalProvisioned = ( | |
| 100 | + persistence: SqlControlPlanePersistence, | |
| 101 | + principal: ControlPlanePrincipal, | |
| 102 | +) => | |
| 103 | + Effect.gen(function* () { | |
| 104 | + const now = Date.now(); | |
| 105 | + const [organizations, memberships, workspaces, profileOption] = yield* Effect.all([ | |
| 106 | + persistence.rows.organizations.list(), | |
| 107 | + persistence.rows.organizationMemberships.list(), | |
| 108 | + persistence.rows.workspaces.list(), | |
| 109 | + persistence.rows.profile.get(), | |
| 110 | + ]); | |
| 111 | + | |
| 112 | + if (organizations.find((organization) => organization.id === principal.organizationId) === undefined) { | |
| 113 | + yield* persistence.rows.organizations.upsert({ | |
| 114 | + id: principal.organizationId as Organization["id"], | |
| 115 | + slug: principal.organizationId, | |
| 116 | + name: principal.displayName | |
| 117 | + ? `${principal.displayName}'s Organization` | |
| 118 | + : principal.organizationId, | |
| 119 | + status: "active", | |
| 120 | + createdByAccountId: principal.accountId as Organization["createdByAccountId"], | |
| 121 | + createdAt: now, | |
| 122 | + updatedAt: now, | |
| 123 | + }); | |
| 124 | + } | |
| 125 | + | |
| 126 | + if ( | |
| 127 | + memberships.find( | |
| 128 | + (membership) => | |
| 129 | + membership.organizationId === principal.organizationId | |
| 130 | + && membership.accountId === principal.accountId, | |
| 131 | + ) === undefined | |
| 132 | + ) { | |
| 133 | + yield* persistence.rows.organizationMemberships.upsert({ | |
| 134 | + id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"], | |
| 135 | + organizationId: principal.organizationId as OrganizationMembership["organizationId"], | |
| 136 | + accountId: principal.accountId as OrganizationMembership["accountId"], | |
| 137 | + role: "owner", | |
| 138 | + status: "active", | |
| 139 | + billable: false, | |
| 140 | + invitedByAccountId: null, | |
| 141 | + joinedAt: now, | |
| 142 | + createdAt: now, | |
| 143 | + updatedAt: now, | |
| 144 | + }); | |
| 145 | + } | |
| 146 | + | |
| 147 | + if (workspaces.find((workspace) => workspace.id === principal.workspaceId) === undefined) { | |
| 148 | + yield* persistence.rows.workspaces.upsert({ | |
| 149 | + id: principal.workspaceId as Workspace["id"], | |
| 150 | + organizationId: principal.organizationId as Workspace["organizationId"], | |
| 151 | + name: principal.displayName | |
| 152 | + ? `${principal.displayName}'s Workspace` | |
| 153 | + : principal.workspaceId, | |
| 154 | + createdByAccountId: principal.accountId as Workspace["createdByAccountId"], | |
| 155 | + createdAt: now, | |
| 156 | + updatedAt: now, | |
| 157 | + }); | |
| 158 | + } | |
| 159 | + | |
| 160 | + const existingProfile = profileOption._tag === "Some" ? profileOption.value : null; | |
| 161 | + | |
| 162 | + if ( | |
| 163 | + existingProfile === null | |
| 164 | + || existingProfile.defaultWorkspaceId !== principal.workspaceId | |
| 165 | + || existingProfile.displayName !== (principal.displayName ?? existingProfile.displayName) | |
| 166 | + ) { | |
| 167 | + yield* persistence.rows.profile.upsert({ | |
| 168 | + id: existingProfile?.id ?? ("profile_local" as any), | |
| 169 | + defaultWorkspaceId: principal.workspaceId as any, | |
| 170 | + displayName: principal.displayName ?? existingProfile?.displayName ?? "Local", | |
| 171 | + runtimeMode: existingProfile?.runtimeMode ?? "local", | |
| 172 | + createdAt: existingProfile?.createdAt ?? now, | |
| 173 | + updatedAt: now, | |
| 174 | + }); | |
| 175 | + } | |
| 176 | + }); | |
| 177 | + | |
| 178 | +let runtimePromise: Promise<ControlPlaneRuntime> | undefined; | |
| 179 | + | |
| 180 | +const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { | |
| 181 | + const stateRootDir = resolveStateRootDir(); | |
| 182 | + | |
| 183 | + const persistence = await Effect.runPromise( | |
| 184 | + makeSqlControlPlanePersistence({ | |
| 185 | + databaseUrl: resolveDatabaseUrl(), | |
| 186 | + sqlitePath: resolveSqlitePath(), | |
| 187 | + postgresApplicationName: "executor-v2-web", | |
| 188 | + }), | |
| 189 | + ); | |
| 190 | + | |
| 191 | + const sourceStore = persistence.sourceStore; | |
| 192 | + const toolArtifactStore = persistence.toolArtifactStore; | |
| 193 | + | |
| 194 | + const sourceCatalog = makeSourceCatalogService(sourceStore); | |
| 195 | + const sourceManager = makeSourceManagerService(toolArtifactStore); | |
| 196 | + const baseSourcesService = makeControlPlaneSourcesService(sourceCatalog); | |
| 197 | + | |
| 198 | + const sourcesService = { | |
| 199 | + ...baseSourcesService, | |
| 200 | + upsertSource: (input: Parameters<typeof baseSourcesService.upsertSource>[0]) => | |
| 201 | + Effect.gen(function* () { | |
| 202 | + const source = yield* baseSourcesService.upsertSource(input); | |
| 203 | + | |
| 204 | + if (source.kind !== "openapi") { | |
| 205 | + return source; | |
| 206 | + } | |
| 207 | + | |
| 208 | + const openApiSpecResult = yield* Effect.tryPromise({ | |
| 209 | + try: () => fetchOpenApiDocument(source.endpoint), | |
| 210 | + catch: (cause) => String(cause), | |
| 211 | + }).pipe(Effect.either); | |
| 212 | + | |
| 213 | + if (openApiSpecResult._tag === "Left") { | |
| 214 | + return source; | |
| 215 | + } | |
| 216 | + | |
| 217 | + yield* sourceManager | |
| 218 | + .refreshOpenApiArtifact({ | |
| 219 | + source, | |
| 220 | + openApiSpec: openApiSpecResult.right, | |
| 221 | + }) | |
| 222 | + .pipe(Effect.ignore); | |
| 223 | + | |
| 224 | + return source; | |
| 225 | + }), | |
| 226 | + }; | |
| 227 | + | |
| 228 | + const controlPlaneService = makeControlPlaneService({ | |
| 229 | + sources: sourcesService, | |
| 230 | + credentials: createPmCredentialsService(persistence.rows), | |
| 231 | + policies: createPmPoliciesService(persistence.rows), | |
| 232 | + organizations: createPmOrganizationsService(persistence.rows), | |
| 233 | + workspaces: createPmWorkspacesService(persistence.rows), | |
| 234 | + tools: createPmToolsService(sourceStore, toolArtifactStore), | |
| 235 | + storage: createPmStorageService(persistence.rows, { | |
| 236 | + stateRootDir, | |
| 237 | + }), | |
| 238 | + approvals: createPmApprovalsService(persistence.rows), | |
| 239 | + }); | |
| 240 | + | |
| 241 | + const controlPlaneWebHandler = makeControlPlaneWebHandler( | |
| 242 | + Layer.succeed(ControlPlaneService, controlPlaneService), | |
| 243 | + PmActorLive(persistence.rows), | |
| 244 | + ); | |
| 245 | + | |
| 246 | + return { | |
| 247 | + persistence, | |
| 248 | + sourceStore, | |
| 249 | + toolArtifactStore, | |
| 250 | + fetchOpenApiDocument, | |
| 251 | + handleControlPlane: controlPlaneWebHandler.handler, | |
| 252 | + dispose: async () => { | |
| 253 | + await controlPlaneWebHandler.dispose(); | |
| 254 | + await persistence.close(); | |
| 255 | + }, | |
| 256 | + }; | |
| 257 | +}; | |
| 258 | + | |
| 259 | +export const getControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { | |
| 260 | + if (!runtimePromise) { | |
| 261 | + runtimePromise = createControlPlaneRuntime(); | |
| 262 | + } | |
| 263 | + | |
| 264 | + return runtimePromise; | |
| 265 | +}; | |
| 266 | + | |
| 267 | +export const createWorkosPrincipal = (input: { | |
| 268 | + subject: string; | |
| 269 | + email: string | null; | |
| 270 | + displayName: string | null; | |
| 271 | +}): ControlPlanePrincipal => { | |
| 272 | + const ids = toAccountScopedIds(input.subject); | |
| 273 | + | |
| 274 | + return { | |
| 275 | + accountId: ids.accountId, | |
| 276 | + provider: "workos", | |
| 277 | + subject: input.subject, | |
| 278 | + email: input.email, | |
| 279 | + displayName: input.displayName, | |
| 280 | + organizationId: ids.organizationId, | |
| 281 | + workspaceId: ids.workspaceId, | |
| 282 | + }; | |
| 283 | +}; | |
| 284 | + | |
| 285 | +export const createLocalPrincipal = (): ControlPlanePrincipal => ({ | |
| 286 | + accountId: "acct_demo", | |
| 287 | + provider: "local", | |
| 288 | + subject: "local:demo", | |
| 289 | + email: null, | |
| 290 | + displayName: "Local Demo", | |
| 291 | + organizationId: "org_demo", | |
| 292 | + workspaceId: "ws_demo", | |
| 293 | +}); | |
| 294 | + | |
| 295 | +export const applyPrincipalHeaders = ( | |
| 296 | + request: Request, | |
| 297 | + principal: ControlPlanePrincipal, | |
| 298 | +): Request => { | |
| 299 | + const headers = new Headers(request.headers); | |
| 300 | + | |
| 301 | + headers.set(ControlPlaneAuthHeaders.accountId, principal.accountId); | |
| 302 | + headers.set(ControlPlaneAuthHeaders.principalProvider, principal.provider); | |
| 303 | + headers.set(ControlPlaneAuthHeaders.principalSubject, principal.subject); | |
| 304 | + | |
| 305 | + if (principal.email) { | |
| 306 | + headers.set(ControlPlaneAuthHeaders.principalEmail, principal.email); | |
| 307 | + } | |
| 308 | + | |
| 309 | + if (principal.displayName) { | |
| 310 | + headers.set(ControlPlaneAuthHeaders.principalDisplayName, principal.displayName); | |
| 311 | + } | |
| 312 | + | |
| 313 | + return new Request(request, { headers }); | |
| 314 | +}; | |
| 315 | + | |
| 316 | +export const provisionPrincipal = async ( | |
| 317 | + runtime: ControlPlaneRuntime, | |
| 318 | + principal: ControlPlanePrincipal, | |
| 319 | +): Promise<void> => { | |
| 320 | + await Effect.runPromise(ensurePrincipalProvisioned(runtime.persistence, principal)); | |
| 321 | +}; | |
| @@ -12,6 +12,8 @@ · 11 unmodified lines | ||
| 12 | 12 | "@effect-atom/atom": "^0.4.11", |
| 13 | 13 | "@effect-atom/atom-react": "^0.4.6", |
| 14 | 14 | "@executor-v2/management-api": "workspace:*", |
| 15 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 16 | + "@executor-v2/persistence-sql": "workspace:*", | |
| 15 | 17 | "@executor-v2/schema": "workspace:*", |
| 16 | 18 | "@modelcontextprotocol/sdk": "^1.27.1", |
| 17 | 19 | "@streamdown/code": "^1.0.3", |
| @@ -16,7 +16,8 @@ · 15 unmodified lines | ||
| 16 | 16 | } |
| 17 | 17 | ], |
| 18 | 18 | "types": [ |
| 19 | − "node" | |
| 19 | + "node", | |
| 20 | + "bun" | |
| 20 | 21 | ], |
| 21 | 22 | "allowJs": true, |
| 22 | 23 | "esModuleInterop": true, |
| @@ -27,7 +27,7 @@ executor doctor # health check · 26 unmodified lines | ||
| 27 | 27 | # Or wire up Claude Code directly: |
| 28 | 28 | executor claude --no-bash`, |
| 29 | 29 | description: |
| 30 | − "Fully self-hosted. Run locally for development, connect to your own Convex backend. No cloud dependency required.", | |
| 30 | + "Fully self-hosted. Run locally with SQLite for development, or connect to your own Postgres backend. No cloud dependency required.", | |
| 31 | 31 | }, |
| 32 | 32 | { |
| 33 | 33 | title: "Use with your team", |
| @@ -5,7 +5,6 @@ · 4 unmodified lines | ||
| 5 | 5 | "": { |
| 6 | 6 | "name": "executor-v2", |
| 7 | 7 | "dependencies": { |
| 8 | − "convex": "^1.32.0", | |
| 9 | 8 | "vercel": "^50.23.2", |
| 10 | 9 | }, |
| 11 | 10 | "devDependencies": { |
| @@ -16,48 +15,9 @@ · 4 unmodified lines | ||
| 16 | 15 | "typescript": "catalog:", |
| 17 | 16 | }, |
| 18 | 17 | }, |
| 19 | − "apps/api": { | |
| 20 | − "name": "@executor-v2/app-api", | |
| 21 | − "dependencies": { | |
| 22 | − "convex": "^1.32.0", | |
| 23 | − "yaml": "^2.8.1", | |
| 24 | − }, | |
| 25 | − "devDependencies": { | |
| 26 | − "@effect/vitest": "^0.27.0", | |
| 27 | − "@types/node": "^20.19.9", | |
| 28 | − "effect": "^3.19.8", | |
| 29 | − "typescript": "^5.9.3", | |
| 30 | − "vitest": "^3.2.4", | |
| 31 | − }, | |
| 32 | − }, | |
| 33 | 18 | "apps/cli": { |
| 34 | 19 | "name": "@executor-v2/app-cli", |
| 35 | 20 | }, |
| 36 | − "apps/convex": { | |
| 37 | − "name": "@executor-v2/app-convex", | |
| 38 | − "dependencies": { | |
| 39 | − "@convex-dev/action-cache": "^0.3.0", | |
| 40 | − "@convex-dev/workflow": "^0.3.4", | |
| 41 | − "@executor-v2/confect": "workspace:*", | |
| 42 | − "@executor-v2/domain": "workspace:*", | |
| 43 | − "@executor-v2/engine": "workspace:*", | |
| 44 | − "@executor-v2/management-api": "workspace:*", | |
| 45 | − "@executor-v2/mcp-gateway": "workspace:*", | |
| 46 | − "@executor-v2/persistence-ports": "workspace:*", | |
| 47 | − "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", | |
| 48 | − "@executor-v2/schema": "workspace:*", | |
| 49 | − "@executor-v2/sdk": "workspace:*", | |
| 50 | − "@modelcontextprotocol/sdk": "^1.27.1", | |
| 51 | − "convex": "catalog:", | |
| 52 | − "effect": "catalog:", | |
| 53 | − "jose": "^6.1.0", | |
| 54 | − }, | |
| 55 | − "devDependencies": { | |
| 56 | − "@effect/vitest": "catalog:", | |
| 57 | − "convex-test": "workspace:*", | |
| 58 | − "vitest": "catalog:", | |
| 59 | − }, | |
| 60 | − }, | |
| 61 | 21 | "apps/pm": { |
| 62 | 22 | "name": "@executor-v2/app-pm", |
| 63 | 23 | "dependencies": { |
| @@ -67,7 +27,8 @@ · 3 unmodified lines | ||
| 67 | 27 | "@executor-v2/engine": "workspace:*", |
| 68 | 28 | "@executor-v2/management-api": "workspace:*", |
| 69 | 29 | "@executor-v2/mcp-gateway": "workspace:*", |
| 70 | − "@executor-v2/persistence-local": "workspace:*", | |
| 30 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 31 | + "@executor-v2/persistence-sql": "workspace:*", | |
| 71 | 32 | "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", |
| 72 | 33 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 73 | 34 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| @@ -86,6 +47,8 @@ · 12 unmodified lines | ||
| 86 | 47 | "@effect-atom/atom": "^0.4.11", |
| 87 | 48 | "@effect-atom/atom-react": "^0.4.6", |
| 88 | 49 | "@executor-v2/management-api": "workspace:*", |
| 50 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 51 | + "@executor-v2/persistence-sql": "workspace:*", | |
| 89 | 52 | "@executor-v2/schema": "workspace:*", |
| 90 | 53 | "@modelcontextprotocol/sdk": "^1.27.1", |
| 91 | 54 | "@streamdown/code": "^1.0.3", |
| @@ -145,31 +108,6 @@ · 53 unmodified lines | ||
| 145 | 108 | "vitest": "catalog:", |
| 146 | 109 | }, |
| 147 | 110 | }, |
| 148 | − "packages/confect": { | |
| 149 | − "name": "@executor-v2/confect", | |
| 150 | − "version": "0.0.0", | |
| 151 | − "dependencies": { | |
| 152 | − "@effect-atom/atom": "^0.4.11", | |
| 153 | − "@effect-atom/atom-react": "^0.4.4", | |
| 154 | − "@effect/platform": "catalog:", | |
| 155 | − "@effect/rpc": "^0.72.2", | |
| 156 | − "convex": "catalog:", | |
| 157 | − "effect": "catalog:", | |
| 158 | − "next": "^16.1.6", | |
| 159 | − }, | |
| 160 | − "devDependencies": { | |
| 161 | − "@types/node": "catalog:", | |
| 162 | − "typescript": "catalog:", | |
| 163 | − "vitest": "catalog:", | |
| 164 | − }, | |
| 165 | − }, | |
| 166 | − "packages/convex-test": { | |
| 167 | − "name": "convex-test", | |
| 168 | − "version": "0.0.41-patched", | |
| 169 | − "peerDependencies": { | |
| 170 | − "convex": "^1.32.0", | |
| 171 | − }, | |
| 172 | − }, | |
| 173 | 111 | "packages/domain": { |
| 174 | 112 | "name": "@executor-v2/domain", |
| 175 | 113 | "dependencies": { |
| @@ -222,6 +160,16 @@ · 46 unmodified lines | ||
| 222 | 160 | "zod": "^4.3.6", |
| 223 | 161 | }, |
| 224 | 162 | }, |
| 163 | + "packages/persistence-sql": { | |
| 164 | + "name": "@executor-v2/persistence-sql", | |
| 165 | + "dependencies": { | |
| 166 | + "@executor-v2/persistence-ports": "workspace:*", | |
| 167 | + "@executor-v2/schema": "workspace:*", | |
| 168 | + "drizzle-orm": "^0.44.7", | |
| 169 | + "effect": "catalog:", | |
| 170 | + "postgres": "^3.4.7", | |
| 171 | + }, | |
| 172 | + }, | |
| 225 | 173 | "packages/runtime-cloudflare-worker-loader": { |
| 226 | 174 | "name": "@executor-v2/runtime-cloudflare-worker-loader", |
| 227 | 175 | "dependencies": { |
| @@ -266,27 +214,11 @@ · 38 unmodified lines | ||
| 266 | 214 | "effect": "catalog:", |
| 267 | 215 | }, |
| 268 | 216 | }, |
| 269 | − "packages/state-local-file": { | |
| 270 | − "name": "@executor-v2/persistence-local", | |
| 271 | − "dependencies": { | |
| 272 | − "@effect/platform": "catalog:", | |
| 273 | − "@executor-v2/persistence-ports": "workspace:*", | |
| 274 | − "@executor-v2/schema": "workspace:*", | |
| 275 | − "effect": "catalog:", | |
| 276 | − }, | |
| 277 | − "devDependencies": { | |
| 278 | − "@effect/platform-bun": "^0.86.0", | |
| 279 | − "@effect/vitest": "catalog:", | |
| 280 | − "@executor-v2/management-api": "workspace:*", | |
| 281 | − "vitest": "catalog:", | |
| 282 | − }, | |
| 283 | − }, | |
| 284 | 217 | }, |
| 285 | 218 | "catalog": { |
| 286 | 219 | "@effect/platform": "^0.93.6", |
| 287 | 220 | "@effect/vitest": "^0.27.0", |
| 288 | 221 | "@types/node": "^20.19.9", |
| 289 | − "convex": "^1.32.0", | |
| 290 | 222 | "effect": "^3.19.8", |
| 291 | 223 | "typescript": "^5.9.3", |
| 292 | 224 | "vitest": "^3.2.4", |
| @@ -386,12 +318,6 @@ · 93 unmodified lines | ||
| 386 | 318 | |
| 387 | 319 | "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260305.0", "", {}, "sha512-sCgPFnQ03SVpC2OVW8wysONLZW/A8hlp9Mq2ckG/h1oId4kr9NawA6vUiOmOjCWRn2hIohejBYVQ+Vu20rCdKA=="], |
| 388 | 320 | |
| 389 | − "@convex-dev/action-cache": ["@convex-dev/action-cache@0.3.0", "", { "peerDependencies": { "convex": "^1.24.8" } }, "sha512-B3GsOgwPv81whdu1mHaMKBwbIYHhNXsv3Fr5oMjynIOrRy1laGLUo9zRWDKWJgtKXfVnn8X/uxYD9RyWmPIbPQ=="], | |
| 390 | − | |
| 391 | − "@convex-dev/workflow": ["@convex-dev/workflow@0.3.4", "", { "dependencies": { "async-channel": "^0.2.0" }, "peerDependencies": { "@convex-dev/workpool": "^0.3.0", "convex": "^1.24.8", "convex-helpers": "^0.1.99" } }, "sha512-uX9qhEbgkgt+touvgLnyKztussT3m5q4c2XH/iBNQxzk8TlL29F1+UeoCxXA6WibWEpOQLeuFA6/QMVP4tEX1w=="], | |
| 392 | − | |
| 393 | − "@convex-dev/workpool": ["@convex-dev/workpool@0.3.2", "", { "peerDependencies": { "convex": "^1.31.7", "convex-helpers": "^0.1.94" } }, "sha512-jNPBHLCmZQXaztbBntZgIoM8JhKDmGwaIezZS/zPmX74PzON0Fgysgd9u+NOjJ04Dx2pN8ppBH9ElpsKxCOBpA=="], | |
| 394 | − | |
| 395 | 321 | "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], |
| 396 | 322 | |
| 397 | 323 | "@edge-runtime/format": ["@edge-runtime/format@2.2.1", "", {}, "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g=="], |
| @@ -502,20 +428,14 @@ · 104 unmodified lines | ||
| 502 | 428 | |
| 503 | 429 | "@executor-v2/ai-sdk-adapter": ["@executor-v2/ai-sdk-adapter@workspace:packages/ai-sdk-adapter"], |
| 504 | 430 | |
| 505 | − "@executor-v2/app-api": ["@executor-v2/app-api@workspace:apps/api"], | |
| 506 | − | |
| 507 | 431 | "@executor-v2/app-cli": ["@executor-v2/app-cli@workspace:apps/cli"], |
| 508 | 432 | |
| 509 | − "@executor-v2/app-convex": ["@executor-v2/app-convex@workspace:apps/convex"], | |
| 510 | − | |
| 511 | 433 | "@executor-v2/app-pm": ["@executor-v2/app-pm@workspace:apps/pm"], |
| 512 | 434 | |
| 513 | 435 | "@executor-v2/app-web": ["@executor-v2/app-web@workspace:apps/web"], |
| 514 | 436 | |
| 515 | 437 | "@executor-v2/app-www": ["@executor-v2/app-www@workspace:apps/www"], |
| 516 | 438 | |
| 517 | − "@executor-v2/confect": ["@executor-v2/confect@workspace:packages/confect"], | |
| 518 | − | |
| 519 | 439 | "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"], |
| 520 | 440 | |
| 521 | 441 | "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"], |
| @@ -524,10 +444,10 @@ · 2 unmodified lines | ||
| 524 | 444 | |
| 525 | 445 | "@executor-v2/mcp-gateway": ["@executor-v2/mcp-gateway@workspace:packages/mcp-gateway"], |
| 526 | 446 | |
| 527 | − "@executor-v2/persistence-local": ["@executor-v2/persistence-local@workspace:packages/state-local-file"], | |
| 528 | − | |
| 529 | 447 | "@executor-v2/persistence-ports": ["@executor-v2/persistence-ports@workspace:packages/state-contracts"], |
| 530 | 448 | |
| 449 | + "@executor-v2/persistence-sql": ["@executor-v2/persistence-sql@workspace:packages/persistence-sql"], | |
| 450 | + | |
| 531 | 451 | "@executor-v2/runtime-cloudflare-worker-loader": ["@executor-v2/runtime-cloudflare-worker-loader@workspace:packages/runtime-cloudflare-worker-loader"], |
| 532 | 452 | |
| 533 | 453 | "@executor-v2/runtime-deno-subprocess": ["@executor-v2/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"], |
| @@ -890,6 +810,8 @@ · 356 unmodified lines | ||
| 890 | 810 | |
| 891 | 811 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], |
| 892 | 812 | |
| 813 | + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], | |
| 814 | + | |
| 893 | 815 | "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], |
| 894 | 816 | |
| 895 | 817 | "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], |
| @@ -1062,8 +984,6 @@ · 166 unmodified lines | ||
| 1062 | 984 | |
| 1063 | 985 | "astro": ["astro@5.18.0", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", "@astrojs/markdown-remark": "6.3.10", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-CHiohwJIS4L0G6/IzE1Fx3dgWqXBCXus/od0eGUfxrZJD2um2pE7ehclMmgL/fXqbU7NfE1Ze2pq34h2QaA6iQ=="], |
| 1064 | 986 | |
| 1065 | − "async-channel": ["async-channel@0.2.0", "", {}, "sha512-BJyjI/sfKlyijaBt2hbOSxT28xGNtLR0QLzAKO1Hlnv5BULY7sAoYoTPW3lfr1ZIC7y+FxabxO9T8GXpyoofGg=="], | |
| 1066 | − | |
| 1067 | 987 | "async-listen": ["async-listen@1.2.0", "", {}, "sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA=="], |
| 1068 | 988 | |
| 1069 | 989 | "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], |
| @@ -1082,14 +1002,20 @@ · 12 unmodified lines | ||
| 1082 | 1002 | |
| 1083 | 1003 | "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], |
| 1084 | 1004 | |
| 1005 | + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], | |
| 1006 | + | |
| 1085 | 1007 | "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], |
| 1086 | 1008 | |
| 1087 | 1009 | "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], |
| 1088 | 1010 | |
| 1011 | + "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], | |
| 1012 | + | |
| 1089 | 1013 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], |
| 1090 | 1014 | |
| 1091 | 1015 | "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], |
| 1092 | 1016 | |
| 1017 | + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], | |
| 1018 | + | |
| 1093 | 1019 | "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], |
| 1094 | 1020 | |
| 1095 | 1021 | "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], |
| @@ -1104,6 +1030,8 @@ · 8 unmodified lines | ||
| 1104 | 1030 | |
| 1105 | 1031 | "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], |
| 1106 | 1032 | |
| 1033 | + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], | |
| 1034 | + | |
| 1107 | 1035 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], |
| 1108 | 1036 | |
| 1109 | 1037 | "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], |
| @@ -1184,12 +1112,6 @@ · 74 unmodified lines | ||
| 1184 | 1112 | |
| 1185 | 1113 | "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], |
| 1186 | 1114 | |
| 1187 | − "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], | |
| 1188 | − | |
| 1189 | − "convex-helpers": ["convex-helpers@0.1.114", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.32.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-elEdh+gG6BDv2dWIWVvBeJPbHnDQS5+WexUuwlGVJXz1EbMkXz/UIQwFIfLMZIXUwW6ot4JYf/1JJKNStrE6lg=="], | |
| 1190 | − | |
| 1191 | − "convex-test": ["convex-test@workspace:packages/convex-test"], | |
| 1192 | − | |
| 1193 | 1115 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], |
| 1194 | 1116 | |
| 1195 | 1117 | "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], |
| @@ -1220,8 +1142,12 @@ · 24 unmodified lines | ||
| 1220 | 1142 | |
| 1221 | 1143 | "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], |
| 1222 | 1144 | |
| 1145 | + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], | |
| 1146 | + | |
| 1223 | 1147 | "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], |
| 1224 | 1148 | |
| 1149 | + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], | |
| 1150 | + | |
| 1225 | 1151 | "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], |
| 1226 | 1152 | |
| 1227 | 1153 | "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], |
| @@ -1258,6 +1184,8 @@ · 30 unmodified lines | ||
| 1258 | 1184 | |
| 1259 | 1185 | "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], |
| 1260 | 1186 | |
| 1187 | + "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], | |
| 1188 | + | |
| 1261 | 1189 | "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], |
| 1262 | 1190 | |
| 1263 | 1191 | "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], |
| @@ -1326,6 +1254,8 @@ · 62 unmodified lines | ||
| 1326 | 1254 | |
| 1327 | 1255 | "execa": ["execa@3.2.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "p-finally": "^2.0.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, "sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw=="], |
| 1328 | 1256 | |
| 1257 | + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], | |
| 1258 | + | |
| 1329 | 1259 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], |
| 1330 | 1260 | |
| 1331 | 1261 | "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], |
| @@ -1370,6 +1300,8 @@ · 38 unmodified lines | ||
| 1370 | 1300 | |
| 1371 | 1301 | "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], |
| 1372 | 1302 | |
| 1303 | + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], | |
| 1304 | + | |
| 1373 | 1305 | "fs-extra": ["fs-extra@11.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw=="], |
| 1374 | 1306 | |
| 1375 | 1307 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], |
| @@ -1394,6 +1326,8 @@ · 18 unmodified lines | ||
| 1394 | 1326 | |
| 1395 | 1327 | "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], |
| 1396 | 1328 | |
| 1329 | + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], | |
| 1330 | + | |
| 1397 | 1331 | "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], |
| 1398 | 1332 | |
| 1399 | 1333 | "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], |
| @@ -1456,10 +1390,14 @@ · 56 unmodified lines | ||
| 1456 | 1390 | |
| 1457 | 1391 | "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], |
| 1458 | 1392 | |
| 1393 | + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], | |
| 1394 | + | |
| 1459 | 1395 | "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], |
| 1460 | 1396 | |
| 1461 | 1397 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], |
| 1462 | 1398 | |
| 1399 | + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], | |
| 1400 | + | |
| 1463 | 1401 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], |
| 1464 | 1402 | |
| 1465 | 1403 | "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], |
| @@ -1510,7 +1448,7 @@ · 44 unmodified lines | ||
| 1510 | 1448 | |
| 1511 | 1449 | "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], |
| 1512 | 1450 | |
| 1513 | − "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], | |
| 1451 | + "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], | |
| 1514 | 1452 | |
| 1515 | 1453 | "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], |
| 1516 | 1454 | |
| @@ -1692,16 +1630,22 @@ · 175 unmodified lines | ||
| 1692 | 1630 | |
| 1693 | 1631 | "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], |
| 1694 | 1632 | |
| 1633 | + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], | |
| 1634 | + | |
| 1695 | 1635 | "miniflare": ["miniflare@4.20260305.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260305.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-jVhtKJtiwaZa3rI+WgoLvSJmEazDsoUmAPYRUmEe2VO6VSbvkhbnDRm+dsPbYRatgNIExwrpqG1rv96jHiSb0w=="], |
| 1696 | 1636 | |
| 1697 | 1637 | "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], |
| 1698 | 1638 | |
| 1639 | + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], | |
| 1640 | + | |
| 1699 | 1641 | "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], |
| 1700 | 1642 | |
| 1701 | 1643 | "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], |
| 1702 | 1644 | |
| 1703 | 1645 | "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], |
| 1704 | 1646 | |
| 1647 | + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], | |
| 1648 | + | |
| 1705 | 1649 | "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], |
| 1706 | 1650 | |
| 1707 | 1651 | "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], |
| @@ -1722,6 +1666,8 @@ · 14 unmodified lines | ||
| 1722 | 1666 | |
| 1723 | 1667 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 1724 | 1668 | |
| 1669 | + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], | |
| 1670 | + | |
| 1725 | 1671 | "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], |
| 1726 | 1672 | |
| 1727 | 1673 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], |
| @@ -1734,6 +1680,8 @@ · 6 unmodified lines | ||
| 1734 | 1680 | |
| 1735 | 1681 | "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], |
| 1736 | 1682 | |
| 1683 | + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], | |
| 1684 | + | |
| 1737 | 1685 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], |
| 1738 | 1686 | |
| 1739 | 1687 | "node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], |
| @@ -1854,6 +1802,10 @@ · 114 unmodified lines | ||
| 1854 | 1802 | |
| 1855 | 1803 | "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], |
| 1856 | 1804 | |
| 1805 | + "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], | |
| 1806 | + | |
| 1807 | + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], | |
| 1808 | + | |
| 1857 | 1809 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| 1858 | 1810 | |
| 1859 | 1811 | "pretty-ms": ["pretty-ms@7.0.1", "", { "dependencies": { "parse-ms": "^2.1.0" } }, "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q=="], |
| @@ -1888,6 +1840,8 @@ · 28 unmodified lines | ||
| 1888 | 1840 | |
| 1889 | 1841 | "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], |
| 1890 | 1842 | |
| 1843 | + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], | |
| 1844 | + | |
| 1891 | 1845 | "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], |
| 1892 | 1846 | |
| 1893 | 1847 | "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], |
| @@ -1896,6 +1850,8 @@ · 2 unmodified lines | ||
| 1896 | 1850 | |
| 1897 | 1851 | "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], |
| 1898 | 1852 | |
| 1853 | + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], | |
| 1854 | + | |
| 1899 | 1855 | "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], |
| 1900 | 1856 | |
| 1901 | 1857 | "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], |
| @@ -1962,6 +1918,8 @@ · 60 unmodified lines | ||
| 1962 | 1918 | |
| 1963 | 1919 | "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], |
| 1964 | 1920 | |
| 1921 | + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], | |
| 1922 | + | |
| 1965 | 1923 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], |
| 1966 | 1924 | |
| 1967 | 1925 | "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], |
| @@ -1996,6 +1954,10 @@ · 28 unmodified lines | ||
| 1996 | 1954 | |
| 1997 | 1955 | "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], |
| 1998 | 1956 | |
| 1957 | + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], | |
| 1958 | + | |
| 1959 | + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], | |
| 1960 | + | |
| 1999 | 1961 | "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], |
| 2000 | 1962 | |
| 2001 | 1963 | "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], |
| @@ -2032,12 +1994,16 @@ · 30 unmodified lines | ||
| 2032 | 1994 | |
| 2033 | 1995 | "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], |
| 2034 | 1996 | |
| 1997 | + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], | |
| 1998 | + | |
| 2035 | 1999 | "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], |
| 2036 | 2000 | |
| 2037 | 2001 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], |
| 2038 | 2002 | |
| 2039 | 2003 | "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], |
| 2040 | 2004 | |
| 2005 | + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], | |
| 2006 | + | |
| 2041 | 2007 | "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], |
| 2042 | 2008 | |
| 2043 | 2009 | "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], |
| @@ -2062,6 +2028,10 @@ · 18 unmodified lines | ||
| 2062 | 2028 | |
| 2063 | 2029 | "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], |
| 2064 | 2030 | |
| 2031 | + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], | |
| 2032 | + | |
| 2033 | + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], | |
| 2034 | + | |
| 2065 | 2035 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], |
| 2066 | 2036 | |
| 2067 | 2037 | "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], |
| @@ -2112,6 +2082,8 @@ · 44 unmodified lines | ||
| 2112 | 2082 | |
| 2113 | 2083 | "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], |
| 2114 | 2084 | |
| 2085 | + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], | |
| 2086 | + | |
| 2115 | 2087 | "turbo": ["turbo@2.8.11", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.11", "turbo-darwin-arm64": "2.8.11", "turbo-linux-64": "2.8.11", "turbo-linux-arm64": "2.8.11", "turbo-windows-64": "2.8.11", "turbo-windows-arm64": "2.8.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-H+rwSHHPLoyPOSoHdmI1zY0zy0GGj1Dmr7SeJW+nZiWLz2nex8EJ+fkdVabxXFMNEux+aywI4Sae8EqhmnOv4A=="], |
| 2116 | 2088 | |
| 2117 | 2089 | "turbo-darwin-64": ["turbo-darwin-64@2.8.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-XKaCWaz4OCt77oYYvGCIRpvYD4c/aNaKjRkUpv+e8rN3RZb+5Xsyew4yRO+gaHdMIUhQznXNXfHlhs+/p7lIhA=="], |
| @@ -2266,7 +2238,7 @@ · 148 unmodified lines | ||
| 2266 | 2238 | |
| 2267 | 2239 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], |
| 2268 | 2240 | |
| 2269 | − "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], | |
| 2241 | + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], | |
| 2270 | 2242 | |
| 2271 | 2243 | "xdg-app-paths": ["xdg-app-paths@5.1.0", "", { "dependencies": { "xdg-portable": "^7.0.0" } }, "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA=="], |
| 2272 | 2244 | |
| @@ -2326,16 +2298,14 @@ · 53 unmodified lines | ||
| 2326 | 2298 | |
| 2327 | 2299 | "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], |
| 2328 | 2300 | |
| 2329 | − "@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], | |
| 2330 | − | |
| 2331 | 2301 | "@executor-v2/app-www/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], |
| 2332 | 2302 | |
| 2333 | − "@executor-v2/confect/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], | |
| 2334 | − | |
| 2335 | 2303 | "@mapbox/node-pre-gyp/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], |
| 2336 | 2304 | |
| 2337 | 2305 | "@mapbox/node-pre-gyp/tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], |
| 2338 | 2306 | |
| 2307 | + "@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], | |
| 2308 | + | |
| 2339 | 2309 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| 2340 | 2310 | |
| 2341 | 2311 | "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], |
| @@ -2412,7 +2382,7 @@ · 70 unmodified lines | ||
| 2412 | 2382 | |
| 2413 | 2383 | "@vercel/static-config/ajv": ["ajv@8.6.3", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw=="], |
| 2414 | 2384 | |
| 2415 | − "@workos-inc/authkit-nextjs/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], | |
| 2385 | + "@workos-inc/node/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], | |
| 2416 | 2386 | |
| 2417 | 2387 | "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], |
| 2418 | 2388 | |
| @@ -2470,6 +2440,8 @@ · 51 unmodified lines | ||
| 2470 | 2440 | |
| 2471 | 2441 | "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], |
| 2472 | 2442 | |
| 2443 | + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], | |
| 2444 | + | |
| 2473 | 2445 | "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], |
| 2474 | 2446 | |
| 2475 | 2447 | "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], |
| @@ -2490,14 +2462,16 @@ · 14 unmodified lines | ||
| 2490 | 2462 | |
| 2491 | 2463 | "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], |
| 2492 | 2464 | |
| 2465 | + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], | |
| 2466 | + | |
| 2467 | + "tar-stream/end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], | |
| 2468 | + | |
| 2493 | 2469 | "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], |
| 2494 | 2470 | |
| 2495 | 2471 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], |
| 2496 | 2472 | |
| 2497 | 2473 | "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], |
| 2498 | 2474 | |
| 2499 | − "vercel/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], | |
| 2500 | − | |
| 2501 | 2475 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], |
| 2502 | 2476 | |
| 2503 | 2477 | "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], |
| @@ -2522,26 +2496,6 @@ · 18 unmodified lines | ||
| 2522 | 2496 | |
| 2523 | 2497 | "@executor-v2/app-www/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], |
| 2524 | 2498 | |
| 2525 | − "@executor-v2/confect/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], | |
| 2526 | − | |
| 2527 | − "@executor-v2/confect/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], | |
| 2528 | − | |
| 2529 | − "@executor-v2/confect/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], | |
| 2530 | − | |
| 2531 | − "@executor-v2/confect/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], | |
| 2532 | − | |
| 2533 | − "@executor-v2/confect/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], | |
| 2534 | − | |
| 2535 | − "@executor-v2/confect/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], | |
| 2536 | − | |
| 2537 | − "@executor-v2/confect/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], | |
| 2538 | − | |
| 2539 | − "@executor-v2/confect/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], | |
| 2540 | − | |
| 2541 | − "@executor-v2/confect/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], | |
| 2542 | − | |
| 2543 | − "@executor-v2/confect/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], | |
| 2544 | − | |
| 2545 | 2499 | "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], |
| 2546 | 2500 | |
| 2547 | 2501 | "@vercel/express/@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| @@ -19,8 +19,7 @@ · 18 unmodified lines | ||
| 19 | 19 | "prepare": "effect-language-service patch", |
| 20 | 20 | "dev": "turbo run dev --parallel", |
| 21 | 21 | "typecheck": "turbo run typecheck", |
| 22 | − "knip": "bunx knip --config knip.json", | |
| 23 | − "db:wipe": "python3 -c \"import zipfile; zipfile.ZipFile('/tmp/_convex_empty.zip','w').close()\" && cd apps/convex && npx convex import --replace-all --yes /tmp/_convex_empty.zip" | |
| 22 | + "knip": "bunx knip --config knip.json" | |
| 24 | 23 | }, |
| 25 | 24 | "devDependencies": { |
| 26 | 25 | "@effect/language-service": "^0.76.0", |
| @@ -30,7 +29,6 @@ · 3 unmodified lines | ||
| 30 | 29 | "typescript": "catalog:" |
| 31 | 30 | }, |
| 32 | 31 | "dependencies": { |
| 33 | − "convex": "^1.32.0", | |
| 34 | 32 | "vercel": "^50.23.2" |
| 35 | 33 | } |
| 36 | 34 | } |
| @@ -1,34 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "name": "@executor-v2/confect", | |
| 3 | − "version": "0.0.0", | |
| 4 | − "private": true, | |
| 5 | − "type": "module", | |
| 6 | − "exports": { | |
| 7 | − ".": "./src/index.ts", | |
| 8 | − "./schema": "./src/schema.ts", | |
| 9 | − "./validators": "./src/validators.ts", | |
| 10 | − "./ctx": "./src/ctx.ts", | |
| 11 | − "./client": "./src/client.ts", | |
| 12 | − "./rpc": "./src/rpc/index.ts", | |
| 13 | − "./testing": "./src/testing.ts" | |
| 14 | − }, | |
| 15 | − "scripts": { | |
| 16 | − "test": "vitest run --no-watch", | |
| 17 | − "test:watch": "vitest", | |
| 18 | − "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 19 | − }, | |
| 20 | − "dependencies": { | |
| 21 | − "@effect-atom/atom": "^0.4.11", | |
| 22 | − "@effect-atom/atom-react": "^0.4.4", | |
| 23 | − "@effect/platform": "catalog:", | |
| 24 | − "@effect/rpc": "^0.72.2", | |
| 25 | − "convex": "catalog:", | |
| 26 | − "effect": "catalog:", | |
| 27 | − "next": "^16.1.6" | |
| 28 | − }, | |
| 29 | − "devDependencies": { | |
| 30 | − "@types/node": "catalog:", | |
| 31 | − "typescript": "catalog:", | |
| 32 | − "vitest": "catalog:" | |
| 33 | − } | |
| 34 | −} | |
| @@ -1,160 +0,0 @@ | ||
| 1 | −import type { FunctionReference, FunctionReturnType } from "convex/server"; | |
| 2 | −import { | |
| 3 | − ConvexClient as ConvexClientImpl, | |
| 4 | − ConvexHttpClient, | |
| 5 | −} from "convex/browser"; | |
| 6 | −import { Context, Effect, Layer, Stream } from "effect"; | |
| 7 | − | |
| 8 | −export interface ConvexRequestMetadata { | |
| 9 | − readonly headers?: Readonly<Record<string, string>>; | |
| 10 | −} | |
| 11 | − | |
| 12 | −export interface ConvexClientService { | |
| 13 | − query<Query extends FunctionReference<"query">>( | |
| 14 | − query: Query, | |
| 15 | − args: Query["_args"], | |
| 16 | − requestMetadata?: ConvexRequestMetadata, | |
| 17 | − ): Effect.Effect<FunctionReturnType<Query>>; | |
| 18 | − | |
| 19 | − mutation<Mutation extends FunctionReference<"mutation">>( | |
| 20 | − mutation: Mutation, | |
| 21 | − args: Mutation["_args"], | |
| 22 | − requestMetadata?: ConvexRequestMetadata, | |
| 23 | − ): Effect.Effect<FunctionReturnType<Mutation>>; | |
| 24 | − | |
| 25 | − action<Action extends FunctionReference<"action">>( | |
| 26 | − action: Action, | |
| 27 | − args: Action["_args"], | |
| 28 | − requestMetadata?: ConvexRequestMetadata, | |
| 29 | − ): Effect.Effect<FunctionReturnType<Action>>; | |
| 30 | − | |
| 31 | − subscribe<Query extends FunctionReference<"query">>( | |
| 32 | − query: Query, | |
| 33 | − args: Query["_args"], | |
| 34 | − ): Stream.Stream<FunctionReturnType<Query>>; | |
| 35 | −} | |
| 36 | − | |
| 37 | −export class ConvexClient extends Context.Tag("@confect/ConvexClient")< | |
| 38 | − ConvexClient, | |
| 39 | − ConvexClientService | |
| 40 | −>() {} | |
| 41 | − | |
| 42 | −export const ConvexClientLayer = ( | |
| 43 | − url: string, | |
| 44 | −): Layer.Layer<ConvexClient> => { | |
| 45 | − const wsClient = new ConvexClientImpl(url); | |
| 46 | − | |
| 47 | − // Shared HTTP client instance — no custom headers that would trigger CORS | |
| 48 | − // preflight failures in browsers. Telemetry context is already embedded | |
| 49 | − // in the payload by `withOptionalRpcTelemetryContext`, so transport-level | |
| 50 | − // headers are not needed for browser clients. | |
| 51 | − const httpClient = new ConvexHttpClient(url); | |
| 52 | − | |
| 53 | − const syncAuth = () => { | |
| 54 | − const auth = wsClient.getAuth(); | |
| 55 | − if (auth !== undefined) { | |
| 56 | − httpClient.setAuth(auth.token); | |
| 57 | − } | |
| 58 | − }; | |
| 59 | − | |
| 60 | − const service: ConvexClientService = { | |
| 61 | − query: <Query extends FunctionReference<"query">>( | |
| 62 | − query: Query, | |
| 63 | − args: Query["_args"], | |
| 64 | − _requestMetadata?: ConvexRequestMetadata, | |
| 65 | − ): Effect.Effect<FunctionReturnType<Query>> => | |
| 66 | − Effect.promise(() => { | |
| 67 | − syncAuth(); | |
| 68 | − return httpClient.query(query, args); | |
| 69 | − }), | |
| 70 | − | |
| 71 | − mutation: <Mutation extends FunctionReference<"mutation">>( | |
| 72 | − mutation: Mutation, | |
| 73 | − args: Mutation["_args"], | |
| 74 | − _requestMetadata?: ConvexRequestMetadata, | |
| 75 | − ): Effect.Effect<FunctionReturnType<Mutation>> => | |
| 76 | − Effect.promise(() => { | |
| 77 | − syncAuth(); | |
| 78 | − return httpClient.mutation(mutation, args); | |
| 79 | − }), | |
| 80 | − | |
| 81 | − action: <Action extends FunctionReference<"action">>( | |
| 82 | − action: Action, | |
78 more lines aren’t shown here.
This diff is large: its 684 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 777 lines aren’t shown here.
This diff is large: its 1106 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 1240 lines aren’t shown here.
This diff is large: its 676 lines aren’t shown here.
This diff is large: its 113 lines aren’t shown here.
This diff is large: its 140 lines aren’t shown here.
This diff is large: its 236 lines aren’t shown here.
This diff is large: its 401 lines aren’t shown here.
This diff is large: its 130 lines aren’t shown here.
This diff is large: its 248 lines aren’t shown here.
This diff is large: its 218 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 4 lines aren’t shown here.
This diff is large: its 81 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 1511 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 140 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 1816 lines aren’t shown here.
This diff is large: its 150 lines aren’t shown here.
File renamed without changes.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 6 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 102 lines aren’t shown here.
This diff is large: its 162 lines aren’t shown here.
This diff is large: its 212 lines aren’t shown here.
This diff is large: its 186 lines aren’t shown here.
This diff is large: its 309 lines aren’t shown here.
This diff is large: its 86 lines aren’t shown here.
This diff is large: its 189 lines aren’t shown here.