Unified · Split
| @@ -11,7 +11,7 @@ Executor today is optimized for enterprise/team correctness (workspaces, org flo · 10 unmodified lines | |||
| 11 | v2 changes the default: | 11 | v2 changes the default: |
| 12 | 12 | ||
| 13 | - First run is local and immediate (`npx executor <agent>` works fast). | 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 | - Cloud/self-host are upgrade targets, not prerequisites. | 15 | - Cloud/self-host are upgrade targets, not prerequisites. |
| 16 | - One architecture for local and remote via shared contracts and thin adapters. | 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 | ### Modes | 105 | ### Modes |
| 106 | 106 | ||
| 107 | - local-lite: PM daemon + in-memory hot state + file durability | 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 | ### Runtime targets | 110 | ### Runtime targets |
| 111 | 111 | ||
| @@ -131,19 +131,19 @@ Important boundary: · 19 unmodified lines | |||
| 131 | 5. Runtime resumes and completes. | 131 | 5. Runtime resumes and completes. |
| 132 | 6. Run/events persisted locally. | 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. | 136 | 1. Request starts run in control-plane API. |
| 137 | 2. Convex dispatches run to Cloudflare loader. | 137 | 2. Runtime dispatch executes through configured runtime adapter. |
| 138 | 3. Worker `tools.*` call hits Convex runtime callback action (`handleToolCall`). | 138 | 3. Worker `tools.*` call hits runtime callback action (`handleToolCall`). |
| 139 | 4. Callback action invokes shared tool invocation pipeline: | 139 | 4. Callback action invokes shared tool invocation pipeline: |
| 140 | - resolve tool | 140 | - resolve tool |
| 141 | - policy check | 141 | - policy check |
| 142 | - approval gating | 142 | - approval gating |
| 143 | - credential resolution from Convex/vault | 143 | - credential resolution from SQL-backed state/secret stores |
| 144 | - provider invoke | 144 | - provider invoke |
| 145 | 5. Worker resumes and returns terminal status. | 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 | Serverless note: | 148 | Serverless note: |
| 149 | - There is no long-lived in-memory `onToolCall` loop in cloud. | 149 | - There is no long-lived in-memory `onToolCall` loop in cloud. |
| @@ -281,12 +281,10 @@ v2/ · 131 unmodified lines | |||
| 281 | cli/ | 281 | cli/ |
| 282 | pm/ | 282 | pm/ |
| 283 | web/ | 283 | web/ |
| 284 | convex/ | ||
| 285 | packages/ | 284 | packages/ |
| 286 | confect/ | ||
| 287 | schema/ | 285 | schema/ |
| 288 | state-contracts/ | 286 | state-contracts/ |
| 289 | state-local-file/ | 287 | persistence-sql/ |
| 290 | domain/ | 288 | domain/ |
| 291 | approvals/ | 289 | approvals/ |
| 292 | source-manager/ | 290 | source-manager/ |
| @@ -309,9 +307,9 @@ Planned follow-up split (after behavior stabilizes): · 16 unmodified lines | |||
| 309 | ## 14) Dependency direction | 307 | ## 14) Dependency direction |
| 310 | 308 | ||
| 311 | ```text | 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 | schema + state-contracts + oauth + secrets -> domain | 314 | schema + state-contracts + oauth + secrets -> domain |
| 317 | domain -> approvals/source-manager/engine | 315 | domain -> approvals/source-manager/engine |
| @@ -319,8 +317,8 @@ source-manager + schema -> control-plane · 1 unmodified line | |||
| 319 | approvals/source-manager/engine -> mcp-gateway | 317 | approvals/source-manager/engine -> mcp-gateway |
| 320 | domain/features -> sdk -> ai-sdk-adapter | 318 | domain/features -> sdk -> ai-sdk-adapter |
| 321 | 319 | ||
| 322 | apps/pm -> mcp-gateway + domain + state-local-file + control-plane | 320 | apps/pm -> mcp-gateway + domain + persistence-sql + control-plane |
| 323 | apps/convex -> mcp-gateway + domain + control-plane | 321 | apps/web -> management-api + persistence-sql + control-plane routes |
| 324 | apps/cli/web -> sdk (and/or ai-sdk bridge) | 322 | apps/cli/web -> sdk (and/or ai-sdk bridge) |
| 325 | ``` | 323 | ``` |
| 326 | Rules: | 324 | Rules: |
| @@ -355,11 +353,11 @@ Rules: · 28 unmodified lines | |||
| 355 | 353 | ||
| 356 | - persisted pending/resume/deny flow | 354 | - persisted pending/resume/deny flow |
| 357 | - capability-aware UX fallbacks | 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 | ### Phase 4: remote parity | 358 | ### Phase 4: remote parity |
| 361 | 359 | ||
| 362 | - Convex adapter wiring for kernel and control | 360 | - SQL adapter wiring for kernel and control |
| 363 | - cloudflare loader integration through runtime dispatch adapter | 361 | - cloudflare loader integration through runtime dispatch adapter |
| 364 | - parity/conformance suite across local and remote | 362 | - parity/conformance suite across local and remote |
| 365 | 363 | ||
| @@ -395,10 +393,9 @@ Rules: · 29 unmodified lines | |||
| 395 | 393 | ||
| 396 | Implemented in `v2` so far: | 394 | Implemented in `v2` so far: |
| 397 | - monorepo app/package skeleton | 395 | - monorepo app/package skeleton |
| 398 | - `confect` imported from quickhub | ||
| 399 | - schema package scaffold with Effect models/event envelope | 396 | - schema package scaffold with Effect models/event envelope |
| 400 | - shared state contracts for source/tool artifacts | 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 | - source manager OpenAPI extraction + artifact refresh/reuse | 399 | - source manager OpenAPI extraction + artifact refresh/reuse |
| 403 | - provider registry contracts and routing in engine | 400 | - provider registry contracts and routing in engine |
| 404 | - OpenAPI provider invocation + in-memory provider path through registry | 401 | - OpenAPI provider invocation + in-memory provider path through registry |
| @@ -420,9 +417,8 @@ Still missing: · 15 unmodified lines | |||
| 420 | ## 19) Immediate next steps | 417 | ## 19) Immediate next steps |
| 421 | 418 | ||
| 422 | 1. Land shared `ToolInvocationService` contract in v2 and wire PM execute callback path through it. | 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 | 3. Implement control handlers for sources (`add/list/remove`) and route both host API and `tools.executor.*` through same code path. | 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 | 5. Add run API split for async + blocking convenience (`submit` + `wait/watch`) without duplicating execution logic. | 423 | 5. Add run API split for async + blocking convenience (`submit` + `wait/watch`) without duplicating execution logic. |
| 427 | 6. Add conformance tests that run same scenarios across local and remote adapters. | 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 | "@executor-v2/domain": "workspace:*", | 15 | "@executor-v2/domain": "workspace:*", |
| 16 | "@executor-v2/engine": "workspace:*", | 16 | "@executor-v2/engine": "workspace:*", |
| 17 | "@executor-v2/mcp-gateway": "workspace:*", | 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 | "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", | 20 | "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", |
| 20 | "@executor-v2/runtime-deno-subprocess": "workspace:*", | 21 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 21 | "@executor-v2/runtime-local-inproc": "workspace:*", | 22 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| @@ -8,17 +8,19 @@ import { · 7 unmodified lines | |||
| 8 | makeActor, | 8 | makeActor, |
| 9 | makeAllowAllActor, | 9 | makeAllowAllActor, |
| 10 | } from "@executor-v2/domain"; | 10 | } from "@executor-v2/domain"; |
| 11 | import { | 11 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; |
| 12 | type LocalStateSnapshot, | ||
| 13 | type LocalStateStore, | ||
| 14 | } from "@executor-v2/persistence-local"; | ||
| 15 | import { | 12 | import { |
| 16 | type OrganizationMembership, | 13 | type OrganizationMembership, |
| 14 | type Workspace, | ||
| 17 | type WorkspaceMembership, | 15 | type WorkspaceMembership, |
| 18 | } from "@executor-v2/schema"; | 16 | } from "@executor-v2/schema"; |
| 19 | import * as PlatformHeaders from "@effect/platform/Headers"; | 17 | import * as PlatformHeaders from "@effect/platform/Headers"; |
| 20 | import * as Effect from "effect/Effect"; | 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 | const isTruthy = (value: string | undefined): boolean => { | 25 | const isTruthy = (value: string | undefined): boolean => { |
| 24 | const normalized = value?.trim().toLowerCase(); | 26 | const normalized = value?.trim().toLowerCase(); |
| @@ -36,19 +38,19 @@ const localAdminFallbackEnabled = (() => { · 11 unmodified lines | |||
| 36 | })(); | 38 | })(); |
| 37 | 39 | ||
| 38 | const organizationMembershipsForAccount = ( | 40 | const organizationMembershipsForAccount = ( |
| 39 | snapshot: LocalStateSnapshot, | 41 | memberships: ReadonlyArray<OrganizationMembership>, |
| 40 | accountId: OrganizationMembership["accountId"], | 42 | accountId: OrganizationMembership["accountId"], |
| 41 | ): ReadonlyArray<OrganizationMembership> => | 43 | ): ReadonlyArray<OrganizationMembership> => |
| 42 | snapshot.organizationMemberships.filter( | 44 | memberships.filter( |
| 43 | (membership) => membership.accountId === accountId, | 45 | (membership) => membership.accountId === accountId, |
| 44 | ); | 46 | ); |
| 45 | 47 | ||
| 46 | const workspaceMembershipsForAccount = ( | 48 | const workspaceMembershipsForAccount = ( |
| 47 | snapshot: LocalStateSnapshot, | 49 | workspaces: ReadonlyArray<Workspace>, |
| 48 | accountId: OrganizationMembership["accountId"], | 50 | accountId: OrganizationMembership["accountId"], |
| 49 | organizationMemberships: ReadonlyArray<OrganizationMembership>, | 51 | organizationMemberships: ReadonlyArray<OrganizationMembership>, |
| 50 | ): ReadonlyArray<WorkspaceMembership> => | 52 | ): ReadonlyArray<WorkspaceMembership> => |
| 51 | snapshot.workspaces.flatMap((workspace) => | 53 | workspaces.flatMap((workspace) => |
| 52 | deriveWorkspaceMembershipsForPrincipal({ | 54 | deriveWorkspaceMembershipsForPrincipal({ |
| 53 | principalAccountId: accountId, | 55 | principalAccountId: accountId, |
| 54 | workspaceId: workspace.id, | 56 | workspaceId: workspace.id, |
| @@ -58,13 +60,16 @@ const workspaceMembershipsForAccount = ( · 3 unmodified lines | |||
| 58 | ); | 60 | ); |
| 59 | 61 | ||
| 60 | const resolveActorFromSnapshot = ( | 62 | const resolveActorFromSnapshot = ( |
| 61 | localStateStore: LocalStateStore, | 63 | rows: ActorRows, |
| 62 | headers: PlatformHeaders.Headers, | 64 | headers: PlatformHeaders.Headers, |
| 63 | ) => | 65 | ) => |
| 64 | Effect.gen(function* () { | 66 | Effect.gen(function* () { |
| 65 | const principal = yield* requirePrincipalFromHeaders(headers); | 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 | Effect.mapError( | 73 | Effect.mapError( |
| 69 | (error) => | 74 | (error) => |
| 70 | new ActorUnauthenticatedError({ | 75 | new ActorUnauthenticatedError({ |
| @@ -73,20 +78,19 @@ const resolveActorFromSnapshot = ( · 2 unmodified lines | |||
| 73 | ), | 78 | ), |
| 74 | ); | 79 | ); |
| 75 | 80 | ||
| 76 | const snapshot = Option.getOrNull(snapshotOption); | 81 | if (memberships.length === 0 && workspaces.length === 0 && localAdminFallbackEnabled) { |
| 77 | |||
| 78 | if (snapshot === null && localAdminFallbackEnabled) { | ||
| 79 | return makeAllowAllActor(principal); | 82 | return makeAllowAllActor(principal); |
| 80 | } | 83 | } |
| 81 | 84 | ||
| 82 | const organizationMemberships = | 85 | const organizationMemberships = organizationMembershipsForAccount( |
| 83 | snapshot === null | 86 | memberships, |
| 84 | ? [] | 87 | principal.accountId, |
| 85 | : organizationMembershipsForAccount(snapshot, principal.accountId); | 88 | ); |
| 86 | const workspaceMemberships = | 89 | const workspaceMemberships = workspaceMembershipsForAccount( |
| 87 | snapshot === null | 90 | workspaces, |
| 88 | ? [] | 91 | principal.accountId, |
| 89 | : workspaceMembershipsForAccount(snapshot, principal.accountId, organizationMemberships); | 92 | organizationMemberships, |
| 93 | ); | ||
| 90 | 94 | ||
| 91 | return yield* makeActor({ | 95 | return yield* makeActor({ |
| 92 | principal, | 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 | ControlPlaneActorResolverLive({ | 103 | ControlPlaneActorResolverLive({ |
| 100 | resolveActor: (input) => resolveActorFromSnapshot(localStateStore, input.headers), | 104 | resolveActor: (input) => resolveActorFromSnapshot(rows, input.headers), |
| 101 | resolveWorkspaceActor: (input) => | 105 | resolveWorkspaceActor: (input) => |
| 102 | Effect.gen(function* () { | 106 | Effect.gen(function* () { |
| 103 | const principal = yield* requirePrincipalFromHeaders(input.headers); | 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 | Effect.mapError( | 113 | Effect.mapError( |
| 107 | (error) => | 114 | (error) => |
| 108 | new ActorUnauthenticatedError({ | 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); | 121 | if (memberships.length === 0 && workspaces.length === 0 && localAdminFallbackEnabled) { |
| 115 | |||
| 116 | if (snapshot === null && localAdminFallbackEnabled) { | ||
| 117 | return makeAllowAllActor(principal); | 122 | return makeAllowAllActor(principal); |
| 118 | } | 123 | } |
| 119 | 124 | ||
| 120 | const organizationMemberships = | 125 | const organizationMemberships = organizationMembershipsForAccount( |
| 121 | snapshot === null | 126 | memberships, |
| 122 | ? [] | 127 | principal.accountId, |
| 123 | : organizationMembershipsForAccount(snapshot, principal.accountId); | 128 | ); |
| 124 | 129 | ||
| 125 | const workspace = | 130 | const workspace = workspaces.find((item) => item.id === input.workspaceId) ?? null; |
| 126 | snapshot === null | ||
| 127 | ? null | ||
| 128 | : snapshot.workspaces.find((item) => item.id === input.workspaceId) ?? null; | ||
| 129 | 131 | ||
| 130 | const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({ | 132 | const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({ |
| 131 | principalAccountId: principal.accountId, | 133 | principalAccountId: principal.accountId, |
| @@ -6,18 +6,15 @@ import { · 5 unmodified lines | |||
| 6 | type ToolApprovalPolicy, | 6 | type ToolApprovalPolicy, |
| 7 | } from "@executor-v2/engine"; | 7 | } from "@executor-v2/engine"; |
| 8 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | 8 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 9 | import { | 9 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; |
| 10 | type LocalStateSnapshot, | ||
| 11 | type LocalStateStore, | ||
| 12 | type LocalStateStoreError, | ||
| 13 | } from "@executor-v2/persistence-local"; | ||
| 14 | import { | 10 | import { |
| 15 | makeControlPlaneApprovalsService, | 11 | makeControlPlaneApprovalsService, |
| 16 | type ControlPlaneApprovalsServiceShape, | 12 | type ControlPlaneApprovalsServiceShape, |
| 17 | } from "@executor-v2/management-api"; | 13 | } from "@executor-v2/management-api"; |
| 18 | import { type Approval } from "@executor-v2/schema"; | 14 | import { type Approval } from "@executor-v2/schema"; |
| 19 | import * as Effect from "effect/Effect"; | 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 | const toSourceStoreError = ( | 19 | const toSourceStoreError = ( |
| 23 | operation: string, | 20 | operation: string, |
| @@ -26,16 +23,16 @@ const toSourceStoreError = ( · 2 unmodified lines | |||
| 26 | ): SourceStoreError => | 23 | ): SourceStoreError => |
| 27 | new SourceStoreError({ | 24 | new SourceStoreError({ |
| 28 | operation, | 25 | operation, |
| 29 | backend: "local-file", | 26 | backend: "sql", |
| 30 | location: "snapshot.json", | 27 | location: "approvals", |
| 31 | message, | 28 | message, |
| 32 | reason: null, | 29 | reason: null, |
| 33 | details, | 30 | details, |
| 34 | }); | 31 | }); |
| 35 | 32 | ||
| 36 | const toSourceStoreErrorFromLocalState = ( | 33 | const toSourceStoreErrorFromRowStore = ( |
| 37 | operation: string, | 34 | operation: string, |
| 38 | error: LocalStateStoreError, | 35 | error: { message: string; details: string | null; reason: string | null }, |
| 39 | ): SourceStoreError => | 36 | ): SourceStoreError => |
| 40 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | 37 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 41 | 38 | ||
| @@ -62,9 +59,9 @@ const toPersistentApprovalStoreError = ( · 20 unmodified lines | |||
| 62 | details, | 59 | details, |
| 63 | }); | 60 | }); |
| 64 | 61 | ||
| 65 | const toPersistentApprovalStoreErrorFromLocalState = ( | 62 | const toPersistentApprovalStoreErrorFromRowStore = ( |
| 66 | operation: string, | 63 | operation: string, |
| 67 | error: LocalStateStoreError, | 64 | error: { message: string; details: string | null; reason: string | null }, |
| 68 | ): PersistentToolApprovalPolicyStoreError => | 65 | ): PersistentToolApprovalPolicyStoreError => |
| 69 | toPersistentApprovalStoreError(operation, error.message, error.details ?? error.reason ?? null); | 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 | reason: approval.reason, | 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 | export type PmPersistentToolApprovalPolicyOptions = { | 78 | export type PmPersistentToolApprovalPolicyOptions = { |
| 97 | requireApprovals?: boolean; | 79 | requireApprovals?: boolean; |
| 98 | retryAfterMs?: number; | 80 | retryAfterMs?: number; |
| 99 | }; | 81 | }; |
| 100 | 82 | ||
| 101 | export const createPmPersistentToolApprovalPolicy = ( | 83 | export const createPmPersistentToolApprovalPolicy = ( |
| 102 | localStateStore: LocalStateStore, | 84 | rows: ApprovalRows, |
| 103 | options: PmPersistentToolApprovalPolicyOptions = {}, | 85 | options: PmPersistentToolApprovalPolicyOptions = {}, |
| 104 | ): ToolApprovalPolicy => { | 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 | const store: PersistentToolApprovalStore = { | 87 | const store: PersistentToolApprovalStore = { |
| 113 | findByRunAndCall: (input) => | 88 | findByRunAndCall: (input) => |
| 114 | localStateStore.getSnapshot().pipe( | 89 | rows.approvals.list().pipe( |
| 115 | Effect.mapError((error) => | 90 | Effect.mapError((error) => |
| 116 | toPersistentApprovalStoreErrorFromLocalState("approvals.read", error), | 91 | toPersistentApprovalStoreErrorFromRowStore("approvals.read", error), |
| 117 | ), | 92 | ), |
| 118 | Effect.flatMap((snapshotOption) => { | 93 | Effect.flatMap((approvals) => { |
| 119 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 120 | if (snapshot === null) { | ||
| 121 | return Effect.fail(missingSnapshotError("approvals.read")); | ||
| 122 | } | ||
| 123 | |||
| 124 | const approval = | 94 | const approval = |
| 125 | snapshot.approvals.find( | 95 | approvals.find( |
| 126 | (candidate) => | 96 | (candidate) => |
| 127 | candidate.workspaceId === input.workspaceId && | 97 | candidate.workspaceId === input.workspaceId && |
| 128 | candidate.taskRunId === input.runId && | 98 | candidate.taskRunId === input.runId && |
| @@ -134,16 +104,11 @@ export const createPmPersistentToolApprovalPolicy = ( · 5 unmodified lines | |||
| 134 | ), | 104 | ), |
| 135 | 105 | ||
| 136 | createPending: (input) => | 106 | createPending: (input) => |
| 137 | localStateStore.getSnapshot().pipe( | 107 | rows.approvals.list().pipe( |
| 138 | Effect.mapError((error) => | 108 | Effect.mapError((error) => |
| 139 | toPersistentApprovalStoreErrorFromLocalState("approvals.read", error), | 109 | toPersistentApprovalStoreErrorFromRowStore("approvals.read", error), |
| 140 | ), | 110 | ), |
| 141 | Effect.flatMap((snapshotOption) => { | 111 | Effect.flatMap(() => { |
| 142 | const snapshot = Option.getOrNull(snapshotOption); | ||
| 143 | if (snapshot === null) { | ||
| 144 | return Effect.fail(missingSnapshotError("approvals.create")); | ||
| 145 | } | ||
| 146 | |||
| 147 | const pendingApproval = { | 112 | const pendingApproval = { |
| 148 | id: `apr_${crypto.randomUUID()}`, | 113 | id: `apr_${crypto.randomUUID()}`, |
| 149 | workspaceId: input.workspaceId, | 114 | workspaceId: input.workspaceId, |
| @@ -157,15 +122,9 @@ export const createPmPersistentToolApprovalPolicy = ( · 7 unmodified lines | |||
| 157 | resolvedAt: null, | 122 | resolvedAt: null, |
| 158 | } as Approval; | 123 | } as Approval; |
| 159 | 124 | ||
| 160 | const nextSnapshot: LocalStateSnapshot = { | 125 | return rows.approvals.upsert(pendingApproval).pipe( |
| 161 | ...snapshot, | ||
| 162 | generatedAt: Date.now(), | ||
| 163 | approvals: [...snapshot.approvals, pendingApproval], | ||
| 164 | }; | ||
| 165 | |||
| 166 | return localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 167 | Effect.mapError((error) => | 126 | Effect.mapError((error) => |
| 168 | toPersistentApprovalStoreErrorFromLocalState("approvals.write", error), | 127 | toPersistentApprovalStoreErrorFromRowStore("approvals.write", error), |
| 169 | ), | 128 | ), |
| 170 | Effect.as(toPersistentApprovalRecord(pendingApproval)), | 129 | Effect.as(toPersistentApprovalRecord(pendingApproval)), |
| 171 | ); | 130 | ); |
| @@ -181,48 +140,34 @@ export const createPmPersistentToolApprovalPolicy = ( · 9 unmodified lines | |||
| 181 | }; | 140 | }; |
| 182 | 141 | ||
| 183 | export const createPmApprovalsService = ( | 142 | export const createPmApprovalsService = ( |
| 184 | localStateStore: LocalStateStore, | 143 | rows: ApprovalRows, |
| 185 | ): ControlPlaneApprovalsServiceShape => | 144 | ): ControlPlaneApprovalsServiceShape => |
| 186 | makeControlPlaneApprovalsService({ | 145 | makeControlPlaneApprovalsService({ |
| 187 | listApprovals: (workspaceId) => | 146 | listApprovals: (workspaceId) => |
| 188 | Effect.gen(function* () { | 147 | Effect.gen(function* () { |
| 189 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 148 | const approvals = yield* rows.approvals.list().pipe( |
| 190 | Effect.mapError((error) => | 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); | 154 | const scopedApprovals = approvals.filter( |
| 196 | if (snapshot === null) { | ||
| 197 | return []; | ||
| 198 | } | ||
| 199 | |||
| 200 | const approvals = snapshot.approvals.filter( | ||
| 201 | (approval) => approval.workspaceId === workspaceId, | 155 | (approval) => approval.workspaceId === workspaceId, |
| 202 | ); | 156 | ); |
| 203 | 157 | ||
| 204 | return sortApprovals(approvals); | 158 | return sortApprovals(scopedApprovals); |
| 205 | }), | 159 | }), |
| 206 | 160 | ||
| 207 | resolveApproval: (input) => | 161 | resolveApproval: (input) => |
| 208 | Effect.gen(function* () { | 162 | Effect.gen(function* () { |
| 209 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 163 | const approvals = yield* rows.approvals.list().pipe( |
| 210 | Effect.mapError((error) => | 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 | const index = findApprovalIndex( | 169 | const index = findApprovalIndex( |
| 225 | snapshot.approvals, | 170 | approvals, |
| 226 | input.workspaceId, | 171 | input.workspaceId, |
| 227 | input.approvalId, | 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 | if (approval.status !== "pending") { | 184 | if (approval.status !== "pending") { |
| 240 | return yield* toSourceStoreError( | 185 | return yield* toSourceStoreError( |
| 241 | "approvals.resolve", | 186 | "approvals.resolve", |
| @@ -251,11 +196,9 @@ export const createPmApprovalsService = ( · 9 unmodified lines | |||
| 251 | resolvedAt: Date.now(), | 196 | resolvedAt: Date.now(), |
| 252 | }; | 197 | }; |
| 253 | 198 | ||
| 254 | const nextSnapshot = updateApproval(snapshot, index, resolvedApproval); | 199 | yield* rows.approvals.upsert(resolvedApproval).pipe( |
| 255 | |||
| 256 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 257 | Effect.mapError((error) => | 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 | sourceIdFromSourceKey, | 5 | sourceIdFromSourceKey, |
| 6 | type ResolveToolCredentials, | 6 | type ResolveToolCredentials, |
| 7 | } from "@executor-v2/engine"; | 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 | import * as Effect from "effect/Effect"; | 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 | const toCredentialResolverError = ( | 30 | const toCredentialResolverError = ( |
| 13 | operation: string, | 31 | operation: string, |
| @@ -151,7 +169,7 @@ const buildSecretHeaders = ( · 137 unmodified lines | |||
| 151 | }; | 169 | }; |
| 152 | 170 | ||
| 153 | export const createPmResolveToolCredentials = ( | 171 | export const createPmResolveToolCredentials = ( |
| 154 | localStateStore: LocalStateStore, | 172 | rows: CredentialResolverRows, |
| 155 | ): ResolveToolCredentials => | 173 | ): ResolveToolCredentials => |
| 156 | makeCredentialResolver((input) => | 174 | makeCredentialResolver((input) => |
| 157 | Effect.gen(function* () { | 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 | Effect.mapError((error) => | 190 | Effect.mapError((error) => |
| 167 | toCredentialResolverError( | 191 | toCredentialResolverError( |
| 168 | "read_local_state_snapshot", | 192 | "read_credential_rows", |
| 169 | "Failed reading local snapshot while resolving credentials", | 193 | "Failed reading credential rows while resolving credentials", |
| 170 | error.details ?? error.message, | 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 | const sourceId = sourceIdFromSourceKey(context.sourceKey); | 199 | const sourceId = sourceIdFromSourceKey(context.sourceKey); |
| 183 | if (!sourceId) { | 200 | if (!sourceId) { |
| 184 | return { | 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 | (item) => item.id === context.workspaceId, | 207 | (item) => item.id === context.workspaceId, |
| 191 | ); | 208 | ); |
| 192 | const organizationId = context.organizationId ?? workspace?.organizationId ?? null; | 209 | const organizationId = context.organizationId ?? workspace?.organizationId ?? null; |
| 193 | 210 | ||
| 194 | const binding = snapshot.sourceAuthBindings | 211 | const binding = bindings |
| 195 | .filter((candidate) => candidate.sourceId === sourceId) | 212 | .filter((candidate) => candidate.sourceId === sourceId) |
| 196 | .map((candidate) => { | 213 | .map((candidate) => { |
| 197 | let score = -1; | 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 | (candidate) => candidate.id === binding.connectionId, | 251 | (candidate) => candidate.id === binding.connectionId, |
| 235 | ); | 252 | ); |
| 236 | if (!connection || connection.status !== "active") { | 253 | if (!connection || connection.status !== "active") { |
| @@ -244,7 +261,7 @@ export const createPmResolveToolCredentials = ( · 7 unmodified lines | |||
| 244 | ); | 261 | ); |
| 245 | 262 | ||
| 246 | if (connection.strategy === "oauth2") { | 263 | if (connection.strategy === "oauth2") { |
| 247 | const oauthState = snapshot.oauthStates.find( | 264 | const oauthState = oauthStates.find( |
| 248 | (candidate) => candidate.connectionId === connection.id, | 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 | (candidate) => candidate.connectionId === connection.id, | 279 | (candidate) => candidate.connectionId === connection.id, |
| 263 | ); | 280 | ); |
| 264 | 281 | ||
| @@ -1,9 +1,5 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | import { | 2 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; |
| 3 | type LocalStateSnapshot, | ||
| 4 | type LocalStateStore, | ||
| 5 | type LocalStateStoreError, | ||
| 6 | } from "@executor-v2/persistence-local"; | ||
| 7 | import { | 3 | import { |
| 8 | makeControlPlaneCredentialsService, | 4 | makeControlPlaneCredentialsService, |
| 9 | type ControlPlaneCredentialsServiceShape, | 5 | type ControlPlaneCredentialsServiceShape, |
| @@ -17,10 +13,19 @@ import { · 7 unmodified lines | |||
| 17 | type OrganizationId, | 13 | type OrganizationId, |
| 18 | type SourceAuthBinding, | 14 | type SourceAuthBinding, |
| 19 | type SourceCredentialBinding, | 15 | type SourceCredentialBinding, |
| 16 | type Workspace, | ||
| 20 | type WorkspaceId, | 17 | type WorkspaceId, |
| 21 | } from "@executor-v2/schema"; | 18 | } from "@executor-v2/schema"; |
| 22 | import * as Effect from "effect/Effect"; | 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 | const toSourceStoreError = ( | 30 | const toSourceStoreError = ( |
| 26 | operation: string, | 31 | operation: string, |
| @@ -29,24 +34,24 @@ const toSourceStoreError = ( · 2 unmodified lines | |||
| 29 | ): SourceStoreError => | 34 | ): SourceStoreError => |
| 30 | new SourceStoreError({ | 35 | new SourceStoreError({ |
| 31 | operation, | 36 | operation, |
| 32 | backend: "local-file", | 37 | backend: "sql", |
| 33 | location: "snapshot.json", | 38 | location: "credentials", |
| 34 | message, | 39 | message, |
| 35 | reason: null, | 40 | reason: null, |
| 36 | details, | 41 | details, |
| 37 | }); | 42 | }); |
| 38 | 43 | ||
| 39 | const toSourceStoreErrorFromLocalState = ( | 44 | const toSourceStoreErrorFromRowStore = ( |
| 40 | operation: string, | 45 | operation: string, |
| 41 | error: LocalStateStoreError, | 46 | error: { message: string; details: string | null; reason: string | null }, |
| 42 | ): SourceStoreError => | 47 | ): SourceStoreError => |
| 43 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | 48 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 44 | 49 | ||
| 45 | const resolveWorkspaceOrganizationId = ( | 50 | const resolveWorkspaceOrganizationId = ( |
| 46 | snapshot: LocalStateSnapshot, | 51 | workspaces: ReadonlyArray<Workspace>, |
| 47 | workspaceId: WorkspaceId, | 52 | workspaceId: WorkspaceId, |
| 48 | ): OrganizationId => { | 53 | ): OrganizationId => { |
| 49 | const workspace = snapshot.workspaces.find((item) => item.id === workspaceId); | 54 | const workspace = workspaces.find((item) => item.id === workspaceId); |
| 50 | 55 | ||
| 51 | if (!workspace) { | 56 | if (!workspace) { |
| 52 | throw new Error(`Workspace not found: ${workspaceId}`); | 57 | throw new Error(`Workspace not found: ${workspaceId}`); |
| @@ -139,21 +144,6 @@ const sortCredentialBindings = ( · 86 unmodified lines | |||
| 139 | return leftKey.localeCompare(rightKey); | 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 | const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { | 147 | const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => { |
| 158 | if (!value) { | 148 | if (!value) { |
| 159 | return {}; | 149 | return {}; |
| @@ -245,88 +235,33 @@ const buildOAuthRefreshConfigFromPayload = ( · 85 unmodified lines | |||
| 245 | ?? existing.clientInformationJson, | 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 | export const createPmCredentialsService = ( | 238 | export const createPmCredentialsService = ( |
| 311 | localStateStore: LocalStateStore, | 239 | rows: CredentialRows, |
| 312 | ): ControlPlaneCredentialsServiceShape => | 240 | ): ControlPlaneCredentialsServiceShape => |
| 313 | makeControlPlaneCredentialsService({ | 241 | makeControlPlaneCredentialsService({ |
| 314 | listCredentialBindings: (workspaceId) => | 242 | listCredentialBindings: (workspaceId) => |
| 315 | Effect.gen(function* () { | 243 | Effect.gen(function* () { |
| 316 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 244 | const [bindings, connections, workspaces] = yield* Effect.all([ |
| 317 | Effect.mapError((error) => | 245 | rows.sourceAuthBindings.list().pipe( |
| 318 | toSourceStoreErrorFromLocalState("credentials.list", error), | 246 | Effect.mapError((error) => |
| 247 | toSourceStoreErrorFromRowStore("credentials.bindings.list", error), | ||
| 248 | ), | ||
| 319 | ), | 249 | ), |
| 320 | ); | 250 | rows.authConnections.list().pipe( |
| 321 | 251 | Effect.mapError((error) => | |
| 322 | const snapshot = Option.getOrNull(snapshotOption); | 252 | toSourceStoreErrorFromRowStore("credentials.connections.list", error), |
| 323 | if (snapshot === null) { | 253 | ), |
| 324 | return []; | 254 | ), |
| 325 | } | 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 | (binding) => | 265 | (binding) => |
| 331 | binding.workspaceId === workspaceId | 266 | binding.workspaceId === workspaceId |
| 332 | || (binding.workspaceId === null && binding.organizationId === organizationId), | 267 | || (binding.workspaceId === null && binding.organizationId === organizationId), |
| @@ -335,7 +270,7 @@ export const createPmCredentialsService = ( · 2 unmodified lines | |||
| 335 | const compatBindings: Array<SourceCredentialBinding> = []; | 270 | const compatBindings: Array<SourceCredentialBinding> = []; |
| 336 | 271 | ||
| 337 | for (const binding of scopedBindings) { | 272 | for (const binding of scopedBindings) { |
| 338 | const connection = snapshot.authConnections.find( | 273 | const connection = connections.find( |
| 339 | (candidate) => candidate.id === binding.connectionId, | 274 | (candidate) => candidate.id === binding.connectionId, |
| 340 | ); | 275 | ); |
| 341 | 276 | ||
| @@ -351,20 +286,33 @@ export const createPmCredentialsService = ( · 9 unmodified lines | |||
| 351 | 286 | ||
| 352 | upsertCredentialBinding: (input) => | 287 | upsertCredentialBinding: (input) => |
| 353 | Effect.gen(function* () { | 288 | Effect.gen(function* () { |
| 354 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 289 | const [bindings, connections, materials, oauthStates, workspaces] = yield* Effect.all([ |
| 355 | Effect.mapError((error) => | 290 | rows.sourceAuthBindings.list().pipe( |
| 356 | toSourceStoreErrorFromLocalState("credentials.upsert", error), | 291 | Effect.mapError((error) => |
| 292 | toSourceStoreErrorFromRowStore("credentials.bindings.list", error), | ||
| 293 | ), | ||
| 357 | ), | 294 | ), |
| 358 | ); | 295 | rows.authConnections.list().pipe( |
| 359 | 296 | Effect.mapError((error) => | |
| 360 | const snapshot = Option.getOrNull(snapshotOption); | 297 | toSourceStoreErrorFromRowStore("credentials.connections.list", error), |
| 361 | if (snapshot === null) { | 298 | ), |
| 362 | return yield* toSourceStoreError( | 299 | ), |
| 363 | "credentials.upsert", | 300 | rows.authMaterials.list().pipe( |
| 364 | "Credential snapshot not found", | 301 | Effect.mapError((error) => |
| 365 | `workspace=${input.workspaceId}`, | 302 | toSourceStoreErrorFromRowStore("credentials.materials.list", error), |
| 366 | ); | 303 | ), |
| 367 | } | 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 | if (input.payload.scopeType === "account" && input.payload.accountId === null) { | 317 | if (input.payload.scopeType === "account" && input.payload.accountId === null) { |
| 370 | return yield* toSourceStoreError( | 318 | return yield* toSourceStoreError( |
| @@ -388,10 +336,10 @@ export const createPmCredentialsService = ( · 17 unmodified lines | |||
| 388 | const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined; | 336 | const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined; |
| 389 | 337 | ||
| 390 | const existingBinding = requestedBindingId | 338 | const existingBinding = requestedBindingId |
| 391 | ? snapshot.sourceAuthBindings.find((binding) => binding.id === requestedBindingId) ?? null | 339 | ? bindings.find((binding) => binding.id === requestedBindingId) ?? null |
| 392 | : null; | 340 | : null; |
| 393 | 341 | ||
| 394 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | 342 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 395 | 343 | ||
| 396 | const scopeWorkspaceId = | 344 | const scopeWorkspaceId = |
| 397 | input.payload.scopeType === "workspace" ? input.workspaceId : null; | 345 | input.payload.scopeType === "workspace" ? input.workspaceId : null; |
| @@ -410,7 +358,7 @@ export const createPmCredentialsService = ( · 12 unmodified lines | |||
| 410 | ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"]) | 358 | ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"]) |
| 411 | ) as AuthConnection["id"]; | 359 | ) as AuthConnection["id"]; |
| 412 | 360 | ||
| 413 | const existingConnection = snapshot.authConnections.find( | 361 | const existingConnection = connections.find( |
| 414 | (connection) => connection.id === requestedConnectionId, | 362 | (connection) => connection.id === requestedConnectionId, |
| 415 | ) ?? null; | 363 | ) ?? null; |
| 416 | 364 | ||
| @@ -465,15 +413,17 @@ export const createPmCredentialsService = ( · 48 unmodified lines | |||
| 465 | updatedAt: now, | 413 | updatedAt: now, |
| 466 | }; | 414 | }; |
| 467 | 415 | ||
| 468 | let nextSnapshot: LocalStateSnapshot = { | 416 | yield* Effect.all([ |
| 469 | ...snapshot, | 417 | rows.authConnections.upsert(nextConnection), |
| 470 | generatedAt: now, | 418 | rows.sourceAuthBindings.upsert(nextBinding), |
| 471 | authConnections: upsertById(snapshot.authConnections, nextConnection), | 419 | ]).pipe( |
| 472 | sourceAuthBindings: upsertById(snapshot.sourceAuthBindings, nextBinding), | 420 | Effect.mapError((error) => |
| 473 | }; | 421 | toSourceStoreErrorFromRowStore("credentials.upsert_rows", error), |
| 422 | ), | ||
| 423 | ); | ||
| 474 | 424 | ||
| 475 | if (nextConnection.strategy === "oauth2") { | 425 | if (nextConnection.strategy === "oauth2") { |
| 476 | const existingOAuth = snapshot.oauthStates.find( | 426 | const existingOAuth = oauthStates.find( |
| 477 | (state) => state.connectionId === requestedConnectionId, | 427 | (state) => state.connectionId === requestedConnectionId, |
| 478 | ) ?? null; | 428 | ) ?? null; |
| 479 | const refreshConfig = buildOAuthRefreshConfigFromPayload( | 429 | const refreshConfig = buildOAuthRefreshConfigFromPayload( |
| @@ -518,15 +468,16 @@ export const createPmCredentialsService = ( · 38 unmodified lines | |||
| 518 | updatedAt: now, | 468 | updatedAt: now, |
| 519 | }; | 469 | }; |
| 520 | 470 | ||
| 521 | nextSnapshot = { | 471 | yield* Effect.all([ |
| 522 | ...nextSnapshot, | 472 | rows.oauthStates.upsert(oauthState), |
| 523 | authMaterials: nextSnapshot.authMaterials.filter( | 473 | rows.authMaterials.removeByConnectionId(requestedConnectionId), |
| 524 | (material) => material.connectionId !== requestedConnectionId, | 474 | ]).pipe( |
| 475 | Effect.mapError((error) => | ||
| 476 | toSourceStoreErrorFromRowStore("credentials.upsert_oauth", error), | ||
| 525 | ), | 477 | ), |
| 526 | oauthStates: upsertById(nextSnapshot.oauthStates, oauthState), | 478 | ); |
| 527 | }; | ||
| 528 | } else { | 479 | } else { |
| 529 | const existingMaterial = snapshot.authMaterials.find( | 480 | const existingMaterial = materials.find( |
| 530 | (material) => material.connectionId === requestedConnectionId, | 481 | (material) => material.connectionId === requestedConnectionId, |
| 531 | ) ?? null; | 482 | ) ?? null; |
| 532 | 483 | ||
| @@ -541,57 +492,81 @@ export const createPmCredentialsService = ( · 8 unmodified lines | |||
| 541 | updatedAt: now, | 492 | updatedAt: now, |
| 542 | }; | 493 | }; |
| 543 | 494 | ||
| 544 | nextSnapshot = { | 495 | yield* Effect.all([ |
| 545 | ...nextSnapshot, | 496 | rows.authMaterials.upsert(material), |
| 546 | authMaterials: upsertById(nextSnapshot.authMaterials, material), | 497 | rows.oauthStates.removeByConnectionId(requestedConnectionId), |
| 547 | oauthStates: nextSnapshot.oauthStates.filter( | 498 | ]).pipe( |
| 548 | (state) => state.connectionId !== requestedConnectionId, | 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 | return toCompatSourceCredentialBinding(nextBinding, nextConnection); | 505 | return toCompatSourceCredentialBinding(nextBinding, nextConnection); |
| 560 | }), | 506 | }), |
| 561 | 507 | ||
| 562 | removeCredentialBinding: (input) => | 508 | removeCredentialBinding: (input) => |
| 563 | Effect.gen(function* () { | 509 | Effect.gen(function* () { |
| 564 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 510 | const [bindings, workspaces] = yield* Effect.all([ |
| 565 | Effect.mapError((error) => | 511 | rows.sourceAuthBindings.list().pipe( |
| 566 | toSourceStoreErrorFromLocalState("credentials.remove", error), | 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); | 533 | if (!binding) { |
| 571 | if (snapshot === null) { | ||
| 572 | return { | 534 | return { |
| 573 | removed: false, | 535 | removed: false, |
| 574 | }; | 536 | }; |
| 575 | } | 537 | } |
| 576 | 538 | ||
| 577 | const next = removeCredentialBindingForWorkspace( | 539 | const removed = yield* rows.sourceAuthBindings |
| 578 | snapshot, | 540 | .removeById(binding.id) |
| 579 | input.workspaceId, | 541 | .pipe( |
| 580 | input.credentialBindingId, | 542 | Effect.mapError((error) => |
| 581 | ); | 543 | toSourceStoreErrorFromRowStore("credentials.remove_binding", error), |
| 544 | ), | ||
| 545 | ); | ||
| 582 | 546 | ||
| 583 | if (!next.removed) { | 547 | if (!removed) { |
| 584 | return { | 548 | return { |
| 585 | removed: false, | 549 | removed: false, |
| 586 | }; | 550 | }; |
| 587 | } | 551 | } |
| 588 | 552 | ||
| 589 | yield* localStateStore.writeSnapshot(next.snapshot).pipe( | 553 | const hasRemainingBindings = bindings.some( |
| 590 | Effect.mapError((error) => | 554 | (candidate) => |
| 591 | toSourceStoreErrorFromLocalState("credentials.remove_write", error), | 555 | candidate.id !== binding.id && candidate.connectionId === binding.connectionId, |
| 592 | ), | ||
| 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 | return { | 570 | return { |
| 596 | removed: true, | 571 | removed: true, |
| 597 | }; | 572 | }; |
| @@ -1,4 +1,3 @@ | |||
| 1 | import * as BunContext from "@effect/platform-bun/BunContext"; | ||
| 2 | import { | 1 | import { |
| 3 | ControlPlaneService, | 2 | ControlPlaneService, |
| 4 | makeControlPlaneService, | 3 | makeControlPlaneService, |
| @@ -22,16 +21,16 @@ import { · 17 unmodified lines | |||
| 22 | parseExecuteToolExposureMode, | 21 | parseExecuteToolExposureMode, |
| 23 | } from "@executor-v2/engine"; | 22 | } from "@executor-v2/engine"; |
| 24 | import { | 23 | import { |
| 25 | makeLocalSourceStore, | 24 | makeSqlControlPlanePersistence, |
| 26 | makeLocalStateStore, | 25 | type SqlControlPlanePersistence, |
| 27 | makeLocalToolArtifactStore, | 26 | } from "@executor-v2/persistence-sql"; |
| 28 | } from "@executor-v2/persistence-local"; | ||
| 29 | import { type RuntimeToolCallResult } from "@executor-v2/sdk"; | 27 | import { type RuntimeToolCallResult } from "@executor-v2/sdk"; |
| 30 | import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; | 28 | import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; |
| 31 | import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess"; | 29 | import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess"; |
| 32 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | 30 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; |
| 33 | import * as Effect from "effect/Effect"; | 31 | import * as Effect from "effect/Effect"; |
| 34 | import * as Layer from "effect/Layer"; | 32 | import * as Layer from "effect/Layer"; |
| 33 | import * as path from "node:path"; | ||
| 35 | 34 | ||
| 36 | import { PmActorLive } from "./actor"; | 35 | import { PmActorLive } from "./actor"; |
| 37 | import { | 36 | import { |
| @@ -91,6 +90,75 @@ const defaultToolExposureMode = readConfiguredToolExposureMode( · 53 unmodified lines | |||
| 91 | process.env.PM_TOOL_EXPOSURE_MODE, | 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 | const pmRuntimeAdapters = [ | 162 | const pmRuntimeAdapters = [ |
| 95 | makeLocalInProcessRuntimeAdapter(), | 163 | makeLocalInProcessRuntimeAdapter(), |
| 96 | makeDenoSubprocessRuntimeAdapter(), | 164 | makeDenoSubprocessRuntimeAdapter(), |
| @@ -101,23 +169,20 @@ const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters); · 4 unmodified lines | |||
| 101 | const defaultRuntimeKind = | 169 | const defaultRuntimeKind = |
| 102 | readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind; | 170 | readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind; |
| 103 | 171 | ||
| 104 | const sourceStore = await Effect.runPromise( | 172 | const persistence: SqlControlPlanePersistence = await Effect.runPromise( |
| 105 | makeLocalSourceStore({ | 173 | makeSqlControlPlanePersistence({ |
| 106 | rootDir: pmStateRootDir, | 174 | databaseUrl: process.env.PM_CONTROL_PLANE_DATABASE_URL, |
| 107 | }).pipe(Effect.provide(BunContext.layer)), | 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( | 182 | const sourceStore = persistence.sourceStore; |
| 111 | makeLocalStateStore({ | 183 | const toolArtifactStore = persistence.toolArtifactStore; |
| 112 | rootDir: pmStateRootDir, | ||
| 113 | }).pipe(Effect.provide(BunContext.layer)), | ||
| 114 | ); | ||
| 115 | 184 | ||
| 116 | const toolArtifactStore = await Effect.runPromise( | 185 | await Effect.runPromise(ensurePmBootstrap(persistence)); |
| 117 | makeLocalToolArtifactStore({ | ||
| 118 | rootDir: pmStateRootDir, | ||
| 119 | }).pipe(Effect.provide(BunContext.layer)), | ||
| 120 | ); | ||
| 121 | 186 | ||
| 122 | const sourceCatalog = makeSourceCatalogService(sourceStore); | 187 | const sourceCatalog = makeSourceCatalogService(sourceStore); |
| 123 | const sourceManager = makeSourceManagerService(toolArtifactStore); | 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); | 220 | const credentialsService = createPmCredentialsService(persistence.rows); |
| 156 | const policiesService = createPmPoliciesService(localStateStore); | 221 | const policiesService = createPmPoliciesService(persistence.rows); |
| 157 | const organizationsService = createPmOrganizationsService(localStateStore); | 222 | const organizationsService = createPmOrganizationsService(persistence.rows); |
| 158 | const workspacesService = createPmWorkspacesService(localStateStore); | 223 | const workspacesService = createPmWorkspacesService(persistence.rows); |
| 159 | const toolsService = createPmToolsService(sourceStore, toolArtifactStore); | 224 | const toolsService = createPmToolsService(sourceStore, toolArtifactStore); |
| 160 | const storageService = createPmStorageService(localStateStore, { | 225 | const storageService = createPmStorageService(persistence.rows, { |
| 161 | stateRootDir: pmStateRootDir, | 226 | stateRootDir: pmStateRootDir, |
| 162 | }); | 227 | }); |
| 163 | const approvalsService = createPmApprovalsService(localStateStore); | 228 | const approvalsService = createPmApprovalsService(persistence.rows); |
| 164 | const controlPlaneService = makeControlPlaneService({ | 229 | const controlPlaneService = makeControlPlaneService({ |
| 165 | sources: sourcesService, | 230 | sources: sourcesService, |
| 166 | credentials: credentialsService, | 231 | credentials: credentialsService, |
| @@ -174,7 +239,7 @@ const controlPlaneService = makeControlPlaneService({ · 7 unmodified lines | |||
| 174 | 239 | ||
| 175 | const controlPlaneWebHandler = makeControlPlaneWebHandler( | 240 | const controlPlaneWebHandler = makeControlPlaneWebHandler( |
| 176 | Layer.succeed(ControlPlaneService, controlPlaneService), | 241 | Layer.succeed(ControlPlaneService, controlPlaneService), |
| 177 | PmActorLive(localStateStore), | 242 | PmActorLive(persistence.rows), |
| 178 | ); | 243 | ); |
| 179 | 244 | ||
| 180 | const toolProviderRegistry = makeToolProviderRegistry([ | 245 | const toolProviderRegistry = makeToolProviderRegistry([ |
| @@ -182,7 +247,7 @@ const toolProviderRegistry = makeToolProviderRegistry([ · 1 unmodified line | |||
| 182 | makeMcpToolProvider(), | 247 | makeMcpToolProvider(), |
| 183 | makeGraphqlToolProvider(), | 248 | makeGraphqlToolProvider(), |
| 184 | ]); | 249 | ]); |
| 185 | const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(localStateStore, { | 250 | const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(persistence.rows, { |
| 186 | requireApprovals: requireToolApprovals, | 251 | requireApprovals: requireToolApprovals, |
| 187 | }); | 252 | }); |
| 188 | const toolRegistry = createSourceToolRegistry({ | 253 | const toolRegistry = createSourceToolRegistry({ |
| @@ -228,6 +293,7 @@ const server = startPmHttpServer({ · 39 unmodified lines | |||
| 228 | const shutdown = async () => { | 293 | const shutdown = async () => { |
| 229 | server.stop(); | 294 | server.stop(); |
| 230 | await controlPlaneWebHandler.dispose(); | 295 | await controlPlaneWebHandler.dispose(); |
| 296 | await persistence.close(); | ||
| 231 | }; | 297 | }; |
| 232 | 298 | ||
| 233 | process.on("SIGINT", () => { | 299 | process.on("SIGINT", () => { |
| @@ -1,19 +1,19 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | ||
| 2 | import { | 3 | import { |
| 3 | makeControlPlaneOrganizationsService, | 4 | makeControlPlaneOrganizationsService, |
| 4 | type ControlPlaneOrganizationsServiceShape, | 5 | type ControlPlaneOrganizationsServiceShape, |
| 5 | } from "@executor-v2/management-api"; | 6 | } from "@executor-v2/management-api"; |
| 6 | import { | ||
| 7 | type LocalStateSnapshot, | ||
| 8 | type LocalStateStore, | ||
| 9 | type LocalStateStoreError, | ||
| 10 | } from "@executor-v2/persistence-local"; | ||
| 11 | import { | 7 | import { |
| 12 | type Organization, | 8 | type Organization, |
| 13 | type OrganizationMembership, | 9 | type OrganizationMembership, |
| 14 | } from "@executor-v2/schema"; | 10 | } from "@executor-v2/schema"; |
| 15 | import * as Effect from "effect/Effect"; | 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 | const toSourceStoreError = ( | 18 | const toSourceStoreError = ( |
| 19 | operation: string, | 19 | operation: string, |
| @@ -22,16 +22,16 @@ const toSourceStoreError = ( · 2 unmodified lines | |||
| 22 | ): SourceStoreError => | 22 | ): SourceStoreError => |
| 23 | new SourceStoreError({ | 23 | new SourceStoreError({ |
| 24 | operation, | 24 | operation, |
| 25 | backend: "local-file", | 25 | backend: "sql", |
| 26 | location: "snapshot.json", | 26 | location: "organizations", |
| 27 | message, | 27 | message, |
| 28 | reason: null, | 28 | reason: null, |
| 29 | details, | 29 | details, |
| 30 | }); | 30 | }); |
| 31 | 31 | ||
| 32 | const toSourceStoreErrorFromLocalState = ( | 32 | const toSourceStoreErrorFromRowStore = ( |
| 33 | operation: string, | 33 | operation: string, |
| 34 | error: LocalStateStoreError, | 34 | error: { message: string; details: string | null; reason: string | null }, |
| 35 | ): SourceStoreError => | 35 | ): SourceStoreError => |
| 36 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | 36 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); |
| 37 | 37 | ||
| @@ -49,83 +49,45 @@ const sortOrganizations = ( · 11 unmodified lines | |||
| 49 | return leftName.localeCompare(rightName); | 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 | export const createPmOrganizationsService = ( | 52 | export const createPmOrganizationsService = ( |
| 86 | localStateStore: LocalStateStore, | 53 | rows: OrganizationRows, |
| 87 | ): ControlPlaneOrganizationsServiceShape => | 54 | ): ControlPlaneOrganizationsServiceShape => |
| 88 | makeControlPlaneOrganizationsService({ | 55 | makeControlPlaneOrganizationsService({ |
| 89 | listOrganizations: () => | 56 | listOrganizations: () => |
| 90 | Effect.gen(function* () { | 57 | Effect.gen(function* () { |
| 91 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 58 | const organizations = yield* rows.organizations.list().pipe( |
| 92 | Effect.mapError((error) => | 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); | 64 | return sortOrganizations(organizations); |
| 98 | if (snapshot === null) { | ||
| 99 | return []; | ||
| 100 | } | ||
| 101 | |||
| 102 | return sortOrganizations(snapshot.organizations); | ||
| 103 | }), | 65 | }), |
| 104 | 66 | ||
| 105 | upsertOrganization: (input) => | 67 | upsertOrganization: (input) => |
| 106 | Effect.gen(function* () { | 68 | Effect.gen(function* () { |
| 107 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 69 | const organizations = yield* rows.organizations.list().pipe( |
| 108 | Effect.mapError((error) => | 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); | 75 | const memberships = yield* rows.organizationMemberships.list().pipe( |
| 114 | if (snapshot === null) { | 76 | Effect.mapError((error) => |
| 115 | return yield* toSourceStoreError( | 77 | toSourceStoreErrorFromRowStore( |
| 116 | "organizations.upsert", | 78 | "organizations.memberships.list", |
| 117 | "Organization snapshot not found", | 79 | error, |
| 118 | null, | 80 | ), |
| 119 | ); | 81 | ), |
| 120 | } | 82 | ); |
| 121 | 83 | ||
| 122 | const now = Date.now(); | 84 | const now = Date.now(); |
| 123 | const existingIndex = input.payload.id | 85 | const existingIndex = input.payload.id |
| 124 | ? snapshot.organizations.findIndex((organization) => organization.id === input.payload.id) | 86 | ? organizations.findIndex((organization) => organization.id === input.payload.id) |
| 125 | : snapshot.organizations.findIndex( | 87 | : organizations.findIndex( |
| 126 | (organization) => organization.slug === input.payload.slug, | 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 | const nextOrganization: Organization = { | 92 | const nextOrganization: Organization = { |
| 131 | id: | 93 | id: |
| @@ -142,19 +104,21 @@ export const createPmOrganizationsService = ( · 10 unmodified lines | |||
| 142 | updatedAt: now, | 104 | updatedAt: now, |
| 143 | }; | 105 | }; |
| 144 | 106 | ||
| 145 | let nextSnapshot = existingIndex >= 0 | 107 | yield* rows.organizations.upsert(nextOrganization).pipe( |
| 146 | ? replaceOrganizationAt(snapshot, existingIndex, nextOrganization) | 108 | Effect.mapError((error) => |
| 147 | : appendOrganization(snapshot, nextOrganization); | 109 | toSourceStoreErrorFromRowStore("organizations.upsert_write", error), |
| 110 | ), | ||
| 111 | ); | ||
| 148 | 112 | ||
| 149 | if (existing === null && nextOrganization.createdByAccountId !== null) { | 113 | if (existing === null && nextOrganization.createdByAccountId !== null) { |
| 150 | const existingMembership = nextSnapshot.organizationMemberships.find( | 114 | const existingMembership = memberships.find( |
| 151 | (membership) => | 115 | (membership) => |
| 152 | membership.organizationId === nextOrganization.id | 116 | membership.organizationId === nextOrganization.id |
| 153 | && membership.accountId === nextOrganization.createdByAccountId, | 117 | && membership.accountId === nextOrganization.createdByAccountId, |
| 154 | ); | 118 | ); |
| 155 | 119 | ||
| 156 | if (!existingMembership) { | 120 | if (!existingMembership) { |
| 157 | nextSnapshot = appendOrganizationMembership(nextSnapshot, { | 121 | const membership: OrganizationMembership = { |
| 158 | id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"], | 122 | id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"], |
| 159 | organizationId: nextOrganization.id, | 123 | organizationId: nextOrganization.id, |
| 160 | accountId: nextOrganization.createdByAccountId, | 124 | accountId: nextOrganization.createdByAccountId, |
| @@ -165,16 +129,19 @@ export const createPmOrganizationsService = ( · 4 unmodified lines | |||
| 165 | joinedAt: now, | 129 | joinedAt: now, |
| 166 | createdAt: now, | 130 | createdAt: now, |
| 167 | updatedAt: now, | 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 | return nextOrganization; | 145 | return nextOrganization; |
| 179 | }), | 146 | }), |
| 180 | }); | 147 | }); |
| @@ -1,60 +1,34 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { | ||
| 3 | LocalStateSnapshot, | ||
| 4 | LocalStateStore, | ||
| 5 | } from "@executor-v2/persistence-local"; | ||
| 6 | import * as Effect from "effect/Effect"; | 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 | import { | 5 | import { |
| 10 | createPmApprovalsService, | 6 | createPmApprovalsService, |
| 11 | createPmPersistentToolApprovalPolicy, | 7 | createPmPersistentToolApprovalPolicy, |
| 12 | } from "./approvals-service"; | 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 | describe("PM persistent approval policy", () => { | 10 | describe("PM persistent approval policy", () => { |
| 43 | it.effect("creates pending approval records and reuses resolved decisions", () => | 11 | it.effect("creates pending approval records and reuses resolved decisions", () => |
| 44 | Effect.gen(function* () { | 12 | Effect.gen(function* () { |
| 45 | let snapshot: LocalStateSnapshot = createTestSnapshot(); | 13 | const approvals: Array<Approval> = []; |
| 46 | 14 | ||
| 47 | const localStateStore: LocalStateStore = { | 15 | const approvalRows = { |
| 48 | getSnapshot: () => Effect.succeed(Option.some(snapshot)), | 16 | approvals: { |
| 49 | writeSnapshot: (nextSnapshot) => | 17 | list: () => Effect.succeed(approvals), |
| 50 | Effect.sync(() => { | 18 | upsert: (approval: Approval) => |
| 51 | snapshot = nextSnapshot; | 19 | Effect.sync(() => { |
| 52 | }), | 20 | const index = approvals.findIndex((item) => item.id === approval.id); |
| 53 | readEvents: () => Effect.succeed([]), | 21 | if (index >= 0) { |
| 54 | appendEvents: () => Effect.void, | 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 | requireApprovals: true, | 32 | requireApprovals: true, |
| 59 | retryAfterMs: 250, | 33 | retryAfterMs: 250, |
| 60 | }); | 34 | }); |
| @@ -80,9 +54,9 @@ describe("PM persistent approval policy", () => { · 19 unmodified lines | |||
| 80 | if (first.kind === "pending") { | 54 | if (first.kind === "pending") { |
| 81 | expect(first.retryAfterMs).toBe(250); | 55 | expect(first.retryAfterMs).toBe(250); |
| 82 | } | 56 | } |
| 83 | expect(snapshot.approvals).toHaveLength(1); | 57 | expect(approvals).toHaveLength(1); |
| 84 | expect(snapshot.approvals[0]?.status).toBe("pending"); | 58 | expect(approvals[0]?.status).toBe("pending"); |
| 85 | expect(snapshot.approvals[0]?.callId).toBe("call_approval_1"); | 59 | expect(approvals[0]?.callId).toBe("call_approval_1"); |
| 86 | 60 | ||
| 87 | const second = yield* Effect.promise(() => | 61 | const second = yield* Effect.promise(() => |
| 88 | Promise.resolve( | 62 | Promise.resolve( |
| @@ -98,10 +72,10 @@ describe("PM persistent approval policy", () => { · 9 unmodified lines | |||
| 98 | ); | 72 | ); |
| 99 | 73 | ||
| 100 | expect(second).toEqual(first); | 74 | expect(second).toEqual(first); |
| 101 | expect(snapshot.approvals).toHaveLength(1); | 75 | expect(approvals).toHaveLength(1); |
| 102 | 76 | ||
| 103 | const approvalsService = createPmApprovalsService(localStateStore); | 77 | const approvalsService = createPmApprovalsService(approvalRows); |
| 104 | const createdApproval = snapshot.approvals[0]; | 78 | const createdApproval = approvals[0]; |
| 105 | if (!createdApproval) { | 79 | if (!createdApproval) { |
| 106 | throw new Error("expected pending approval record"); | 80 | throw new Error("expected pending approval record"); |
| 107 | } | 81 | } |
| @@ -1,16 +1,13 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | import { | 2 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; |
| 3 | type LocalStateSnapshot, | ||
| 4 | type LocalStateStore, | ||
| 5 | type LocalStateStoreError, | ||
| 6 | } from "@executor-v2/persistence-local"; | ||
| 7 | import { | 3 | import { |
| 8 | makeControlPlanePoliciesService, | 4 | makeControlPlanePoliciesService, |
| 9 | type ControlPlanePoliciesServiceShape, | 5 | type ControlPlanePoliciesServiceShape, |
| 10 | } from "@executor-v2/management-api"; | 6 | } from "@executor-v2/management-api"; |
| 11 | import { type Policy } from "@executor-v2/schema"; | 7 | import { type Policy } from "@executor-v2/schema"; |
| 12 | import * as Effect from "effect/Effect"; | 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 | const toSourceStoreError = ( | 12 | const toSourceStoreError = ( |
| 16 | operation: string, | 13 | operation: string, |
| @@ -19,16 +16,16 @@ const toSourceStoreError = ( · 2 unmodified lines | |||
| 19 | ): SourceStoreError => | 16 | ): SourceStoreError => |
| 20 | new SourceStoreError({ | 17 | new SourceStoreError({ |
| 21 | operation, | 18 | operation, |
| 22 | backend: "local-file", | 19 | backend: "sql", |
| 23 | location: "snapshot.json", | 20 | location: "policies", |
| 24 | message, | 21 | message, |
| 25 | reason: null, | 22 | reason: null, |
| 26 | details, | 23 | details, |
| 27 | }); | 24 | }); |
| 28 | 25 | ||
| 29 | const toSourceStoreErrorFromLocalState = ( | 26 | const toSourceStoreErrorFromRowStore = ( |
| 30 | operation: string, | 27 | operation: string, |
| 31 | error: LocalStateStoreError, | 28 | error: { message: string; details: string | null; reason: string | null }, |
| 32 | ): SourceStoreError => | 29 | ): SourceStoreError => |
| 33 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | 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 | return leftPattern.localeCompare(rightPattern); | 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 | export const createPmPoliciesService = ( | 43 | export const createPmPoliciesService = ( |
| 100 | localStateStore: LocalStateStore, | 44 | rows: PolicyRows, |
| 101 | ): ControlPlanePoliciesServiceShape => | 45 | ): ControlPlanePoliciesServiceShape => |
| 102 | makeControlPlanePoliciesService({ | 46 | makeControlPlanePoliciesService({ |
| 103 | listPolicies: (workspaceId) => | 47 | listPolicies: (workspaceId) => |
| 104 | Effect.gen(function* () { | 48 | Effect.gen(function* () { |
| 105 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 49 | const policies = yield* rows.policies.list().pipe( |
| 106 | Effect.mapError((error) => | 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 | return sortPolicies( | 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 | upsertPolicy: (input) => | 60 | upsertPolicy: (input) => |
| 122 | Effect.gen(function* () { | 61 | Effect.gen(function* () { |
| 123 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 62 | const policies = yield* rows.policies.list().pipe( |
| 124 | Effect.mapError((error) => | 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 | const now = Date.now(); | 68 | const now = Date.now(); |
| 139 | const requestedId = input.payload.id; | 69 | const requestedId = input.payload.id; |
| 140 | 70 | ||
| 141 | const existingIndex = requestedId | 71 | const existingIndex = requestedId |
| 142 | ? snapshot.policies.findIndex( | 72 | ? policies.findIndex( |
| 143 | (policy) => | 73 | (policy) => |
| 144 | policy.workspaceId === input.workspaceId && policy.id === requestedId, | 74 | policy.workspaceId === input.workspaceId && policy.id === requestedId, |
| 145 | ) | 75 | ) |
| 146 | : -1; | 76 | : -1; |
| 147 | 77 | ||
| 148 | const existing = existingIndex >= 0 ? snapshot.policies[existingIndex] : null; | 78 | const existing = existingIndex >= 0 ? policies[existingIndex] : null; |
| 149 | 79 | ||
| 150 | const nextPolicy: Policy = { | 80 | const nextPolicy: Policy = { |
| 151 | id: existing?.id ?? (requestedId ?? (`pol_${crypto.randomUUID()}` as Policy["id"])), | 81 | id: existing?.id ?? (requestedId ?? (`pol_${crypto.randomUUID()}` as Policy["id"])), |
| @@ -156,13 +86,9 @@ export const createPmPoliciesService = ( · 4 unmodified lines | |||
| 156 | updatedAt: now, | 86 | updatedAt: now, |
| 157 | }; | 87 | }; |
| 158 | 88 | ||
| 159 | const nextSnapshot = existingIndex >= 0 | 89 | yield* rows.policies.upsert(nextPolicy).pipe( |
| 160 | ? replacePolicyAt(snapshot, existingIndex, nextPolicy) | ||
| 161 | : appendPolicy(snapshot, nextPolicy); | ||
| 162 | |||
| 163 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 164 | Effect.mapError((error) => | 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 | removePolicy: (input) => | 98 | removePolicy: (input) => |
| 173 | Effect.gen(function* () { | 99 | Effect.gen(function* () { |
| 174 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 100 | const policies = yield* rows.policies.list().pipe( |
| 175 | Effect.mapError((error) => | 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); | 106 | const existing = policies.find( |
| 181 | if (snapshot === null) { | 107 | (policy) => policy.workspaceId === input.workspaceId && policy.id === input.policyId, |
| 182 | return { | ||
| 183 | removed: false, | ||
| 184 | }; | ||
| 185 | } | ||
| 186 | |||
| 187 | const next = removePolicyForWorkspace( | ||
| 188 | snapshot, | ||
| 189 | input.workspaceId, | ||
| 190 | input.policyId, | ||
| 191 | ); | 108 | ); |
| 192 | if (!next.removed) { | 109 | |
| 110 | if (!existing) { | ||
| 193 | return { | 111 | return { |
| 194 | removed: false, | 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 | Effect.mapError((error) => | 117 | Effect.mapError((error) => |
| 200 | toSourceStoreErrorFromLocalState("policies.remove_write", error), | 118 | toSourceStoreErrorFromRowStore("policies.remove_write", error), |
| 201 | ), | 119 | ), |
| 202 | ); | 120 | ); |
| 203 | 121 | ||
| 204 | return { | 122 | return { |
| 205 | removed: true, | 123 | removed, |
| 206 | }; | 124 | }; |
| 207 | }), | 125 | }), |
| 208 | }); | 126 | }); |
| @@ -1,53 +1,21 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { | 2 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; |
| 3 | LocalStateSnapshot, | 3 | import { |
| 4 | LocalStateStore, | 4 | type OrganizationId, |
| 5 | } from "@executor-v2/persistence-local"; | 5 | type StorageInstance, |
| 6 | import type { StorageInstance, WorkspaceId } from "@executor-v2/schema"; | 6 | type WorkspaceId, |
| 7 | } from "@executor-v2/schema"; | ||
| 7 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | 9 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 10 | import { tmpdir } from "node:os"; | 10 | import { tmpdir } from "node:os"; |
| 11 | import * as path from "node:path"; | 11 | import * as path from "node:path"; |
| 12 | 12 | ||
| 13 | import { createPmStorageService } from "./storage-service"; | 13 | import { createPmStorageService } from "./storage-service"; |
| 14 | 14 | ||
| 15 | const createTestSnapshot = (): LocalStateSnapshot => | 15 | type StorageRows = Pick< |
| 16 | ({ | 16 | SqlControlPlanePersistence["rows"], |
| 17 | schemaVersion: 1, | 17 | "workspaces" | "storageInstances" |
| 18 | generatedAt: Date.now(), | 18 | >; |
| 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; | ||
| 51 | 19 | ||
| 52 | describe("PM storage service", () => { | 20 | describe("PM storage service", () => { |
| 53 | it.effect("opens, inspects, queries, and removes storage instances", () => | 21 | it.effect("opens, inspects, queries, and removes storage instances", () => |
| @@ -56,24 +24,52 @@ describe("PM storage service", () => { · 2 unmodified lines | |||
| 56 | mkdtemp(path.join(tmpdir(), "executor-v2-pm-storage-")), | 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 = { | 37 | const storageInstances: Array<StorageInstance> = []; |
| 62 | getSnapshot: () => Effect.succeed(Option.some(snapshot)), | 38 | |
| 63 | writeSnapshot: (nextSnapshot) => | 39 | const rows: StorageRows = { |
| 64 | Effect.sync(() => { | 40 | workspaces: { |
| 65 | snapshot = nextSnapshot; | 41 | list: () => Effect.succeed([workspace]), |
| 66 | }), | 42 | upsert: () => Effect.void, |
| 67 | readEvents: () => Effect.succeed([]), | 43 | }, |
| 68 | appendEvents: () => Effect.void, | 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 | stateRootDir, | 70 | stateRootDir, |
| 73 | }); | 71 | }); |
| 74 | 72 | ||
| 75 | const workspaceId = "ws_local" as WorkspaceId; | ||
| 76 | |||
| 77 | const storageInstance = yield* service.openStorageInstance({ | 73 | const storageInstance = yield* service.openStorageInstance({ |
| 78 | workspaceId, | 74 | workspaceId, |
| 79 | payload: { | 75 | payload: { |
| @@ -3,18 +3,14 @@ import { · 2 unmodified lines | |||
| 3 | type ControlPlaneStorageServiceShape, | 3 | type ControlPlaneStorageServiceShape, |
| 4 | } from "@executor-v2/management-api"; | 4 | } from "@executor-v2/management-api"; |
| 5 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | 5 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 6 | import { | 6 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; |
| 7 | type LocalStateSnapshot, | ||
| 8 | type LocalStateStore, | ||
| 9 | type LocalStateStoreError, | ||
| 10 | } from "@executor-v2/persistence-local"; | ||
| 11 | import { | 7 | import { |
| 12 | type OrganizationId, | 8 | type OrganizationId, |
| 13 | type StorageInstance, | 9 | type StorageInstance, |
| 10 | type Workspace, | ||
| 14 | type WorkspaceId, | 11 | type WorkspaceId, |
| 15 | } from "@executor-v2/schema"; | 12 | } from "@executor-v2/schema"; |
| 16 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 17 | import * as Option from "effect/Option"; | ||
| 18 | import { | 14 | import { |
| 19 | mkdir, | 15 | mkdir, |
| 20 | readdir, | 16 | readdir, |
| @@ -25,6 +21,11 @@ import { · 4 unmodified lines | |||
| 25 | } from "node:fs/promises"; | 21 | } from "node:fs/promises"; |
| 26 | import * as path from "node:path"; | 22 | import * as path from "node:path"; |
| 27 | 23 | ||
| 24 | type StorageRows = Pick< | ||
| 25 | SqlControlPlanePersistence["rows"], | ||
| 26 | "workspaces" | "storageInstances" | ||
| 27 | >; | ||
| 28 | |||
| 28 | const DEFAULT_EPHEMERAL_TTL_HOURS = 24; | 29 | const DEFAULT_EPHEMERAL_TTL_HOURS = 24; |
| 29 | const DEFAULT_KV_LIMIT = 100; | 30 | const DEFAULT_KV_LIMIT = 100; |
| 30 | const MAX_KV_LIMIT = 1000; | 31 | const MAX_KV_LIMIT = 1000; |
| @@ -68,16 +69,16 @@ const toSourceStoreError = ( · 37 unmodified lines | |||
| 68 | ): SourceStoreError => | 69 | ): SourceStoreError => |
| 69 | new SourceStoreError({ | 70 | new SourceStoreError({ |
| 70 | operation, | 71 | operation, |
| 71 | backend: "local-file", | 72 | backend: "sql", |
| 72 | location: "snapshot.json", | 73 | location: "storage", |
| 73 | message, | 74 | message, |
| 74 | reason: null, | 75 | reason: null, |
| 75 | details, | 76 | details, |
| 76 | }); | 77 | }); |
| 77 | 78 | ||
| 78 | const toSourceStoreErrorFromLocalState = ( | 79 | const toSourceStoreErrorFromRowStore = ( |
| 79 | operation: string, | 80 | operation: string, |
| 80 | error: LocalStateStoreError, | 81 | error: { message: string; details: string | null; reason: string | null }, |
| 81 | ): SourceStoreError => | 82 | ): SourceStoreError => |
| 82 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | 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 | const resolveWorkspaceOrganizationId = ( | 96 | const resolveWorkspaceOrganizationId = ( |
| 96 | snapshot: LocalStateSnapshot, | 97 | workspaces: ReadonlyArray<Workspace>, |
| 97 | workspaceId: WorkspaceId, | 98 | workspaceId: WorkspaceId, |
| 98 | ): OrganizationId => { | 99 | ): OrganizationId => { |
| 99 | const workspace = snapshot.workspaces.find((item) => item.id === workspaceId); | 100 | const workspace = workspaces.find((item) => item.id === workspaceId); |
| 100 | 101 | ||
| 101 | if (!workspace) { | 102 | if (!workspace) { |
| 102 | throw new Error(`Workspace not found: ${workspaceId}`); | 103 | throw new Error(`Workspace not found: ${workspaceId}`); |
| @@ -124,70 +125,6 @@ const sortStorageInstances = ( · 21 unmodified lines | |||
| 124 | return left.id.localeCompare(right.id); | 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 | const storageInstanceRootPath = (stateRootDir: string, storageInstanceId: string): string => | 128 | const storageInstanceRootPath = (stateRootDir: string, storageInstanceId: string): string => |
| 192 | path.resolve(stateRootDir, STORAGE_ROOT_DIRECTORY, storageInstanceId); | 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 | const findStorageInstance = ( | 231 | const findStorageInstance = ( |
| 295 | snapshot: LocalStateSnapshot, | 232 | storageInstances: ReadonlyArray<StorageInstance>, |
| 233 | organizationId: OrganizationId, | ||
| 296 | input: { | 234 | input: { |
| 297 | workspaceId: WorkspaceId; | 235 | workspaceId: WorkspaceId; |
| 298 | storageInstanceId: StorageInstance["id"]; | 236 | storageInstanceId: StorageInstance["id"]; |
| 299 | }, | 237 | }, |
| 300 | ): { | 238 | ): { |
| 301 | storageInstance: StorageInstance; | 239 | storageInstance: StorageInstance; |
| 302 | storageInstanceIndex: number; | ||
| 303 | } | null => { | 240 | } | null => { |
| 304 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | 241 | const storageInstance = storageInstances.find((instance) => |
| 305 | const storageInstanceIndex = snapshot.storageInstances.findIndex((instance) => | ||
| 306 | instance.id === input.storageInstanceId | 242 | instance.id === input.storageInstanceId |
| 307 | && canAccessStorageInstance(instance, input.workspaceId, organizationId) | 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 | if (!storageInstance) { | 246 | if (!storageInstance) { |
| 316 | return null; | 247 | return null; |
| 317 | } | 248 | } |
| 318 | 249 | ||
| 319 | return { | 250 | return { |
| 320 | storageInstance, | 251 | storageInstance, |
| 321 | storageInstanceIndex, | ||
| 322 | }; | 252 | }; |
| 323 | }; | 253 | }; |
| 324 | 254 | ||
| 325 | const touchStorageInstance = ( | 255 | const touchStorageInstance = ( |
| 326 | snapshot: LocalStateSnapshot, | 256 | rows: StorageRows, |
| 327 | storageInstanceIndex: number, | 257 | existing: StorageInstance, |
| 328 | ): { | 258 | operation: string, |
| 329 | snapshot: LocalStateSnapshot; | 259 | ): Effect.Effect<StorageInstance, SourceStoreError> => { |
| 330 | storageInstance: StorageInstance; | ||
| 331 | } => { | ||
| 332 | const existing = snapshot.storageInstances[storageInstanceIndex]; | ||
| 333 | const now = Date.now(); | 260 | const now = Date.now(); |
| 334 | 261 | const next: StorageInstance = { | |
| 335 | const touchedStorageInstance: StorageInstance = { | ||
| 336 | ...existing, | 262 | ...existing, |
| 337 | updatedAt: now, | 263 | updatedAt: now, |
| 338 | lastSeenAt: now, | 264 | lastSeenAt: now, |
| 339 | }; | 265 | }; |
| 340 | 266 | ||
| 341 | return { | 267 | return rows.storageInstances.upsert(next).pipe( |
| 342 | storageInstance: touchedStorageInstance, | 268 | Effect.mapError((error) => toSourceStoreErrorFromRowStore(operation, error)), |
| 343 | snapshot: replaceStorageInstanceAt(snapshot, storageInstanceIndex, touchedStorageInstance), | 269 | Effect.as(next), |
| 344 | }; | 270 | ); |
| 345 | }; | 271 | }; |
| 346 | 272 | ||
| 347 | export const createPmStorageService = ( | 273 | export const createPmStorageService = ( |
| 348 | localStateStore: LocalStateStore, | 274 | rows: StorageRows, |
| 349 | options: { | 275 | options: { |
| 350 | stateRootDir: string; | 276 | stateRootDir: string; |
| 351 | }, | 277 | }, |
| @@ -353,21 +279,23 @@ export const createPmStorageService = ( · 1 unmodified line | |||
| 353 | makeControlPlaneStorageService({ | 279 | makeControlPlaneStorageService({ |
| 354 | listStorageInstances: (workspaceId) => | 280 | listStorageInstances: (workspaceId) => |
| 355 | Effect.gen(function* () { | 281 | Effect.gen(function* () { |
| 356 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 282 | const [storageInstances, workspaces] = yield* Effect.all([ |
| 357 | Effect.mapError((error) => | 283 | rows.storageInstances.list().pipe( |
| 358 | toSourceStoreErrorFromLocalState("storage.list", error), | 284 | Effect.mapError((error) => |
| 285 | toSourceStoreErrorFromRowStore("storage.list_instances", error), | ||
| 286 | ), | ||
| 359 | ), | 287 | ), |
| 360 | ); | 288 | rows.workspaces.list().pipe( |
| 361 | 289 | Effect.mapError((error) => | |
| 362 | const snapshot = Option.getOrNull(snapshotOption); | 290 | toSourceStoreErrorFromRowStore("storage.list_instances.workspaces", error), |
| 363 | if (snapshot === null) { | 291 | ), |
| 364 | return []; | 292 | ), |
| 365 | } | 293 | ]); |
| 366 | 294 | ||
| 367 | const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId); | 295 | const organizationId = resolveWorkspaceOrganizationId(workspaces, workspaceId); |
| 368 | 296 | ||
| 369 | return sortStorageInstances( | 297 | return sortStorageInstances( |
| 370 | snapshot.storageInstances.filter((instance) => | 298 | storageInstances.filter((instance) => |
| 371 | canAccessStorageInstance(instance, workspaceId, organizationId) | 299 | canAccessStorageInstance(instance, workspaceId, organizationId) |
| 372 | ), | 300 | ), |
| 373 | ); | 301 | ); |
| @@ -375,21 +303,6 @@ export const createPmStorageService = ( · 1 unmodified line | |||
| 375 | 303 | ||
| 376 | openStorageInstance: (input) => | 304 | openStorageInstance: (input) => |
| 377 | Effect.gen(function* () { | 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 | if (input.payload.scopeType === "account" && input.payload.accountId === undefined) { | 306 | if (input.payload.scopeType === "account" && input.payload.accountId === undefined) { |
| 394 | return yield* toSourceStoreError( | 307 | return yield* toSourceStoreError( |
| 395 | "storage.open", | 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 | const now = Date.now(); | 320 | const now = Date.now(); |
| 402 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | 321 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 403 | const storageInstanceId = | 322 | const storageInstanceId = |
| 404 | `storage_${crypto.randomUUID()}` as StorageInstance["id"]; | 323 | `storage_${crypto.randomUUID()}` as StorageInstance["id"]; |
| 405 | const ttlHours = | 324 | const ttlHours = |
| @@ -450,11 +369,9 @@ export const createPmStorageService = ( · 44 unmodified lines | |||
| 450 | : null, | 369 | : null, |
| 451 | }; | 370 | }; |
| 452 | 371 | ||
| 453 | const nextSnapshot = appendStorageInstance(snapshot, nextStorageInstance); | 372 | yield* rows.storageInstances.upsert(nextStorageInstance).pipe( |
| 454 | |||
| 455 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 456 | Effect.mapError((error) => | 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 | closeStorageInstance: (input) => | 381 | closeStorageInstance: (input) => |
| 465 | Effect.gen(function* () { | 382 | Effect.gen(function* () { |
| 466 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 383 | const [storageInstances, workspaces] = yield* Effect.all([ |
| 467 | Effect.mapError((error) => | 384 | rows.storageInstances.list().pipe( |
| 468 | toSourceStoreErrorFromLocalState("storage.close", error), | 385 | Effect.mapError((error) => |
| 386 | toSourceStoreErrorFromRowStore("storage.close.instances", error), | ||
| 387 | ), | ||
| 469 | ), | 388 | ), |
| 470 | ); | 389 | rows.workspaces.list().pipe( |
| 471 | 390 | Effect.mapError((error) => | |
| 472 | const snapshot = Option.getOrNull(snapshotOption); | 391 | toSourceStoreErrorFromRowStore("storage.close.workspaces", error), |
| 473 | if (snapshot === null) { | 392 | ), |
| 474 | return yield* toSourceStoreError( | 393 | ), |
| 475 | "storage.close", | 394 | ]); |
| 476 | "Storage snapshot not found", | ||
| 477 | `workspace=${input.workspaceId}`, | ||
| 478 | ); | ||
| 479 | } | ||
| 480 | 395 | ||
| 481 | const found = findStorageInstance(snapshot, { | 396 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 397 | const found = findStorageInstance(storageInstances, organizationId, { | ||
| 482 | workspaceId: input.workspaceId, | 398 | workspaceId: input.workspaceId, |
| 483 | storageInstanceId: input.storageInstanceId, | 399 | storageInstanceId: input.storageInstanceId, |
| 484 | }); | 400 | }); |
| @@ -501,15 +417,9 @@ export const createPmStorageService = ( · 16 unmodified lines | |||
| 501 | closedAt: found.storageInstance.closedAt ?? now, | 417 | closedAt: found.storageInstance.closedAt ?? now, |
| 502 | }; | 418 | }; |
| 503 | 419 | ||
| 504 | const nextSnapshot = replaceStorageInstanceAt( | 420 | yield* rows.storageInstances.upsert(nextStorageInstance).pipe( |
| 505 | snapshot, | ||
| 506 | found.storageInstanceIndex, | ||
| 507 | nextStorageInstance, | ||
| 508 | ); | ||
| 509 | |||
| 510 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 511 | Effect.mapError((error) => | 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 | removeStorageInstance: (input) => | 429 | removeStorageInstance: (input) => |
| 520 | Effect.gen(function* () { | 430 | Effect.gen(function* () { |
| 521 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 431 | const [storageInstances, workspaces] = yield* Effect.all([ |
| 522 | Effect.mapError((error) => | 432 | rows.storageInstances.list().pipe( |
| 523 | toSourceStoreErrorFromLocalState("storage.remove", error), | 433 | Effect.mapError((error) => |
| 434 | toSourceStoreErrorFromRowStore("storage.remove.instances", error), | ||
| 435 | ), | ||
| 524 | ), | 436 | ), |
| 525 | ); | 437 | rows.workspaces.list().pipe( |
| 526 | 438 | Effect.mapError((error) => | |
| 527 | const snapshot = Option.getOrNull(snapshotOption); | 439 | toSourceStoreErrorFromRowStore("storage.remove.workspaces", error), |
| 528 | if (snapshot === null) { | 440 | ), |
| 529 | return { | 441 | ), |
| 530 | removed: false, | 442 | ]); |
| 531 | }; | ||
| 532 | } | ||
| 533 | |||
| 534 | const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId); | ||
| 535 | 443 | ||
| 536 | const next = removeStorageInstanceById(snapshot, { | 444 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 445 | const found = findStorageInstance(storageInstances, organizationId, { | ||
| 537 | workspaceId: input.workspaceId, | 446 | workspaceId: input.workspaceId, |
| 538 | organizationId, | ||
| 539 | storageInstanceId: input.storageInstanceId, | 447 | storageInstanceId: input.storageInstanceId, |
| 540 | }); | 448 | }); |
| 541 | 449 | ||
| 542 | if (!next.removed) { | 450 | if (found === null) { |
| 543 | return { | 451 | return { |
| 544 | removed: false, | 452 | removed: false, |
| 545 | }; | 453 | }; |
| @@ -547,13 +455,10 @@ export const createPmStorageService = ( · 1 unmodified line | |||
| 547 | 455 | ||
| 548 | yield* Effect.tryPromise({ | 456 | yield* Effect.tryPromise({ |
| 549 | try: () => | 457 | try: () => |
| 550 | rm( | 458 | rm(storageInstanceRootPath(options.stateRootDir, input.storageInstanceId), { |
| 551 | storageInstanceRootPath(options.stateRootDir, input.storageInstanceId), | 459 | recursive: true, |
| 552 | { | 460 | force: true, |
| 553 | recursive: true, | 461 | }), |
| 554 | force: true, | ||
| 555 | }, | ||
| 556 | ), | ||
| 557 | catch: (cause) => | 462 | catch: (cause) => |
| 558 | toSourceStoreErrorFromCause( | 463 | toSourceStoreErrorFromCause( |
| 559 | "storage.remove_files", | 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 | Effect.mapError((error) => | 471 | Effect.mapError((error) => |
| 567 | toSourceStoreErrorFromLocalState("storage.remove_write", error), | 472 | toSourceStoreErrorFromRowStore("storage.remove_write", error), |
| 568 | ), | 473 | ), |
| 569 | ); | 474 | ); |
| 570 | 475 | ||
| 571 | return { | 476 | return { |
| 572 | removed: true, | 477 | removed, |
| 573 | }; | 478 | }; |
| 574 | }), | 479 | }), |
| 575 | 480 | ||
| 576 | listStorageDirectory: (input) => | 481 | listStorageDirectory: (input) => |
| 577 | Effect.gen(function* () { | 482 | Effect.gen(function* () { |
| 578 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 483 | const [storageInstances, workspaces] = yield* Effect.all([ |
| 579 | Effect.mapError((error) => | 484 | rows.storageInstances.list().pipe( |
| 580 | toSourceStoreErrorFromLocalState("storage.listDirectory", error), | 485 | Effect.mapError((error) => |
| 486 | toSourceStoreErrorFromRowStore("storage.list_directory.instances", error), | ||
| 487 | ), | ||
| 581 | ), | 488 | ), |
| 582 | ); | 489 | rows.workspaces.list().pipe( |
| 583 | 490 | Effect.mapError((error) => | |
| 584 | const snapshot = Option.getOrNull(snapshotOption); | 491 | toSourceStoreErrorFromRowStore("storage.list_directory.workspaces", error), |
| 585 | if (snapshot === null) { | 492 | ), |
| 586 | return yield* toSourceStoreError( | 493 | ), |
| 587 | "storage.listDirectory", | 494 | ]); |
| 588 | "Storage snapshot not found", | ||
| 589 | `workspace=${input.workspaceId}`, | ||
| 590 | ); | ||
| 591 | } | ||
| 592 | 495 | ||
| 593 | const found = findStorageInstance(snapshot, { | 496 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 497 | const found = findStorageInstance(storageInstances, organizationId, { | ||
| 594 | workspaceId: input.workspaceId, | 498 | workspaceId: input.workspaceId, |
| 595 | storageInstanceId: input.storageInstanceId, | 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); | 567 | yield* touchStorageInstance( |
| 664 | 568 | rows, | |
| 665 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | 569 | found.storageInstance, |
| 666 | Effect.mapError((error) => | 570 | "storage.listDirectory_touch", |
| 667 | toSourceStoreErrorFromLocalState( | ||
| 668 | "storage.listDirectory_touch", | ||
| 669 | error, | ||
| 670 | ), | ||
| 671 | ), | ||
| 672 | ); | 571 | ); |
| 673 | 572 | ||
| 674 | return result; | 573 | return result; |
| @@ -676,22 +575,21 @@ export const createPmStorageService = ( · 1 unmodified line | |||
| 676 | 575 | ||
| 677 | readStorageFile: (input) => | 576 | readStorageFile: (input) => |
| 678 | Effect.gen(function* () { | 577 | Effect.gen(function* () { |
| 679 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 578 | const [storageInstances, workspaces] = yield* Effect.all([ |
| 680 | Effect.mapError((error) => | 579 | rows.storageInstances.list().pipe( |
| 681 | toSourceStoreErrorFromLocalState("storage.readFile", error), | 580 | Effect.mapError((error) => |
| 581 | toSourceStoreErrorFromRowStore("storage.read_file.instances", error), | ||
| 582 | ), | ||
| 682 | ), | 583 | ), |
| 683 | ); | 584 | rows.workspaces.list().pipe( |
| 684 | 585 | Effect.mapError((error) => | |
| 685 | const snapshot = Option.getOrNull(snapshotOption); | 586 | toSourceStoreErrorFromRowStore("storage.read_file.workspaces", error), |
| 686 | if (snapshot === null) { | 587 | ), |
| 687 | return yield* toSourceStoreError( | 588 | ), |
| 688 | "storage.readFile", | 589 | ]); |
| 689 | "Storage snapshot not found", | ||
| 690 | `workspace=${input.workspaceId}`, | ||
| 691 | ); | ||
| 692 | } | ||
| 693 | 590 | ||
| 694 | const found = findStorageInstance(snapshot, { | 591 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 592 | const found = findStorageInstance(storageInstances, organizationId, { | ||
| 695 | workspaceId: input.workspaceId, | 593 | workspaceId: input.workspaceId, |
| 696 | storageInstanceId: input.storageInstanceId, | 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); | 642 | yield* touchStorageInstance(rows, found.storageInstance, "storage.readFile_touch"); |
| 745 | |||
| 746 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | ||
| 747 | Effect.mapError((error) => | ||
| 748 | toSourceStoreErrorFromLocalState("storage.readFile_touch", error), | ||
| 749 | ), | ||
| 750 | ); | ||
| 751 | 643 | ||
| 752 | return result; | 644 | return result; |
| 753 | }), | 645 | }), |
| 754 | 646 | ||
| 755 | listStorageKv: (input) => | 647 | listStorageKv: (input) => |
| 756 | Effect.gen(function* () { | 648 | Effect.gen(function* () { |
| 757 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 649 | const [storageInstances, workspaces] = yield* Effect.all([ |
| 758 | Effect.mapError((error) => | 650 | rows.storageInstances.list().pipe( |
| 759 | toSourceStoreErrorFromLocalState("storage.listKv", error), | 651 | Effect.mapError((error) => |
| 652 | toSourceStoreErrorFromRowStore("storage.list_kv.instances", error), | ||
| 653 | ), | ||
| 760 | ), | 654 | ), |
| 761 | ); | 655 | rows.workspaces.list().pipe( |
| 762 | 656 | Effect.mapError((error) => | |
| 763 | const snapshot = Option.getOrNull(snapshotOption); | 657 | toSourceStoreErrorFromRowStore("storage.list_kv.workspaces", error), |
| 764 | if (snapshot === null) { | 658 | ), |
| 765 | return yield* toSourceStoreError( | 659 | ), |
| 766 | "storage.listKv", | 660 | ]); |
| 767 | "Storage snapshot not found", | ||
| 768 | `workspace=${input.workspaceId}`, | ||
| 769 | ); | ||
| 770 | } | ||
| 771 | 661 | ||
| 772 | const found = findStorageInstance(snapshot, { | 662 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 663 | const found = findStorageInstance(storageInstances, organizationId, { | ||
| 773 | workspaceId: input.workspaceId, | 664 | workspaceId: input.workspaceId, |
| 774 | storageInstanceId: input.storageInstanceId, | 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); | 709 | yield* touchStorageInstance(rows, found.storageInstance, "storage.listKv_touch"); |
| 819 | |||
| 820 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | ||
| 821 | Effect.mapError((error) => | ||
| 822 | toSourceStoreErrorFromLocalState("storage.listKv_touch", error), | ||
| 823 | ), | ||
| 824 | ); | ||
| 825 | 710 | ||
| 826 | return result; | 711 | return result; |
| 827 | }), | 712 | }), |
| 828 | 713 | ||
| 829 | queryStorageSql: (input) => | 714 | queryStorageSql: (input) => |
| 830 | Effect.gen(function* () { | 715 | Effect.gen(function* () { |
| 831 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 716 | const [storageInstances, workspaces] = yield* Effect.all([ |
| 832 | Effect.mapError((error) => | 717 | rows.storageInstances.list().pipe( |
| 833 | toSourceStoreErrorFromLocalState("storage.querySql", error), | 718 | Effect.mapError((error) => |
| 719 | toSourceStoreErrorFromRowStore("storage.query_sql.instances", error), | ||
| 720 | ), | ||
| 834 | ), | 721 | ), |
| 835 | ); | 722 | rows.workspaces.list().pipe( |
| 836 | 723 | Effect.mapError((error) => | |
| 837 | const snapshot = Option.getOrNull(snapshotOption); | 724 | toSourceStoreErrorFromRowStore("storage.query_sql.workspaces", error), |
| 838 | if (snapshot === null) { | 725 | ), |
| 839 | return yield* toSourceStoreError( | 726 | ), |
| 840 | "storage.querySql", | 727 | ]); |
| 841 | "Storage snapshot not found", | ||
| 842 | `workspace=${input.workspaceId}`, | ||
| 843 | ); | ||
| 844 | } | ||
| 845 | 728 | ||
| 846 | const found = findStorageInstance(snapshot, { | 729 | const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId); |
| 730 | const found = findStorageInstance(storageInstances, organizationId, { | ||
| 847 | workspaceId: input.workspaceId, | 731 | workspaceId: input.workspaceId, |
| 848 | storageInstanceId: input.storageInstanceId, | 732 | storageInstanceId: input.storageInstanceId, |
| 849 | }); | 733 | }); |
| @@ -894,16 +778,16 @@ export const createPmStorageService = ( · 44 unmodified lines | |||
| 894 | try { | 778 | try { |
| 895 | const statement = db.query(sqlText); | 779 | const statement = db.query(sqlText); |
| 896 | const rawRows = statement.all() as Array<Record<string, unknown>>; | 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 | const columns = | 782 | const columns = |
| 899 | rows.length > 0 | 783 | rowsLimited.length > 0 |
| 900 | ? Array.from(new Set(rows.flatMap((row) => Object.keys(row)))) | 784 | ? Array.from(new Set(rowsLimited.flatMap((row) => Object.keys(row)))) |
| 901 | : []; | 785 | : []; |
| 902 | 786 | ||
| 903 | return { | 787 | return { |
| 904 | rows, | 788 | rows: rowsLimited, |
| 905 | columns, | 789 | columns, |
| 906 | rowCount: rows.length, | 790 | rowCount: rowsLimited.length, |
| 907 | }; | 791 | }; |
| 908 | } catch { | 792 | } catch { |
| 909 | db.run(sqlText); | 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); | 813 | yield* touchStorageInstance(rows, found.storageInstance, "storage.querySql_touch"); |
| 930 | |||
| 931 | yield* localStateStore.writeSnapshot(touched.snapshot).pipe( | ||
| 932 | Effect.mapError((error) => | ||
| 933 | toSourceStoreErrorFromLocalState("storage.querySql_touch", error), | ||
| 934 | ), | ||
| 935 | ); | ||
| 936 | 814 | ||
| 937 | return result; | 815 | return result; |
| 938 | }), | 816 | }), |
| @@ -4,21 +4,31 @@ import { · 3 unmodified lines | |||
| 4 | } from "@executor-v2/engine"; | 4 | } from "@executor-v2/engine"; |
| 5 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Option from "effect/Option"; | ||
| 8 | 7 | ||
| 9 | import { createPmResolveToolCredentials } from "./credential-resolver"; | 8 | import { createPmResolveToolCredentials } from "./credential-resolver"; |
| 10 | 9 | ||
| 11 | const emptyLocalStateStore = { | 10 | const emptyCredentialRows = { |
| 12 | getSnapshot: () => Effect.succeed(Option.none()), | 11 | workspaces: { |
| 13 | writeSnapshot: () => Effect.void, | 12 | list: () => Effect.succeed([]), |
| 14 | readEvents: () => Effect.succeed([]), | 13 | }, |
| 15 | appendEvents: () => Effect.void, | 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 | describe("PM runtime tool-call handling", () => { | 28 | describe("PM runtime tool-call handling", () => { |
| 19 | it.effect("returns failed callback result for unimplemented invoker", () => | 29 | it.effect("returns failed callback result for unimplemented invoker", () => |
| 20 | Effect.gen(function* () { | 30 | Effect.gen(function* () { |
| 21 | const resolveCredentials = createPmResolveToolCredentials(emptyLocalStateStore); | 31 | const resolveCredentials = createPmResolveToolCredentials(emptyCredentialRows); |
| 22 | const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("pm"); | 32 | const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("pm"); |
| 23 | const handleToolCall = createRuntimeToolCallHandler({ | 33 | const handleToolCall = createRuntimeToolCallHandler({ |
| 24 | resolveCredentials, | 34 | resolveCredentials, |
| @@ -31,8 +31,8 @@ const toSourceStoreError = ( · 30 unmodified lines | |||
| 31 | ): SourceStoreError => | 31 | ): SourceStoreError => |
| 32 | new SourceStoreError({ | 32 | new SourceStoreError({ |
| 33 | operation, | 33 | operation, |
| 34 | backend: "local-file", | 34 | backend: "sql", |
| 35 | location: "tool-artifacts.json", | 35 | location: "tool_artifacts", |
| 36 | message, | 36 | message, |
| 37 | reason: null, | 37 | reason: null, |
| 38 | details, | 38 | details, |
| @@ -1,16 +1,13 @@ | |||
| 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; | 1 | import { SourceStoreError } from "@executor-v2/persistence-ports"; |
| 2 | import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql"; | ||
| 2 | import { | 3 | import { |
| 3 | makeControlPlaneWorkspacesService, | 4 | makeControlPlaneWorkspacesService, |
| 4 | type ControlPlaneWorkspacesServiceShape, | 5 | type ControlPlaneWorkspacesServiceShape, |
| 5 | } from "@executor-v2/management-api"; | 6 | } from "@executor-v2/management-api"; |
| 6 | import { | ||
| 7 | type LocalStateSnapshot, | ||
| 8 | type LocalStateStore, | ||
| 9 | type LocalStateStoreError, | ||
| 10 | } from "@executor-v2/persistence-local"; | ||
| 11 | import { type Workspace } from "@executor-v2/schema"; | 7 | import { type Workspace } from "@executor-v2/schema"; |
| 12 | import * as Effect from "effect/Effect"; | 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 | const toSourceStoreError = ( | 12 | const toSourceStoreError = ( |
| 16 | operation: string, | 13 | operation: string, |
| @@ -19,16 +16,16 @@ const toSourceStoreError = ( · 2 unmodified lines | |||
| 19 | ): SourceStoreError => | 16 | ): SourceStoreError => |
| 20 | new SourceStoreError({ | 17 | new SourceStoreError({ |
| 21 | operation, | 18 | operation, |
| 22 | backend: "local-file", | 19 | backend: "sql", |
| 23 | location: "snapshot.json", | 20 | location: "workspaces", |
| 24 | message, | 21 | message, |
| 25 | reason: null, | 22 | reason: null, |
| 26 | details, | 23 | details, |
| 27 | }); | 24 | }); |
| 28 | 25 | ||
| 29 | const toSourceStoreErrorFromLocalState = ( | 26 | const toSourceStoreErrorFromRowStore = ( |
| 30 | operation: string, | 27 | operation: string, |
| 31 | error: LocalStateStoreError, | 28 | error: { message: string; details: string | null; reason: string | null }, |
| 32 | ): SourceStoreError => | 29 | ): SourceStoreError => |
| 33 | toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null); | 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 | return leftName.localeCompare(rightName); | 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 | export const createPmWorkspacesService = ( | 44 | export const createPmWorkspacesService = ( |
| 72 | localStateStore: LocalStateStore, | 45 | rows: WorkspaceRows, |
| 73 | ): ControlPlaneWorkspacesServiceShape => | 46 | ): ControlPlaneWorkspacesServiceShape => |
| 74 | makeControlPlaneWorkspacesService({ | 47 | makeControlPlaneWorkspacesService({ |
| 75 | listWorkspaces: () => | 48 | listWorkspaces: () => |
| 76 | Effect.gen(function* () { | 49 | Effect.gen(function* () { |
| 77 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 50 | const workspaces = yield* rows.workspaces.list().pipe( |
| 78 | Effect.mapError((error) => | 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); | 56 | return sortWorkspaces(workspaces); |
| 84 | if (snapshot === null) { | ||
| 85 | return []; | ||
| 86 | } | ||
| 87 | |||
| 88 | return sortWorkspaces(snapshot.workspaces); | ||
| 89 | }), | 57 | }), |
| 90 | 58 | ||
| 91 | upsertWorkspace: (input) => | 59 | upsertWorkspace: (input) => |
| 92 | Effect.gen(function* () { | 60 | Effect.gen(function* () { |
| 93 | const snapshotOption = yield* localStateStore.getSnapshot().pipe( | 61 | const workspaces = yield* rows.workspaces.list().pipe( |
| 94 | Effect.mapError((error) => | 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 | const now = Date.now(); | 67 | const now = Date.now(); |
| 109 | const existingIndex = input.payload.id | 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 | : -1; | 70 | : -1; |
| 112 | const existing = existingIndex >= 0 ? snapshot.workspaces[existingIndex] : null; | 71 | const existing = existingIndex >= 0 ? workspaces[existingIndex] : null; |
| 113 | 72 | ||
| 114 | const nextWorkspace: Workspace = { | 73 | const nextWorkspace: Workspace = { |
| 115 | id: existing?.id ?? (input.payload.id ?? (`ws_${crypto.randomUUID()}` as Workspace["id"])), | 74 | id: existing?.id ?? (input.payload.id ?? (`ws_${crypto.randomUUID()}` as Workspace["id"])), |
| @@ -120,13 +79,9 @@ export const createPmWorkspacesService = ( · 4 unmodified lines | |||
| 120 | updatedAt: now, | 79 | updatedAt: now, |
| 121 | }; | 80 | }; |
| 122 | 81 | ||
| 123 | const nextSnapshot = existingIndex >= 0 | 82 | yield* rows.workspaces.upsert(nextWorkspace).pipe( |
| 124 | ? replaceWorkspaceAt(snapshot, existingIndex, nextWorkspace) | ||
| 125 | : appendWorkspace(snapshot, nextWorkspace); | ||
| 126 | |||
| 127 | yield* localStateStore.writeSnapshot(nextSnapshot).pipe( | ||
| 128 | Effect.mapError((error) => | 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 | By default, browser control-plane API calls go through the same-origin proxy at | 13 | By default, browser control-plane API calls go through the same-origin proxy at |
| 14 | `/api/control-plane`. | 14 | `/api/control-plane`. |
| 15 | 15 | ||
| 16 | The proxy forwards to the first available upstream value in: | 16 | The API route now runs the control-plane server in-process. |
| 17 | `CONTROL_PLANE_SERVER_BASE_URL`, `CONTROL_PLANE_UPSTREAM_URL`, `NEXT_PUBLIC_CONVEX_URL`, `CONVEX_URL`. | 17 | |
| 18 | If none are set, fallback is `http://127.0.0.1:8788`. | 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 | If needed, `NEXT_PUBLIC_CONTROL_PLANE_BASE_URL` can override the browser base URL. | 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 | - Derives from existing control-plane/frontend config. | 33 | - Derives from existing control-plane/frontend config. |
| 25 | - Prioritizes server-side `CONTROL_PLANE_UPSTREAM_URL` (or server/base control-plane URL) when available. | 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 | WorkOS auth setup (optional but recommended): | 37 | WorkOS auth setup (optional but recommended): |
| 29 | 38 | ||
| @@ -5,8 +5,6 @@ const resolveMcpBaseUrl = (): string | null => { · 4 unmodified lines | |||
| 5 | process.env.CONTROL_PLANE_UPSTREAM_URL, | 5 | process.env.CONTROL_PLANE_UPSTREAM_URL, |
| 6 | process.env.CONTROL_PLANE_SERVER_BASE_URL, | 6 | process.env.CONTROL_PLANE_SERVER_BASE_URL, |
| 7 | process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL, | 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 | for (const candidate of candidates) { | 10 | for (const candidate of candidates) { |
| @@ -1,9 +1,17 @@ | |||
| 1 | import { withAuth } from "@workos-inc/authkit-nextjs"; | 1 | import { withAuth } from "@workos-inc/authkit-nextjs"; |
| 2 | import type { NextRequest } from "next/server"; | 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 | import { isWorkosEnabled } from "../../../../lib/workos"; | 11 | import { isWorkosEnabled } from "../../../../lib/workos"; |
| 5 | 12 | ||
| 6 | export const dynamic = "force-dynamic"; | 13 | export const dynamic = "force-dynamic"; |
| 14 | export const runtime = "nodejs"; | ||
| 7 | 15 | ||
| 8 | type RouteContext = { | 16 | type RouteContext = { |
| 9 | params: Promise<{ | 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 | const isCsrfSafeMethod = (method: string): boolean => | 22 | const isCsrfSafeMethod = (method: string): boolean => |
| 40 | method === "GET" || method === "HEAD" || method === "OPTIONS"; | 23 | method === "GET" || method === "HEAD" || method === "OPTIONS"; |
| 41 | 24 | ||
| 42 | const toAuthorizationHeader = (token: string): string => | 25 | const rewriteControlPlaneRequest = ( |
| 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 = ( | ||
| 84 | request: NextRequest, | 26 | request: NextRequest, |
| 85 | pathSegments: ReadonlyArray<string>, | 27 | path: ReadonlyArray<string>, |
| 86 | ): URL => { | 28 | ): Request => { |
| 87 | const pathname = `/${pathSegments.join("/")}`; | 29 | const rewrittenUrl = new URL(request.url); |
| 88 | return new URL(`${pathname}${request.nextUrl.search}`, controlPlaneUpstreamBaseUrl); | 30 | rewrittenUrl.pathname = `/${path.join("/")}`; |
| 31 | return new Request(rewrittenUrl, request); | ||
| 89 | }; | 32 | }; |
| 90 | 33 | ||
| 91 | const handle = async (request: NextRequest, context: RouteContext): Promise<Response> => { | 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 | return Response.json({ error: "Not found" }, { status: 404 }); | 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 | if (!isCsrfSafeMethod(method)) { | 42 | if (!isCsrfSafeMethod(method)) { |
| 107 | const origin = request.headers.get("origin"); | 43 | const origin = request.headers.get("origin"); |
| 108 | if (origin && origin !== request.nextUrl.origin) { | 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 | try { | 68 | try { |
| 115 | ({ accessToken } = await withAuth()); | 69 | ({ user } = await withAuth()); |
| 116 | } catch { | 70 | } catch { |
| 117 | return Response.json( | 71 | return Response.json({ error: "Authentication unavailable" }, { status: 503 }); |
| 118 | { error: "Authentication unavailable" }, | ||
| 119 | { status: 503 }, | ||
| 120 | ); | ||
| 121 | } | 72 | } |
| 122 | 73 | ||
| 123 | if (!accessToken) { | 74 | if (!user) { |
| 124 | return Response.json({ error: "Unauthorized" }, { status: 401 }); | 75 | return Response.json({ error: "Unauthorized" }, { status: 401 }); |
| 125 | } | 76 | } |
| 126 | 77 | ||
| 127 | const body = methodAllowsBody(method) | 78 | const displayName = [user.firstName, user.lastName] |
| 128 | ? await request.arrayBuffer() | 79 | .filter((part): part is string => typeof part === "string" && part.trim().length > 0) |
| 129 | : undefined; | 80 | .join(" ") |
| 81 | || null; | ||
| 130 | 82 | ||
| 131 | const upstreamResponse = await fetch(resolveUpstreamUrl(request, path), { | 83 | const principal = createWorkosPrincipal({ |
| 132 | method, | 84 | subject: user.id, |
| 133 | headers: buildUpstreamHeaders(request, accessToken), | 85 | email: user.email ?? null, |
| 134 | body, | 86 | displayName, |
| 135 | redirect: "manual", | ||
| 136 | }); | 87 | }); |
| 137 | 88 | ||
| 138 | return new Response(upstreamResponse.body, { | 89 | await provisionPrincipal(runtime, principal); |
| 139 | status: upstreamResponse.status, | 90 | |
| 140 | statusText: upstreamResponse.statusText, | 91 | return runtime.handleControlPlane(applyPrincipalHeaders(controlPlaneRequest, principal)); |
| 141 | headers: sanitizeUpstreamResponseHeaders(upstreamResponse.headers), | ||
| 142 | }); | ||
| 143 | }; | 92 | }; |
| 144 | 93 | ||
| 145 | export const GET = handle; | 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 | "@effect-atom/atom": "^0.4.11", | 12 | "@effect-atom/atom": "^0.4.11", |
| 13 | "@effect-atom/atom-react": "^0.4.6", | 13 | "@effect-atom/atom-react": "^0.4.6", |
| 14 | "@executor-v2/management-api": "workspace:*", | 14 | "@executor-v2/management-api": "workspace:*", |
| 15 | "@executor-v2/persistence-ports": "workspace:*", | ||
| 16 | "@executor-v2/persistence-sql": "workspace:*", | ||
| 15 | "@executor-v2/schema": "workspace:*", | 17 | "@executor-v2/schema": "workspace:*", |
| 16 | "@modelcontextprotocol/sdk": "^1.27.1", | 18 | "@modelcontextprotocol/sdk": "^1.27.1", |
| 17 | "@streamdown/code": "^1.0.3", | 19 | "@streamdown/code": "^1.0.3", |
| @@ -16,7 +16,8 @@ · 15 unmodified lines | |||
| 16 | } | 16 | } |
| 17 | ], | 17 | ], |
| 18 | "types": [ | 18 | "types": [ |
| 19 | "node" | 19 | "node", |
| 20 | "bun" | ||
| 20 | ], | 21 | ], |
| 21 | "allowJs": true, | 22 | "allowJs": true, |
| 22 | "esModuleInterop": true, | 23 | "esModuleInterop": true, |
| @@ -27,7 +27,7 @@ executor doctor # health check · 26 unmodified lines | |||
| 27 | # Or wire up Claude Code directly: | 27 | # Or wire up Claude Code directly: |
| 28 | executor claude --no-bash`, | 28 | executor claude --no-bash`, |
| 29 | description: | 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 | title: "Use with your team", | 33 | title: "Use with your team", |
| @@ -5,7 +5,6 @@ · 4 unmodified lines | |||
| 5 | "": { | 5 | "": { |
| 6 | "name": "executor-v2", | 6 | "name": "executor-v2", |
| 7 | "dependencies": { | 7 | "dependencies": { |
| 8 | "convex": "^1.32.0", | ||
| 9 | "vercel": "^50.23.2", | 8 | "vercel": "^50.23.2", |
| 10 | }, | 9 | }, |
| 11 | "devDependencies": { | 10 | "devDependencies": { |
| @@ -16,48 +15,9 @@ · 4 unmodified lines | |||
| 16 | "typescript": "catalog:", | 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 | "apps/cli": { | 18 | "apps/cli": { |
| 34 | "name": "@executor-v2/app-cli", | 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 | "apps/pm": { | 21 | "apps/pm": { |
| 62 | "name": "@executor-v2/app-pm", | 22 | "name": "@executor-v2/app-pm", |
| 63 | "dependencies": { | 23 | "dependencies": { |
| @@ -67,7 +27,8 @@ · 3 unmodified lines | |||
| 67 | "@executor-v2/engine": "workspace:*", | 27 | "@executor-v2/engine": "workspace:*", |
| 68 | "@executor-v2/management-api": "workspace:*", | 28 | "@executor-v2/management-api": "workspace:*", |
| 69 | "@executor-v2/mcp-gateway": "workspace:*", | 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 | "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", | 32 | "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*", |
| 72 | "@executor-v2/runtime-deno-subprocess": "workspace:*", | 33 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 73 | "@executor-v2/runtime-local-inproc": "workspace:*", | 34 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| @@ -86,6 +47,8 @@ · 12 unmodified lines | |||
| 86 | "@effect-atom/atom": "^0.4.11", | 47 | "@effect-atom/atom": "^0.4.11", |
| 87 | "@effect-atom/atom-react": "^0.4.6", | 48 | "@effect-atom/atom-react": "^0.4.6", |
| 88 | "@executor-v2/management-api": "workspace:*", | 49 | "@executor-v2/management-api": "workspace:*", |
| 50 | "@executor-v2/persistence-ports": "workspace:*", | ||
| 51 | "@executor-v2/persistence-sql": "workspace:*", | ||
| 89 | "@executor-v2/schema": "workspace:*", | 52 | "@executor-v2/schema": "workspace:*", |
| 90 | "@modelcontextprotocol/sdk": "^1.27.1", | 53 | "@modelcontextprotocol/sdk": "^1.27.1", |
| 91 | "@streamdown/code": "^1.0.3", | 54 | "@streamdown/code": "^1.0.3", |
| @@ -145,31 +108,6 @@ · 53 unmodified lines | |||
| 145 | "vitest": "catalog:", | 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 | "packages/domain": { | 111 | "packages/domain": { |
| 174 | "name": "@executor-v2/domain", | 112 | "name": "@executor-v2/domain", |
| 175 | "dependencies": { | 113 | "dependencies": { |
| @@ -222,6 +160,16 @@ · 46 unmodified lines | |||
| 222 | "zod": "^4.3.6", | 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 | "packages/runtime-cloudflare-worker-loader": { | 173 | "packages/runtime-cloudflare-worker-loader": { |
| 226 | "name": "@executor-v2/runtime-cloudflare-worker-loader", | 174 | "name": "@executor-v2/runtime-cloudflare-worker-loader", |
| 227 | "dependencies": { | 175 | "dependencies": { |
| @@ -266,27 +214,11 @@ · 38 unmodified lines | |||
| 266 | "effect": "catalog:", | 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 | "catalog": { | 218 | "catalog": { |
| 286 | "@effect/platform": "^0.93.6", | 219 | "@effect/platform": "^0.93.6", |
| 287 | "@effect/vitest": "^0.27.0", | 220 | "@effect/vitest": "^0.27.0", |
| 288 | "@types/node": "^20.19.9", | 221 | "@types/node": "^20.19.9", |
| 289 | "convex": "^1.32.0", | ||
| 290 | "effect": "^3.19.8", | 222 | "effect": "^3.19.8", |
| 291 | "typescript": "^5.9.3", | 223 | "typescript": "^5.9.3", |
| 292 | "vitest": "^3.2.4", | 224 | "vitest": "^3.2.4", |
| @@ -386,12 +318,6 @@ · 93 unmodified lines | |||
| 386 | 318 | ||
| 387 | "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260305.0", "", {}, "sha512-sCgPFnQ03SVpC2OVW8wysONLZW/A8hlp9Mq2ckG/h1oId4kr9NawA6vUiOmOjCWRn2hIohejBYVQ+Vu20rCdKA=="], | 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 | "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], | 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 | "@edge-runtime/format": ["@edge-runtime/format@2.2.1", "", {}, "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g=="], | 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 | "@executor-v2/ai-sdk-adapter": ["@executor-v2/ai-sdk-adapter@workspace:packages/ai-sdk-adapter"], | 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 | "@executor-v2/app-cli": ["@executor-v2/app-cli@workspace:apps/cli"], | 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 | "@executor-v2/app-pm": ["@executor-v2/app-pm@workspace:apps/pm"], | 433 | "@executor-v2/app-pm": ["@executor-v2/app-pm@workspace:apps/pm"], |
| 512 | 434 | ||
| 513 | "@executor-v2/app-web": ["@executor-v2/app-web@workspace:apps/web"], | 435 | "@executor-v2/app-web": ["@executor-v2/app-web@workspace:apps/web"], |
| 514 | 436 | ||
| 515 | "@executor-v2/app-www": ["@executor-v2/app-www@workspace:apps/www"], | 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 | "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"], | 439 | "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"], |
| 520 | 440 | ||
| 521 | "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"], | 441 | "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"], |
| @@ -524,10 +444,10 @@ · 2 unmodified lines | |||
| 524 | 444 | ||
| 525 | "@executor-v2/mcp-gateway": ["@executor-v2/mcp-gateway@workspace:packages/mcp-gateway"], | 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 | "@executor-v2/persistence-ports": ["@executor-v2/persistence-ports@workspace:packages/state-contracts"], | 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 | "@executor-v2/runtime-cloudflare-worker-loader": ["@executor-v2/runtime-cloudflare-worker-loader@workspace:packages/runtime-cloudflare-worker-loader"], | 451 | "@executor-v2/runtime-cloudflare-worker-loader": ["@executor-v2/runtime-cloudflare-worker-loader@workspace:packages/runtime-cloudflare-worker-loader"], |
| 532 | 452 | ||
| 533 | "@executor-v2/runtime-deno-subprocess": ["@executor-v2/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"], | 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 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], | 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 | "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], | 815 | "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], |
| 894 | 816 | ||
| 895 | "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], | 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 | "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=="], | 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 | "async-listen": ["async-listen@1.2.0", "", {}, "sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA=="], | 987 | "async-listen": ["async-listen@1.2.0", "", {}, "sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA=="], |
| 1068 | 988 | ||
| 1069 | "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="], | 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 | "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], | 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 | "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], | 1007 | "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], |
| 1086 | 1008 | ||
| 1087 | "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], | 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 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], | 1013 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], |
| 1090 | 1014 | ||
| 1091 | "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], | 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 | "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], | 1019 | "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], |
| 1094 | 1020 | ||
| 1095 | "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=="], | 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 | "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=="], | 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 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], | 1035 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], |
| 1108 | 1036 | ||
| 1109 | "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], | 1037 | "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], |
| @@ -1184,12 +1112,6 @@ · 74 unmodified lines | |||
| 1184 | 1112 | ||
| 1185 | "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], | 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 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], | 1115 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], |
| 1194 | 1116 | ||
| 1195 | "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], | 1117 | "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], |
| @@ -1220,8 +1142,12 @@ · 24 unmodified lines | |||
| 1220 | 1142 | ||
| 1221 | "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], | 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 | "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], | 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 | "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], | 1151 | "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], |
| 1226 | 1152 | ||
| 1227 | "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=="], | 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 | "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=="], | 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 | "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], | 1189 | "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], |
| 1262 | 1190 | ||
| 1263 | "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=="], | 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 | "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=="], | 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 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], | 1259 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], |
| 1330 | 1260 | ||
| 1331 | "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=="], | 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 | "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], | 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 | "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=="], | 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 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], | 1307 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], |
| @@ -1394,6 +1326,8 @@ · 18 unmodified lines | |||
| 1394 | 1326 | ||
| 1395 | "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=="], | 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 | "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], | 1331 | "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], |
| 1398 | 1332 | ||
| 1399 | "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], | 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 | "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], | 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 | "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], | 1395 | "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], |
| 1460 | 1396 | ||
| 1461 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], | 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 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], | 1401 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], |
| 1464 | 1402 | ||
| 1465 | "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], | 1403 | "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], |
| @@ -1510,7 +1448,7 @@ · 44 unmodified lines | |||
| 1510 | 1448 | ||
| 1511 | "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], | 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 | "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], | 1453 | "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], |
| 1516 | 1454 | ||
| @@ -1692,16 +1630,22 @@ · 175 unmodified lines | |||
| 1692 | 1630 | ||
| 1693 | "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], | 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 | "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=="], | 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 | "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], | 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 | "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], | 1641 | "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], |
| 1700 | 1642 | ||
| 1701 | "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], | 1643 | "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], |
| 1702 | 1644 | ||
| 1703 | "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], | 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 | "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], | 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 | "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], | 1651 | "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], |
| @@ -1722,6 +1666,8 @@ · 14 unmodified lines | |||
| 1722 | 1666 | ||
| 1723 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | 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 | "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], | 1671 | "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], |
| 1726 | 1672 | ||
| 1727 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], | 1673 | "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], |
| @@ -1734,6 +1680,8 @@ · 6 unmodified lines | |||
| 1734 | 1680 | ||
| 1735 | "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], | 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 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], | 1685 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], |
| 1738 | 1686 | ||
| 1739 | "node-fetch": ["node-fetch@2.6.7", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ=="], | 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 | "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], | 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 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], | 1809 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| 1858 | 1810 | ||
| 1859 | "pretty-ms": ["pretty-ms@7.0.1", "", { "dependencies": { "parse-ms": "^2.1.0" } }, "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q=="], | 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 | "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=="], | 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 | "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], | 1845 | "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], |
| 1892 | 1846 | ||
| 1893 | "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], | 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 | "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], | 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 | "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], | 1855 | "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], |
| 1900 | 1856 | ||
| 1901 | "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], | 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 | "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], | 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 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], | 1923 | "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], |
| 1966 | 1924 | ||
| 1967 | "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], | 1925 | "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], |
| @@ -1996,6 +1954,10 @@ · 28 unmodified lines | |||
| 1996 | 1954 | ||
| 1997 | "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], | 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 | "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], | 1961 | "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], |
| 2000 | 1962 | ||
| 2001 | "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], | 1963 | "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], |
| @@ -2032,12 +1994,16 @@ · 30 unmodified lines | |||
| 2032 | 1994 | ||
| 2033 | "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=="], | 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 | "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=="], | 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 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], | 2001 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], |
| 2038 | 2002 | ||
| 2039 | "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], | 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 | "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], | 2007 | "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], |
| 2042 | 2008 | ||
| 2043 | "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], | 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 | "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=="], | 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 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], | 2035 | "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], |
| 2066 | 2036 | ||
| 2067 | "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], | 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 | "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=="], | 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 | "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=="], | 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 | "turbo-darwin-64": ["turbo-darwin-64@2.8.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-XKaCWaz4OCt77oYYvGCIRpvYD4c/aNaKjRkUpv+e8rN3RZb+5Xsyew4yRO+gaHdMIUhQznXNXfHlhs+/p7lIhA=="], | 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 | "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], | 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 | "xdg-app-paths": ["xdg-app-paths@5.1.0", "", { "dependencies": { "xdg-portable": "^7.0.0" } }, "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA=="], | 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 | "@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=="], | 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 | "@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=="], | 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 | "@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=="], | 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 | "@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=="], | 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 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], | 2309 | "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], |
| 2340 | 2310 | ||
| 2341 | "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], | 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 | "@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=="], | 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 | "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | 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 | "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], | 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 | "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], | 2445 | "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], |
| 2474 | 2446 | ||
| 2475 | "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=="], | 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 | "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], | 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 | "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], | 2469 | "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], |
| 2494 | 2470 | ||
| 2495 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], | 2471 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], |
| 2496 | 2472 | ||
| 2497 | "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], | 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 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], | 2475 | "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], |
| 2502 | 2476 | ||
| 2503 | "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=="], | 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 | "@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=="], | 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 | "@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=="], | 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 | "@vercel/express/@vercel/nft/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], | 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 | "prepare": "effect-language-service patch", | 19 | "prepare": "effect-language-service patch", |
| 20 | "dev": "turbo run dev --parallel", | 20 | "dev": "turbo run dev --parallel", |
| 21 | "typecheck": "turbo run typecheck", | 21 | "typecheck": "turbo run typecheck", |
| 22 | "knip": "bunx knip --config knip.json", | 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" | ||
| 24 | }, | 23 | }, |
| 25 | "devDependencies": { | 24 | "devDependencies": { |
| 26 | "@effect/language-service": "^0.76.0", | 25 | "@effect/language-service": "^0.76.0", |
| @@ -30,7 +29,6 @@ · 3 unmodified lines | |||
| 30 | "typescript": "catalog:" | 29 | "typescript": "catalog:" |
| 31 | }, | 30 | }, |
| 32 | "dependencies": { | 31 | "dependencies": { |
| 33 | "convex": "^1.32.0", | ||
| 34 | "vercel": "^50.23.2" | 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.