History

refactor(v2): remove snapshot persistence APIs and run on SQL rows only

Rhys Sullivan committed 8 months ago

Commit 95f0e3a1 parent 336a0c8120 files · +3298 −26617Browse files

Unified · Split

120 files
  1. PLAN.md +18 −22
  2. apps/api/.gitignore +0 −1
  3. apps/api/api/parse-openapi.fidelity.test.ts +0 −461
  4. apps/api/api/parse-openapi.ts +0 −812
  5. apps/api/package.json +0 −19
  6. apps/api/vercel.json +0 −9
  7. apps/convex/.gitignore +0 −2
  8. apps/convex/README.md +0 −11
  9. apps/convex/convex/README.md +0 −90
  10. apps/convex/convex/_generated/api.d.ts +0 −600
  11. apps/convex/convex/_generated/api.js +0 −23
  12. apps/convex/convex/_generated/dataModel.d.ts +0 −60
  13. apps/convex/convex/_generated/server.d.ts +0 −143
  14. apps/convex/convex/_generated/server.js +0 −93
  15. apps/convex/convex/auth.config.ts +0 −33
  16. apps/convex/convex/controlPlane.ts +0 −72
  17. apps/convex/convex/control_plane/actor.ts +0 −403
  18. apps/convex/convex/control_plane/approvals.ts +0 −95
  19. apps/convex/convex/control_plane/credentials.ts +0 −1780
  20. apps/convex/convex/control_plane/errors.ts +0 −82
  21. apps/convex/convex/control_plane/http.ts +0 −121
  22. apps/convex/convex/control_plane/openapi_ingest.ts +0 −1538
  23. apps/convex/convex/control_plane/organizations.ts +0 −124
  24. apps/convex/convex/control_plane/policies.ts +0 −120
  25. apps/convex/convex/control_plane/service.ts +0 −283
  26. apps/convex/convex/control_plane/sources.ts +0 −395
  27. apps/convex/convex/control_plane/storage.ts +0 −1068
  28. apps/convex/convex/control_plane/tool_registry.ts +0 −1611
  29. apps/convex/convex/control_plane/tools.ts +0 −431
  30. apps/convex/convex/control_plane/workspaces.ts +0 −78
  31. apps/convex/convex/convex.config.ts +0 −9
  32. apps/convex/convex/credential_crypto.ts +0 −192
  33. apps/convex/convex/executor.test.ts +0 −1782
  34. apps/convex/convex/executor.ts +0 −29
  35. apps/convex/convex/http.ts +0 −62
  36. apps/convex/convex/http_security.ts +0 −69
  37. apps/convex/convex/mcp.ts +0 −192
  38. apps/convex/convex/mcp_auth.ts +0 −229
  39. apps/convex/convex/run_executor.ts +0 −5
  40. apps/convex/convex/runtimeCallbacks.ts +0 −165
  41. apps/convex/convex/runtime_adapter.ts +0 −10
  42. apps/convex/convex/runtime_execution_port.ts +0 −74
  43. apps/convex/convex/schema.ts +0 −253
  44. apps/convex/convex/source_tool_registry.ts +0 −2128
  45. apps/convex/convex/task_runs.ts +0 −106
  46. apps/convex/convex/tsconfig.json +0 −25
  47. apps/convex/convex/workflow.ts +0 −15
  48. apps/convex/package.json +0 −33
  49. apps/convex/src/main.ts +0 −1
  50. apps/convex/tsconfig.json +0 −4
  51. apps/pm/package.json +2 −1
  52. apps/pm/src/actor.ts +38 −36
  53. apps/pm/src/approvals-service.ts +30 −87
  54. apps/pm/src/credential-resolver.ts +35 −18
  55. apps/pm/src/credentials-service.ts +134 −159
  56. apps/pm/src/main.ts +93 −27
  57. apps/pm/src/organizations-service.ts +44 −77
  58. apps/pm/src/persistent-approval-policy.test.ts +23 −49
  59. apps/pm/src/policies-service.ts +26 −108
  60. apps/pm/src/storage-service.test.ts +50 −54
  61. apps/pm/src/storage-service.ts +148 −270
  62. apps/pm/src/tool-call-handler.test.ts +17 −7
  63. apps/pm/src/tools-service.ts +2 −2
  64. apps/pm/src/workspaces-service.ts +17 −62
  65. apps/web/README.md +13 −4
  66. apps/web/app/(console)/tools/page.tsx +0 −2
  67. apps/web/app/api/control-plane/[...path]/route.ts +47 −98
  68. apps/web/lib/control-plane/server.ts +321 −0
  69. apps/web/package.json +2 −0
  70. apps/web/tsconfig.json +2 −1
  71. apps/www/src/components/UsageCards.tsx +1 −1
  72. bun.lock +87 −133
  73. package.json +1 −3
  74. packages/confect/package.json +0 −34
  75. packages/confect/src/client.ts +0 −160
  76. packages/confect/src/ctx.ts +0 −683
  77. packages/confect/src/index.ts +0 −43
  78. packages/confect/src/pagination.ts +0 −19
  79. packages/confect/src/rpc/client.test.ts +0 −776
  80. packages/confect/src/rpc/client.ts +0 −1105
  81. packages/confect/src/rpc/go-to-definition.test.ts +0 −87
  82. packages/confect/src/rpc/index.ts +0 −49
  83. packages/confect/src/rpc/server.test.ts +0 −1239
  84. packages/confect/src/rpc/server.ts +0 −675
  85. packages/confect/src/rpc/telemetry.test.ts +0 −112
  86. packages/confect/src/rpc/telemetry.ts +0 −139
  87. packages/confect/src/schema.test.ts +0 −235
  88. packages/confect/src/schema.ts +0 −400
  89. packages/confect/src/testing.ts +0 −129
  90. packages/confect/src/validators.test.ts +0 −247
  91. packages/confect/src/validators.ts +0 −217
  92. packages/confect/tsconfig.json +0 −8
  93. packages/confect/vitest.config.mts +0 −11
  94. packages/convex-test/compare.d.ts +0 −3
  95. packages/convex-test/compare.js +0 −80
  96. packages/convex-test/index.d.ts +0 −98
  97. packages/convex-test/index.js +0 −1510
  98. packages/convex-test/package.json +0 −21
  99. packages/persistence-sql/drizzle/0000_initial_schema.sql +139 −0
  100. packages/persistence-sql/drizzle/meta/_journal.json +13 −0
  101. packages/persistence-sql/package.json +18 −0
  102. packages/persistence-sql/src/index.ts +1815 −0
  103. packages/persistence-sql/src/schema.ts +149 −0
  104. packages/persistence-sql/tsconfig.json +0 −0
  105. packages/schema/README.md +1 −1
  106. packages/state-contracts/README.md +1 −1
  107. packages/state-contracts/src/index.ts +1 −0
  108. packages/state-contracts/src/row-store.ts +10 −0
  109. packages/state-local-file/README.md +0 −11
  110. packages/state-local-file/package.json +0 −24
  111. packages/state-local-file/src/index.ts +0 −26
  112. packages/state-local-file/src/local-state-snapshot.ts +0 −46
  113. packages/state-local-file/src/local-state-store.ts +0 −27
  114. packages/state-local-file/src/persistence-error.ts +0 −101
  115. packages/state-local-file/src/source-store.test.ts +0 −161
  116. packages/state-local-file/src/source-store.ts +0 −211
  117. packages/state-local-file/src/state-store.test.ts +0 −185
  118. packages/state-local-file/src/state-store.ts +0 −308
  119. packages/state-local-file/src/tool-artifact-store.test.ts +0 −85
  120. packages/state-local-file/src/tool-artifact-store.ts +0 −188
PLAN.md+18−22
@@ -11,7 +11,7 @@ Executor today is optimized for enterprise/team correctness (workspaces, org flo · 10 unmodified lines
11v2 changes the default:11v2 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### Modes105### Modes
106 106
107- local-lite: PM daemon + in-memory hot state + file durability107- 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 targets110### Runtime targets
111 111
@@ -131,19 +131,19 @@ Important boundary: · 19 unmodified lines
1315. Runtime resumes and completes.1315. Runtime resumes and completes.
1326. Run/events persisted locally.1326. Run/events persisted locally.
133 133
134### Cloud flow (Convex + Cloudflare worker loader)134### Remote flow (SQL + Cloudflare worker loader)
135 135
1361. Request starts run in Convex.1361. Request starts run in control-plane API.
1372. Convex dispatches run to Cloudflare loader.1372. Runtime dispatch executes through configured runtime adapter.
1383. Worker `tools.*` call hits Convex runtime callback action (`handleToolCall`).1383. Worker `tools.*` call hits runtime callback action (`handleToolCall`).
1394. Callback action invokes shared tool invocation pipeline:1394. Callback action invokes shared tool invocation pipeline:
140 - resolve tool140 - resolve tool
141 - policy check141 - policy check
142 - approval gating142 - approval gating
143 - credential resolution from Convex/vault143 - credential resolution from SQL-backed state/secret stores
144 - provider invoke144 - provider invoke
1455. Worker resumes and returns terminal status.1455. Worker resumes and returns terminal status.
1466. Convex marks run terminal and emits events.1466. Control plane marks run terminal and emits events.
147 147
148Serverless note:148Serverless 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 direction307## 14) Dependency direction
310 308
311```text309```text
312confect -> schema -> state-contracts310schema -> state-contracts
313 311
314state-contracts -> state-local-file312state-contracts -> persistence-sql
315 313
316schema + state-contracts + oauth + secrets -> domain314schema + state-contracts + oauth + secrets -> domain
317domain -> approvals/source-manager/engine315domain -> approvals/source-manager/engine
@@ -319,8 +317,8 @@ source-manager + schema -> control-plane · 1 unmodified line
319approvals/source-manager/engine -> mcp-gateway317approvals/source-manager/engine -> mcp-gateway
320domain/features -> sdk -> ai-sdk-adapter318domain/features -> sdk -> ai-sdk-adapter
321 319
322apps/pm -> mcp-gateway + domain + state-local-file + control-plane320apps/pm -> mcp-gateway + domain + persistence-sql + control-plane
323apps/convex -> mcp-gateway + domain + control-plane321apps/web -> management-api + persistence-sql + control-plane routes
324apps/cli/web -> sdk (and/or ai-sdk bridge)322apps/cli/web -> sdk (and/or ai-sdk bridge)
325```323```
326Rules:324Rules:
@@ -355,11 +353,11 @@ Rules: · 28 unmodified lines
355 353
356- persisted pending/resume/deny flow354- persisted pending/resume/deny flow
357- capability-aware UX fallbacks355- 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 parity358### Phase 4: remote parity
361 359
362- Convex adapter wiring for kernel and control360- SQL adapter wiring for kernel and control
363- cloudflare loader integration through runtime dispatch adapter361- cloudflare loader integration through runtime dispatch adapter
364- parity/conformance suite across local and remote362- parity/conformance suite across local and remote
365 363
@@ -395,10 +393,9 @@ Rules: · 29 unmodified lines
395 393
396Implemented in `v2` so far:394Implemented in `v2` so far:
397- monorepo app/package skeleton395- monorepo app/package skeleton
398- `confect` imported from quickhub
399- schema package scaffold with Effect models/event envelope396- schema package scaffold with Effect models/event envelope
400- shared state contracts for source/tool artifacts397- shared state contracts for source/tool artifacts
401- local file adapters for source/tool artifacts + snapshot/WAL state store398- SQL adapters for source/tool artifacts + snapshot/event-log state store
402- source manager OpenAPI extraction + artifact refresh/reuse399- source manager OpenAPI extraction + artifact refresh/reuse
403- provider registry contracts and routing in engine400- provider registry contracts and routing in engine
404- OpenAPI provider invocation + in-memory provider path through registry401- OpenAPI provider invocation + in-memory provider path through registry
@@ -420,9 +417,8 @@ Still missing: · 15 unmodified lines
420## 19) Immediate next steps417## 19) Immediate next steps
421 418
4221. Land shared `ToolInvocationService` contract in v2 and wire PM execute callback path through it.4191. Land shared `ToolInvocationService` contract in v2 and wire PM execute callback path through it.
4232. Add `CredentialResolver` interfaces and local/convex implementations; integrate at invocation-time only.4202. Add `CredentialResolver` interfaces and local/remote implementations; integrate at invocation-time only.
4243. Implement control handlers for sources (`add/list/remove`) and route both host API and `tools.executor.*` through same code path.4213. Implement control handlers for sources (`add/list/remove`) and route both host API and `tools.executor.*` through same code path.
4254. Implement cloudflare-worker-loader runtime dispatch adapter in v2 and wire Convex callback endpoints.4224. Implement cloudflare-worker-loader runtime dispatch adapter in v2 and wire callback endpoints.
4265. Add run API split for async + blocking convenience (`submit` + `wait/watch`) without duplicating execution logic.4235. Add run API split for async + blocking convenience (`submit` + `wait/watch`) without duplicating execution logic.
4276. Add conformance tests that run same scenarios across local and remote adapters.4246. Add conformance tests that run same scenarios across local and remote adapters.
428
apps/api/.gitignore+0−1
@@ -1 +0,0 @@
1.vercel
apps/api/api/parse-openapi.fidelity.test.ts+0−461
@@ -1,461 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { parse as parseYaml } from "yaml";
4
5import { extractOpenApiManifest } from "./parse-openapi";
6
7const cloudflareSpecUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
8const vercelSpecUrl = "https://openapi.vercel.sh/";
9const httpMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const;
10
11const isRecord = (value: unknown): value is Record<string, unknown> =>
12 typeof value === "object" && value !== null && !Array.isArray(value);
13
14const parseSpecDocument = (input: string): unknown => {
15 const trimmed = input.trim();
16 if (trimmed.length === 0) {
17 throw new Error("Spec document is empty");
18 }
19
20 try {
21 return JSON.parse(trimmed);
22 } catch {
23 return parseYaml(trimmed);
24 }
25};
26
27const loadSpec = async (url: string): Promise<unknown> => {
28 const response = await fetch(url);
29 if (!response.ok) {
30 throw new Error(`Failed to fetch spec ${url}: ${response.status} ${response.statusText}`);
31 }
32
33 return parseSpecDocument(await response.text());
34};
35
36const collectRefKeys = (value: unknown, refs: Set<string>): void => {
37 if (Array.isArray(value)) {
38 for (const item of value) {
39 collectRefKeys(item, refs);
40 }
41 return;
42 }
43
44 if (!isRecord(value)) {
45 return;
46 }
47
48 const refValue = value.$ref;
49 if (typeof refValue === "string" && refValue.startsWith("#/")) {
50 refs.add(refValue);
51 }
52
53 for (const nested of Object.values(value)) {
54 collectRefKeys(nested, refs);
55 }
56};
57
58const unescapeJsonPointer = (value: string): string =>
59 value.replace(/~1/g, "/").replace(/~0/g, "~");
60
61const getByJsonPointer = (root: unknown, pointer: string): unknown => {
62 if (!pointer.startsWith("#/")) {
63 return null;
64 }
65
66 const segments = pointer
67 .slice(2)
68 .split("/")
69 .map((segment) => unescapeJsonPointer(segment));
70
71 let current: unknown = root;
72 for (const segment of segments) {
73 if (Array.isArray(current)) {
74 const index = Number(segment);
75 if (!Number.isInteger(index) || index < 0 || index >= current.length) {
76 return null;
77 }
78 current = current[index];
79 continue;
80 }
81
82 if (!isRecord(current) || !(segment in current)) {
83 return null;
84 }
85
86 current = current[segment];
87 }
88
89 return current;
90};
91
92const resolveLocalRef = (root: unknown, value: unknown, depth = 0): unknown => {
93 if (depth > 8 || !isRecord(value) || typeof value.$ref !== "string") {
94 return value;
95 }
96
97 const resolved = getByJsonPointer(root, value.$ref);
98 if (resolved === null) {
99 return value;
100 }
101
102 return resolveLocalRef(root, resolved, depth + 1);
103};
104
105const extractSchemaFromContent = (content: unknown): unknown | null => {
106 if (!isRecord(content)) {
107 return null;
108 }
109
110 const jsonMedia =
111 (content["application/json"] as Record<string, unknown> | undefined)
112 ?? (content["application/*+json"] as Record<string, unknown> | undefined);
113
114 if (jsonMedia && isRecord(jsonMedia.schema)) {
115 return jsonMedia.schema;
116 }
117
118 for (const mediaType of Object.values(content)) {
119 if (isRecord(mediaType) && isRecord(mediaType.schema)) {
120 return mediaType.schema;
121 }
122 }
123
124 return null;
125};
126
127const extractParameterSchema = (root: unknown, value: unknown): unknown => {
128 const parameter = resolveLocalRef(root, value);
129 if (!isRecord(parameter)) {
130 return null;
131 }
132
133 if (isRecord(parameter.schema)) {
134 return parameter.schema;
135 }
136
137 return extractSchemaFromContent(parameter.content);
138};
139
140const extractRequestBodySchema = (root: unknown, operation: Record<string, unknown>): unknown => {
141 const requestBody = resolveLocalRef(root, operation.requestBody);
142 if (!isRecord(requestBody)) {
143 return null;
144 }
145
146 return extractSchemaFromContent(requestBody.content);
147};
148
149const extractResponseSchema = (root: unknown, operation: Record<string, unknown>): unknown => {
150 const responses = resolveLocalRef(root, operation.responses);
151 if (!isRecord(responses)) {
152 return null;
153 }
154
155 const preferred = Object.keys(responses)
156 .filter((key) => /^2\d\d$/.test(key))
157 .sort();
158 if ("default" in responses) {
159 preferred.push("default");
160 }
161
162 for (const key of preferred) {
163 const response = resolveLocalRef(root, responses[key]);
164 if (!isRecord(response)) {
165 continue;
166 }
167
168 const schema = extractSchemaFromContent(response.content);
169 if (schema !== null) {
170 return schema;
171 }
172 }
173
174 return null;
175};
176
177const hasMeaningfulSchemaShape = (value: unknown): boolean => {
178 if (!isRecord(value)) {
179 return false;
180 }
181
182 if (typeof value.$ref === "string") {
183 return true;
184 }
185
186 if (typeof value.type === "string") {
187 return true;
188 }
189
190 if (Array.isArray(value.enum) && value.enum.length > 0) {
191 return true;
192 }
193
194 if (isRecord(value.properties) && Object.keys(value.properties).length > 0) {
195 return true;
196 }
197
198 if (value.items !== undefined && value.items !== null) {
199 return true;
200 }
201
202 if (Array.isArray(value.oneOf) || Array.isArray(value.anyOf) || Array.isArray(value.allOf)) {
203 return true;
204 }
205
206 if (value.additionalProperties !== undefined) {
207 return true;
208 }
209
210 return false;
211};
212
213const isUnknownLikeSchema = (value: unknown): boolean => !hasMeaningfulSchemaShape(value);
214
215type OperationExpectation = {
216 parameterSchemas: Record<string, unknown>;
217 requestBodySchema: unknown;
218 responseSchema: unknown;
219};
220
221const operationSignature = (method: string, path: string): string =>
222 `${method.toLowerCase()} ${path}`;
223
224const buildOperationExpectations = (spec: unknown): Map<string, OperationExpectation> => {
225 if (!isRecord(spec) || !isRecord(spec.paths)) {
226 throw new Error("Spec missing paths object");
227 }
228
229 const expectations = new Map<string, OperationExpectation>();
230
231 for (const [path, pathItemValue] of Object.entries(spec.paths)) {
232 if (!isRecord(pathItemValue)) {
233 continue;
234 }
235
236 const pathLevelParameters = Array.isArray(pathItemValue.parameters)
237 ? pathItemValue.parameters
238 : [];
239
240 for (const method of httpMethods) {
241 const operationValue = pathItemValue[method];
242 if (!isRecord(operationValue)) {
243 continue;
244 }
245
246 const parameterSchemas: Record<string, unknown> = {};
247 const operationLevelParameters = Array.isArray(operationValue.parameters)
248 ? operationValue.parameters
249 : [];
250
251 const parameters = [...pathLevelParameters, ...operationLevelParameters];
252 for (const parameter of parameters) {
253 const resolved = resolveLocalRef(spec, parameter);
254 if (!isRecord(resolved)) {
255 continue;
256 }
257
258 const name = typeof resolved.name === "string" ? resolved.name.trim() : "";
259 const location = typeof resolved.in === "string" ? resolved.in.trim().toLowerCase() : "";
260 if (
261 name.length === 0
262 || (
263 location !== "path"
264 && location !== "query"
265 && location !== "header"
266 && location !== "cookie"
267 )
268 ) {
269 continue;
270 }
271
272 const schema = extractParameterSchema(spec, resolved);
273 if (schema !== null) {
274 parameterSchemas[name] = schema;
275 }
276 }
277
278 expectations.set(operationSignature(method, path), {
279 parameterSchemas,
280 requestBodySchema: extractRequestBodySchema(spec, operationValue),
281 responseSchema: extractResponseSchema(spec, operationValue),
282 });
283 }
284 }
285
286 return expectations;
287};
288
289const assertManifestFidelityForSpec = (
290 specName: string,
291 spec: unknown,
292): void => {
293 const manifest = extractOpenApiManifest(specName, spec);
294 const expectations = buildOperationExpectations(spec);
295
296 for (const tool of manifest.tools) {
297 const signature = operationSignature(tool.method, tool.path);
298 const expectation = expectations.get(signature);
299 expect(expectation, `Missing operation expectation for ${signature}`).toBeDefined();
300
301 const inputSchema = tool.typing?.inputSchemaJson
302 ? JSON.parse(tool.typing.inputSchemaJson)
303 : null;
304 const outputSchema = tool.typing?.outputSchemaJson
305 ? JSON.parse(tool.typing.outputSchemaJson)
306 : null;
307
308 if (expectation) {
309 const expectedParamSchemas = Object.entries(expectation.parameterSchemas);
310 const hasStructuredParams = expectedParamSchemas.some(([, schema]) => hasMeaningfulSchemaShape(schema));
311 const hasStructuredBody = hasMeaningfulSchemaShape(expectation.requestBodySchema);
312 const hasStructuredOutput = hasMeaningfulSchemaShape(expectation.responseSchema);
313
314 if (hasStructuredParams || hasStructuredBody) {
315 expect(tool.typing?.inputSchemaJson, `Missing input schema for ${signature}`).toBeDefined();
316 expect(isRecord(inputSchema)).toBe(true);
317 expect(isRecord(inputSchema?.properties)).toBe(true);
318 }
319
320 if (hasStructuredParams && isRecord(inputSchema) && isRecord(inputSchema.properties)) {
321 for (const [paramName, sourceSchema] of expectedParamSchemas) {
322 if (!hasMeaningfulSchemaShape(sourceSchema)) {
323 continue;
324 }
325
326 expect(
327 inputSchema.properties[paramName],
328 `Missing input property '${paramName}' for ${signature}`,
329 ).toBeDefined();
330 expect(
331 isUnknownLikeSchema(inputSchema.properties[paramName]),
332 `Unknown-like input schema for '${paramName}' in ${signature}`,
333 ).toBe(false);
334 }
335 }
336
337 if (hasStructuredBody && isRecord(inputSchema) && isRecord(inputSchema.properties)) {
338 expect(inputSchema.properties.body, `Missing body schema for ${signature}`).toBeDefined();
339 expect(
340 isUnknownLikeSchema(inputSchema.properties.body),
341 `Unknown-like body schema for ${signature}`,
342 ).toBe(false);
343 }
344
345 if (hasStructuredOutput) {
346 expect(tool.typing?.outputSchemaJson, `Missing output schema for ${signature}`).toBeDefined();
347 expect(
348 isUnknownLikeSchema(outputSchema),
349 `Unknown-like output schema for ${signature}`,
350 ).toBe(false);
351 }
352 }
353
354 const refs = new Set<string>();
355 if (inputSchema) {
356 collectRefKeys(inputSchema, refs);
357 }
358 if (outputSchema) {
359 collectRefKeys(outputSchema, refs);
360 }
361
362 for (const refKey of refs) {
363 const resolvedRef = manifest.refHintTable[refKey];
364 expect(resolvedRef, `Missing refHintTable entry ${refKey} for ${signature}`).toBeDefined();
365 expect(() => JSON.parse(resolvedRef!)).not.toThrow();
366 }
367 }
368};
369
370describe("parse-openapi extractor fidelity against real specs", () => {
371 it.effect("Cloudflare path params and output refs are preserved", () =>
372 Effect.gen(function* () {
373 const cloudflareSpec = yield* Effect.tryPromise(() => loadSpec(cloudflareSpecUrl));
374 const manifest = extractOpenApiManifest("cloudflare", cloudflareSpec);
375
376 expect(manifest.tools.length).toBeGreaterThan(2000);
377
378 const accessDeleteTool = manifest.tools.find(
379 (tool) =>
380 tool.method === "delete"
381 && tool.path === "/accounts/{account_id}/access/apps/{app_id}",
382 );
383 expect(accessDeleteTool).toBeDefined();
384 expect(accessDeleteTool?.typing?.inputSchemaJson).toBeDefined();
385
386 const inputSchema = JSON.parse(accessDeleteTool!.typing!.inputSchemaJson!);
387 expect(inputSchema.type).toBe("object");
388 expect(Array.isArray(inputSchema.required)).toBe(true);
389 expect(inputSchema.required).toContain("account_id");
390 expect(inputSchema.required).toContain("app_id");
391 expect(isRecord(inputSchema.properties)).toBe(true);
392 expect(isRecord(inputSchema.properties.account_id)).toBe(true);
393 expect(isRecord(inputSchema.properties.app_id)).toBe(true);
394
395 const ipDetailsTool = manifest.tools.find(
396 (tool) =>
397 tool.name.toLowerCase().includes("jd cloud ip details")
398 || (tool.method === "get" && tool.path === "/ips"),
399 );
400 expect(ipDetailsTool).toBeDefined();
401 expect(ipDetailsTool?.typing?.outputSchemaJson).toBeDefined();
402
403 const outputSchema = JSON.parse(ipDetailsTool!.typing!.outputSchemaJson!);
404 const outputRefs = new Set<string>();
405 collectRefKeys(outputSchema, outputRefs);
406 expect(outputRefs.size).toBeGreaterThan(0);
407
408 for (const refKey of outputRefs) {
409 const schemaJson = manifest.refHintTable[refKey];
410 expect(schemaJson).toBeDefined();
411 expect(() => JSON.parse(schemaJson)).not.toThrow();
412 }
413 }),
414 30_000,
415 );
416
417 it.effect("Vercel extract keeps typed invocation + schema coverage", () =>
418 Effect.gen(function* () {
419 const vercelSpec = yield* Effect.tryPromise(() => loadSpec(vercelSpecUrl));
420 const manifest = extractOpenApiManifest("vercel", vercelSpec);
421
422 expect(manifest.tools.length).toBeGreaterThan(100);
423
424 const toolWithPathParams = manifest.tools.find(
425 (tool) => tool.invocation.parameters.some((parameter) => parameter.location === "path"),
426 );
427 expect(toolWithPathParams).toBeDefined();
428 expect(toolWithPathParams?.typing?.inputSchemaJson).toBeDefined();
429
430 const inputSchema = JSON.parse(toolWithPathParams!.typing!.inputSchemaJson!);
431 expect(isRecord(inputSchema)).toBe(true);
432 expect(inputSchema.type).toBe("object");
433 expect(isRecord(inputSchema.properties)).toBe(true);
434
435 for (const pathParameter of toolWithPathParams!.invocation.parameters.filter(
436 (parameter) => parameter.location === "path",
437 )) {
438 expect(Array.isArray(inputSchema.required)).toBe(true);
439 expect(inputSchema.required).toContain(pathParameter.name);
440 expect(inputSchema.properties[pathParameter.name]).toBeDefined();
441 }
442 }),
443 30_000,
444 );
445
446 it.effect("Cloudflare has no unknown-like schemas for structured operations", () =>
447 Effect.gen(function* () {
448 const cloudflareSpec = yield* Effect.tryPromise(() => loadSpec(cloudflareSpecUrl));
449 assertManifestFidelityForSpec("cloudflare", cloudflareSpec);
450 }),
451 60_000,
452 );
453
454 it.effect("Vercel has no unknown-like schemas for structured operations", () =>
455 Effect.gen(function* () {
456 const vercelSpec = yield* Effect.tryPromise(() => loadSpec(vercelSpecUrl));
457 assertManifestFidelityForSpec("vercel", vercelSpec);
458 }),
459 60_000,
460 );
461});
apps/api/api/parse-openapi.ts+0−812
@@ -1,812 +0,0 @@
1import { createHash } from "node:crypto";
2
3import { ConvexHttpClient } from "convex/browser";
4import { parse as parseYaml } from "yaml";
5
6const maxBodyBytes = 256 * 1024;
7const httpMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const;
8
9type HttpMethod = typeof httpMethods[number];
10type OpenApiParameterLocation = "path" | "query" | "header" | "cookie";
11
12type 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
39type OpenApiManifest = {
40 sourceHash: string;
41 tools: Array<ExtractedTool>;
42 refHintTable: Record<string, string>;
43};
44
45const parseJsonDocument = (input: string): unknown => JSON.parse(input);
46const parseYamlDocument = (input: string): unknown => parseYaml(input);
47
48const isRecord = (value: unknown): value is Record<string, unknown> =>
49 typeof value === "object" && value !== null && !Array.isArray(value);
50
51const 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
66const hashUnknown = (value: unknown): string =>
67 createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
68
69const 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
78const normalizeSegment = (value: string): string =>
79 value
80 .toLowerCase()
81 .replace(/[^a-z0-9]+/g, "_")
82 .replace(/^_+|_+$/g, "");
83
84const normalizeToolId = (value: string): string =>
85 value
86 .toLowerCase()
87 .replace(/[^a-z0-9]+/g, ".")
88 .replace(/\.+/g, ".")
89 .replace(/^\.|\.$/g, "");
90
91const escapeJsonPointer = (value: string): string =>
92 value.replace(/~/g, "~0").replace(/\//g, "~1");
93
94const unescapeJsonPointer = (value: string): string =>
95 value.replace(/~1/g, "/").replace(/~0/g, "~");
96
97const 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
128const 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
141const 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
163const 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
196const 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
242const 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
283const 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
348const 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
378const 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
396const 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
430export 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
566const 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
587const 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
609const 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
618const 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
628const sourceNamespace = (sourceName: string, sourceId: string): string => {
629 const sourceIdSuffix = sourceId.slice(-6).toLowerCase();
630 return `${normalizeNamespacePart(sourceName)}_${sourceIdSuffix}`;
631};
632
633const parseSourceEnabled = (value: unknown): boolean =>
634 typeof value === "boolean" ? value : true;
635
636const 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
696const 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
806export const config = {
807 api: {
808 bodyParser: false,
809 },
810};
811
812export default parseEndpoint;
apps/api/package.json+0−19
@@ -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}
apps/api/vercel.json+0−9
@@ -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}
apps/convex/.gitignore+0−2
@@ -1,2 +0,0 @@
1
2.env.local
apps/convex/README.md+0−11
@@ -1,11 +0,0 @@
1# app-convex
2
3Convex remote server app scaffold for Executor v2.
4
5Current 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`
apps/convex/convex/README.md+0−90
@@ -1,90 +0,0 @@
1# Welcome to your Convex functions directory!
2
3Write your Convex functions here.
4See https://docs.convex.dev/functions for more.
5
6A query function that takes two arguments looks like:
7
8```ts
9// convex/myFunctions.ts
10import { query } from "./_generated/server";
11import { v } from "convex/values";
12
13export 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
36Using this query function in a React component looks like:
37
38```ts
39const data = useQuery(api.myFunctions.myQueryFunction, {
40 first: 10,
41 second: "hello",
42});
43```
44
45A mutation function looks like:
46
47```ts
48// convex/myFunctions.ts
49import { mutation } from "./_generated/server";
50import { v } from "convex/values";
51
52export 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
73Using this mutation function in a React component looks like:
74
75```ts
76const mutation = useMutation(api.myFunctions.myMutationFunction);
77function 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
88Use the Convex CLI to push your functions to a deployment. See everything
89the Convex CLI can do by running `npx convex -h` in your project root
90directory. To learn more, launch the docs with `npx convex docs`.
apps/convex/convex/_generated/api.d.ts+0−600
@@ -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
11import type * as controlPlane from "../controlPlane.js";
12import type * as control_plane_actor from "../control_plane/actor.js";
13import type * as control_plane_approvals from "../control_plane/approvals.js";
14import type * as control_plane_credentials from "../control_plane/credentials.js";
15import type * as control_plane_errors from "../control_plane/errors.js";
16import type * as control_plane_http from "../control_plane/http.js";
17import type * as control_plane_openapi_ingest from "../control_plane/openapi_ingest.js";
18import type * as control_plane_organizations from "../control_plane/organizations.js";
19import type * as control_plane_policies from "../control_plane/policies.js";
20import type * as control_plane_service from "../control_plane/service.js";
21import type * as control_plane_sources from "../control_plane/sources.js";
22import type * as control_plane_storage from "../control_plane/storage.js";
23import type * as control_plane_tool_registry from "../control_plane/tool_registry.js";
24import type * as control_plane_tools from "../control_plane/tools.js";
25import type * as control_plane_workspaces from "../control_plane/workspaces.js";
26import type * as credential_crypto from "../credential_crypto.js";
27import type * as executor from "../executor.js";
28import type * as http from "../http.js";
29import type * as http_security from "../http_security.js";
30import type * as mcp from "../mcp.js";
31import type * as mcp_auth from "../mcp_auth.js";
32import type * as run_executor from "../run_executor.js";
33import type * as runtimeCallbacks from "../runtimeCallbacks.js";
34import type * as runtime_adapter from "../runtime_adapter.js";
35import type * as runtime_execution_port from "../runtime_execution_port.js";
36import type * as source_tool_registry from "../source_tool_registry.js";
37import type * as task_runs from "../task_runs.js";
38import type * as workflow from "../workflow.js";
39
40import type {
41 ApiFromModules,
42 FilterApi,
43 FunctionReference,
44} from "convex/server";
45
46declare 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 */
85export 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 */
98export declare const internal: FilterApi<
99 typeof fullApi,
100 FunctionReference<any, "internal">
101>;
102
103export 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};
apps/convex/convex/_generated/api.js+0−23
@@ -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
11import { 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 */
21export const api = anyApi;
22export const internal = anyApi;
23export const components = componentsGeneric();
apps/convex/convex/_generated/dataModel.d.ts+0−60
@@ -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
11import type {
12 DataModelFromSchemaDefinition,
13 DocumentByName,
14 TableNamesInDataModel,
15 SystemTableNames,
16} from "convex/server";
17import type { GenericId } from "convex/values";
18import schema from "../schema.js";
19
20/**
21 * The names of all of your Convex tables.
22 */
23export 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 */
30export 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 */
48export 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 */
60export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
apps/convex/convex/_generated/server.d.ts+0−143
@@ -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
11import {
12 ActionBuilder,
13 HttpActionBuilder,
14 MutationBuilder,
15 QueryBuilder,
16 GenericActionCtx,
17 GenericMutationCtx,
18 GenericQueryCtx,
19 GenericDatabaseReader,
20 GenericDatabaseWriter,
21} from "convex/server";
22import 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 */
32export 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 */
42export 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 */
52export 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 */
62export 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 */
75export 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 */
83export 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 */
96export 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 */
107export 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 */
115export 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 */
123export 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 */
132export 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 */
143export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
apps/convex/convex/_generated/server.js+0−93
@@ -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
11import {
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 */
29export 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 */
39export 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 */
49export 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 */
59export 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 */
72export 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 */
80export 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 */
93export const httpAction = httpActionGeneric;
apps/convex/convex/auth.config.ts+0−33
@@ -1,33 +0,0 @@
1import type { AuthConfig } from "convex/server";
2
3const trim = (value: string | undefined): string | undefined => {
4 const candidate = value?.trim();
5 return candidate && candidate.length > 0 ? candidate : undefined;
6};
7
8const rawClientId = trim(process.env.WORKOS_CLIENT_ID);
9const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined;
10
11const 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
29const authConfig: AuthConfig = {
30 providers,
31};
32
33export default authConfig;
apps/convex/convex/controlPlane.ts+0−72
@@ -1,72 +0,0 @@
1import {
2 listApprovals as listApprovalsImpl,
3 resolveApproval as resolveApprovalImpl,
4} from "./control_plane/approvals";
5import {
6 listCredentialBindings as listCredentialBindingsImpl,
7 removeCredentialBinding as removeCredentialBindingImpl,
8 upsertCredentialBinding as upsertCredentialBindingImpl,
9} from "./control_plane/credentials";
10import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http";
11import {
12 listOrganizations as listOrganizationsImpl,
13 upsertOrganization as upsertOrganizationImpl,
14} from "./control_plane/organizations";
15import {
16 listPolicies as listPoliciesImpl,
17 removePolicy as removePolicyImpl,
18 upsertPolicy as upsertPolicyImpl,
19} from "./control_plane/policies";
20import {
21 listSources as listSourcesImpl,
22 removeSource as removeSourceImpl,
23 upsertSource as upsertSourceImpl,
24} from "./control_plane/sources";
25import {
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";
35import {
36 getToolDetail as getToolDetailImpl,
37 listSourceTools as listSourceToolsImpl,
38 listWorkspaceTools as listWorkspaceToolsImpl,
39} from "./control_plane/tools";
40import {
41 listWorkspaces as listWorkspacesImpl,
42 upsertWorkspace as upsertWorkspaceImpl,
43} from "./control_plane/workspaces";
44
45export const listSources = listSourcesImpl;
46export const upsertSource = upsertSourceImpl;
47export const removeSource = removeSourceImpl;
48export const listCredentialBindings = listCredentialBindingsImpl;
49export const upsertCredentialBinding = upsertCredentialBindingImpl;
50export const removeCredentialBinding = removeCredentialBindingImpl;
51export const listPolicies = listPoliciesImpl;
52export const upsertPolicy = upsertPolicyImpl;
53export const removePolicy = removePolicyImpl;
54export const listOrganizations = listOrganizationsImpl;
55export const upsertOrganization = upsertOrganizationImpl;
56export const listWorkspaces = listWorkspacesImpl;
57export const upsertWorkspace = upsertWorkspaceImpl;
58export const listWorkspaceTools = listWorkspaceToolsImpl;
59export const listSourceTools = listSourceToolsImpl;
60export const getToolDetail = getToolDetailImpl;
61export const listStorageInstances = listStorageInstancesImpl;
62export const openStorageInstance = openStorageInstanceImpl;
63export const closeStorageInstance = closeStorageInstanceImpl;
64export const removeStorageInstance = removeStorageInstanceImpl;
65export const listStorageDirectory = listStorageDirectoryImpl;
66export const readStorageFile = readStorageFileImpl;
67export const listStorageKv = listStorageKvImpl;
68export const queryStorageSql = queryStorageSqlImpl;
69export const listApprovals = listApprovalsImpl;
70export const resolveApproval = resolveApprovalImpl;
71
72export const controlPlaneHttpHandler = controlPlaneHttpHandlerImpl;
apps/convex/convex/control_plane/actor.ts+0−403
@@ -1,403 +0,0 @@
1import {
2 ControlPlaneActorResolverLive,
3 deriveWorkspaceMembershipsForPrincipal,
4} from "@executor-v2/management-api";
5import { ActorUnauthenticatedError, makeActor } from "@executor-v2/domain";
6import {
7 OrganizationMembershipSchema,
8 PrincipalSchema,
9 type Principal,
10 WorkspaceSchema,
11 type OrganizationMembership,
12 type Workspace,
13} from "@executor-v2/schema";
14import { type UserIdentity } from "convex/server";
15import { v } from "convex/values";
16import * as Effect from "effect/Effect";
17import * as Schema from "effect/Schema";
18
19import { internal } from "../_generated/api";
20import {
21 internalMutation,
22 internalQuery,
23 type ActionCtx,
24} from "../_generated/server";
25
26const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
27const decodeOrganizationMembership = Schema.decodeUnknownSync(
28 OrganizationMembershipSchema,
29);
30const decodePrincipal = Schema.decodeUnknownSync(PrincipalSchema);
31
32const stripConvexSystemFields = (
33 value: Record<string, unknown>,
34): Record<string, unknown> => {
35 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
36 return rest;
37};
38
39const 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
50export 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
70export 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
88export 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
116export 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
218const 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
230const inferPrincipalProvider = (
231 identity: UserIdentity,
232): Principal["provider"] =>
233 identity.issuer.trim().toLowerCase().includes("workos")
234 ? "workos"
235 : "local";
236
237const 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
266const 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
289export 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 });
apps/convex/convex/control_plane/approvals.ts+0−95
@@ -1,95 +0,0 @@
1import {
2 type ResolveApprovalPayload,
3} from "@executor-v2/management-api";
4import {
5 ApprovalSchema,
6 type Approval,
7} from "@executor-v2/schema";
8import { v } from "convex/values";
9import * as Schema from "effect/Schema";
10
11import { internalMutation, internalQuery } from "../_generated/server";
12
13const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema);
14
15const stripConvexSystemFields = (
16 value: Record<string, unknown>,
17): Record<string, unknown> => {
18 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
19 return rest;
20};
21
22const approvalStatusValidator = v.union(
23 v.literal("approved"),
24 v.literal("denied"),
25);
26
27const sortApprovals = (approvals: ReadonlyArray<Approval>): Array<Approval> =>
28 [...approvals].sort((left, right) => right.requestedAt - left.requestedAt);
29
30export 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
50export 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});
apps/convex/convex/control_plane/credentials.ts+0−1780
@@ -1,1780 +0,0 @@
1import {
2 type UpsertCredentialBindingPayload,
3} from "@executor-v2/management-api";
4import {
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";
18import { v } from "convex/values";
19import * as Schema from "effect/Schema";
20
21import { internal } from "../_generated/api";
22import {
23 currentCredentialEncryptionKeyVersion,
24 decryptSecretValue,
25 encryptSecretValue,
26} from "../credential_crypto";
27import {
28 internalAction,
29 internalMutation,
30 internalQuery,
31 type MutationCtx,
32 type QueryCtx,
33} from "../_generated/server";
34
35const runtimeInternal = internal;
36
37const decodeAuthConnection = Schema.decodeUnknownSync(AuthConnectionSchema);
38const decodeSourceAuthBinding = Schema.decodeUnknownSync(SourceAuthBindingSchema);
39const decodeAuthMaterial = Schema.decodeUnknownSync(AuthMaterialSchema);
40const decodeOAuthState = Schema.decodeUnknownSync(OAuthStateSchema);
41const decodeCompatSourceCredentialBinding = Schema.decodeUnknownSync(
42 SourceCredentialBindingSchema,
43);
44
45const 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
53const credentialSecretProviderValidator = v.literal("local");
54
55const credentialScopeTypeValidator = v.union(
56 v.literal("workspace"),
57 v.literal("organization"),
58 v.literal("account"),
59);
60
61const 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
84type 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
97const stripConvexSystemFields = (
98 value: Record<string, unknown>,
99): Record<string, unknown> => {
100 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
101 return rest;
102};
103
104const 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
113const 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
130const 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
148const parseAdditionalHeaders = (
149 additionalHeadersJson: string | null,
150): Record<string, string> => {
151 if (!additionalHeadersJson) {
152 return {};
153 }
154
155 return toStringRecord(parseJsonObject(additionalHeadersJson));
156};
157
158const parseConnectionMetadata = (
159 metadataJson: string | null,
160): Record<string, unknown> => parseJsonObject(metadataJson);
161
162const 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
175const 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
185const 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
195const 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
226const 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
255const 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
265const sourceKeyFromSourceId = (sourceId: string): string => `source:${sourceId}`;
266
267const toAuthConnection = (document: Record<string, unknown>): AuthConnection =>
268 decodeAuthConnection(stripConvexSystemFields(document));
269
270const toSourceAuthBinding = (document: Record<string, unknown>): SourceAuthBinding =>
271 decodeSourceAuthBinding(stripConvexSystemFields(document));
272
273const toAuthMaterial = (document: Record<string, unknown>): AuthMaterial =>
274 decodeAuthMaterial(stripConvexSystemFields(document));
275
276const toOAuthState = (document: Record<string, unknown>): OAuthState =>
277 decodeOAuthState(stripConvexSystemFields(document));
278
279const 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
295const 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
321const 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
370const 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
384const 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
398const 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
419const 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
429type OAuthRefreshConfig = {
430 tokenEndpoint?: string;
431 authorizationServer?: string;
432 clientId?: string;
433 clientSecretCiphertext?: string;
434 sourceUrl?: string;
435 clientInformationJson?: string;
436};
437
438const 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
466const 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
487const oauthRefreshWindowMs = 5 * 60 * 1000;
488const oauthRefreshLeaseMs = 30 * 1000;
489
490type OAuthRefreshErrorClass =
491 | "transient"
492 | "invalid_grant"
493 | "invalid_client"
494 | "unsupported"
495 | "unknown";
496
497const 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
522const 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
537const 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
566const 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
691const 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
723const 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
739const 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
755const 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
771const 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
798export 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
824export 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
883export 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
962export 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
1047export 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
1106const 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
1207export 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
1215export 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
1322export 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
1330const 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
1338const 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
1359export 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
1588export 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
1632export 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
1723export 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});
apps/convex/convex/control_plane/errors.ts+0−82
@@ -1,82 +0,0 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";
2
3type ControlPlaneErrorCode =
4 | "bad_request"
5 | "unauthorized"
6 | "forbidden"
7 | "not_found"
8 | "method_not_allowed"
9 | "internal";
10
11type ControlPlaneErrorResponse = {
12 ok: false;
13 error: {
14 code: ControlPlaneErrorCode;
15 message: string;
16 details: string | null;
17 };
18};
19
20const renderCause = (cause: unknown): string => String(cause);
21
22export 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
35const 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
52export 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
69const isJsonResponse = (response: Response): boolean =>
70 response.headers.get("content-type")?.toLowerCase().includes("application/json") ?? false;
71
72export 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};
apps/convex/convex/control_plane/http.ts+0−121
@@ -1,121 +0,0 @@
1import {
2 ControlPlaneService,
3 controlPlaneOpenApiSpec,
4 makeControlPlaneWebHandler,
5} from "@executor-v2/management-api";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8
9import { httpAction, type ActionCtx } from "../_generated/server";
10import { ConvexControlPlaneActorLive } from "./actor";
11import {
12 controlPlaneErrorResponse,
13 normalizeControlPlaneErrorResponse,
14 toSourceStoreError,
15} from "./errors";
16import { makeConvexControlPlaneService } from "./service";
17
18const isOpenApiRequest = (request: Request): boolean =>
19 new URL(request.url).pathname === "/v1/openapi.json";
20
21const 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
47const 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
62const 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
119export const controlPlaneHttpHandler = httpAction((ctx, request) =>
120 Effect.runPromise(handleControlPlaneRequest(ctx, request)),
121);
apps/convex/convex/control_plane/openapi_ingest.ts+0−1538
@@ -1,1538 +0,0 @@
1"use node";
2
3import { createHash } from "node:crypto";
4
5import { type Source } from "@executor-v2/schema";
6import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
8import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9import { v } from "convex/values";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12
13import { internal } from "../_generated/api";
14import { internalAction } from "../_generated/server";
15
16const runtimeInternal = internal as any;
17
18const graphqlExtractorVersion = "graphql_v1";
19const mcpExtractorVersion = "mcp_v1";
20const writeBatchSize = 500;
21const stagedArtifactBatchSize = 500;
22
23const graphqlSchemaRootsQuery = `query SchemaRoots {
24 __schema {
25 queryType { name }
26 mutationType { name }
27 }
28}`;
29
30const 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
57type 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
67type McpIngestTool = {
68 toolId: string;
69 name: string;
70 description: string | null;
71 toolName: string;
72 operationHash: string;
73 invocationJson: string;
74};
75
76type McpTransport = "sse" | "streamable-http";
77
78type McpConnection = {
79 client: Client;
80 close: () => Promise<void>;
81 transport: McpTransport;
82};
83
84const SourceIngestStatusSchema = Schema.Union(
85 Schema.Literal("error"),
86 Schema.Literal("auth_required"),
87);
88
89type SourceIngestStatus = typeof SourceIngestStatusSchema.Type;
90
91class SourceIngestError extends Schema.TaggedError<SourceIngestError>()(
92 "SourceIngestError",
93 {
94 status: SourceIngestStatusSchema,
95 message: Schema.String,
96 },
97) {}
98
99type 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
109const isUnknownRecord = (value: unknown): value is Record<string, unknown> =>
110 typeof value === "object" && value !== null && !Array.isArray(value);
111
112const 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
121const 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
139const 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
154const hashUnknown = (value: unknown): string =>
155 createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
156
157const 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
166const 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
175const 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
185const sourceNamespace = (source: Source): string => {
186 const sourceIdSuffix = source.id.slice(-6).toLowerCase();
187 return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`;
188};
189
190const sourceToolPath = (source: Source, toolId: string): string =>
191 `${sourceNamespace(source)}.${toolId}`;
192
193const 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
199const 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
218const 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
242const 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
251const safeJsonStringify = (value: unknown): string | null => {
252 try {
253 return JSON.stringify(value);
254 } catch {
255 return null;
256 }
257};
258
259const 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
270const 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
280const 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
304const isRequiredType = (typeRef: unknown): boolean =>
305 isUnknownRecord(typeRef) && normalizeString(typeRef.kind) === "NON_NULL";
306
307const 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
316const 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
335const 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
343const collectMcpHeadersFromSourceConfig = (source: Source): Record<string, string> => {
344 const config = parseSourceConfig(source.configJson);
345 return {
346 ...collectConfiguredHeadersFromSourceConfig(config),
347 };
348};
349
350const 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
376const graphqlAuthErrorCodes = new Set(["unauthenticated", "forbidden", "unauthorized"]);
377
378const 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
402const looksLikeAuthErrorMessage = (value: string): boolean => {
403 const message = value.toLowerCase();
404 return message.includes("unauthor") || message.includes("forbidden") || message.includes("auth required");
405};
406
407const 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
416const 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
430const 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
481const 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
516const 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
550const 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
568const 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
666const 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
681const 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
701const 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
717const 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
792const 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
804const 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
812const 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
830const 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
887const formatError = (cause: unknown): string =>
888 cause instanceof Error ? cause.message : String(cause);
889
890const 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
903const openApiParserUrl = process.env.OPENAPI_PARSE_API_URL?.trim() ?? "";
904const openApiParserToken = process.env.OPENAPI_PARSE_API_TOKEN?.trim() ?? "";
905
906type 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
919const 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
949const 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
967const 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
985const 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
1029const 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
1051const 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
1106export 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
1121type IngestOptions = {
1122 rebuildIndex: boolean;
1123 stageArtifactWrites: boolean;
1124};
1125
1126const 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
1147const 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
1302const 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
1475export 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});
apps/convex/convex/control_plane/organizations.ts+0−124
@@ -1,124 +0,0 @@
1import { type UpsertOrganizationPayload } from "@executor-v2/management-api";
2import {
3 OrganizationSchema,
4 type Organization,
5 type OrganizationMembership,
6} from "@executor-v2/schema";
7import { v } from "convex/values";
8import * as Schema from "effect/Schema";
9
10import { internalMutation, internalQuery } from "../_generated/server";
11
12const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema);
13
14const stripConvexSystemFields = (
15 value: Record<string, unknown>,
16): Record<string, unknown> => {
17 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
18 return rest;
19};
20
21const toOrganization = (document: Record<string, unknown>): Organization =>
22 decodeOrganization(stripConvexSystemFields(document));
23
24const organizationStatusValidator = v.union(
25 v.literal("active"),
26 v.literal("suspended"),
27 v.literal("archived"),
28);
29
30const 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
44export 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
55export 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});
apps/convex/convex/control_plane/policies.ts+0−120
@@ -1,120 +0,0 @@
1import { type UpsertPolicyPayload } from "@executor-v2/management-api";
2import { PolicySchema, type Policy } from "@executor-v2/schema";
3import { v } from "convex/values";
4import * as Schema from "effect/Schema";
5
6import { internalMutation, internalQuery } from "../_generated/server";
7
8const decodePolicy = Schema.decodeUnknownSync(PolicySchema);
9
10const stripConvexSystemFields = (
11 value: Record<string, unknown>,
12): Record<string, unknown> => {
13 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
14 return rest;
15};
16
17const toPolicy = (document: Record<string, unknown>): Policy =>
18 decodePolicy(stripConvexSystemFields(document));
19
20const policyDecisionValidator = v.union(
21 v.literal("allow"),
22 v.literal("require_approval"),
23 v.literal("deny"),
24);
25
26const 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
38export 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
54export 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
96export 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});
apps/convex/convex/control_plane/service.ts+0−283
@@ -1,283 +0,0 @@
1import {
2 ControlPlaneService,
3 type ControlPlaneServiceShape,
4} from "@executor-v2/management-api";
5import type { SourceToolSummary } from "@executor-v2/management-api/tools/api";
6import * as Effect from "effect/Effect";
7
8import { internal } from "../_generated/api";
9import type { ActionCtx } from "../_generated/server";
10import { toSourceStoreError } from "./errors";
11
12const runtimeInternal = internal as any;
13runtimeInternal.controlPlane = runtimeInternal.controlPlane ?? runtimeInternal.control_plane;
14const toolPageSize = 250;
15
16const 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
50export 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 });
apps/convex/convex/control_plane/sources.ts+0−395
@@ -1,395 +0,0 @@
1import { type UpsertSourcePayload } from "@executor-v2/management-api";
2import { SourceSchema, type Source } from "@executor-v2/schema";
3import { v } from "convex/values";
4import * as Schema from "effect/Schema";
5
6import { internal } from "../_generated/api";
7import {
8 internalAction,
9 internalMutation,
10 internalQuery,
11} from "../_generated/server";
12
13const runtimeInternal = internal as any;
14
15const decodeSource = Schema.decodeUnknownSync(SourceSchema);
16
17const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`;
18
19const isRecord = (value: unknown): value is Record<string, unknown> =>
20 Boolean(value) && typeof value === "object" && !Array.isArray(value);
21
22const 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
35const protectedHeaderNames = new Set([
36 "authorization",
37 "proxy-authorization",
38 "x-api-key",
39]);
40
41const 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
65const 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
83const 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
96const 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
111const 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
123const toSource = (document: Record<string, unknown>): Source => {
124 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...source } = document;
125 return decodeSource(source);
126};
127
128const sourceKindValidator = v.union(
129 v.literal("mcp"),
130 v.literal("openapi"),
131 v.literal("graphql"),
132 v.literal("internal"),
133);
134
135const 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
143export 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
161export 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
237export 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
318export 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
336export 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
362export 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});
apps/convex/convex/control_plane/storage.ts+0−1068
@@ -1,1068 +0,0 @@
1import {
2 type ListStorageDirectoryResult,
3 type ListStorageKvResult,
4 type OpenStorageInstancePayload,
5 type QueryStorageSqlResult,
6 type ReadStorageFileResult,
7} from "@executor-v2/management-api";
8import { StorageInstanceSchema, type StorageInstance } from "@executor-v2/schema";
9import { v } from "convex/values";
10import * as Schema from "effect/Schema";
11
12import {
13 internalQuery,
14 internalMutation,
15 type MutationCtx,
16 type QueryCtx,
17} from "../_generated/server";
18
19const decodeStorageInstance = Schema.decodeUnknownSync(StorageInstanceSchema);
20
21const 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
30type StorageFileEntry = typeof StorageFileEntrySchema.Type;
31const decodeStorageFileEntry = Schema.decodeUnknownSync(StorageFileEntrySchema);
32
33const 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
41type StorageKvEntry = typeof StorageKvEntrySchema.Type;
42const decodeStorageKvEntry = Schema.decodeUnknownSync(StorageKvEntrySchema);
43
44const 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
52type StorageSqlKvEntry = typeof StorageSqlKvEntrySchema.Type;
53const decodeStorageSqlKvEntry = Schema.decodeUnknownSync(StorageSqlKvEntrySchema);
54
55const DEFAULT_KV_LIMIT = 100;
56const MAX_KV_LIMIT = 1000;
57const DEFAULT_SQL_MAX_ROWS = 200;
58const MAX_SQL_MAX_ROWS = 5000;
59
60const stripConvexSystemFields = (
61 value: Record<string, unknown>,
62): Record<string, unknown> => {
63 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
64 return rest;
65};
66
67const toStorageInstance = (document: Record<string, unknown>): StorageInstance =>
68 decodeStorageInstance(stripConvexSystemFields(document));
69
70const toStorageFileEntry = (document: Record<string, unknown>): StorageFileEntry =>
71 decodeStorageFileEntry(stripConvexSystemFields(document));
72
73const toStorageKvEntry = (document: Record<string, unknown>): StorageKvEntry =>
74 decodeStorageKvEntry(stripConvexSystemFields(document));
75
76const toStorageSqlKvEntry = (document: Record<string, unknown>): StorageSqlKvEntry =>
77 decodeStorageSqlKvEntry(stripConvexSystemFields(document));
78
79const storageScopeTypeValidator = v.union(
80 v.literal("scratch"),
81 v.literal("account"),
82 v.literal("workspace"),
83 v.literal("organization"),
84);
85
86const storageDurabilityValidator = v.union(
87 v.literal("ephemeral"),
88 v.literal("durable"),
89);
90
91const storageProviderValidator = v.union(
92 v.literal("agentfs-local"),
93 v.literal("agentfs-cloudflare"),
94);
95
96const readStorageEncodingValidator = v.union(
97 v.literal("utf8"),
98 v.literal("base64"),
99);
100
101const 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
112const 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
128const 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
141const 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
173const 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
186const 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
196const parseJsonOrString = (value: string): unknown => {
197 try {
198 return JSON.parse(value) as unknown;
199 } catch {
200 return value;
201 }
202};
203
204const 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
237export 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
290export 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
339const decodeSqlStringLiteral = (value: string): string => value.replaceAll("''", "'");
340
341const 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
368const 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
398type SqlWrite =
399 | {
400 type: "upsert";
401 key: string;
402 value: string;
403 insertOnly: boolean;
404 }
405 | {
406 type: "delete";
407 key: string;
408 };
409
410const 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
549export 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
586export 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
650export 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
689export 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
751export 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
871export 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
916export 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
963export 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});
apps/convex/convex/control_plane/tool_registry.ts+0−1611
@@ -1,1611 +0,0 @@
1import { ActionCache } from "@convex-dev/action-cache";
2import { v } from "convex/values";
3import { components, internal } from "../_generated/api";
4
5import { action, internalAction, internalMutation, internalQuery } from "../_generated/server";
6
7const defaultSearchLimit = 50;
8const maxSearchLimit = 5_000;
9const writeBatchSize = 500;
10const runtimeInternal = internal as any;
11const 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
17const stripConvexSystemFields = (
18 value: Record<string, unknown>,
19): Record<string, unknown> => {
20 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
21 return rest;
22};
23
24const normalizePathForLookup = (path: string): string =>
25 path
26 .trim()
27 .toLowerCase()
28 .replace(/[^a-z0-9]+/g, ".")
29 .replace(/\.+/g, ".")
30 .replace(/^\.|\.$/g, "");
31
32const 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
40const artifactSchemaRefId = (artifactId: string, refKey: string): string =>
41 `${artifactId}:ref:${encodeURIComponent(refKey)}`;
42
43const 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
53const 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
61const 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
76const 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
95const 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
101const 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
123const 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
157const 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
168const 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
263const 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
274const workspaceToolPageSize = 250;
275
276const 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
354export 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
530export 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
582export 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
600export 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
630export 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
644export 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
728export 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
760export 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
798export 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
819export 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
842export 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
914export 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
935export 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
1045export 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
1080export 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
1097export 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
1111export 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
1125export 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
1141export 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
1167export 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
1187export 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
1324export 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
1355export 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
1372export 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
1427export 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
1448export 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
1465export 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
1489export 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
1517export 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
1601export 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});
apps/convex/convex/control_plane/tools.ts+0−431
@@ -1,431 +0,0 @@
1import { type SourceToolDetail, type SourceToolSummary } from "@executor-v2/management-api";
2import {
3 SourceSchema,
4 type OpenApiHttpMethod,
5 type Source,
6} from "@executor-v2/schema";
7import { v } from "convex/values";
8import * as Schema from "effect/Schema";
9import { internal } from "../_generated/api";
10
11import { internalAction, internalQuery } from "../_generated/server";
12
13const decodeSource = Schema.decodeUnknownSync(SourceSchema);
14const runtimeInternal = internal as any;
15
16const stripConvexSystemFields = (
17 value: Record<string, unknown>,
18): Record<string, unknown> => {
19 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
20 return rest;
21};
22
23const toSource = (document: Record<string, unknown>): Source =>
24 decodeSource(stripConvexSystemFields(document));
25
26const 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
41const 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
53const asNullableString = (value: unknown): string | null =>
54 typeof value === "string" && value.trim().length > 0 ? value : null;
55
56const 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
66const sourceNamespace = (source: Source): string => {
67 const sourceIdSuffix = source.id.slice(-6).toLowerCase();
68 return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`;
69};
70
71const 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
83const 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
92const 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
116const 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
139const 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
195const 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
220const 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
242const 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
261const workspaceToolsPageSize = 250;
262
263export 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
301export 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
332export 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
366export 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});
apps/convex/convex/control_plane/workspaces.ts+0−78
@@ -1,78 +0,0 @@
1import { type UpsertWorkspacePayload } from "@executor-v2/management-api";
2import { WorkspaceSchema, type Workspace } from "@executor-v2/schema";
3import { v } from "convex/values";
4import * as Schema from "effect/Schema";
5
6import { internalMutation, internalQuery } from "../_generated/server";
7
8const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
9
10const stripConvexSystemFields = (
11 value: Record<string, unknown>,
12): Record<string, unknown> => {
13 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
14 return rest;
15};
16
17const toWorkspace = (document: Record<string, unknown>): Workspace =>
18 decodeWorkspace(stripConvexSystemFields(document));
19
20const 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
32export 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
43export 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});
apps/convex/convex/convex.config.ts+0−9
@@ -1,9 +0,0 @@
1import actionCache from "@convex-dev/action-cache/convex.config";
2import workflow from "@convex-dev/workflow/convex.config.js";
3import { defineApp } from "convex/server";
4
5const app = defineApp();
6app.use(actionCache);
7app.use(workflow);
8
9export default app;
apps/convex/convex/credential_crypto.ts+0−192
@@ -1,192 +0,0 @@
1const encryptedPrefix = "enc:v1:";
2
3const encoder = new TextEncoder();
4const decoder = new TextDecoder();
5
6type EncryptionKeyEntry = {
7 keyVersion: string;
8 passphrase: string;
9};
10
11const 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
19const 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
28const normalizeKeyVersion = (value: string): string => {
29 const trimmed = value.trim();
30 return trimmed.length > 0 ? trimmed : "v1";
31};
32
33const 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
72const resolveEncryptionKeyEntries = (): Array<EncryptionKeyEntry> =>
73 parseEncryptionKeyEntries();
74
75const keyCache = new Map<string, Promise<CryptoKey>>();
76
77const 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
102const 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
135const 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
152export const currentCredentialEncryptionKeyVersion = (): string =>
153 resolveEncryptionKeyEntries()[0]!.keyVersion;
154
155export const isEncryptedSecret = (value: string): boolean =>
156 value.startsWith(encryptedPrefix);
157
158export 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
174export 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};
apps/convex/convex/executor.test.ts+0−1782
@@ -1,1782 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { extractOpenApiManifest } from "@executor-v2/management-api";
3import { convexTest } from "convex-test";
4import * as Effect from "effect/Effect";
5
6import { internal } from "./_generated/api";
7import { executeRunImpl } from "./executor";
8import schema from "./schema";
9
10const runtimeInternal = internal as any;
11const api = {
12 controlPlane: runtimeInternal.controlPlane ?? runtimeInternal.control_plane,
13} as any;
14
15const 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
34const 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
48describe("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});
apps/convex/convex/executor.ts+0−29
@@ -1,29 +0,0 @@
1import {
2 createRunExecutor,
3 type ToolRegistry,
4} from "@executor-v2/engine";
5import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
6import * as Effect from "effect/Effect";
7
8import { createExecuteRuntimeRunInConvex } from "./runtime_execution_port";
9
10export type ExecuteRunImplOptions = {
11 toolRegistry?: ToolRegistry;
12 makeRunId?: () => string;
13};
14
15export 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};
apps/convex/convex/http.ts+0−62
@@ -1,62 +0,0 @@
1import { httpRouter } from "convex/server";
2
3import { controlPlaneHttpHandler } from "./controlPlane";
4import { mcpHandler } from "./mcp";
5import {
6 oauthAuthorizationServerHandler,
7 oauthProtectedResourceHandler,
8} from "./mcp_auth";
9import { handleToolCallHttp } from "./runtimeCallbacks";
10
11const http = httpRouter();
12
13http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });
14http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });
15http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });
16http.route({
17 path: "/.well-known/oauth-protected-resource",
18 method: "GET",
19 handler: oauthProtectedResourceHandler,
20});
21http.route({
22 path: "/.well-known/oauth-authorization-server",
23 method: "GET",
24 handler: oauthAuthorizationServerHandler,
25});
26http.route({
27 path: "/v1/runtime/tool-call",
28 method: "POST",
29 handler: handleToolCallHttp,
30});
31http.route({
32 pathPrefix: "/v1/",
33 method: "GET",
34 handler: controlPlaneHttpHandler,
35});
36http.route({
37 pathPrefix: "/v1/",
38 method: "POST",
39 handler: controlPlaneHttpHandler,
40});
41http.route({
42 pathPrefix: "/v1/",
43 method: "PUT",
44 handler: controlPlaneHttpHandler,
45});
46http.route({
47 pathPrefix: "/v1/",
48 method: "PATCH",
49 handler: controlPlaneHttpHandler,
50});
51http.route({
52 pathPrefix: "/v1/",
53 method: "DELETE",
54 handler: controlPlaneHttpHandler,
55});
56http.route({
57 pathPrefix: "/v1/",
58 method: "OPTIONS",
59 handler: controlPlaneHttpHandler,
60});
61
62export default http;
apps/convex/convex/http_security.ts+0−69
@@ -1,69 +0,0 @@
1type RequestDenyOptions = {
2 status: number;
3 message: string;
4};
5
6type InternalSecretOptions = {
7 context: string;
8 envVarNames: ReadonlyArray<string>;
9 headerName?: string;
10};
11
12const trim = (value: string | undefined): string | undefined => {
13 const candidate = value?.trim();
14 return candidate && candidate.length > 0 ? candidate : undefined;
15};
16
17const denyRequest = (options: RequestDenyOptions): Response =>
18 Response.json(
19 {
20 error: options.message,
21 },
22 { status: options.status },
23 );
24
25const readEnvValue = (name: string): string | undefined => trim(process.env[name]);
26
27const 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
38const readRequestHeader = (request: Request, name: string): string | null =>
39 trim(request.headers.get(name) ?? undefined) ?? null;
40
41export 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};
apps/convex/convex/mcp.ts+0−192
@@ -1,192 +0,0 @@
1import {
2 buildExecuteToolDescription,
3 defaultExecuteToolDescription,
4 defaultExecuteToolExposureMode,
5 parseExecuteToolExposureMode,
6 type ExecuteToolExposureMode,
7} from "@executor-v2/engine";
8import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
9import { createExecutorRunClient } from "@executor-v2/sdk";
10import * as Effect from "effect/Effect";
11
12import { internal } from "./_generated/api";
13import { httpAction, type ActionCtx } from "./_generated/server";
14import { executeRunImpl } from "./executor";
15import {
16 getMcpAuthConfig,
17 unauthorizedMcpResponse,
18 verifyMcpToken,
19} from "./mcp_auth";
20import { createConvexSourceToolRegistry } from "./source_tool_registry";
21
22const 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
27const 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
40const 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
56const 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
91const defaultToolExposureMode =
92 parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ??
93 defaultExecuteToolExposureMode;
94const runtimeInternal = internal as any;
95
96export 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});
apps/convex/convex/mcp_auth.ts+0−229
@@ -1,229 +0,0 @@
1import { createRemoteJWKSet, jwtVerify } from "jose";
2
3import { httpAction } from "./_generated/server";
4
5export const MCP_PATH = "/v1/mcp";
6
7type McpAuthConfig = {
8 required: boolean;
9 enabled: boolean;
10 authorizationServer: string | null;
11 jwks: ReturnType<typeof createRemoteJWKSet> | null;
12};
13
14type VerifiedMcpToken = {
15 provider: "workos";
16 subject: string;
17};
18
19const isTruthyEnvValue = (value: string | undefined): boolean => {
20 const normalized = value?.trim().toLowerCase();
21 return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
22};
23
24const 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
43const 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
59const 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
66export 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
87const 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
97const 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
109export 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
127export 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
164const 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
174export 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
208export 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});
apps/convex/convex/run_executor.ts+0−5
@@ -1,5 +0,0 @@
1import { type ExecuteRunInput } from "@executor-v2/sdk";
2
3import { executeRunImpl } from "./executor";
4
5export const executeRun = (input: ExecuteRunInput) => executeRunImpl(input);
apps/convex/convex/runtimeCallbacks.ts+0−165
@@ -1,165 +0,0 @@
1import {
2 RuntimeAdapterError,
3 invokeRuntimeToolCallResult,
4} from "@executor-v2/engine";
5import type {
6 RuntimeToolCallRequest,
7 RuntimeToolCallResult,
8} from "@executor-v2/sdk";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as ParseResult from "effect/ParseResult";
12import * as Schema from "effect/Schema";
13
14import { internal } from "./_generated/api";
15import { httpAction, type ActionCtx } from "./_generated/server";
16import { enforceInternalSecret } from "./http_security";
17import { createConvexSourceToolRegistry } from "./source_tool_registry";
18
19class RuntimeToolCallBadRequestError extends Data.TaggedError(
20 "RuntimeToolCallBadRequestError",
21)<{
22 message: string;
23 details: string;
24}> {}
25
26const 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
33const 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
46const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
47
48const 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
58const formatBadRequestMessage = (error: RuntimeToolCallBadRequestError): string =>
59 error.details.length > 0 ? `${error.message}: ${error.details}` : error.message;
60
61const formatRuntimeAdapterError = (error: RuntimeAdapterError): string =>
62 error.details ? `${error.message}: ${error.details}` : error.message;
63
64const formatUnknownDetails = (cause: unknown): string => String(cause);
65
66const 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
88const 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
126const 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
163export const handleToolCallHttp = httpAction((ctx, request) =>
164 Effect.runPromise(handleToolCallHttpEffect(ctx, request)),
165);
apps/convex/convex/runtime_adapter.ts+0−10
@@ -1,10 +0,0 @@
1import {
2 type RuntimeAdapter,
3 type RuntimeExecuteError,
4 type RuntimeExecuteInput,
5} from "@executor-v2/engine";
6import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";
7
8export type { RuntimeAdapter, RuntimeExecuteError, RuntimeExecuteInput };
9
10export { makeCloudflareWorkerLoaderRuntimeAdapter };
apps/convex/convex/runtime_execution_port.ts+0−74
@@ -1,74 +0,0 @@
1import {
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";
11import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";
12import * as Effect from "effect/Effect";
13
14export type ConvexRuntimeExecutionPortOptions = {
15 toolRegistry?: ToolRegistry;
16};
17
18export 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
74export const executeRuntimeRunInConvex = createExecuteRuntimeRunInConvex();
apps/convex/convex/schema.ts+0−253
@@ -1,253 +0,0 @@
1import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect";
2import {
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";
20import * as Schema from "effect/Schema";
21
22const 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
31const 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
39const 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
47const 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
58const 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
75const 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
84const 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
92const 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
103const 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
128export 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
251export type ExecutorConfectTables = TablesFromSchemaDefinition<typeof executorConfectSchema>;
252
253export default executorConfectSchema.convexSchemaDefinition;
apps/convex/convex/source_tool_registry.ts+0−2128
@@ -1,2128 +0,0 @@
1import {
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";
22import {
23 ApprovalSchema,
24 OpenApiInvocationPayloadSchema,
25 type Approval,
26 type CanonicalToolDescriptor,
27 type Source,
28} from "@executor-v2/schema";
29import type { RuntimeToolCallResult } from "@executor-v2/sdk";
30import { v } from "convex/values";
31import * as Effect from "effect/Effect";
32import * as ParseResult from "effect/ParseResult";
33import * as Schema from "effect/Schema";
34
35import { internal } from "./_generated/api";
36import { internalAction, internalMutation, type ActionCtx } from "./_generated/server";
37
38const runtimeInternal = internal as any;
39
40const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema);
41const decodeOpenApiInvocationPayload = Schema.decodeUnknownSync(OpenApiInvocationPayloadSchema);
42
43const sourceToolRegistryRuntimeKind = "source-tool-registry";
44const defaultPendingRetryAfterMs = 1_000;
45const maxCatalogNamespacesLimit = 5_000;
46const maxCatalogToolsLimit = 50_000;
47const maxUnknownToolSuggestions = 3;
48
49const readBooleanFlag = (value: string | undefined): boolean => {
50 const normalized = value?.trim().toLowerCase();
51 return normalized === "1" || normalized === "true" || normalized === "yes";
52};
53
54const requireToolApprovalsByDefault = readBooleanFlag(
55 process.env.CONVEX_REQUIRE_TOOL_APPROVALS,
56);
57
58const stripConvexSystemFields = (
59 value: Record<string, unknown>,
60): Record<string, unknown> => {
61 const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
62 return rest;
63};
64
65const 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
77const 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
85const 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
94const 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
125const serializeInputPreview = (input: Record<string, unknown> | undefined): string => {
126 try {
127 return JSON.stringify(input ?? {});
128 } catch {
129 return "{}";
130 }
131};
132
133const toPersistentApprovalStoreError = (
134 operation: string,
135 message: string,
136 details: string | null,
137): PersistentToolApprovalPolicyStoreError =>
138 new PersistentToolApprovalPolicyStoreError({
139 operation,
140 message,
141 details,
142 });
143
144const 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
156const normalizeToolPathForLookup = (path: string): string =>
157 path
158 .trim()
159 .toLowerCase()
160 .replace(/[^a-z0-9]+/g, ".")
161 .replace(/\.+/g, ".")
162 .replace(/^\.|\.$/g, "");
163
164const safeJsonParse = (value: string): unknown => {
165 try {
166 return JSON.parse(value);
167 } catch {
168 return null;
169 }
170};
171
172const 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
180const 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
189const 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
203const parseSourceConfig = (source: Source): Record<string, unknown> =>
204 jsonObjectFromUnknown(safeJsonParse(source.configJson));
205
206const 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
223const 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
249const 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
297const 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
325const 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
351const 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
366const textHasInvalidTokenMarker = (value: string): boolean => {
367 const normalized = value.toLowerCase();
368 return normalized.includes("invalid_token") || normalized.includes("invalid token");
369};
370
371const 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
408type HttpInvocationEnvelope = {
409 status: number | null;
410 headers: Record<string, string>;
411 body: unknown;
412};
413
414const 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
440const 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
453const 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
464const 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
472const 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
480const GraphqlRawInvocationSchema = Schema.Struct({
481 kind: Schema.Literal("graphql_raw"),
482 endpoint: Schema.String,
483});
484
485const 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
493const GraphqlInvocationPayloadSchema = Schema.Union(
494 GraphqlRawInvocationSchema,
495 GraphqlFieldInvocationSchema,
496);
497
498type GraphqlInvocationPayload = typeof GraphqlInvocationPayloadSchema.Type;
499
500const decodeGraphqlInvocationPayload = Schema.decodeUnknownSync(GraphqlInvocationPayloadSchema);
501
502const 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
515type McpInvocationPayload = typeof McpInvocationPayloadSchema.Type;
516
517const decodeMcpInvocationPayload = Schema.decodeUnknownSync(McpInvocationPayloadSchema);
518
519const asOptionalString = (value: unknown): string | null =>
520 typeof value === "string" && value.trim().length > 0 ? value : null;
521
522const 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
530const 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
570const 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
579const 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
589const 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
615const 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
626const 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
638const 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
682type 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
702type 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
716const toToolProviderDetails = (cause: unknown): string =>
717 ParseResult.isParseError(cause)
718 ? ParseResult.TreeFormatter.formatErrorSync(cause)
719 : String(cause);
720
721const 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
734const 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
745const 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
761type NormalizedDiscoverQuery = {
762 text: string;
763 lowerText: string;
764 depth: ToolRegistryDiscoverDepth;
765};
766
767const 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
793const 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
871const 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
911const 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
929const 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
966const 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
973const 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
989const 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
1009const 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
1030const 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
1062const 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
1096const 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
1120const 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
1145const 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
1164const 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
1189const 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
1516const 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
1531const 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
1627const approvalModeValidator = v.union(v.literal("auto"), v.literal("required"));
1628
1629export 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
1752const 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
1773const 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
1811const describeSource = (row: {
1812 sourceName: string;
1813 sourceKind: string;
1814}): string => `${row.sourceKind.toUpperCase()} source: ${row.sourceName}`;
1815
1816export type ConvexSourceToolRegistryOptions = {
1817 accountId: string;
1818 requireToolApprovals?: boolean;
1819 approvalRetryAfterMs?: number;
1820};
1821
1822export 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
2105export 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});
apps/convex/convex/task_runs.ts+0−106
@@ -1,106 +0,0 @@
1import { type TaskRun } from "@executor-v2/schema";
2import { v } from "convex/values";
3
4import { internalMutation, internalQuery } from "./_generated/server";
5
6const taskRunTerminalStatusValidator = v.union(
7 v.literal("completed"),
8 v.literal("failed"),
9 v.literal("timed_out"),
10 v.literal("denied"),
11);
12
13export 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
58export 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
87export 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});
apps/convex/convex/tsconfig.json+0−25
@@ -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}
apps/convex/convex/workflow.ts+0−15
@@ -1,15 +0,0 @@
1import { WorkflowManager } from "@convex-dev/workflow";
2
3import { components } from "./_generated/api";
4
5export 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});
apps/convex/package.json+0−33
@@ -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}
apps/convex/src/main.ts+0−1
@@ -1 +0,0 @@
1export {};
apps/convex/tsconfig.json+0−4
@@ -1,4 +0,0 @@
1{
2 "extends": "../../tsconfig.json",
3 "include": ["src", "convex"]
4}
apps/pm/package.json+2−1
@@ -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:*",
apps/pm/src/actor.ts+38−36
@@ -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";
11import {11import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
12 type LocalStateSnapshot,
13 type LocalStateStore,
14} from "@executor-v2/persistence-local";
15import {12import {
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";
19import * as PlatformHeaders from "@effect/platform/Headers";17import * as PlatformHeaders from "@effect/platform/Headers";
20import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
21import * as Option from "effect/Option";19
20type ActorRows = Pick<
21 SqlControlPlanePersistence["rows"],
22 "organizationMemberships" | "workspaces"
23>;
22 24
23const isTruthy = (value: string | undefined): boolean => {25const 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
38const organizationMembershipsForAccount = (40const 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
46const workspaceMembershipsForAccount = (48const 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
60const resolveActorFromSnapshot = (62const 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 === null86 memberships,
84 ? []87 principal.accountId,
85 : organizationMembershipsForAccount(snapshot, principal.accountId);88 );
86 const workspaceMemberships =89 const workspaceMemberships = workspaceMembershipsForAccount(
87 snapshot === null90 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
98export const PmActorLive = (localStateStore: LocalStateStore) =>102export 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 === null126 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,
apps/pm/src/approvals-service.ts+30−87
@@ -6,18 +6,15 @@ import { · 5 unmodified lines
6 type ToolApprovalPolicy,6 type ToolApprovalPolicy,
7} from "@executor-v2/engine";7} from "@executor-v2/engine";
8import { SourceStoreError } from "@executor-v2/persistence-ports";8import { SourceStoreError } from "@executor-v2/persistence-ports";
9import {9import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
10 type LocalStateSnapshot,
11 type LocalStateStore,
12 type LocalStateStoreError,
13} from "@executor-v2/persistence-local";
14import {10import {
15 makeControlPlaneApprovalsService,11 makeControlPlaneApprovalsService,
16 type ControlPlaneApprovalsServiceShape,12 type ControlPlaneApprovalsServiceShape,
17} from "@executor-v2/management-api";13} from "@executor-v2/management-api";
18import { type Approval } from "@executor-v2/schema";14import { type Approval } from "@executor-v2/schema";
19import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";16
17type ApprovalRows = Pick<SqlControlPlanePersistence["rows"], "approvals">;
21 18
22const toSourceStoreError = (19const 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
36const toSourceStoreErrorFromLocalState = (33const 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
65const toPersistentApprovalStoreErrorFromLocalState = (62const 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
81const 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
96export type PmPersistentToolApprovalPolicyOptions = {78export type PmPersistentToolApprovalPolicyOptions = {
97 requireApprovals?: boolean;79 requireApprovals?: boolean;
98 retryAfterMs?: number;80 retryAfterMs?: number;
99};81};
100 82
101export const createPmPersistentToolApprovalPolicy = (83export 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
183export const createPmApprovalsService = (142export 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
apps/pm/src/credential-resolver.ts+35−18
@@ -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";
8import { type LocalStateStore } from "@executor-v2/persistence-local";8import { type RowStoreError } from "@executor-v2/persistence-ports";
9import {
10 type AuthConnection,
11 type AuthMaterial,
12 type OAuthState,
13 type SourceAuthBinding,
14 type Workspace,
15} from "@executor-v2/schema";
9import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";17
18type RowReader<A> = {
19 list: () => Effect.Effect<ReadonlyArray<A>, RowStoreError>;
20};
21
22type CredentialResolverRows = {
23 workspaces: RowReader<Workspace>;
24 sourceAuthBindings: RowReader<SourceAuthBinding>;
25 authConnections: RowReader<AuthConnection>;
26 authMaterials: RowReader<AuthMaterial>;
27 oauthStates: RowReader<OAuthState>;
28};
11 29
12const toCredentialResolverError = (30const toCredentialResolverError = (
13 operation: string,31 operation: string,
@@ -151,7 +169,7 @@ const buildSecretHeaders = ( · 137 unmodified lines
151};169};
152 170
153export const createPmResolveToolCredentials = (171export 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.sourceAuthBindings211 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
apps/pm/src/credentials-service.ts+134−159
@@ -1,9 +1,5 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import {2import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
3 type LocalStateSnapshot,
4 type LocalStateStore,
5 type LocalStateStoreError,
6} from "@executor-v2/persistence-local";
7import {3import {
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";
22import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
23import * as Option from "effect/Option";20
21type CredentialRows = Pick<
22 SqlControlPlanePersistence["rows"],
23 | "workspaces"
24 | "authConnections"
25 | "sourceAuthBindings"
26 | "authMaterials"
27 | "oauthStates"
28>;
24 29
25const toSourceStoreError = (30const 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
39const toSourceStoreErrorFromLocalState = (44const 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
45const resolveWorkspaceOrganizationId = (50const 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
142const 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
157const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {147const 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
248const 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
310export const createPmCredentialsService = (238export 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 === workspaceId266 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 = requestedBindingId338 const existingBinding = requestedBindingId
391 ? snapshot.sourceAuthBindings.find((binding) => binding.id === requestedBindingId) ?? null339 ? 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 };
apps/pm/src/main.ts+93−27
@@ -1,4 +1,3 @@
1import * as BunContext from "@effect/platform-bun/BunContext";
2import {1import {
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";
24import {23import {
25 makeLocalSourceStore,24 makeSqlControlPlanePersistence,
26 makeLocalStateStore,25 type SqlControlPlanePersistence,
27 makeLocalToolArtifactStore,26} from "@executor-v2/persistence-sql";
28} from "@executor-v2/persistence-local";
29import { type RuntimeToolCallResult } from "@executor-v2/sdk";27import { type RuntimeToolCallResult } from "@executor-v2/sdk";
30import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";28import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";
31import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess";29import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess";
32import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";30import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";
33import * as Effect from "effect/Effect";31import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";32import * as Layer from "effect/Layer";
33import * as path from "node:path";
35 34
36import { PmActorLive } from "./actor";35import { PmActorLive } from "./actor";
37import {36import {
@@ -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
93const 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
94const pmRuntimeAdapters = [162const pmRuntimeAdapters = [
95 makeLocalInProcessRuntimeAdapter(),163 makeLocalInProcessRuntimeAdapter(),
96 makeDenoSubprocessRuntimeAdapter(),164 makeDenoSubprocessRuntimeAdapter(),
@@ -101,23 +169,20 @@ const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters); · 4 unmodified lines
101const defaultRuntimeKind =169const defaultRuntimeKind =
102 readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind;170 readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind;
103 171
104const sourceStore = await Effect.runPromise(172const 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
110const localStateStore = await Effect.runPromise(182const sourceStore = persistence.sourceStore;
111 makeLocalStateStore({183const toolArtifactStore = persistence.toolArtifactStore;
112 rootDir: pmStateRootDir,
113 }).pipe(Effect.provide(BunContext.layer)),
114);
115 184
116const toolArtifactStore = await Effect.runPromise(185await Effect.runPromise(ensurePmBootstrap(persistence));
117 makeLocalToolArtifactStore({
118 rootDir: pmStateRootDir,
119 }).pipe(Effect.provide(BunContext.layer)),
120);
121 186
122const sourceCatalog = makeSourceCatalogService(sourceStore);187const sourceCatalog = makeSourceCatalogService(sourceStore);
123const sourceManager = makeSourceManagerService(toolArtifactStore);188const sourceManager = makeSourceManagerService(toolArtifactStore);
@@ -152,15 +217,15 @@ const sourcesService = { · 28 unmodified lines
152 }),217 }),
153};218};
154 219
155const credentialsService = createPmCredentialsService(localStateStore);220const credentialsService = createPmCredentialsService(persistence.rows);
156const policiesService = createPmPoliciesService(localStateStore);221const policiesService = createPmPoliciesService(persistence.rows);
157const organizationsService = createPmOrganizationsService(localStateStore);222const organizationsService = createPmOrganizationsService(persistence.rows);
158const workspacesService = createPmWorkspacesService(localStateStore);223const workspacesService = createPmWorkspacesService(persistence.rows);
159const toolsService = createPmToolsService(sourceStore, toolArtifactStore);224const toolsService = createPmToolsService(sourceStore, toolArtifactStore);
160const storageService = createPmStorageService(localStateStore, {225const storageService = createPmStorageService(persistence.rows, {
161 stateRootDir: pmStateRootDir,226 stateRootDir: pmStateRootDir,
162});227});
163const approvalsService = createPmApprovalsService(localStateStore);228const approvalsService = createPmApprovalsService(persistence.rows);
164const controlPlaneService = makeControlPlaneService({229const 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
175const controlPlaneWebHandler = makeControlPlaneWebHandler(240const controlPlaneWebHandler = makeControlPlaneWebHandler(
176 Layer.succeed(ControlPlaneService, controlPlaneService),241 Layer.succeed(ControlPlaneService, controlPlaneService),
177 PmActorLive(localStateStore),242 PmActorLive(persistence.rows),
178);243);
179 244
180const toolProviderRegistry = makeToolProviderRegistry([245const toolProviderRegistry = makeToolProviderRegistry([
@@ -182,7 +247,7 @@ const toolProviderRegistry = makeToolProviderRegistry([ · 1 unmodified line
182 makeMcpToolProvider(),247 makeMcpToolProvider(),
183 makeGraphqlToolProvider(),248 makeGraphqlToolProvider(),
184]);249]);
185const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(localStateStore, {250const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(persistence.rows, {
186 requireApprovals: requireToolApprovals,251 requireApprovals: requireToolApprovals,
187});252});
188const toolRegistry = createSourceToolRegistry({253const toolRegistry = createSourceToolRegistry({
@@ -228,6 +293,7 @@ const server = startPmHttpServer({ · 39 unmodified lines
228const shutdown = async () => {293const shutdown = async () => {
229 server.stop();294 server.stop();
230 await controlPlaneWebHandler.dispose();295 await controlPlaneWebHandler.dispose();
296 await persistence.close();
231};297};
232 298
233process.on("SIGINT", () => {299process.on("SIGINT", () => {
apps/pm/src/organizations-service.ts+44−77
@@ -1,19 +1,19 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
2import {3import {
3 makeControlPlaneOrganizationsService,4 makeControlPlaneOrganizationsService,
4 type ControlPlaneOrganizationsServiceShape,5 type ControlPlaneOrganizationsServiceShape,
5} from "@executor-v2/management-api";6} from "@executor-v2/management-api";
6import {
7 type LocalStateSnapshot,
8 type LocalStateStore,
9 type LocalStateStoreError,
10} from "@executor-v2/persistence-local";
11import {7import {
12 type Organization,8 type Organization,
13 type OrganizationMembership,9 type OrganizationMembership,
14} from "@executor-v2/schema";10} from "@executor-v2/schema";
15import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
16import * as Option from "effect/Option";12
13type OrganizationRows = Pick<
14 SqlControlPlanePersistence["rows"],
15 "organizations" | "organizationMemberships"
16>;
17 17
18const toSourceStoreError = (18const 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
32const toSourceStoreErrorFromLocalState = (32const 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
52const 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
67const appendOrganization = (
68 snapshot: LocalStateSnapshot,
69 organization: Organization,
70): LocalStateSnapshot => ({
71 ...snapshot,
72 generatedAt: Date.now(),
73 organizations: [...snapshot.organizations, organization],
74});
75
76const appendOrganizationMembership = (
77 snapshot: LocalStateSnapshot,
78 membership: OrganizationMembership,
79): LocalStateSnapshot => ({
80 ...snapshot,
81 generatedAt: Date.now(),
82 organizationMemberships: [...snapshot.organizationMemberships, membership],
83});
84
85export const createPmOrganizationsService = (52export 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.id85 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 >= 0107 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.id116 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 });
apps/pm/src/persistent-approval-policy.test.ts+23−49
@@ -1,60 +1,34 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type {
3 LocalStateSnapshot,
4 LocalStateStore,
5} from "@executor-v2/persistence-local";
6import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";3import { type Approval } from "@executor-v2/schema";
8 4
9import {5import {
10 createPmApprovalsService,6 createPmApprovalsService,
11 createPmPersistentToolApprovalPolicy,7 createPmPersistentToolApprovalPolicy,
12} from "./approvals-service";8} from "./approvals-service";
13 9
14const 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
42describe("PM persistent approval policy", () => {10describe("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 }
apps/pm/src/policies-service.ts+26−108
@@ -1,16 +1,13 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import {2import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
3 type LocalStateSnapshot,
4 type LocalStateStore,
5 type LocalStateStoreError,
6} from "@executor-v2/persistence-local";
7import {3import {
8 makeControlPlanePoliciesService,4 makeControlPlanePoliciesService,
9 type ControlPlanePoliciesServiceShape,5 type ControlPlanePoliciesServiceShape,
10} from "@executor-v2/management-api";6} from "@executor-v2/management-api";
11import { type Policy } from "@executor-v2/schema";7import { type Policy } from "@executor-v2/schema";
12import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";9
10type PolicyRows = Pick<SqlControlPlanePersistence["rows"], "policies">;
14 11
15const toSourceStoreError = (12const 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
29const toSourceStoreErrorFromLocalState = (26const 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
46const 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
61const appendPolicy = (
62 snapshot: LocalStateSnapshot,
63 policy: Policy,
64): LocalStateSnapshot => ({
65 ...snapshot,
66 generatedAt: Date.now(),
67 policies: [...snapshot.policies, policy],
68});
69
70const 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
99export const createPmPoliciesService = (43export 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 = requestedId71 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 >= 089 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 });
apps/pm/src/storage-service.test.ts+50−54
@@ -1,53 +1,21 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type {2import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
3 LocalStateSnapshot,3import {
4 LocalStateStore,4 type OrganizationId,
5} from "@executor-v2/persistence-local";5 type StorageInstance,
6import type { StorageInstance, WorkspaceId } from "@executor-v2/schema";6 type WorkspaceId,
7} from "@executor-v2/schema";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import { mkdtemp, rm, writeFile } from "node:fs/promises";9import { mkdtemp, rm, writeFile } from "node:fs/promises";
10import { tmpdir } from "node:os";10import { tmpdir } from "node:os";
11import * as path from "node:path";11import * as path from "node:path";
12 12
13import { createPmStorageService } from "./storage-service";13import { createPmStorageService } from "./storage-service";
14 14
15const createTestSnapshot = (): LocalStateSnapshot =>15type 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
52describe("PM storage service", () => {20describe("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: {
apps/pm/src/storage-service.ts+148−270
@@ -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";
5import { SourceStoreError } from "@executor-v2/persistence-ports";5import { SourceStoreError } from "@executor-v2/persistence-ports";
6import {6import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
7 type LocalStateSnapshot,
8 type LocalStateStore,
9 type LocalStateStoreError,
10} from "@executor-v2/persistence-local";
11import {7import {
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";
16import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
17import * as Option from "effect/Option";
18import {14import {
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";
26import * as path from "node:path";22import * as path from "node:path";
27 23
24type StorageRows = Pick<
25 SqlControlPlanePersistence["rows"],
26 "workspaces" | "storageInstances"
27>;
28
28const DEFAULT_EPHEMERAL_TTL_HOURS = 24;29const DEFAULT_EPHEMERAL_TTL_HOURS = 24;
29const DEFAULT_KV_LIMIT = 100;30const DEFAULT_KV_LIMIT = 100;
30const MAX_KV_LIMIT = 1000;31const 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
78const toSourceStoreErrorFromLocalState = (79const 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
95const resolveWorkspaceOrganizationId = (96const 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
127const 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
142const appendStorageInstance = (
143 snapshot: LocalStateSnapshot,
144 storageInstance: StorageInstance,
145): LocalStateSnapshot => ({
146 ...snapshot,
147 generatedAt: Date.now(),
148 storageInstances: [...snapshot.storageInstances, storageInstance],
149});
150
151const 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
191const storageInstanceRootPath = (stateRootDir: string, storageInstanceId: string): string =>128const 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
294const findStorageInstance = (231const 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.storageInstanceId242 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
325const touchStorageInstance = (255const 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
347export const createPmStorageService = (273export 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 > 0783 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 }),
apps/pm/src/tool-call-handler.test.ts+17−7
@@ -4,21 +4,31 @@ import { · 3 unmodified lines
4} from "@executor-v2/engine";4} from "@executor-v2/engine";
5import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8 7
9import { createPmResolveToolCredentials } from "./credential-resolver";8import { createPmResolveToolCredentials } from "./credential-resolver";
10 9
11const emptyLocalStateStore = {10const 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
18describe("PM runtime tool-call handling", () => {28describe("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,
apps/pm/src/tools-service.ts+2−2
@@ -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,
apps/pm/src/workspaces-service.ts+17−62
@@ -1,16 +1,13 @@
1import { SourceStoreError } from "@executor-v2/persistence-ports";1import { SourceStoreError } from "@executor-v2/persistence-ports";
2import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
2import {3import {
3 makeControlPlaneWorkspacesService,4 makeControlPlaneWorkspacesService,
4 type ControlPlaneWorkspacesServiceShape,5 type ControlPlaneWorkspacesServiceShape,
5} from "@executor-v2/management-api";6} from "@executor-v2/management-api";
6import {
7 type LocalStateSnapshot,
8 type LocalStateStore,
9 type LocalStateStoreError,
10} from "@executor-v2/persistence-local";
11import { type Workspace } from "@executor-v2/schema";7import { type Workspace } from "@executor-v2/schema";
12import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";9
10type WorkspaceRows = Pick<SqlControlPlanePersistence["rows"], "workspaces">;
14 11
15const toSourceStoreError = (12const 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
29const toSourceStoreErrorFromLocalState = (26const 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
47const 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
62const appendWorkspace = (
63 snapshot: LocalStateSnapshot,
64 workspace: Workspace,
65): LocalStateSnapshot => ({
66 ...snapshot,
67 generatedAt: Date.now(),
68 workspaces: [...snapshot.workspaces, workspace],
69});
70
71export const createPmWorkspacesService = (44export 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.id68 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 >= 082 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
apps/web/README.md+13−4
@@ -13,9 +13,18 @@ Run: · 12 unmodified lines
13By default, browser control-plane API calls go through the same-origin proxy at13By default, browser control-plane API calls go through the same-origin proxy at
14`/api/control-plane`.14`/api/control-plane`.
15 15
16The proxy forwards to the first available upstream value in:16The 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
18If none are set, fallback is `http://127.0.0.1:8788`.18Database selection priority:
19
20- `CONTROL_PLANE_DATABASE_URL`
21- `DATABASE_URL`
22- `POSTGRES_URL`
23
24If 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
20If needed, `NEXT_PUBLIC_CONTROL_PLANE_BASE_URL` can override the browser base URL.29If 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
28WorkOS auth setup (optional but recommended):37WorkOS auth setup (optional but recommended):
29 38
apps/web/app/(console)/tools/page.tsx+0−2
@@ -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) {
apps/web/app/api/control-plane/[...path]/route.ts+47−98
@@ -1,9 +1,17 @@
1import { withAuth } from "@workos-inc/authkit-nextjs";1import { withAuth } from "@workos-inc/authkit-nextjs";
2import type { NextRequest } from "next/server";2import type { NextRequest } from "next/server";
3 3
4import {
5 applyPrincipalHeaders,
6 createLocalPrincipal,
7 createWorkosPrincipal,
8 getControlPlaneRuntime,
9 provisionPrincipal,
10} from "../../../../lib/control-plane/server";
4import { isWorkosEnabled } from "../../../../lib/workos";11import { isWorkosEnabled } from "../../../../lib/workos";
5 12
6export const dynamic = "force-dynamic";13export const dynamic = "force-dynamic";
14export const runtime = "nodejs";
7 15
8type RouteContext = {16type RouteContext = {
9 params: Promise<{17 params: Promise<{
@@ -11,81 +19,16 @@ type RouteContext = { · 1 unmodified line
11 }>;19 }>;
12};20};
13 21
14const trim = (value: string | undefined): string | undefined => {
15 const candidate = value?.trim();
16 return candidate && candidate.length > 0 ? candidate : undefined;
17};
18
19const defaultControlPlaneUpstreamUrl = "http://127.0.0.1:8788";
20
21const 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
28const methodAllowsBody = (method: string): boolean =>
29 method !== "GET" && method !== "HEAD";
30
31const forwardableRequestHeaders = [
32 "accept",
33 "content-type",
34 "traceparent",
35 "b3",
36 "x-request-id",
37] as const;
38
39const isCsrfSafeMethod = (method: string): boolean =>22const isCsrfSafeMethod = (method: string): boolean =>
40 method === "GET" || method === "HEAD" || method === "OPTIONS";23 method === "GET" || method === "HEAD" || method === "OPTIONS";
41 24
42const toAuthorizationHeader = (token: string): string =>25const rewriteControlPlaneRequest = (
43 token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
44
45const 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
65const responseHeadersToStrip = [
66 "content-encoding",
67 "content-length",
68 "transfer-encoding",
69 "connection",
70 "keep-alive",
71] as const;
72
73const 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
83const 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
91const handle = async (request: NextRequest, context: RouteContext): Promise<Response> => {34const 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
145export const GET = handle;94export const GET = handle;
apps/web/lib/control-plane/server.ts+321−0
@@ -0,0 +1,321 @@
1import {
2 ControlPlaneAuthHeaders,
3 ControlPlaneService,
4 fetchOpenApiDocument,
5 makeControlPlaneService,
6 makeControlPlaneSourcesService,
7 makeControlPlaneWebHandler,
8 makeSourceCatalogService,
9 makeSourceManagerService,
10} from "@executor-v2/management-api";
11import {
12 makeSqlControlPlanePersistence,
13 type SqlControlPlanePersistence,
14} from "@executor-v2/persistence-sql";
15import {
16 type SourceStore,
17 type ToolArtifactStore,
18} from "@executor-v2/persistence-ports";
19import {
20 type Organization,
21 type OrganizationMembership,
22 type Workspace,
23} from "@executor-v2/schema";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26
27import { PmActorLive } from "../../../pm/src/actor";
28import { createPmApprovalsService } from "../../../pm/src/approvals-service";
29import { createPmCredentialsService } from "../../../pm/src/credentials-service";
30import { createPmOrganizationsService } from "../../../pm/src/organizations-service";
31import { createPmPoliciesService } from "../../../pm/src/policies-service";
32import { createPmStorageService } from "../../../pm/src/storage-service";
33import { createPmToolsService } from "../../../pm/src/tools-service";
34import { createPmWorkspacesService } from "../../../pm/src/workspaces-service";
35
36const trim = (value: string | undefined): string | undefined => {
37 const candidate = value?.trim();
38 return candidate && candidate.length > 0 ? candidate : undefined;
39};
40
41const defaultControlPlaneStateRootDir = ".executor-v2/web-state";
42const defaultControlPlaneSqliteFile = `${defaultControlPlaneStateRootDir}/control-plane.sqlite`;
43
44type 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
53type 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
63const normalizeIdPart = (value: string): string =>
64 value.replace(/[^a-zA-Z0-9_-]/g, "_");
65
66const 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
76const 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
93const resolveSqlitePath = (): string =>
94 trim(process.env.CONTROL_PLANE_SQLITE_PATH) ?? defaultControlPlaneSqliteFile;
95
96const resolveStateRootDir = (): string =>
97 trim(process.env.CONTROL_PLANE_STATE_ROOT_DIR) ?? defaultControlPlaneStateRootDir;
98
99const 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
178let runtimePromise: Promise<ControlPlaneRuntime> | undefined;
179
180const 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
259export const getControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => {
260 if (!runtimePromise) {
261 runtimePromise = createControlPlaneRuntime();
262 }
263
264 return runtimePromise;
265};
266
267export 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
285export 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
295export 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
316export const provisionPrincipal = async (
317 runtime: ControlPlaneRuntime,
318 principal: ControlPlanePrincipal,
319): Promise<void> => {
320 await Effect.runPromise(ensurePrincipalProvisioned(runtime.persistence, principal));
321};
apps/web/package.json+2−0
@@ -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",
apps/web/tsconfig.json+2−1
@@ -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,
apps/www/src/components/UsageCards.tsx+1−1
@@ -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:
28executor claude --no-bash`,28executor 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",
bun.lock+87−133
@@ -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=="],
package.json+1−3
@@ -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}
packages/confect/package.json+0−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}
packages/confect/src/client.ts+0−160
@@ -1,160 +0,0 @@
1import type { FunctionReference, FunctionReturnType } from "convex/server";
2import {
3 ConvexClient as ConvexClientImpl,
4 ConvexHttpClient,
5} from "convex/browser";
6import { Context, Effect, Layer, Stream } from "effect";
7
8export interface ConvexRequestMetadata {
9 readonly headers?: Readonly<Record<string, string>>;
10}
11
12export 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
37export class ConvexClient extends Context.Tag("@confect/ConvexClient")<
38 ConvexClient,
39 ConvexClientService
40>() {}
41
42export 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.

packages/confect/src/ctx.ts+0−683

This diff is large: its 684 lines aren’t shown here.

packages/confect/src/index.ts+0−43

This diff is large: its 44 lines aren’t shown here.

packages/confect/src/pagination.ts+0−19

This diff is large: its 20 lines aren’t shown here.

packages/confect/src/rpc/client.test.ts+0−776

This diff is large: its 777 lines aren’t shown here.

packages/confect/src/rpc/client.ts+0−1105

This diff is large: its 1106 lines aren’t shown here.

packages/confect/src/rpc/go-to-definition.test.ts+0−87

This diff is large: its 88 lines aren’t shown here.

packages/confect/src/rpc/index.ts+0−49

This diff is large: its 50 lines aren’t shown here.

packages/confect/src/rpc/server.test.ts+0−1239

This diff is large: its 1240 lines aren’t shown here.

packages/confect/src/rpc/server.ts+0−675

This diff is large: its 676 lines aren’t shown here.

packages/confect/src/rpc/telemetry.test.ts+0−112

This diff is large: its 113 lines aren’t shown here.

packages/confect/src/rpc/telemetry.ts+0−139

This diff is large: its 140 lines aren’t shown here.

packages/confect/src/schema.test.ts+0−235

This diff is large: its 236 lines aren’t shown here.

packages/confect/src/schema.ts+0−400

This diff is large: its 401 lines aren’t shown here.

packages/confect/src/testing.ts+0−129

This diff is large: its 130 lines aren’t shown here.

packages/confect/src/validators.test.ts+0−247

This diff is large: its 248 lines aren’t shown here.

packages/confect/src/validators.ts+0−217

This diff is large: its 218 lines aren’t shown here.

packages/confect/tsconfig.json+0−8

This diff is large: its 9 lines aren’t shown here.

packages/confect/vitest.config.mts+0−11

This diff is large: its 12 lines aren’t shown here.

packages/convex-test/compare.d.ts+0−3

This diff is large: its 4 lines aren’t shown here.

packages/convex-test/compare.js+0−80

This diff is large: its 81 lines aren’t shown here.

packages/convex-test/index.d.ts+0−98

This diff is large: its 99 lines aren’t shown here.

packages/convex-test/index.js+0−1510

This diff is large: its 1511 lines aren’t shown here.

packages/convex-test/package.json+0−21

This diff is large: its 22 lines aren’t shown here.

packages/persistence-sql/drizzle/0000_initial_schema.sql+139−0

This diff is large: its 140 lines aren’t shown here.

packages/persistence-sql/drizzle/meta/_journal.json+13−0

This diff is large: its 14 lines aren’t shown here.

packages/persistence-sql/package.json+18−0

This diff is large: its 19 lines aren’t shown here.

packages/persistence-sql/src/index.ts+1815−0

This diff is large: its 1816 lines aren’t shown here.

packages/persistence-sql/src/schema.ts+149−0

This diff is large: its 150 lines aren’t shown here.

packages/persistence-sql/tsconfig.jsonrenamed from packages/state-local-file/tsconfig.json+0−0

File renamed without changes.

packages/schema/README.md+1−1

This diff is large: its 6 lines aren’t shown here.

packages/state-contracts/README.md+1−1

This diff is large: its 7 lines aren’t shown here.

packages/state-contracts/src/index.ts+1−0

This diff is large: its 6 lines aren’t shown here.

packages/state-contracts/src/row-store.ts+10−0

This diff is large: its 11 lines aren’t shown here.

packages/state-local-file/README.md+0−11

This diff is large: its 12 lines aren’t shown here.

packages/state-local-file/package.json+0−24

This diff is large: its 25 lines aren’t shown here.

packages/state-local-file/src/index.ts+0−26

This diff is large: its 27 lines aren’t shown here.

packages/state-local-file/src/local-state-snapshot.ts+0−46

This diff is large: its 47 lines aren’t shown here.

packages/state-local-file/src/local-state-store.ts+0−27

This diff is large: its 28 lines aren’t shown here.

packages/state-local-file/src/persistence-error.ts+0−101

This diff is large: its 102 lines aren’t shown here.

packages/state-local-file/src/source-store.test.ts+0−161

This diff is large: its 162 lines aren’t shown here.

packages/state-local-file/src/source-store.ts+0−211

This diff is large: its 212 lines aren’t shown here.

packages/state-local-file/src/state-store.test.ts+0−185

This diff is large: its 186 lines aren’t shown here.

packages/state-local-file/src/state-store.ts+0−308

This diff is large: its 309 lines aren’t shown here.

packages/state-local-file/src/tool-artifact-store.test.ts+0−85

This diff is large: its 86 lines aren’t shown here.

packages/state-local-file/src/tool-artifact-store.ts+0−188

This diff is large: its 189 lines aren’t shown here.