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
1111 v2 changes the default:
1212
1313 - 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.
1515 - Cloud/self-host are upgrade targets, not prerequisites.
1616 - One architecture for local and remote via shared contracts and thin adapters.
1717
@@ -105,7 +105,7 @@ Important boundary: · 87 unmodified lines
105105 ### Modes
106106
107107 - 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)
109109
110110 ### Runtime targets
111111
@@ -131,19 +131,19 @@ Important boundary: · 19 unmodified lines
131131 5. Runtime resumes and completes.
132132 6. Run/events persisted locally.
133133
134−### Cloud flow (Convex + Cloudflare worker loader)
134+### Remote flow (SQL + Cloudflare worker loader)
135135
136−1. Request starts run in Convex.
137−2. Convex dispatches run to Cloudflare loader.
138−3. Worker `tools.*` call hits Convex runtime callback action (`handleToolCall`).
136+1. Request starts run in control-plane API.
137+2. Runtime dispatch executes through configured runtime adapter.
138+3. Worker `tools.*` call hits runtime callback action (`handleToolCall`).
139139 4. Callback action invokes shared tool invocation pipeline:
140140 - resolve tool
141141 - policy check
142142 - approval gating
143− - credential resolution from Convex/vault
143+ - credential resolution from SQL-backed state/secret stores
144144 - provider invoke
145145 5. Worker resumes and returns terminal status.
146−6. Convex marks run terminal and emits events.
146+6. Control plane marks run terminal and emits events.
147147
148148 Serverless note:
149149 - There is no long-lived in-memory `onToolCall` loop in cloud.
@@ -281,12 +281,10 @@ v2/ · 131 unmodified lines
281281 cli/
282282 pm/
283283 web/
284− convex/
285284 packages/
286− confect/
287285 schema/
288286 state-contracts/
289− state-local-file/
287+ persistence-sql/
290288 domain/
291289 approvals/
292290 source-manager/
@@ -309,9 +307,9 @@ Planned follow-up split (after behavior stabilizes): · 16 unmodified lines
309307 ## 14) Dependency direction
310308
311309 ```text
312−confect -> schema -> state-contracts
310+schema -> state-contracts
313311
314−state-contracts -> state-local-file
312+state-contracts -> persistence-sql
315313
316314 schema + state-contracts + oauth + secrets -> domain
317315 domain -> approvals/source-manager/engine
@@ -319,8 +317,8 @@ source-manager + schema -> control-plane · 1 unmodified line
319317 approvals/source-manager/engine -> mcp-gateway
320318 domain/features -> sdk -> ai-sdk-adapter
321319
322−apps/pm -> mcp-gateway + domain + state-local-file + control-plane
323−apps/convex -> mcp-gateway + domain + control-plane
320+apps/pm -> mcp-gateway + domain + persistence-sql + control-plane
321+apps/web -> management-api + persistence-sql + control-plane routes
324322 apps/cli/web -> sdk (and/or ai-sdk bridge)
325323 ```
326324 Rules:
@@ -355,11 +353,11 @@ Rules: · 28 unmodified lines
355353
356354 - persisted pending/resume/deny flow
357355 - capability-aware UX fallbacks
358−- callback-time credential resolver implementations (local + convex)
356+- callback-time credential resolver implementations (local + remote SQL)
359357
360358 ### Phase 4: remote parity
361359
362−- Convex adapter wiring for kernel and control
360+- SQL adapter wiring for kernel and control
363361 - cloudflare loader integration through runtime dispatch adapter
364362 - parity/conformance suite across local and remote
365363
@@ -395,10 +393,9 @@ Rules: · 29 unmodified lines
395393
396394 Implemented in `v2` so far:
397395 - monorepo app/package skeleton
398−- `confect` imported from quickhub
399396 - schema package scaffold with Effect models/event envelope
400397 - shared state contracts for source/tool artifacts
401−- local file adapters for source/tool artifacts + snapshot/WAL state store
398+- SQL adapters for source/tool artifacts + snapshot/event-log state store
402399 - source manager OpenAPI extraction + artifact refresh/reuse
403400 - provider registry contracts and routing in engine
404401 - OpenAPI provider invocation + in-memory provider path through registry
@@ -420,9 +417,8 @@ Still missing: · 15 unmodified lines
420417 ## 19) Immediate next steps
421418
422419 1. Land shared `ToolInvocationService` contract in v2 and wire PM execute callback path through it.
423−2. Add `CredentialResolver` interfaces and local/convex implementations; integrate at invocation-time only.
420+2. Add `CredentialResolver` interfaces and local/remote implementations; integrate at invocation-time only.
424421 3. Implement control handlers for sources (`add/list/remove`) and route both host API and `tools.executor.*` through same code path.
425−4. Implement cloudflare-worker-loader runtime dispatch adapter in v2 and wire Convex callback endpoints.
422+4. Implement cloudflare-worker-loader runtime dispatch adapter in v2 and wire callback endpoints.
426423 5. Add run API split for async + blocking convenience (`submit` + `wait/watch`) without duplicating execution logic.
427424 6. 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 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import * as Effect from "effect/Effect";
3−import { parse as parseYaml } from "yaml";
4−
5−import { extractOpenApiManifest } from "./parse-openapi";
6−
7−const cloudflareSpecUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
8−const vercelSpecUrl = "https://openapi.vercel.sh/";
9−const httpMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const;
10−
11−const isRecord = (value: unknown): value is Record<string, unknown> =>
12− typeof value === "object" && value !== null && !Array.isArray(value);
13−
14−const parseSpecDocument = (input: string): unknown => {
15− const trimmed = input.trim();
16− if (trimmed.length === 0) {
17− throw new Error("Spec document is empty");
18− }
19−
20− try {
21− return JSON.parse(trimmed);
22− } catch {
23− return parseYaml(trimmed);
24− }
25−};
26−
27−const loadSpec = async (url: string): Promise<unknown> => {
28− const response = await fetch(url);
29− if (!response.ok) {
30− throw new Error(`Failed to fetch spec ${url}: ${response.status} ${response.statusText}`);
31− }
32−
33− return parseSpecDocument(await response.text());
34−};
35−
36−const collectRefKeys = (value: unknown, refs: Set<string>): void => {
37− if (Array.isArray(value)) {
38− for (const item of value) {
39− collectRefKeys(item, refs);
40− }
41− return;
42− }
43−
44− if (!isRecord(value)) {
45− return;
46− }
47−
48− const refValue = value.$ref;
49− if (typeof refValue === "string" && refValue.startsWith("#/")) {
50− refs.add(refValue);
51− }
52−
53− for (const nested of Object.values(value)) {
54− collectRefKeys(nested, refs);
55− }
56−};
57−
58−const unescapeJsonPointer = (value: string): string =>
59− value.replace(/~1/g, "/").replace(/~0/g, "~");
60−
61−const getByJsonPointer = (root: unknown, pointer: string): unknown => {
62− if (!pointer.startsWith("#/")) {
63− return null;
64− }
65−
66− const segments = pointer
67− .slice(2)
68− .split("/")
69− .map((segment) => unescapeJsonPointer(segment));
70−
71− let current: unknown = root;
72− for (const segment of segments) {
73− if (Array.isArray(current)) {
74− const index = Number(segment);
75− if (!Number.isInteger(index) || index < 0 || index >= current.length) {
76− return null;
77− }
78− current = current[index];
79− continue;
80− }
81−
82− if (!isRecord(current) || !(segment in current)) {
83− return null;
84− }
85−
86− current = current[segment];
87− }
88−
89− return current;
90−};
91−
92−const resolveLocalRef = (root: unknown, value: unknown, depth = 0): unknown => {
93− if (depth > 8 || !isRecord(value) || typeof value.$ref !== "string") {
94− return value;
95− }
96−
97− const resolved = getByJsonPointer(root, value.$ref);
98− if (resolved === null) {
99− return value;
100− }
101−
102− return resolveLocalRef(root, resolved, depth + 1);
103−};
104−
105−const extractSchemaFromContent = (content: unknown): unknown | null => {
106− if (!isRecord(content)) {
107− return null;
108− }
109−
110− const jsonMedia =
111− (content["application/json"] as Record<string, unknown> | undefined)
112− ?? (content["application/*+json"] as Record<string, unknown> | undefined);
113−
114− if (jsonMedia && isRecord(jsonMedia.schema)) {
115− return jsonMedia.schema;
116− }
117−
118− for (const mediaType of Object.values(content)) {
119− if (isRecord(mediaType) && isRecord(mediaType.schema)) {
120− return mediaType.schema;
121− }
122− }
123−
124− return null;
125−};
126−
127−const extractParameterSchema = (root: unknown, value: unknown): unknown => {
128− const parameter = resolveLocalRef(root, value);
129− if (!isRecord(parameter)) {
130− return null;
131− }
132−
133− if (isRecord(parameter.schema)) {
134− return parameter.schema;
135− }
136−
137− return extractSchemaFromContent(parameter.content);
138−};
139−
140−const extractRequestBodySchema = (root: unknown, operation: Record<string, unknown>): unknown => {
141− const requestBody = resolveLocalRef(root, operation.requestBody);
142− if (!isRecord(requestBody)) {
143− return null;
144− }
145−
146− return extractSchemaFromContent(requestBody.content);
147−};
148−
149−const extractResponseSchema = (root: unknown, operation: Record<string, unknown>): unknown => {
150− const responses = resolveLocalRef(root, operation.responses);
151− if (!isRecord(responses)) {
152− return null;
153− }
154−
155− const preferred = Object.keys(responses)
156− .filter((key) => /^2\d\d$/.test(key))
157− .sort();
158− if ("default" in responses) {
159− preferred.push("default");
160− }
161−
162− for (const key of preferred) {
163− const response = resolveLocalRef(root, responses[key]);
164− if (!isRecord(response)) {
165− continue;
166− }
167−
168− const schema = extractSchemaFromContent(response.content);
169− if (schema !== null) {
170− return schema;
171− }
172− }
173−
174− return null;
175−};
176−
177−const hasMeaningfulSchemaShape = (value: unknown): boolean => {
178− if (!isRecord(value)) {
179− return false;
180− }
181−
182− if (typeof value.$ref === "string") {
183− return true;
184− }
185−
186− if (typeof value.type === "string") {
187− return true;
188− }
189−
190− if (Array.isArray(value.enum) && value.enum.length > 0) {
191− return true;
192− }
193−
194− if (isRecord(value.properties) && Object.keys(value.properties).length > 0) {
195− return true;
196− }
197−
198− if (value.items !== undefined && value.items !== null) {
199− return true;
200− }
201−
202− if (Array.isArray(value.oneOf) || Array.isArray(value.anyOf) || Array.isArray(value.allOf)) {
203− return true;
204− }
205−
206− if (value.additionalProperties !== undefined) {
207− return true;
208− }
209−
210− return false;
211−};
212−
213−const isUnknownLikeSchema = (value: unknown): boolean => !hasMeaningfulSchemaShape(value);
214−
215−type OperationExpectation = {
216− parameterSchemas: Record<string, unknown>;
217− requestBodySchema: unknown;
218− responseSchema: unknown;
219−};
220−
221−const operationSignature = (method: string, path: string): string =>
222− `${method.toLowerCase()} ${path}`;
223−
224−const buildOperationExpectations = (spec: unknown): Map<string, OperationExpectation> => {
225− if (!isRecord(spec) || !isRecord(spec.paths)) {
226− throw new Error("Spec missing paths object");
227− }
228−
229− const expectations = new Map<string, OperationExpectation>();
230−
231− for (const [path, pathItemValue] of Object.entries(spec.paths)) {
232− if (!isRecord(pathItemValue)) {
233− continue;
234− }
235−
236− const pathLevelParameters = Array.isArray(pathItemValue.parameters)
237− ? pathItemValue.parameters
238− : [];
239−
240− for (const method of httpMethods) {
241− const operationValue = pathItemValue[method];
242− if (!isRecord(operationValue)) {
243− continue;
244− }
245−
246− const parameterSchemas: Record<string, unknown> = {};
247− const operationLevelParameters = Array.isArray(operationValue.parameters)
248− ? operationValue.parameters
249− : [];
250−
251− const parameters = [...pathLevelParameters, ...operationLevelParameters];
252− for (const parameter of parameters) {
253− const resolved = resolveLocalRef(spec, parameter);
254− if (!isRecord(resolved)) {
255− continue;
256− }
257−
258− const name = typeof resolved.name === "string" ? resolved.name.trim() : "";
259− const location = typeof resolved.in === "string" ? resolved.in.trim().toLowerCase() : "";
260− if (
261− name.length === 0
262− || (
263− location !== "path"
264− && location !== "query"
265− && location !== "header"
266− && location !== "cookie"
267− )
268− ) {
269− continue;
270− }
271−
272− const schema = extractParameterSchema(spec, resolved);
273− if (schema !== null) {
274− parameterSchemas[name] = schema;
275− }
276− }
277−
278− expectations.set(operationSignature(method, path), {
279− parameterSchemas,
280− requestBodySchema: extractRequestBodySchema(spec, operationValue),
281− responseSchema: extractResponseSchema(spec, operationValue),
282− });
283− }
284− }
285−
286− return expectations;
287−};
288−
289−const assertManifestFidelityForSpec = (
290− specName: string,
291− spec: unknown,
292−): void => {
293− const manifest = extractOpenApiManifest(specName, spec);
294− const expectations = buildOperationExpectations(spec);
295−
296− for (const tool of manifest.tools) {
297− const signature = operationSignature(tool.method, tool.path);
298− const expectation = expectations.get(signature);
299− expect(expectation, `Missing operation expectation for ${signature}`).toBeDefined();
300−
301− const inputSchema = tool.typing?.inputSchemaJson
302− ? JSON.parse(tool.typing.inputSchemaJson)
303− : null;
304− const outputSchema = tool.typing?.outputSchemaJson
305− ? JSON.parse(tool.typing.outputSchemaJson)
306− : null;
307−
308− if (expectation) {
309− const expectedParamSchemas = Object.entries(expectation.parameterSchemas);
310− const hasStructuredParams = expectedParamSchemas.some(([, schema]) => hasMeaningfulSchemaShape(schema));
311− const hasStructuredBody = hasMeaningfulSchemaShape(expectation.requestBodySchema);
312− const hasStructuredOutput = hasMeaningfulSchemaShape(expectation.responseSchema);
313−
314− if (hasStructuredParams || hasStructuredBody) {
315− expect(tool.typing?.inputSchemaJson, `Missing input schema for ${signature}`).toBeDefined();
316− expect(isRecord(inputSchema)).toBe(true);
317− expect(isRecord(inputSchema?.properties)).toBe(true);
318− }
319−
320− if (hasStructuredParams && isRecord(inputSchema) && isRecord(inputSchema.properties)) {
321− for (const [paramName, sourceSchema] of expectedParamSchemas) {
322− if (!hasMeaningfulSchemaShape(sourceSchema)) {
323− continue;
324− }
325−
326− expect(
327− inputSchema.properties[paramName],
328− `Missing input property '${paramName}' for ${signature}`,
329− ).toBeDefined();
330− expect(
331− isUnknownLikeSchema(inputSchema.properties[paramName]),
332− `Unknown-like input schema for '${paramName}' in ${signature}`,
333− ).toBe(false);
334− }
335− }
336−
337− if (hasStructuredBody && isRecord(inputSchema) && isRecord(inputSchema.properties)) {
338− expect(inputSchema.properties.body, `Missing body schema for ${signature}`).toBeDefined();
339− expect(
340− isUnknownLikeSchema(inputSchema.properties.body),
341− `Unknown-like body schema for ${signature}`,
342− ).toBe(false);
343− }
344−
345− if (hasStructuredOutput) {
346− expect(tool.typing?.outputSchemaJson, `Missing output schema for ${signature}`).toBeDefined();
347− expect(
348− isUnknownLikeSchema(outputSchema),
349− `Unknown-like output schema for ${signature}`,
350− ).toBe(false);
351− }
352− }
353−
354− const refs = new Set<string>();
355− if (inputSchema) {
356− collectRefKeys(inputSchema, refs);
357− }
358− if (outputSchema) {
359− collectRefKeys(outputSchema, refs);
360− }
361−
362− for (const refKey of refs) {
363− const resolvedRef = manifest.refHintTable[refKey];
364− expect(resolvedRef, `Missing refHintTable entry ${refKey} for ${signature}`).toBeDefined();
365− expect(() => JSON.parse(resolvedRef!)).not.toThrow();
366− }
367− }
368−};
369−
370−describe("parse-openapi extractor fidelity against real specs", () => {
371− it.effect("Cloudflare path params and output refs are preserved", () =>
372− Effect.gen(function* () {
373− const cloudflareSpec = yield* Effect.tryPromise(() => loadSpec(cloudflareSpecUrl));
374− const manifest = extractOpenApiManifest("cloudflare", cloudflareSpec);
375−
376− expect(manifest.tools.length).toBeGreaterThan(2000);
377−
378− const accessDeleteTool = manifest.tools.find(
379− (tool) =>
380− tool.method === "delete"
381− && tool.path === "/accounts/{account_id}/access/apps/{app_id}",
382− );
383− expect(accessDeleteTool).toBeDefined();
384− expect(accessDeleteTool?.typing?.inputSchemaJson).toBeDefined();
385−
386− const inputSchema = JSON.parse(accessDeleteTool!.typing!.inputSchemaJson!);
387− expect(inputSchema.type).toBe("object");
388− expect(Array.isArray(inputSchema.required)).toBe(true);
389− expect(inputSchema.required).toContain("account_id");
390− expect(inputSchema.required).toContain("app_id");
391− expect(isRecord(inputSchema.properties)).toBe(true);
392− expect(isRecord(inputSchema.properties.account_id)).toBe(true);
393− expect(isRecord(inputSchema.properties.app_id)).toBe(true);
394−
395− const ipDetailsTool = manifest.tools.find(
396− (tool) =>
397− tool.name.toLowerCase().includes("jd cloud ip details")
398− || (tool.method === "get" && tool.path === "/ips"),
399− );
400− expect(ipDetailsTool).toBeDefined();
401− expect(ipDetailsTool?.typing?.outputSchemaJson).toBeDefined();
402−
403− const outputSchema = JSON.parse(ipDetailsTool!.typing!.outputSchemaJson!);
404− const outputRefs = new Set<string>();
405− collectRefKeys(outputSchema, outputRefs);
406− expect(outputRefs.size).toBeGreaterThan(0);
407−
408− for (const refKey of outputRefs) {
409− const schemaJson = manifest.refHintTable[refKey];
410− expect(schemaJson).toBeDefined();
411− expect(() => JSON.parse(schemaJson)).not.toThrow();
412− }
413− }),
414− 30_000,
415− );
416−
417− it.effect("Vercel extract keeps typed invocation + schema coverage", () =>
418− Effect.gen(function* () {
419− const vercelSpec = yield* Effect.tryPromise(() => loadSpec(vercelSpecUrl));
420− const manifest = extractOpenApiManifest("vercel", vercelSpec);
421−
422− expect(manifest.tools.length).toBeGreaterThan(100);
423−
424− const toolWithPathParams = manifest.tools.find(
425− (tool) => tool.invocation.parameters.some((parameter) => parameter.location === "path"),
426− );
427− expect(toolWithPathParams).toBeDefined();
428− expect(toolWithPathParams?.typing?.inputSchemaJson).toBeDefined();
429−
430− const inputSchema = JSON.parse(toolWithPathParams!.typing!.inputSchemaJson!);
431− expect(isRecord(inputSchema)).toBe(true);
432− expect(inputSchema.type).toBe("object");
433− expect(isRecord(inputSchema.properties)).toBe(true);
434−
435− for (const pathParameter of toolWithPathParams!.invocation.parameters.filter(
436− (parameter) => parameter.location === "path",
437− )) {
438− expect(Array.isArray(inputSchema.required)).toBe(true);
439− expect(inputSchema.required).toContain(pathParameter.name);
440− expect(inputSchema.properties[pathParameter.name]).toBeDefined();
441− }
442− }),
443− 30_000,
444− );
445−
446− it.effect("Cloudflare has no unknown-like schemas for structured operations", () =>
447− Effect.gen(function* () {
448− const cloudflareSpec = yield* Effect.tryPromise(() => loadSpec(cloudflareSpecUrl));
449− assertManifestFidelityForSpec("cloudflare", cloudflareSpec);
450− }),
451− 60_000,
452− );
453−
454− it.effect("Vercel has no unknown-like schemas for structured operations", () =>
455− Effect.gen(function* () {
456− const vercelSpec = yield* Effect.tryPromise(() => loadSpec(vercelSpecUrl));
457− assertManifestFidelityForSpec("vercel", vercelSpec);
458− }),
459− 60_000,
460− );
461−});
apps/api/api/parse-openapi.ts+0−812
@@ -1,812 +0,0 @@
1−import { createHash } from "node:crypto";
2−
3−import { ConvexHttpClient } from "convex/browser";
4−import { parse as parseYaml } from "yaml";
5−
6−const maxBodyBytes = 256 * 1024;
7−const httpMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"] as const;
8−
9−type HttpMethod = typeof httpMethods[number];
10−type OpenApiParameterLocation = "path" | "query" | "header" | "cookie";
11−
12−type ExtractedTool = {
13− toolId: string;
14− name: string;
15− description: string | null;
16− method: HttpMethod;
17− path: string;
18− invocation: {
19− method: HttpMethod;
20− pathTemplate: string;
21− parameters: Array<{
22− name: string;
23− location: OpenApiParameterLocation;
24− required: boolean;
25− }>;
26− requestBody: {
27− required: boolean;
28− contentTypes: Array<string>;
29− } | null;
30− };
31− operationHash: string;
32− typing?: {
33− inputSchemaJson?: string;
34− outputSchemaJson?: string;
35− refHintKeys?: Array<string>;
36− };
37−};
38−
39−type OpenApiManifest = {
40− sourceHash: string;
41− tools: Array<ExtractedTool>;
42− refHintTable: Record<string, string>;
43−};
44−
45−const parseJsonDocument = (input: string): unknown => JSON.parse(input);
46−const parseYamlDocument = (input: string): unknown => parseYaml(input);
47−
48−const isRecord = (value: unknown): value is Record<string, unknown> =>
49− typeof value === "object" && value !== null && !Array.isArray(value);
50−
51−const stableStringify = (value: unknown): string => {
52− if (Array.isArray(value)) {
53− return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
54− }
55−
56− if (isRecord(value)) {
57− const keys = Object.keys(value).sort();
58− return `{${keys
59− .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
60− .join(",")}}`;
61− }
62−
63− return JSON.stringify(value);
64−};
65−
66−const hashUnknown = (value: unknown): string =>
67− createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
68−
69−const normalizeText = (value: unknown): string | null => {
70− if (typeof value !== "string") {
71− return null;
72− }
73−
74− const trimmed = value.trim();
75− return trimmed.length > 0 ? trimmed : null;
76−};
77−
78−const normalizeSegment = (value: string): string =>
79− value
80− .toLowerCase()
81− .replace(/[^a-z0-9]+/g, "_")
82− .replace(/^_+|_+$/g, "");
83−
84−const normalizeToolId = (value: string): string =>
85− value
86− .toLowerCase()
87− .replace(/[^a-z0-9]+/g, ".")
88− .replace(/\.+/g, ".")
89− .replace(/^\.|\.$/g, "");
90−
91−const escapeJsonPointer = (value: string): string =>
92− value.replace(/~/g, "~0").replace(/\//g, "~1");
93−
94−const unescapeJsonPointer = (value: string): string =>
95− value.replace(/~1/g, "/").replace(/~0/g, "~");
96−
97−const getByJsonPointer = (root: unknown, pointer: string): unknown => {
98− if (!pointer.startsWith("#/")) {
99− return null;
100− }
101−
102− const segments = pointer
103− .slice(2)
104− .split("/")
105− .map((segment) => unescapeJsonPointer(segment));
106−
107− let current: unknown = root;
108− for (const segment of segments) {
109− if (Array.isArray(current)) {
110− const index = Number(segment);
111− if (!Number.isInteger(index) || index < 0 || index >= current.length) {
112− return null;
113− }
114− current = current[index];
115− continue;
116− }
117−
118− if (!isRecord(current) || !(segment in current)) {
119− return null;
120− }
121−
122− current = current[segment];
123− }
124−
125− return current;
126−};
127−
128−const resolveLocalRef = (root: unknown, value: unknown, depth = 0): unknown => {
129− if (depth > 8 || !isRecord(value) || typeof value.$ref !== "string") {
130− return value;
131− }
132−
133− const resolved = getByJsonPointer(root, value.$ref);
134− if (resolved === null) {
135− return value;
136− }
137−
138− return resolveLocalRef(root, resolved, depth + 1);
139−};
140−
141−const collectLocalRefKeys = (value: unknown, target: Set<string>): void => {
142− if (Array.isArray(value)) {
143− for (const entry of value) {
144− collectLocalRefKeys(entry, target);
145− }
146− return;
147− }
148−
149− if (!isRecord(value)) {
150− return;
151− }
152−
153− for (const [key, entry] of Object.entries(value)) {
154− if (key === "$ref" && typeof entry === "string" && entry.startsWith("#/")) {
155− target.add(entry);
156− continue;
157− }
158−
159− collectLocalRefKeys(entry, target);
160− }
161−};
162−
163−const extractParameterSchema = (
164− root: unknown,
165− parameter: Record<string, unknown>,
166−): unknown => {
167− const resolved = resolveLocalRef(root, parameter);
168− if (!isRecord(resolved)) {
169− return {};
170− }
171−
172− if (isRecord(resolved.schema)) {
173− return resolved.schema;
174− }
175−
176− const content = resolved.content;
177− if (isRecord(content)) {
178− const jsonMedia =
179− (content["application/json"] as Record<string, unknown> | undefined)
180− ?? (content["application/*+json"] as Record<string, unknown> | undefined);
181−
182− if (jsonMedia && isRecord(jsonMedia.schema)) {
183− return jsonMedia.schema;
184− }
185−
186− for (const mediaType of Object.values(content)) {
187− if (isRecord(mediaType) && isRecord(mediaType.schema)) {
188− return mediaType.schema;
189− }
190− }
191− }
192−
193− return {};
194−};
195−
196−const extractRequestBody = (
197− root: unknown,
198− operation: Record<string, unknown>,
199−): {
200− required: boolean;
201− contentTypes: Array<string>;
202− schema: unknown;
203−} | null => {
204− const requestBodyValue = resolveLocalRef(root, operation.requestBody);
205− if (!isRecord(requestBodyValue)) {
206− return null;
207− }
208−
209− const content = requestBodyValue.content;
210− if (!isRecord(content)) {
211− return {
212− required: requestBodyValue.required === true,
213− contentTypes: [],
214− schema: {},
215− };
216− }
217−
218− const contentTypes = Object.keys(content).filter((entry) => entry.trim().length > 0);
219− const jsonMedia =
220− (content["application/json"] as Record<string, unknown> | undefined)
221− ?? (content["application/*+json"] as Record<string, unknown> | undefined);
222− let schema: unknown = {};
223−
224− if (jsonMedia && isRecord(jsonMedia.schema)) {
225− schema = jsonMedia.schema;
226− } else {
227− for (const mediaType of Object.values(content)) {
228− if (isRecord(mediaType) && isRecord(mediaType.schema)) {
229− schema = mediaType.schema;
230− break;
231− }
232− }
233− }
234−
235− return {
236− required: requestBodyValue.required === true,
237− contentTypes,
238− schema,
239− };
240−};
241−
242−const extractResponseSchema = (
243− root: unknown,
244− operation: Record<string, unknown>,
245−): unknown | null => {
246− const responsesValue = resolveLocalRef(root, operation.responses);
247− if (!isRecord(responsesValue)) {
248− return null;
249− }
250−
251− const preferredKeys = Object.keys(responsesValue)
252− .filter((key) => /^2\d\d$/.test(key))
253− .sort();
254− if ("default" in responsesValue) {
255− preferredKeys.push("default");
256− }
257−
258− for (const key of preferredKeys) {
259− const candidate = resolveLocalRef(root, responsesValue[key]);
260− if (!isRecord(candidate) || !isRecord(candidate.content)) {
261− continue;
262− }
263−
264− const content = candidate.content;
265− const jsonMedia =
266− (content["application/json"] as Record<string, unknown> | undefined)
267− ?? (content["application/*+json"] as Record<string, unknown> | undefined);
268−
269− if (jsonMedia && isRecord(jsonMedia.schema)) {
270− return jsonMedia.schema;
271− }
272−
273− for (const mediaType of Object.values(content)) {
274− if (isRecord(mediaType) && isRecord(mediaType.schema)) {
275− return mediaType.schema;
276− }
277− }
278− }
279−
280− return null;
281−};
282−
283−const buildInputSchema = (
284− root: unknown,
285− parameters: Array<Record<string, unknown>>,
286− requestBody: {
287− required: boolean;
288− schema: unknown;
289− } | null,
290−): { schemaJson: string; refHintKeys: Array<string> } | null => {
291− const properties: Record<string, unknown> = {};
292− const required = new Set<string>();
293− const refKeys = new Set<string>();
294−
295− for (const parameter of parameters) {
296− const resolved = resolveLocalRef(root, parameter);
297− if (!isRecord(resolved)) {
298− continue;
299− }
300−
301− const location = normalizeText(resolved.in)?.toLowerCase();
302− if (
303− location !== "path"
304− && location !== "query"
305− && location !== "header"
306− && location !== "cookie"
307− ) {
308− continue;
309− }
310−
311− const name = normalizeText(resolved.name);
312− if (!name) {
313− continue;
314− }
315−
316− const schema = extractParameterSchema(root, resolved);
317− properties[name] = schema;
318− collectLocalRefKeys(schema, refKeys);
319−
320− const isRequired = resolved.required === true || location === "path";
321− if (isRequired) {
322− required.add(name);
323− }
324− }
325−
326− if (requestBody) {
327− properties.body = requestBody.schema;
328− collectLocalRefKeys(requestBody.schema, refKeys);
329− if (requestBody.required) {
330− required.add("body");
331− }
332− }
333−
334− if (Object.keys(properties).length === 0) {
335− return null;
336− }
337−
338− return {
339− schemaJson: JSON.stringify({
340− type: "object",
341− properties,
342− ...(required.size > 0 ? { required: Array.from(required).sort() } : {}),
343− }),
344− refHintKeys: Array.from(refKeys).sort(),
345− };
346−};
347−
348−const buildToolId = (
349− method: HttpMethod,
350− path: string,
351− operation: Record<string, unknown>,
352−): string => {
353− const operationId = normalizeText(operation.operationId);
354− if (operationId) {
355− const normalized = normalizeToolId(operationId);
356− if (normalized.length > 0) {
357− return normalized;
358− }
359− }
360−
361− const rawSegments = path
362− .split("/")
363− .map((segment) => segment.trim())
364− .filter((segment) => segment.length > 0)
365− .map((segment) => {
366− if (segment.startsWith("{") && segment.endsWith("}")) {
367− return segment.slice(1, -1);
368− }
369− return segment;
370− })
371− .map((segment) => normalizeSegment(segment))
372− .filter((segment) => segment.length > 0);
373−
374− const segments = rawSegments.length > 0 ? rawSegments.slice(0, 6) : ["operation"];
375− return normalizeToolId(`${segments.join(".")}.${method}`);
376−};
377−
378−const dedupeToolIds = (tools: Array<ExtractedTool>): Array<ExtractedTool> => {
379− const counts = new Map<string, number>();
380−
381− return tools.map((tool) => {
382− const count = (counts.get(tool.toolId) ?? 0) + 1;
383− counts.set(tool.toolId, count);
384−
385− if (count === 1) {
386− return tool;
387− }
388−
389− return {
390− ...tool,
391− toolId: `${tool.toolId}_${count}`,
392− };
393− });
394−};
395−
396−const buildRefHintTable = (
397− root: unknown,
398− initialRefs: ReadonlyArray<string>,
399−): Record<string, string> => {
400− const table: Record<string, string> = {};
401− const queue = [...initialRefs];
402− const visited = new Set<string>();
403−
404− while (queue.length > 0) {
405− const ref = queue.shift();
406− if (!ref || visited.has(ref)) {
407− continue;
408− }
409− visited.add(ref);
410−
411− const resolved = getByJsonPointer(root, ref);
412− if (resolved === null) {
413− continue;
414− }
415−
416− table[ref] = JSON.stringify(resolved);
417−
418− const nestedRefs = new Set<string>();
419− collectLocalRefKeys(resolved, nestedRefs);
420− for (const nestedRef of nestedRefs) {
421− if (!visited.has(nestedRef)) {
422− queue.push(nestedRef);
423− }
424− }
425− }
426−
427− return table;
428−};
429−
430−export const extractOpenApiManifest = (
431− _sourceName: string,
432− openApiSpec: unknown,
433−): OpenApiManifest => {
434− if (!isRecord(openApiSpec)) {
435− throw new Error("OpenAPI document must be an object");
436− }
437−
438− const paths = openApiSpec.paths;
439− if (!isRecord(paths)) {
440− throw new Error("OpenAPI document is missing paths");
441− }
442−
443− const tools: Array<ExtractedTool> = [];
444− const globalRefKeys = new Set<string>();
445−
446− for (const [path, pathItemValue] of Object.entries(paths)) {
447− if (!isRecord(pathItemValue)) {
448− continue;
449− }
450−
451− const sharedParameters = Array.isArray(pathItemValue.parameters)
452− ? pathItemValue.parameters.filter((entry): entry is Record<string, unknown> => isRecord(entry))
453− : [];
454−
455− for (const method of httpMethods) {
456− const operationValue = pathItemValue[method];
457− if (!isRecord(operationValue)) {
458− continue;
459− }
460−
461− const operationParameters = Array.isArray(operationValue.parameters)
462− ? operationValue.parameters.filter((entry): entry is Record<string, unknown> => isRecord(entry))
463− : [];
464− const parameters = [...sharedParameters, ...operationParameters];
465− const requestBody = extractRequestBody(openApiSpec, operationValue);
466−
467− const invocation = {
468− method,
469− pathTemplate: path,
470− parameters: parameters
471− .map((parameter) => resolveLocalRef(openApiSpec, parameter))
472− .filter((parameter): parameter is Record<string, unknown> => isRecord(parameter))
473− .map((parameter) => ({
474− name: normalizeText(parameter.name) ?? "",
475− location:
476− (normalizeText(parameter.in)?.toLowerCase() as OpenApiParameterLocation | undefined)
477− ?? "query",
478− required:
479− parameter.required === true
480− || normalizeText(parameter.in)?.toLowerCase() === "path",
481− }))
482− .filter((parameter) =>
483− parameter.name.length > 0
484− && (
485− parameter.location === "path"
486− || parameter.location === "query"
487− || parameter.location === "header"
488− || parameter.location === "cookie"
489− ))
490− .sort((left, right) =>
491− left.location === right.location
492− ? left.name.localeCompare(right.name)
493− : left.location.localeCompare(right.location)),
494− requestBody: requestBody
495− ? {
496− required: requestBody.required,
497− contentTypes: requestBody.contentTypes,
498− }
499− : null,
500− };
501−
502− const inputTyping = buildInputSchema(openApiSpec, parameters, requestBody);
503− const outputSchema = extractResponseSchema(openApiSpec, operationValue);
504− const outputSchemaJson = outputSchema === null ? null : JSON.stringify(outputSchema);
505− if (outputSchema !== null) {
506− collectLocalRefKeys(outputSchema, globalRefKeys);
507− }
508−
509− if (inputTyping) {
510− for (const refKey of inputTyping.refHintKeys) {
511− globalRefKeys.add(refKey);
512− }
513− }
514−
515− const name =
516− normalizeText(operationValue.summary)
517− ?? normalizeText(operationValue.operationId)
518− ?? `${method.toUpperCase()} ${path}`;
519− const description =
520− normalizeText(operationValue.description)
521− ?? normalizeText(operationValue.summary)
522− ?? null;
523− const toolId = buildToolId(method, path, operationValue);
524−
525− tools.push({
526− toolId,
527− name,
528− description,
529− method,
530− path,
531− invocation,
532− operationHash: hashUnknown({
533− method,
534− path,
535− invocation,
536− inputSchemaJson: inputTyping?.schemaJson ?? null,
537− outputSchemaJson,
538− }),
539− typing: {
540− ...(inputTyping ? { inputSchemaJson: inputTyping.schemaJson } : {}),
541− ...(outputSchemaJson ? { outputSchemaJson } : {}),
542− ...(inputTyping && inputTyping.refHintKeys.length > 0
543− ? { refHintKeys: inputTyping.refHintKeys }
544− : {}),
545− },
546− });
547− }
548− }
549−
550− const deduped = dedupeToolIds(tools);
551− const refHintTable = buildRefHintTable(openApiSpec, Array.from(globalRefKeys));
552−
553− return {
554− sourceHash: hashUnknown({
555− tools: deduped.map((tool) => ({
556− toolId: tool.toolId,
557− operationHash: tool.operationHash,
558− })),
559− refKeys: Object.keys(refHintTable).sort(),
560− }),
561− tools: deduped,
562− refHintTable,
563− };
564−};
565−
566−const parseOpenApiDocument = (input: string): unknown => {
567− const text = input.trim();
568− if (text.length === 0) {
569− throw new Error("OpenAPI document is empty");
570− }
571−
572− try {
573− return parseJsonDocument(text);
574− } catch {
575− try {
576− return parseYamlDocument(text);
577− } catch (cause) {
578− throw new Error(
579− `Unable to parse OpenAPI document as JSON or YAML: ${
580− cause instanceof Error ? cause.message : String(cause)
581− }`,
582− );
583− }
584− }
585−};
586−
587−const readRawBody = async (request: AsyncIterable<unknown>): Promise<string> => {
588− const chunks: Array<Buffer> = [];
589− let bytes = 0;
590−
591− for await (const chunk of request) {
592− const buffer = Buffer.isBuffer(chunk)
593− ? chunk
594− : typeof chunk === "string"
595− ? Buffer.from(chunk)
596− : chunk instanceof Uint8Array
597− ? Buffer.from(chunk)
598− : Buffer.from(String(chunk));
599− bytes += buffer.length;
600− if (bytes > maxBodyBytes) {
601− throw new Error("Request body too large");
602− }
603− chunks.push(buffer);
604− }
605−
606− return Buffer.concat(chunks).toString("utf8");
607−};
608−
609−const json = (
610− response: any,
611− status: number,
612− payload: Record<string, unknown>,
613−): void => {
614− response.status(status).setHeader("content-type", "application/json; charset=utf-8");
615− response.send(JSON.stringify(payload));
616−};
617−
618−const normalizeNamespacePart = (value: string): string => {
619− const normalized = value
620− .trim()
621− .toLowerCase()
622− .replace(/[^a-z0-9]+/g, "_")
623− .replace(/^_+|_+$/g, "");
624−
625− return normalized.length > 0 ? normalized : "source";
626−};
627−
628−const sourceNamespace = (sourceName: string, sourceId: string): string => {
629− const sourceIdSuffix = sourceId.slice(-6).toLowerCase();
630− return `${normalizeNamespacePart(sourceName)}_${sourceIdSuffix}`;
631−};
632−
633−const parseSourceEnabled = (value: unknown): boolean =>
634− typeof value === "boolean" ? value : true;
635−
636−const toIngestPayload = async (
637− openApiSpec: unknown,
638− input: {
639− convexUrl: string;
640− ingestToken: string;
641− workspaceId: string;
642− sourceId: string;
643− sourceName: string;
644− sourceEndpoint: string;
645− sourceEnabled: boolean;
646− },
647−): Promise<{
648− artifactId: string;
649− sourceHash: string;
650− toolCount: number;
651− namespace: string;
652−}> => {
653− const manifest = extractOpenApiManifest(input.sourceName, openApiSpec);
654−
655− const refs = Object.entries(manifest.refHintTable ?? {}).map(([refKey, schemaJson]) => ({
656− refKey,
657− schemaJson,
658− }));
659−
660− const tools = manifest.tools.map((tool) => ({
661− toolId: tool.toolId,
662− name: tool.name,
663− description: tool.description,
664− method: tool.method,
665− path: tool.path,
666− operationHash: tool.operationHash,
667− invocationJson: JSON.stringify(tool.invocation),
668− inputSchemaJson: tool.typing?.inputSchemaJson ?? null,
669− outputSchemaJson: tool.typing?.outputSchemaJson ?? null,
670− }));
671−
672− const convex = new ConvexHttpClient(input.convexUrl);
673− const result = await (convex as any).action("control_plane/tool_registry:ingestOpenApiManifest", {
674− token: input.ingestToken,
675− workspaceId: input.workspaceId,
676− sourceId: input.sourceId,
677− sourceName: input.sourceName,
678− sourceEndpoint: input.sourceEndpoint,
679− sourceEnabled: input.sourceEnabled,
680− sourceHash: manifest.sourceHash,
681− toolCount: manifest.tools.length,
682− refs,
683− tools,
684− }) as {
685− artifactId: string;
686− };
687−
688− return {
689− artifactId: result.artifactId,
690− sourceHash: manifest.sourceHash,
691− toolCount: manifest.tools.length,
692− namespace: sourceNamespace(input.sourceName, input.sourceId),
693− };
694−};
695−
696−const parseEndpoint = async (request: any, response: any) => {
697− if (request.method !== "POST") {
698− response.setHeader("allow", "POST");
699− json(response, 405, { error: "Method not allowed" });
700− return;
701− }
702−
703− try {
704− const expectedToken = process.env.OPENAPI_PARSE_API_TOKEN?.trim();
705− const provided =
706− typeof request.headers["x-openapi-parse-token"] === "string"
707− ? request.headers["x-openapi-parse-token"].trim()
708− : "";
709− if (expectedToken && expectedToken.length > 0 && provided !== expectedToken) {
710− json(response, 401, { error: "Unauthorized" });
711− return;
712− }
713−
714− const rawBody = await readRawBody(request);
715− const payload = JSON.parse(rawBody) as Record<string, unknown>;
716− const specUrl = typeof payload.specUrl === "string" ? payload.specUrl.trim() : "";
717−
718− if (specUrl.length === 0) {
719− json(response, 400, { error: "specUrl is required" });
720− return;
721− }
722−
723− const parsedUrl = new URL(specUrl);
724− const protocol = parsedUrl.protocol.toLowerCase();
725− if (protocol !== "https:" && protocol !== "http:") {
726− json(response, 400, { error: "specUrl must use http:// or https://" });
727− return;
728− }
729−
730− const upstream = await fetch(specUrl);
731− if (!upstream.ok) {
732− json(response, 502, {
733− error: "Failed fetching OpenAPI document",
734− status: upstream.status,
735− statusText: upstream.statusText,
736− });
737− return;
738− }
739−
740− const bodyText = await upstream.text();
741− const openApiSpec = parseOpenApiDocument(bodyText);
742−
743− const workspaceId = typeof payload.workspaceId === "string" ? payload.workspaceId.trim() : "";
744− const sourceId = typeof payload.sourceId === "string" ? payload.sourceId.trim() : "";
745− const sourceName = typeof payload.sourceName === "string" ? payload.sourceName.trim() : "";
746−
747− const convexUrl =
748− process.env.CONVEX_URL?.trim()
749− || process.env.NEXT_PUBLIC_CONVEX_URL?.trim()
750− || "";
751− const ingestToken =
752− process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || "";
753−
754− const shouldIngest =
755− workspaceId.length > 0
756− && sourceId.length > 0
757− && sourceName.length > 0
758− && convexUrl.length > 0;
759−
760− if (shouldIngest && ingestToken.length === 0) {
761− json(response, 503, {
762− ok: false,
763− error: "OPENAPI_INGEST_SERVICE_TOKEN is required for ingest mode",
764− });
765− return;
766− }
767−
768− if (shouldIngest) {
769− const ingested = await toIngestPayload(openApiSpec, {
770− convexUrl,
771− ingestToken,
772− workspaceId,
773− sourceId,
774− sourceName,
775− sourceEndpoint: specUrl,
776− sourceEnabled: parseSourceEnabled(payload.sourceEnabled),
777− });
778−
779− json(response, 200, {
780− ok: true,
781− mode: "ingested",
782− specUrl,
783− artifactId: ingested.artifactId,
784− sourceHash: ingested.sourceHash,
785− toolCount: ingested.toolCount,
786− namespace: ingested.namespace,
787− artifactBatchCount: 0,
788− });
789− return;
790− }
791−
792− json(response, 200, {
793− ok: true,
794− mode: "parsed",
795− specUrl,
796− openApiSpec,
797− });
798− } catch (cause) {
799− json(response, 500, {
800− ok: false,
801− error: cause instanceof Error ? cause.message : String(cause),
802− });
803− }
804−};
805−
806−export const config = {
807− api: {
808− bodyParser: false,
809− },
810−};
811−
812−export default parseEndpoint;
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−
3−Convex remote server app scaffold for Executor v2.
4−
5−Current scaffold includes:
6− Convex schema wiring lives directly in `convex/schema.ts`
7− MCP HTTP endpoint at `GET/POST/DELETE /v1/mcp` via `convex/http.ts` and `convex/mcp.ts`
8− runtime callback endpoint at `POST /v1/runtime/tool-call`
9− control-plane source endpoints at `GET/POST /v1/workspaces/:workspaceId/sources`
10− control-plane source removal endpoint at `DELETE /v1/workspaces/:workspaceId/sources/:sourceId`
11− generated OpenAPI spec endpoint at `GET /v1/openapi.json`
apps/convex/convex/README.md+0−90
@@ -1,90 +0,0 @@
1−# Welcome to your Convex functions directory!
2−
3−Write your Convex functions here.
4−See https://docs.convex.dev/functions for more.
5−
6−A query function that takes two arguments looks like:
7−
8−```ts
9−// convex/myFunctions.ts
10−import { query } from "./_generated/server";
11−import { v } from "convex/values";
12−
13−export const myQueryFunction = query({
14− // Validators for arguments.
15− args: {
16− first: v.number(),
17− second: v.string(),
18− },
19−
20− // Function implementation.
21− handler: async (ctx, args) => {
22− // Read the database as many times as you need here.
23− // See https://docs.convex.dev/database/reading-data.
24− const documents = await ctx.db.query("tablename").collect();
25−
26− // Arguments passed from the client are properties of the args object.
27− console.log(args.first, args.second);
28−
29− // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30− // remove non-public properties, or create new objects.
31− return documents;
32− },
33−});
34−```
35−
36−Using this query function in a React component looks like:
37−
38−```ts
39−const data = useQuery(api.myFunctions.myQueryFunction, {
40− first: 10,
41− second: "hello",
42−});
43−```
44−
45−A mutation function looks like:
46−
47−```ts
48−// convex/myFunctions.ts
49−import { mutation } from "./_generated/server";
50−import { v } from "convex/values";
51−
52−export const myMutationFunction = mutation({
53− // Validators for arguments.
54− args: {
55− first: v.string(),
56− second: v.string(),
57− },
58−
59− // Function implementation.
60− handler: async (ctx, args) => {
61− // Insert or modify documents in the database here.
62− // Mutations can also read from the database like queries.
63− // See https://docs.convex.dev/database/writing-data.
64− const message = { body: args.first, author: args.second };
65− const id = await ctx.db.insert("messages", message);
66−
67− // Optionally, return a value from your mutation.
68− return await ctx.db.get("messages", id);
69− },
70−});
71−```
72−
73−Using this mutation function in a React component looks like:
74−
75−```ts
76−const mutation = useMutation(api.myFunctions.myMutationFunction);
77−function handleButtonPress() {
78− // fire and forget, the most common way to use mutations
79− mutation({ first: "Hello!", second: "me" });
80− // OR
81− // use the result once the mutation has completed
82− mutation({ first: "Hello!", second: "me" }).then((result) =>
83− console.log(result),
84− );
85−}
86−```
87−
88−Use the Convex CLI to push your functions to a deployment. See everything
89−the Convex CLI can do by running `npx convex -h` in your project root
90−directory. To learn more, launch the docs with `npx convex docs`.
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−
11−import type * as controlPlane from "../controlPlane.js";
12−import type * as control_plane_actor from "../control_plane/actor.js";
13−import type * as control_plane_approvals from "../control_plane/approvals.js";
14−import type * as control_plane_credentials from "../control_plane/credentials.js";
15−import type * as control_plane_errors from "../control_plane/errors.js";
16−import type * as control_plane_http from "../control_plane/http.js";
17−import type * as control_plane_openapi_ingest from "../control_plane/openapi_ingest.js";
18−import type * as control_plane_organizations from "../control_plane/organizations.js";
19−import type * as control_plane_policies from "../control_plane/policies.js";
20−import type * as control_plane_service from "../control_plane/service.js";
21−import type * as control_plane_sources from "../control_plane/sources.js";
22−import type * as control_plane_storage from "../control_plane/storage.js";
23−import type * as control_plane_tool_registry from "../control_plane/tool_registry.js";
24−import type * as control_plane_tools from "../control_plane/tools.js";
25−import type * as control_plane_workspaces from "../control_plane/workspaces.js";
26−import type * as credential_crypto from "../credential_crypto.js";
27−import type * as executor from "../executor.js";
28−import type * as http from "../http.js";
29−import type * as http_security from "../http_security.js";
30−import type * as mcp from "../mcp.js";
31−import type * as mcp_auth from "../mcp_auth.js";
32−import type * as run_executor from "../run_executor.js";
33−import type * as runtimeCallbacks from "../runtimeCallbacks.js";
34−import type * as runtime_adapter from "../runtime_adapter.js";
35−import type * as runtime_execution_port from "../runtime_execution_port.js";
36−import type * as source_tool_registry from "../source_tool_registry.js";
37−import type * as task_runs from "../task_runs.js";
38−import type * as workflow from "../workflow.js";
39−
40−import type {
41− ApiFromModules,
42− FilterApi,
43− FunctionReference,
44−} from "convex/server";
45−
46−declare const fullApi: ApiFromModules<{
47− controlPlane: typeof controlPlane;
48− "control_plane/actor": typeof control_plane_actor;
49− "control_plane/approvals": typeof control_plane_approvals;
50− "control_plane/credentials": typeof control_plane_credentials;
51− "control_plane/errors": typeof control_plane_errors;
52− "control_plane/http": typeof control_plane_http;
53− "control_plane/openapi_ingest": typeof control_plane_openapi_ingest;
54− "control_plane/organizations": typeof control_plane_organizations;
55− "control_plane/policies": typeof control_plane_policies;
56− "control_plane/service": typeof control_plane_service;
57− "control_plane/sources": typeof control_plane_sources;
58− "control_plane/storage": typeof control_plane_storage;
59− "control_plane/tool_registry": typeof control_plane_tool_registry;
60− "control_plane/tools": typeof control_plane_tools;
61− "control_plane/workspaces": typeof control_plane_workspaces;
62− credential_crypto: typeof credential_crypto;
63− executor: typeof executor;
64− http: typeof http;
65− http_security: typeof http_security;
66− mcp: typeof mcp;
67− mcp_auth: typeof mcp_auth;
68− run_executor: typeof run_executor;
69− runtimeCallbacks: typeof runtimeCallbacks;
70− runtime_adapter: typeof runtime_adapter;
71− runtime_execution_port: typeof runtime_execution_port;
72− source_tool_registry: typeof source_tool_registry;
73− task_runs: typeof task_runs;
74− workflow: typeof workflow;
75−}>;
76−
77−/**
78− * A utility for referencing Convex functions in your app's public API.
79− *
80− * Usage:
81− * ```js
82− * const myFunctionReference = api.myModule.myFunction;
83− * ```
84− */
85−export declare const api: FilterApi<
86− typeof fullApi,
87− FunctionReference<any, "public">
88−>;
89−
90−/**
91− * A utility for referencing Convex functions in your app's internal API.
92− *
93− * Usage:
94− * ```js
95− * const myFunctionReference = internal.myModule.myFunction;
96− * ```
97− */
98−export declare const internal: FilterApi<
99− typeof fullApi,
100− FunctionReference<any, "internal">
101−>;
102−
103−export declare const components: {
104− actionCache: {
105− crons: {
106− purge: FunctionReference<
107− "mutation",
108− "internal",
109− { expiresAt?: number },
110− null
111− >;
112− };
113− lib: {
114− get: FunctionReference<
115− "query",
116− "internal",
117− { args: any; name: string; ttl: number | null },
118− { kind: "hit"; value: any } | { expiredEntry?: string; kind: "miss" }
119− >;
120− put: FunctionReference<
121− "mutation",
122− "internal",
123− {
124− args: any;
125− expiredEntry?: string;
126− name: string;
127− ttl: number | null;
128− value: any;
129− },
130− { cacheHit: boolean; deletedExpiredEntry: boolean }
131− >;
132− remove: FunctionReference<
133− "mutation",
134− "internal",
135− { args: any; name: string },
136− null
137− >;
138− removeAll: FunctionReference<
139− "mutation",
140− "internal",
141− { batchSize?: number; before?: number; name?: string },
142− null
143− >;
144− };
145− };
146− workflow: {
147− event: {
148− create: FunctionReference<
149− "mutation",
150− "internal",
151− { name: string; workflowId: string },
152− string
153− >;
154− send: FunctionReference<
155− "mutation",
156− "internal",
157− {
158− eventId?: string;
159− name?: string;
160− result:
161− | { kind: "success"; returnValue: any }
162− | { error: string; kind: "failed" }
163− | { kind: "canceled" };
164− workflowId?: string;
165− workpoolOptions?: {
166− defaultRetryBehavior?: {
167− base: number;
168− initialBackoffMs: number;
169− maxAttempts: number;
170− };
171− logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR";
172− maxParallelism?: number;
173− retryActionsByDefault?: boolean;
174− };
175− },
176− string
177− >;
178− };
179− journal: {
180− load: FunctionReference<
181− "query",
182− "internal",
183− { shortCircuit?: boolean; workflowId: string },
184− {
185− blocked?: boolean;
186− journalEntries: Array<{
187− _creationTime: number;
188− _id: string;
189− step:
190− | {
191− args: any;
192− argsSize: number;
193− completedAt?: number;
194− functionType: "query" | "mutation" | "action";
195− handle: string;
196− inProgress: boolean;
197− kind?: "function";
198− name: string;
199− runResult?:
200− | { kind: "success"; returnValue: any }
201− | { error: string; kind: "failed" }
202− | { kind: "canceled" };
203− startedAt: number;
204− workId?: string;
205− }
206− | {
207− args: any;
208− argsSize: number;
209− completedAt?: number;
210− handle: string;
211− inProgress: boolean;
212− kind: "workflow";
213− name: string;
214− runResult?:
215− | { kind: "success"; returnValue: any }
216− | { error: string; kind: "failed" }
217− | { kind: "canceled" };
218− startedAt: number;
219− workflowId?: string;
220− }
221− | {
222− args: { eventId?: string };
223− argsSize: number;
224− completedAt?: number;
225− eventId?: string;
226− inProgress: boolean;
227− kind: "event";
228− name: string;
229− runResult?:
230− | { kind: "success"; returnValue: any }
231− | { error: string; kind: "failed" }
232− | { kind: "canceled" };
233− startedAt: number;
234− };
235− stepNumber: number;
236− workflowId: string;
237− }>;
238− logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR";
239− ok: boolean;
240− workflow: {
241− _creationTime: number;
242− _id: string;
243− args: any;
244− generationNumber: number;
245− logLevel?: any;
246− name?: string;
247− onComplete?: { context?: any; fnHandle: string };
248− runResult?:
249− | { kind: "success"; returnValue: any }
250− | { error: string; kind: "failed" }
251− | { kind: "canceled" };
252− startedAt?: any;
253− state?: any;
254− workflowHandle: string;
255− };
256− }
257− >;
258− startSteps: FunctionReference<
259− "mutation",
260− "internal",
261− {
262− generationNumber: number;
263− steps: Array<{
264− retry?:
265− | boolean
266− | { base: number; initialBackoffMs: number; maxAttempts: number };
267− schedulerOptions?: { runAt?: number } | { runAfter?: number };
268− step:
269− | {
270− args: any;
271− argsSize: number;
272− completedAt?: number;
273− functionType: "query" | "mutation" | "action";
274− handle: string;
275− inProgress: boolean;
276− kind?: "function";
277− name: string;
278− runResult?:
279− | { kind: "success"; returnValue: any }
280− | { error: string; kind: "failed" }
281− | { kind: "canceled" };
282− startedAt: number;
283− workId?: string;
284− }
285− | {
286− args: any;
287− argsSize: number;
288− completedAt?: number;
289− handle: string;
290− inProgress: boolean;
291− kind: "workflow";
292− name: string;
293− runResult?:
294− | { kind: "success"; returnValue: any }
295− | { error: string; kind: "failed" }
296− | { kind: "canceled" };
297− startedAt: number;
298− workflowId?: string;
299− }
300− | {
301− args: { eventId?: string };
302− argsSize: number;
303− completedAt?: number;
304− eventId?: string;
305− inProgress: boolean;
306− kind: "event";
307− name: string;
308− runResult?:
309− | { kind: "success"; returnValue: any }
310− | { error: string; kind: "failed" }
311− | { kind: "canceled" };
312− startedAt: number;
313− };
314− }>;
315− workflowId: string;
316− workpoolOptions?: {
317− defaultRetryBehavior?: {
318− base: number;
319− initialBackoffMs: number;
320− maxAttempts: number;
321− };
322− logLevel?: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR";
323− maxParallelism?: number;
324− retryActionsByDefault?: boolean;
325− };
326− },
327− Array<{
328− _creationTime: number;
329− _id: string;
330− step:
331− | {
332− args: any;
333− argsSize: number;
334− completedAt?: number;
335− functionType: "query" | "mutation" | "action";
336− handle: string;
337− inProgress: boolean;
338− kind?: "function";
339− name: string;
340− runResult?:
341− | { kind: "success"; returnValue: any }
342− | { error: string; kind: "failed" }
343− | { kind: "canceled" };
344− startedAt: number;
345− workId?: string;
346− }
347− | {
348− args: any;
349− argsSize: number;
350− completedAt?: number;
351− handle: string;
352− inProgress: boolean;
353− kind: "workflow";
354− name: string;
355− runResult?:
356− | { kind: "success"; returnValue: any }
357− | { error: string; kind: "failed" }
358− | { kind: "canceled" };
359− startedAt: number;
360− workflowId?: string;
361− }
362− | {
363− args: { eventId?: string };
364− argsSize: number;
365− completedAt?: number;
366− eventId?: string;
367− inProgress: boolean;
368− kind: "event";
369− name: string;
370− runResult?:
371− | { kind: "success"; returnValue: any }
372− | { error: string; kind: "failed" }
373− | { kind: "canceled" };
374− startedAt: number;
375− };
376− stepNumber: number;
377− workflowId: string;
378− }>
379− >;
380− };
381− workflow: {
382− cancel: FunctionReference<
383− "mutation",
384− "internal",
385− { workflowId: string },
386− null
387− >;
388− cleanup: FunctionReference<
389− "mutation",
390− "internal",
391− { workflowId: string },
392− boolean
393− >;
394− complete: FunctionReference<
395− "mutation",
396− "internal",
397− {
398− generationNumber: number;
399− runResult:
400− | { kind: "success"; returnValue: any }
401− | { error: string; kind: "failed" }
402− | { kind: "canceled" };
403− workflowId: string;
404− },
405− null
406− >;
407− create: FunctionReference<
408− "mutation",
409− "internal",
410− {
411− maxParallelism?: number;
412− onComplete?: { context?: any; fnHandle: string };
413− startAsync?: boolean;
414− workflowArgs: any;
415− workflowHandle: string;
416− workflowName: string;
417− },
418− string
419− >;
420− getStatus: FunctionReference<
421− "query",
422− "internal",
423− { workflowId: string },
424− {
425− inProgress: Array<{
426− _creationTime: number;
427− _id: string;
428− step:
429− | {
430− args: any;
431− argsSize: number;
432− completedAt?: number;
433− functionType: "query" | "mutation" | "action";
434− handle: string;
435− inProgress: boolean;
436− kind?: "function";
437− name: string;
438− runResult?:
439− | { kind: "success"; returnValue: any }
440− | { error: string; kind: "failed" }
441− | { kind: "canceled" };
442− startedAt: number;
443− workId?: string;
444− }
445− | {
446− args: any;
447− argsSize: number;
448− completedAt?: number;
449− handle: string;
450− inProgress: boolean;
451− kind: "workflow";
452− name: string;
453− runResult?:
454− | { kind: "success"; returnValue: any }
455− | { error: string; kind: "failed" }
456− | { kind: "canceled" };
457− startedAt: number;
458− workflowId?: string;
459− }
460− | {
461− args: { eventId?: string };
462− argsSize: number;
463− completedAt?: number;
464− eventId?: string;
465− inProgress: boolean;
466− kind: "event";
467− name: string;
468− runResult?:
469− | { kind: "success"; returnValue: any }
470− | { error: string; kind: "failed" }
471− | { kind: "canceled" };
472− startedAt: number;
473− };
474− stepNumber: number;
475− workflowId: string;
476− }>;
477− logLevel: "DEBUG" | "TRACE" | "INFO" | "REPORT" | "WARN" | "ERROR";
478− workflow: {
479− _creationTime: number;
480− _id: string;
481− args: any;
482− generationNumber: number;
483− logLevel?: any;
484− name?: string;
485− onComplete?: { context?: any; fnHandle: string };
486− runResult?:
487− | { kind: "success"; returnValue: any }
488− | { error: string; kind: "failed" }
489− | { kind: "canceled" };
490− startedAt?: any;
491− state?: any;
492− workflowHandle: string;
493− };
494− }
495− >;
496− list: FunctionReference<
497− "query",
498− "internal",
499− {
500− order: "asc" | "desc";
501− paginationOpts: {
502− cursor: string | null;
503− endCursor?: string | null;
504− id?: number;
505− maximumBytesRead?: number;
506− maximumRowsRead?: number;
507− numItems: number;
508− };
509− },
510− {
511− continueCursor: string;
512− isDone: boolean;
513− page: Array<{
514− args: any;
515− context?: any;
516− name?: string;
517− runResult?:
518− | { kind: "success"; returnValue: any }
519− | { error: string; kind: "failed" }
520− | { kind: "canceled" };
521− workflowId: string;
522− }>;
523− pageStatus?: "SplitRecommended" | "SplitRequired" | null;
524− splitCursor?: string | null;
525− }
526− >;
527− listByName: FunctionReference<
528− "query",
529− "internal",
530− {
531− name: string;
532− order: "asc" | "desc";
533− paginationOpts: {
534− cursor: string | null;
535− endCursor?: string | null;
536− id?: number;
537− maximumBytesRead?: number;
538− maximumRowsRead?: number;
539− numItems: number;
540− };
541− },
542− {
543− continueCursor: string;
544− isDone: boolean;
545− page: Array<{
546− args: any;
547− context?: any;
548− name?: string;
549− runResult?:
550− | { kind: "success"; returnValue: any }
551− | { error: string; kind: "failed" }
552− | { kind: "canceled" };
553− workflowId: string;
554− }>;
555− pageStatus?: "SplitRecommended" | "SplitRequired" | null;
556− splitCursor?: string | null;
557− }
558− >;
559− listSteps: FunctionReference<
560− "query",
561− "internal",
562− {
563− order: "asc" | "desc";
564− paginationOpts: {
565− cursor: string | null;
566− endCursor?: string | null;
567− id?: number;
568− maximumBytesRead?: number;
569− maximumRowsRead?: number;
570− numItems: number;
571− };
572− workflowId: string;
573− },
574− {
575− continueCursor: string;
576− isDone: boolean;
577− page: Array<{
578− args: any;
579− completedAt?: number;
580− eventId?: string;
581− kind: "function" | "workflow" | "event";
582− name: string;
583− nestedWorkflowId?: string;
584− runResult?:
585− | { kind: "success"; returnValue: any }
586− | { error: string; kind: "failed" }
587− | { kind: "canceled" };
588− startedAt: number;
589− stepId: string;
590− stepNumber: number;
591− workId?: string;
592− workflowId: string;
593− }>;
594− pageStatus?: "SplitRecommended" | "SplitRequired" | null;
595− splitCursor?: string | null;
596− }
597− >;
598− };
599− };
600−};
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−
11−import { anyApi, componentsGeneric } from "convex/server";
12−
13−/**
14− * A utility for referencing Convex functions in your app's API.
15− *
16− * Usage:
17− * ```js
18− * const myFunctionReference = api.myModule.myFunction;
19− * ```
20− */
21−export const api = anyApi;
22−export const internal = anyApi;
23−export const components = componentsGeneric();
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−
11−import type {
12− DataModelFromSchemaDefinition,
13− DocumentByName,
14− TableNamesInDataModel,
15− SystemTableNames,
16−} from "convex/server";
17−import type { GenericId } from "convex/values";
18−import schema from "../schema.js";
19−
20−/**
21− * The names of all of your Convex tables.
22− */
23−export type TableNames = TableNamesInDataModel<DataModel>;
24−
25−/**
26− * The type of a document stored in Convex.
27− *
28− * @typeParam TableName - A string literal type of the table name (like "users").
29− */
30−export type Doc<TableName extends TableNames> = DocumentByName<
31− DataModel,
32− TableName
33−>;
34−
35−/**
36− * An identifier for a document in Convex.
37− *
38− * Convex documents are uniquely identified by their `Id`, which is accessible
39− * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40− *
41− * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
42− *
43− * IDs are just strings at runtime, but this type can be used to distinguish them from other
44− * strings when type checking.
45− *
46− * @typeParam TableName - A string literal type of the table name (like "users").
47− */
48−export type Id<TableName extends TableNames | SystemTableNames> =
49− GenericId<TableName>;
50−
51−/**
52− * A type describing your Convex data model.
53− *
54− * This type includes information about what tables you have, the type of
55− * documents stored in those tables, and the indexes defined on them.
56− *
57− * This type is used to parameterize methods like `queryGeneric` and
58− * `mutationGeneric` to make them type-safe.
59− */
60−export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
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−
11−import {
12− ActionBuilder,
13− HttpActionBuilder,
14− MutationBuilder,
15− QueryBuilder,
16− GenericActionCtx,
17− GenericMutationCtx,
18− GenericQueryCtx,
19− GenericDatabaseReader,
20− GenericDatabaseWriter,
21−} from "convex/server";
22−import type { DataModel } from "./dataModel.js";
23−
24−/**
25− * Define a query in this Convex app's public API.
26− *
27− * This function will be allowed to read your Convex database and will be accessible from the client.
28− *
29− * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
30− * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
31− */
32−export declare const query: QueryBuilder<DataModel, "public">;
33−
34−/**
35− * Define a query that is only accessible from other Convex functions (but not from the client).
36− *
37− * This function will be allowed to read from your Convex database. It will not be accessible from the client.
38− *
39− * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
40− * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
41− */
42−export declare const internalQuery: QueryBuilder<DataModel, "internal">;
43−
44−/**
45− * Define a mutation in this Convex app's public API.
46− *
47− * This function will be allowed to modify your Convex database and will be accessible from the client.
48− *
49− * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
50− * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
51− */
52−export declare const mutation: MutationBuilder<DataModel, "public">;
53−
54−/**
55− * Define a mutation that is only accessible from other Convex functions (but not from the client).
56− *
57− * This function will be allowed to modify your Convex database. It will not be accessible from the client.
58− *
59− * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
60− * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
61− */
62−export declare const internalMutation: MutationBuilder<DataModel, "internal">;
63−
64−/**
65− * Define an action in this Convex app's public API.
66− *
67− * An action is a function which can execute any JavaScript code, including non-deterministic
68− * code and code with side-effects, like calling third-party services.
69− * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
70− * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
71− *
72− * @param func - The action. It receives an {@link ActionCtx} as its first argument.
73− * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
74− */
75−export declare const action: ActionBuilder<DataModel, "public">;
76−
77−/**
78− * Define an action that is only accessible from other Convex functions (but not from the client).
79− *
80− * @param func - The function. It receives an {@link ActionCtx} as its first argument.
81− * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
82− */
83−export declare const internalAction: ActionBuilder<DataModel, "internal">;
84−
85−/**
86− * Define an HTTP action.
87− *
88− * The wrapped function will be used to respond to HTTP requests received
89− * by a Convex deployment if the requests matches the path and method where
90− * this action is routed. Be sure to route your httpAction in `convex/http.js`.
91− *
92− * @param func - The function. It receives an {@link ActionCtx} as its first argument
93− * and a Fetch API `Request` object as its second.
94− * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95− */
96−export declare const httpAction: HttpActionBuilder;
97−
98−/**
99− * A set of services for use within Convex query functions.
100− *
101− * The query context is passed as the first argument to any Convex query
102− * function run on the server.
103− *
104− * This differs from the {@link MutationCtx} because all of the services are
105− * read-only.
106− */
107−export type QueryCtx = GenericQueryCtx<DataModel>;
108−
109−/**
110− * A set of services for use within Convex mutation functions.
111− *
112− * The mutation context is passed as the first argument to any Convex mutation
113− * function run on the server.
114− */
115−export type MutationCtx = GenericMutationCtx<DataModel>;
116−
117−/**
118− * A set of services for use within Convex action functions.
119− *
120− * The action context is passed as the first argument to any Convex action
121− * function run on the server.
122− */
123−export type ActionCtx = GenericActionCtx<DataModel>;
124−
125−/**
126− * An interface to read from the database within Convex query functions.
127− *
128− * The two entry points are {@link DatabaseReader.get}, which fetches a single
129− * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130− * building a query.
131− */
132−export type DatabaseReader = GenericDatabaseReader<DataModel>;
133−
134−/**
135− * An interface to read from and write to the database within Convex mutation
136− * functions.
137− *
138− * Convex guarantees that all writes within a single mutation are
139− * executed atomically, so you never have to worry about partial writes leaving
140− * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141− * for the guarantees Convex provides your functions.
142− */
143−export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
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−
11−import {
12− actionGeneric,
13− httpActionGeneric,
14− queryGeneric,
15− mutationGeneric,
16− internalActionGeneric,
17− internalMutationGeneric,
18− internalQueryGeneric,
19−} from "convex/server";
20−
21−/**
22− * Define a query in this Convex app's public API.
23− *
24− * This function will be allowed to read your Convex database and will be accessible from the client.
25− *
26− * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
27− * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
28− */
29−export const query = queryGeneric;
30−
31−/**
32− * Define a query that is only accessible from other Convex functions (but not from the client).
33− *
34− * This function will be allowed to read from your Convex database. It will not be accessible from the client.
35− *
36− * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
37− * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
38− */
39−export const internalQuery = internalQueryGeneric;
40−
41−/**
42− * Define a mutation in this Convex app's public API.
43− *
44− * This function will be allowed to modify your Convex database and will be accessible from the client.
45− *
46− * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
47− * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
48− */
49−export const mutation = mutationGeneric;
50−
51−/**
52− * Define a mutation that is only accessible from other Convex functions (but not from the client).
53− *
54− * This function will be allowed to modify your Convex database. It will not be accessible from the client.
55− *
56− * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
57− * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
58− */
59−export const internalMutation = internalMutationGeneric;
60−
61−/**
62− * Define an action in this Convex app's public API.
63− *
64− * An action is a function which can execute any JavaScript code, including non-deterministic
65− * code and code with side-effects, like calling third-party services.
66− * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
67− * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
68− *
69− * @param func - The action. It receives an {@link ActionCtx} as its first argument.
70− * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
71− */
72−export const action = actionGeneric;
73−
74−/**
75− * Define an action that is only accessible from other Convex functions (but not from the client).
76− *
77− * @param func - The function. It receives an {@link ActionCtx} as its first argument.
78− * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
79− */
80−export const internalAction = internalActionGeneric;
81−
82−/**
83− * Define an HTTP action.
84− *
85− * The wrapped function will be used to respond to HTTP requests received
86− * by a Convex deployment if the requests matches the path and method where
87− * this action is routed. Be sure to route your httpAction in `convex/http.js`.
88− *
89− * @param func - The function. It receives an {@link ActionCtx} as its first argument
90− * and a Fetch API `Request` object as its second.
91− * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
92− */
93−export const httpAction = httpActionGeneric;
apps/convex/convex/auth.config.ts+0−33
@@ -1,33 +0,0 @@
1−import type { AuthConfig } from "convex/server";
2−
3−const trim = (value: string | undefined): string | undefined => {
4− const candidate = value?.trim();
5− return candidate && candidate.length > 0 ? candidate : undefined;
6−};
7−
8−const rawClientId = trim(process.env.WORKOS_CLIENT_ID);
9−const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined;
10−
11−const providers: AuthConfig["providers"] = clientId
12− ? [
13− {
14− type: "customJwt",
15− issuer: "https://api.workos.com/",
16− algorithm: "RS256",
17− applicationID: clientId,
18− jwks: `https://api.workos.com/sso/jwks/${clientId}`,
19− },
20− {
21− type: "customJwt",
22− issuer: `https://api.workos.com/user_management/${clientId}`,
23− algorithm: "RS256",
24− jwks: `https://api.workos.com/sso/jwks/${clientId}`,
25− },
26− ]
27− : [];
28−
29−const authConfig: AuthConfig = {
30− providers,
31−};
32−
33−export default authConfig;
apps/convex/convex/controlPlane.ts+0−72
@@ -1,72 +0,0 @@
1−import {
2− listApprovals as listApprovalsImpl,
3− resolveApproval as resolveApprovalImpl,
4−} from "./control_plane/approvals";
5−import {
6− listCredentialBindings as listCredentialBindingsImpl,
7− removeCredentialBinding as removeCredentialBindingImpl,
8− upsertCredentialBinding as upsertCredentialBindingImpl,
9−} from "./control_plane/credentials";
10−import { controlPlaneHttpHandler as controlPlaneHttpHandlerImpl } from "./control_plane/http";
11−import {
12− listOrganizations as listOrganizationsImpl,
13− upsertOrganization as upsertOrganizationImpl,
14−} from "./control_plane/organizations";
15−import {
16− listPolicies as listPoliciesImpl,
17− removePolicy as removePolicyImpl,
18− upsertPolicy as upsertPolicyImpl,
19−} from "./control_plane/policies";
20−import {
21− listSources as listSourcesImpl,
22− removeSource as removeSourceImpl,
23− upsertSource as upsertSourceImpl,
24−} from "./control_plane/sources";
25−import {
26− closeStorageInstance as closeStorageInstanceImpl,
27− listStorageDirectory as listStorageDirectoryImpl,
28− listStorageInstances as listStorageInstancesImpl,
29− listStorageKv as listStorageKvImpl,
30− openStorageInstance as openStorageInstanceImpl,
31− queryStorageSql as queryStorageSqlImpl,
32− readStorageFile as readStorageFileImpl,
33− removeStorageInstance as removeStorageInstanceImpl,
34−} from "./control_plane/storage";
35−import {
36− getToolDetail as getToolDetailImpl,
37− listSourceTools as listSourceToolsImpl,
38− listWorkspaceTools as listWorkspaceToolsImpl,
39−} from "./control_plane/tools";
40−import {
41− listWorkspaces as listWorkspacesImpl,
42− upsertWorkspace as upsertWorkspaceImpl,
43−} from "./control_plane/workspaces";
44−
45−export const listSources = listSourcesImpl;
46−export const upsertSource = upsertSourceImpl;
47−export const removeSource = removeSourceImpl;
48−export const listCredentialBindings = listCredentialBindingsImpl;
49−export const upsertCredentialBinding = upsertCredentialBindingImpl;
50−export const removeCredentialBinding = removeCredentialBindingImpl;
51−export const listPolicies = listPoliciesImpl;
52−export const upsertPolicy = upsertPolicyImpl;
53−export const removePolicy = removePolicyImpl;
54−export const listOrganizations = listOrganizationsImpl;
55−export const upsertOrganization = upsertOrganizationImpl;
56−export const listWorkspaces = listWorkspacesImpl;
57−export const upsertWorkspace = upsertWorkspaceImpl;
58−export const listWorkspaceTools = listWorkspaceToolsImpl;
59−export const listSourceTools = listSourceToolsImpl;
60−export const getToolDetail = getToolDetailImpl;
61−export const listStorageInstances = listStorageInstancesImpl;
62−export const openStorageInstance = openStorageInstanceImpl;
63−export const closeStorageInstance = closeStorageInstanceImpl;
64−export const removeStorageInstance = removeStorageInstanceImpl;
65−export const listStorageDirectory = listStorageDirectoryImpl;
66−export const readStorageFile = readStorageFileImpl;
67−export const listStorageKv = listStorageKvImpl;
68−export const queryStorageSql = queryStorageSqlImpl;
69−export const listApprovals = listApprovalsImpl;
70−export const resolveApproval = resolveApprovalImpl;
71−
72−export const controlPlaneHttpHandler = controlPlaneHttpHandlerImpl;
apps/convex/convex/control_plane/actor.ts+0−403
@@ -1,403 +0,0 @@
1−import {
2− ControlPlaneActorResolverLive,
3− deriveWorkspaceMembershipsForPrincipal,
4−} from "@executor-v2/management-api";
5−import { ActorUnauthenticatedError, makeActor } from "@executor-v2/domain";
6−import {
7− OrganizationMembershipSchema,
8− PrincipalSchema,
9− type Principal,
10− WorkspaceSchema,
11− type OrganizationMembership,
12− type Workspace,
13−} from "@executor-v2/schema";
14−import { type UserIdentity } from "convex/server";
15−import { v } from "convex/values";
16−import * as Effect from "effect/Effect";
17−import * as Schema from "effect/Schema";
18−
19−import { internal } from "../_generated/api";
20−import {
21− internalMutation,
22− internalQuery,
23− type ActionCtx,
24−} from "../_generated/server";
25−
26−const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
27−const decodeOrganizationMembership = Schema.decodeUnknownSync(
28− OrganizationMembershipSchema,
29−);
30−const decodePrincipal = Schema.decodeUnknownSync(PrincipalSchema);
31−
32−const stripConvexSystemFields = (
33− value: Record<string, unknown>,
34−): Record<string, unknown> => {
35− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
36− return rest;
37−};
38−
39−const toOrganizationSlug = (accountId: string): string => {
40− const slug = accountId
41− .trim()
42− .toLowerCase()
43− .replace(/[^a-z0-9]+/g, "-")
44− .replace(/^-+/, "")
45− .replace(/-+$/, "");
46−
47− return slug.length > 0 ? `acct-${slug}` : `acct-${crypto.randomUUID().slice(0, 8)}`;
48−};
49−
50−export const getWorkspaceForActor = internalQuery({
51− args: {
52− workspaceId: v.string(),
53− },
54− handler: async (ctx, args): Promise<Workspace | null> => {
55− const row = await ctx.db
56− .query("workspaces")
57− .withIndex("by_domainId", (q) => q.eq("id", args.workspaceId))
58− .first();
59−
60− if (row === null) {
61− return null;
62− }
63−
64− return decodeWorkspace(
65− stripConvexSystemFields(row as unknown as Record<string, unknown>),
66− );
67− },
68−});
69−
70−export const listOrganizationMembershipsForActor = internalQuery({
71− args: {
72− accountId: v.string(),
73− },
74− handler: async (ctx, args): Promise<ReadonlyArray<OrganizationMembership>> => {
75− const rows = await ctx.db
76− .query("organizationMemberships")
77− .withIndex("by_accountId", (q) => q.eq("accountId", args.accountId))
78− .collect();
79−
80− return rows.map((row) =>
81− decodeOrganizationMembership(
82− stripConvexSystemFields(row as unknown as Record<string, unknown>),
83− ),
84− );
85− },
86−});
87−
88−export const listWorkspacesForActor = internalQuery({
89− args: {
90− accountId: v.string(),
91− organizationIds: v.array(v.string()),
92− },
93− handler: async (ctx, args): Promise<ReadonlyArray<Workspace>> => {
94− const ownRows = await ctx.db
95− .query("workspaces")
96− .withIndex("by_createdByAccountId", (q) => q.eq("createdByAccountId", args.accountId))
97− .collect();
98−
99− const organizationRows = await Promise.all(
100− args.organizationIds.map((organizationId) =>
101− ctx.db
102− .query("workspaces")
103− .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId))
104− .collect(),
105− ),
106− );
107−
108− const workspaces = [...ownRows, ...organizationRows.flat()].map((row) =>
109− decodeWorkspace(stripConvexSystemFields(row as unknown as Record<string, unknown>)),
110− );
111−
112− return Array.from(new Map(workspaces.map((workspace) => [workspace.id, workspace])).values());
113− },
114−});
115−
116−export const ensureWorkspaceForActor = internalMutation({
117− args: {
118− workspaceId: v.string(),
119− accountId: v.string(),
120− },
121− handler: async (ctx, args): Promise<Workspace> => {
122− const now = Date.now();
123−
124− const ensureOrganizationMembership = async (organizationId: string) => {
125− const existingOrganization = await ctx.db
126− .query("organizations")
127− .withIndex("by_domainId", (q) => q.eq("id", organizationId))
128− .first();
129−
130− if (existingOrganization === null) {
131− await ctx.db.insert("organizations", {
132− id: organizationId,
133− slug: toOrganizationSlug(args.accountId),
134− name: `${args.accountId} Organization`,
135− status: "active",
136− createdByAccountId: args.accountId,
137− createdAt: now,
138− updatedAt: now,
139− });
140− }
141−
142− const existingMembership = await ctx.db
143− .query("organizationMemberships")
144− .withIndex("by_organizationId_accountId", (q) =>
145− q.eq("organizationId", organizationId).eq("accountId", args.accountId)
146− )
147− .first();
148−
149− if (existingMembership !== null) {
150− return;
151− }
152−
153− await ctx.db.insert("organizationMemberships", {
154− id: `org_member_${crypto.randomUUID()}`,
155− organizationId,
156− accountId: args.accountId,
157− role: "owner",
158− status: "active",
159− billable: false,
160− invitedByAccountId: null,
161− joinedAt: now,
162− createdAt: now,
163− updatedAt: now,
164− });
165− };
166−
167− const existing = await ctx.db
168− .query("workspaces")
169− .withIndex("by_domainId", (q) => q.eq("id", args.workspaceId))
170− .first();
171−
172− if (existing !== null) {
173− const existingWorkspace = decodeWorkspace(
174− stripConvexSystemFields(existing as unknown as Record<string, unknown>),
175− );
176−
177− await ensureOrganizationMembership(existingWorkspace.organizationId);
178−
179− if (existingWorkspace.createdByAccountId !== null) {
180− return existingWorkspace;
181− }
182−
183− await ctx.db.patch(existing._id, {
184− createdByAccountId: args.accountId,
185− updatedAt: now,
186− });
187−
188− return decodeWorkspace({
189− ...existingWorkspace,
190− createdByAccountId: args.accountId,
191− updatedAt: now,
192− });
193− }
194−
195− const organizationId = `org_${args.accountId}`;
196− await ensureOrganizationMembership(organizationId);
197−
198− await ctx.db.insert("workspaces", {
199− id: args.workspaceId,
200− organizationId,
201− name: args.workspaceId,
202− createdByAccountId: args.accountId,
203− createdAt: now,
204− updatedAt: now,
205− });
206−
207− return decodeWorkspace({
208− id: args.workspaceId,
209− organizationId,
210− name: args.workspaceId,
211− createdByAccountId: args.accountId,
212− createdAt: now,
213− updatedAt: now,
214− });
215− },
216−});
217−
218−const runQueryEffect = <A>(
219− operation: string,
220− run: () => Promise<A>,
221−): Effect.Effect<A, ActorUnauthenticatedError> =>
222− Effect.tryPromise({
223− try: run,
224− catch: (cause) =>
225− new ActorUnauthenticatedError({
226− message: `Failed resolving actor (${operation}): ${String(cause)}`,
227− }),
228− });
229−
230−const inferPrincipalProvider = (
231− identity: UserIdentity,
232−): Principal["provider"] =>
233− identity.issuer.trim().toLowerCase().includes("workos")
234− ? "workos"
235− : "local";
236−
237−const toPrincipalFromIdentity = (
238− identity: UserIdentity,
239−): Principal => {
240− const accountId = identity.subject.trim();
241−
242− if (accountId.length === 0) {
243− throw new ActorUnauthenticatedError({
244− message: "Authenticated identity subject is required",
245− });
246− }
247−
248− const provider = inferPrincipalProvider(identity);
249− const tokenIdentifier = identity.tokenIdentifier.trim();
250−
251− try {
252− return decodePrincipal({
253− accountId,
254− provider,
255− subject: tokenIdentifier.length > 0 ? tokenIdentifier : `${provider}:${accountId}`,
256− email: identity.email?.trim() ?? null,
257− displayName: identity.name?.trim() ?? null,
258− });
259− } catch (cause) {
260− throw new ActorUnauthenticatedError({
261− message: `Failed decoding authenticated principal: ${String(cause)}`,
262− });
263− }
264−};
265−
266−const resolveAuthenticatedPrincipal = (
267− ctx: ActionCtx,
268−): Effect.Effect<Principal, ActorUnauthenticatedError> =>
269− Effect.tryPromise({
270− try: async () => {
271− const identity = await ctx.auth.getUserIdentity();
272−
273− if (identity === null) {
274− throw new ActorUnauthenticatedError({
275− message: "Authenticated principal is required",
276− });
277− }
278−
279− return toPrincipalFromIdentity(identity);
280− },
281− catch: (cause) =>
282− cause instanceof ActorUnauthenticatedError
283− ? cause
284− : new ActorUnauthenticatedError({
285− message: `Failed resolving authenticated principal: ${String(cause)}`,
286− }),
287− });
288−
289−export const ConvexControlPlaneActorLive = (ctx: ActionCtx) =>
290− ControlPlaneActorResolverLive({
291− resolveActor: (input) =>
292− Effect.gen(function* () {
293− void input;
294−
295− const principal = yield* resolveAuthenticatedPrincipal(ctx);
296−
297− let organizationMemberships = yield* runQueryEffect(
298− "organizationMembership.list",
299− () =>
300− ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {
301− accountId: principal.accountId,
302− }),
303− );
304−
305− let organizationIds = organizationMemberships.map(
306− (membership) => membership.organizationId,
307− );
308−
309− let workspaces = yield* runQueryEffect("workspace.list", () =>
310− ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, {
311− accountId: principal.accountId,
312− organizationIds,
313− }),
314− );
315−
316− if (organizationMemberships.length === 0) {
317− const workspaceId = workspaces[0]?.id ?? `ws_${principal.accountId}`;
318−
319− yield* runQueryEffect("workspace.ensureDefault", () =>
320− ctx.runMutation(internal.control_plane.actor.ensureWorkspaceForActor, {
321− workspaceId,
322− accountId: principal.accountId,
323− }),
324− );
325−
326− organizationMemberships = yield* runQueryEffect(
327− "organizationMembership.list",
328− () =>
329− ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {
330− accountId: principal.accountId,
331− }),
332− );
333−
334− organizationIds = organizationMemberships.map(
335− (membership) => membership.organizationId,
336− );
337−
338− workspaces = yield* runQueryEffect("workspace.list", () =>
339− ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, {
340− accountId: principal.accountId,
341− organizationIds,
342− }),
343− );
344− }
345−
346− const workspaceMemberships = workspaces.flatMap((workspace) =>
347− deriveWorkspaceMembershipsForPrincipal({
348− principalAccountId: principal.accountId,
349− workspaceId: workspace.id,
350− workspace,
351− organizationMemberships,
352− }),
353− );
354−
355− return yield* makeActor({
356− principal,
357− workspaceMemberships,
358− organizationMemberships,
359− });
360− }),
361− resolveWorkspaceActor: (input) =>
362− Effect.gen(function* () {
363− void input.headers;
364−
365− const principal = yield* resolveAuthenticatedPrincipal(ctx);
366−
367− let workspace = yield* runQueryEffect("workspace.read", () =>
368− ctx.runQuery(internal.control_plane.actor.getWorkspaceForActor, {
369− workspaceId: input.workspaceId,
370− }),
371− );
372−
373− if (workspace === null || workspace.createdByAccountId === null) {
374− workspace = yield* runQueryEffect("workspace.ensure", () =>
375− ctx.runMutation(internal.control_plane.actor.ensureWorkspaceForActor, {
376− workspaceId: input.workspaceId,
377− accountId: principal.accountId,
378− }),
379− );
380− }
381−
382− const organizationMemberships = yield* runQueryEffect(
383− "organizationMembership.list",
384− () =>
385− ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {
386− accountId: principal.accountId,
387− }),
388− );
389−
390− const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({
391− principalAccountId: principal.accountId,
392− workspaceId: input.workspaceId,
393− workspace,
394− organizationMemberships,
395− });
396−
397− return yield* makeActor({
398− principal,
399− workspaceMemberships,
400− organizationMemberships,
401− });
402− }),
403− });
apps/convex/convex/control_plane/approvals.ts+0−95
@@ -1,95 +0,0 @@
1−import {
2− type ResolveApprovalPayload,
3−} from "@executor-v2/management-api";
4−import {
5− ApprovalSchema,
6− type Approval,
7−} from "@executor-v2/schema";
8−import { v } from "convex/values";
9−import * as Schema from "effect/Schema";
10−
11−import { internalMutation, internalQuery } from "../_generated/server";
12−
13−const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema);
14−
15−const stripConvexSystemFields = (
16− value: Record<string, unknown>,
17−): Record<string, unknown> => {
18− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
19− return rest;
20−};
21−
22−const approvalStatusValidator = v.union(
23− v.literal("approved"),
24− v.literal("denied"),
25−);
26−
27−const sortApprovals = (approvals: ReadonlyArray<Approval>): Array<Approval> =>
28− [...approvals].sort((left, right) => right.requestedAt - left.requestedAt);
29−
30−export const listApprovals = internalQuery({
31− args: {
32− workspaceId: v.string(),
33− },
34− handler: async (ctx, args): Promise<Array<Approval>> => {
35− const rows = await ctx.db
36− .query("approvals")
37− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
38− .collect();
39−
40− return sortApprovals(
41− rows.map((row) =>
42− decodeApproval(
43− stripConvexSystemFields(row as unknown as Record<string, unknown>),
44− ),
45− ),
46− );
47− },
48−});
49−
50−export const resolveApproval = internalMutation({
51− args: {
52− workspaceId: v.string(),
53− approvalId: v.string(),
54− payload: v.object({
55− status: approvalStatusValidator,
56− reason: v.optional(v.union(v.string(), v.null())),
57− }),
58− },
59− handler: async (ctx, args): Promise<Approval> => {
60− const row = await ctx.db
61− .query("approvals")
62− .withIndex("by_domainId", (q) => q.eq("id", args.approvalId))
63− .unique();
64−
65− if (!row || row.workspaceId !== args.workspaceId) {
66− throw new Error(`Approval not found: ${args.approvalId}`);
67− }
68−
69− const approval = decodeApproval(
70− stripConvexSystemFields(row as unknown as Record<string, unknown>),
71− );
72−
73− if (approval.status !== "pending") {
74− throw new Error(`Approval is not pending: ${args.approvalId}`);
75− }
76−
77− const payload = args.payload as ResolveApprovalPayload;
78− const updatedAt = Date.now();
79−
80− const resolvedApproval: Approval = {
81− ...approval,
82− status: payload.status,
83− reason: payload.reason ?? approval.reason ?? null,
84− resolvedAt: updatedAt,
85− };
86−
87− await ctx.db.patch(row._id, {
88− status: resolvedApproval.status,
89− reason: resolvedApproval.reason,
90− resolvedAt: resolvedApproval.resolvedAt,
91− });
92−
93− return resolvedApproval;
94− },
95−});
apps/convex/convex/control_plane/credentials.ts+0−1780
@@ -1,1780 +0,0 @@
1−import {
2− type UpsertCredentialBindingPayload,
3−} from "@executor-v2/management-api";
4−import {
5− AuthConnectionSchema,
6− AuthMaterialSchema,
7− OAuthStateSchema,
8− SourceAuthBindingSchema,
9− SourceCredentialBindingSchema,
10− type AuthConnection,
11− type AuthConnectionStatus,
12− type AuthConnectionStrategy,
13− type AuthMaterial,
14− type OAuthState,
15− type SourceAuthBinding,
16− type SourceCredentialBinding,
17−} from "@executor-v2/schema";
18−import { v } from "convex/values";
19−import * as Schema from "effect/Schema";
20−
21−import { internal } from "../_generated/api";
22−import {
23− currentCredentialEncryptionKeyVersion,
24− decryptSecretValue,
25− encryptSecretValue,
26−} from "../credential_crypto";
27−import {
28− internalAction,
29− internalMutation,
30− internalQuery,
31− type MutationCtx,
32− type QueryCtx,
33−} from "../_generated/server";
34−
35−const runtimeInternal = internal;
36−
37−const decodeAuthConnection = Schema.decodeUnknownSync(AuthConnectionSchema);
38−const decodeSourceAuthBinding = Schema.decodeUnknownSync(SourceAuthBindingSchema);
39−const decodeAuthMaterial = Schema.decodeUnknownSync(AuthMaterialSchema);
40−const decodeOAuthState = Schema.decodeUnknownSync(OAuthStateSchema);
41−const decodeCompatSourceCredentialBinding = Schema.decodeUnknownSync(
42− SourceCredentialBindingSchema,
43−);
44−
45−const credentialProviderValidator = v.union(
46− v.literal("api_key"),
47− v.literal("bearer"),
48− v.literal("oauth2"),
49− v.literal("basic"),
50− v.literal("custom"),
51−);
52−
53−const credentialSecretProviderValidator = v.literal("local");
54−
55−const credentialScopeTypeValidator = v.union(
56− v.literal("workspace"),
57− v.literal("organization"),
58− v.literal("account"),
59−);
60−
61−const sourceCredentialBindingPayloadValidator = v.object({
62− id: v.optional(v.string()),
63− credentialId: v.string(),
64− scopeType: credentialScopeTypeValidator,
65− sourceKey: v.string(),
66− provider: credentialProviderValidator,
67− secretProvider: v.optional(credentialSecretProviderValidator),
68− secretRef: v.string(),
69− accountId: v.optional(v.union(v.string(), v.null())),
70− additionalHeadersJson: v.optional(v.union(v.string(), v.null())),
71− boundAuthFingerprint: v.optional(v.union(v.string(), v.null())),
72− oauthRefreshToken: v.optional(v.union(v.string(), v.null())),
73− oauthExpiresAt: v.optional(v.union(v.number(), v.null())),
74− oauthScope: v.optional(v.union(v.string(), v.null())),
75− oauthIssuer: v.optional(v.union(v.string(), v.null())),
76− oauthTokenEndpoint: v.optional(v.union(v.string(), v.null())),
77− oauthAuthorizationServer: v.optional(v.union(v.string(), v.null())),
78− oauthClientId: v.optional(v.union(v.string(), v.null())),
79− oauthClientSecret: v.optional(v.union(v.string(), v.null())),
80− oauthSourceUrl: v.optional(v.union(v.string(), v.null())),
81− oauthClientInformationJson: v.optional(v.union(v.string(), v.null())),
82−});
83−
84−type ExtendedUpsertCredentialBindingPayload = UpsertCredentialBindingPayload & {
85− oauthRefreshToken?: string | null;
86− oauthExpiresAt?: number | null;
87− oauthScope?: string | null;
88− oauthIssuer?: string | null;
89− oauthTokenEndpoint?: string | null;
90− oauthAuthorizationServer?: string | null;
91− oauthClientId?: string | null;
92− oauthClientSecret?: string | null;
93− oauthSourceUrl?: string | null;
94− oauthClientInformationJson?: string | null;
95−};
96−
97−const stripConvexSystemFields = (
98− value: Record<string, unknown>,
99−): Record<string, unknown> => {
100− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
101− return rest;
102−};
103−
104−const normalizeString = (value: unknown): string | null => {
105− if (typeof value !== "string") {
106− return null;
107− }
108−
109− const trimmed = value.trim();
110− return trimmed.length > 0 ? trimmed : null;
111−};
112−
113−const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {
114− if (!value) {
115− return {};
116− }
117−
118− try {
119− const parsed = JSON.parse(value) as unknown;
120− if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
121− return {};
122− }
123−
124− return parsed as Record<string, unknown>;
125− } catch {
126− return {};
127− }
128−};
129−
130−const toStringRecord = (
131− value: Record<string, unknown>,
132−): Record<string, string> => {
133− const record: Record<string, string> = {};
134−
135− for (const [rawKey, rawValue] of Object.entries(value)) {
136− const key = normalizeString(rawKey);
137− const normalizedValue = normalizeString(rawValue);
138− if (!key || !normalizedValue) {
139− continue;
140− }
141−
142− record[key] = normalizedValue;
143− }
144−
145− return record;
146−};
147−
148−const parseAdditionalHeaders = (
149− additionalHeadersJson: string | null,
150−): Record<string, string> => {
151− if (!additionalHeadersJson) {
152− return {};
153− }
154−
155− return toStringRecord(parseJsonObject(additionalHeadersJson));
156−};
157−
158−const parseConnectionMetadata = (
159− metadataJson: string | null,
160−): Record<string, unknown> => parseJsonObject(metadataJson);
161−
162−const base64Encode = (value: string): string => {
163− if (typeof btoa === "function") {
164− return btoa(value);
165− }
166−
167− const maybeBuffer = (globalThis as { Buffer?: typeof Buffer }).Buffer;
168− if (maybeBuffer) {
169− return maybeBuffer.from(value, "utf8").toString("base64");
170− }
171−
172− throw new Error("No base64 encoder available");
173−};
174−
175−const strategyFromProvider = (
176− provider: UpsertCredentialBindingPayload["provider"],
177−): AuthConnectionStrategy => {
178− if (provider === "api_key") return "api_key";
179− if (provider === "bearer") return "bearer";
180− if (provider === "oauth2") return "oauth2";
181− if (provider === "basic") return "basic";
182− return "custom";
183−};
184−
185−const providerFromStrategy = (
186− strategy: AuthConnectionStrategy,
187−): SourceCredentialBinding["provider"] => {
188− if (strategy === "api_key") return "api_key";
189− if (strategy === "bearer") return "bearer";
190− if (strategy === "oauth2") return "oauth2";
191− if (strategy === "basic") return "basic";
192− return "custom";
193−};
194−
195−const scopeScore = (
196− binding: SourceAuthBinding,
197− input: {
198− workspaceId: string;
199− organizationId: string;
200− accountId: string | null;
201− },
202−): number => {
203− if (!binding.enabled) {
204− return -1;
205− }
206−
207− if (binding.scopeType === "account") {
208− if (!input.accountId || binding.accountId !== input.accountId) {
209− return -1;
210− }
211−
212− return binding.organizationId === input.organizationId ? 30 : -1;
213− }
214−
215− if (binding.scopeType === "workspace") {
216− return binding.workspaceId === input.workspaceId ? 20 : -1;
217− }
218−
219− if (binding.scopeType === "organization") {
220− return binding.organizationId === input.organizationId ? 10 : -1;
221− }
222−
223− return -1;
224−};
225−
226−const selectBestBinding = (
227− bindings: ReadonlyArray<SourceAuthBinding>,
228− input: {
229− workspaceId: string;
230− organizationId: string;
231− accountId: string | null;
232− },
233−): SourceAuthBinding | null => {
234− const ranked = bindings
235− .map((binding) => ({
236− binding,
237− score: scopeScore(binding, input),
238− }))
239− .filter((entry) => entry.score >= 0)
240− .sort((left, right) => {
241− if (left.score !== right.score) {
242− return right.score - left.score;
243− }
244−
245− if (left.binding.updatedAt !== right.binding.updatedAt) {
246− return right.binding.updatedAt - left.binding.updatedAt;
247− }
248−
249− return right.binding.createdAt - left.binding.createdAt;
250− });
251−
252− return ranked[0]?.binding ?? null;
253−};
254−
255−const sourceIdFromSourceKey = (sourceKey: string): string | null => {
256− const trimmed = sourceKey.trim();
257− if (!trimmed.startsWith("source:")) {
258− return null;
259− }
260−
261− const sourceId = trimmed.slice("source:".length).trim();
262− return sourceId.length > 0 ? sourceId : null;
263−};
264−
265−const sourceKeyFromSourceId = (sourceId: string): string => `source:${sourceId}`;
266−
267−const toAuthConnection = (document: Record<string, unknown>): AuthConnection =>
268− decodeAuthConnection(stripConvexSystemFields(document));
269−
270−const toSourceAuthBinding = (document: Record<string, unknown>): SourceAuthBinding =>
271− decodeSourceAuthBinding(stripConvexSystemFields(document));
272−
273−const toAuthMaterial = (document: Record<string, unknown>): AuthMaterial =>
274− decodeAuthMaterial(stripConvexSystemFields(document));
275−
276−const toOAuthState = (document: Record<string, unknown>): OAuthState =>
277− decodeOAuthState(stripConvexSystemFields(document));
278−
279−const resolveWorkspaceOrganizationId = async (
280− ctx: QueryCtx | MutationCtx,
281− workspaceId: string,
282−): Promise<string> => {
283− const workspace = await ctx.db
284− .query("workspaces")
285− .withIndex("by_domainId", (q) => q.eq("id", workspaceId))
286− .unique();
287−
288− if (!workspace) {
289− throw new Error(`Workspace not found: ${workspaceId}`);
290− }
291−
292− return workspace.organizationId;
293−};
294−
295−const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => {
296− const merged: Record<string, string> = {};
297− const keyByLower = new Map<string, string>();
298−
299− for (const set of sets) {
300− for (const [rawKey, rawValue] of Object.entries(set)) {
301− const key = rawKey.trim();
302− const value = rawValue.trim();
303− if (key.length === 0 || value.length === 0) {
304− continue;
305− }
306−
307− const lower = key.toLowerCase();
308− const existing = keyByLower.get(lower);
309− if (existing && existing !== key) {
310− delete merged[existing];
311− }
312−
313− keyByLower.set(lower, key);
314− merged[key] = value;
315− }
316− }
317−
318− return merged;
319−};
320−
321−const buildSecretHeaders = (
322− strategy: AuthConnectionStrategy,
323− secret: string,
324− metadata: Record<string, unknown>,
325−): Record<string, string> => {
326− const trimmed = secret.trim();
327− if (trimmed.length === 0) {
328− return {};
329− }
330−
331− if (strategy === "api_key") {
332− const header = normalizeString(metadata.apiKeyHeader) ?? "x-api-key";
333− return { [header]: trimmed };
334− }
335−
336− if (strategy === "bearer") {
337− return { Authorization: `Bearer ${trimmed}` };
338− }
339−
340− if (strategy === "basic") {
341− const asJson = parseJsonObject(trimmed);
342− const usernameFromJson = normalizeString(asJson.username);
343− const passwordFromJson = normalizeString(asJson.password);
344−
345− const pair = usernameFromJson && passwordFromJson
346− ? `${usernameFromJson}:${passwordFromJson}`
347− : trimmed.includes(":")
348− ? trimmed
349− : null;
350−
351− if (!pair) {
352− return {};
353− }
354−
355− return {
356− Authorization: `Basic ${base64Encode(pair)}`,
357− };
358− }
359−
360− if (strategy === "custom") {
361− const customHeaderName = normalizeString(metadata.customHeaderName);
362− if (customHeaderName) {
363− return { [customHeaderName]: trimmed };
364− }
365− }
366−
367− return {};
368−};
369−
370−const sortCompatBindings = (
371− bindings: ReadonlyArray<SourceCredentialBinding>,
372−): Array<SourceCredentialBinding> =>
373− [...bindings].sort((left, right) => {
374− const leftKey = `${left.sourceKey}:${left.provider}`.toLowerCase();
375− const rightKey = `${right.sourceKey}:${right.provider}`.toLowerCase();
376−
377− if (leftKey === rightKey) {
378− return left.id.localeCompare(right.id);
379− }
380−
381− return leftKey.localeCompare(rightKey);
382− });
383−
384−const maskedSecretRef = (connection: AuthConnection): string => {
385− if (connection.strategy === "oauth2") {
386− if (connection.status === "reauth_required") {
387− return "oauth2://reauth_required";
388− }
389−
390− if (connection.status === "active") {
391− return "oauth2://connected";
392− }
393− }
394−
395− return "********";
396−};
397−
398−const toCompatSourceCredentialBinding = (
399− binding: SourceAuthBinding,
400− connection: AuthConnection,
401−): SourceCredentialBinding =>
402− decodeCompatSourceCredentialBinding({
403− id: binding.id,
404− credentialId: connection.id,
405− organizationId: binding.organizationId,
406− workspaceId: binding.workspaceId,
407− accountId: binding.accountId,
408− scopeType: binding.scopeType,
409− sourceKey: sourceKeyFromSourceId(binding.sourceId),
410− provider: providerFromStrategy(connection.strategy),
411− secretProvider: "local",
412− secretRef: maskedSecretRef(connection),
413− additionalHeadersJson: connection.additionalHeadersJson,
414− boundAuthFingerprint: null,
415− createdAt: binding.createdAt,
416− updatedAt: Math.max(binding.updatedAt, connection.updatedAt),
417− });
418−
419−const normalizeAuthConnectionStatus = (
420− connection: AuthConnection,
421−): AuthConnectionStatus => {
422− if (connection.status === "active") return "active";
423− if (connection.status === "reauth_required") return "reauth_required";
424− if (connection.status === "revoked") return "revoked";
425− if (connection.status === "disabled") return "disabled";
426− return "error";
427−};
428−
429−type OAuthRefreshConfig = {
430− tokenEndpoint?: string;
431− authorizationServer?: string;
432− clientId?: string;
433− clientSecretCiphertext?: string;
434− sourceUrl?: string;
435− clientInformationJson?: string;
436−};
437−
438−const parseOAuthRefreshConfig = (value: string | null): OAuthRefreshConfig => {
439− if (!value) {
440− return {};
441− }
442−
443− const parsed = parseJsonObject(value);
444− return {
445− ...(normalizeString(parsed.tokenEndpoint)
446− ? { tokenEndpoint: normalizeString(parsed.tokenEndpoint)! }
447− : {}),
448− ...(normalizeString(parsed.authorizationServer)
449− ? { authorizationServer: normalizeString(parsed.authorizationServer)! }
450− : {}),
451− ...(normalizeString(parsed.clientId)
452− ? { clientId: normalizeString(parsed.clientId)! }
453− : {}),
454− ...(normalizeString(parsed.clientSecretCiphertext)
455− ? { clientSecretCiphertext: normalizeString(parsed.clientSecretCiphertext)! }
456− : {}),
457− ...(normalizeString(parsed.sourceUrl)
458− ? { sourceUrl: normalizeString(parsed.sourceUrl)! }
459− : {}),
460− ...(normalizeString(parsed.clientInformationJson)
461− ? { clientInformationJson: normalizeString(parsed.clientInformationJson)! }
462− : {}),
463− };
464−};
465−
466−const encodeOAuthRefreshConfig = (config: OAuthRefreshConfig): string | null => {
467− const payload: Record<string, string> = {};
468−
469− if (config.tokenEndpoint) payload.tokenEndpoint = config.tokenEndpoint;
470− if (config.authorizationServer) payload.authorizationServer = config.authorizationServer;
471− if (config.clientId) payload.clientId = config.clientId;
472− if (config.clientSecretCiphertext) {
473− payload.clientSecretCiphertext = config.clientSecretCiphertext;
474− }
475− if (config.sourceUrl) payload.sourceUrl = config.sourceUrl;
476− if (config.clientInformationJson) {
477− payload.clientInformationJson = config.clientInformationJson;
478− }
479−
480− if (Object.keys(payload).length === 0) {
481− return null;
482− }
483−
484− return JSON.stringify(payload);
485−};
486−
487−const oauthRefreshWindowMs = 5 * 60 * 1000;
488−const oauthRefreshLeaseMs = 30 * 1000;
489−
490−type OAuthRefreshErrorClass =
491− | "transient"
492− | "invalid_grant"
493− | "invalid_client"
494− | "unsupported"
495− | "unknown";
496−
497−const classifyOAuthRefreshFailure = (
498− responseStatus: number,
499− payload: Record<string, unknown>,
500−): OAuthRefreshErrorClass => {
501− const errorCode = normalizeString(payload.error)?.toLowerCase() ?? "";
502−
503− if (errorCode === "invalid_grant") {
504− return "invalid_grant";
505− }
506−
507− if (errorCode === "invalid_client") {
508− return "invalid_client";
509− }
510−
511− if (responseStatus >= 500) {
512− return "transient";
513− }
514−
515− if (responseStatus === 400 || responseStatus === 401 || responseStatus === 403) {
516− return errorCode.length > 0 ? "unknown" : "transient";
517− }
518−
519− return "unknown";
520−};
521−
522−const readJsonResponse = async (
523− response: Response,
524−): Promise<Record<string, unknown>> => {
525− try {
526− const parsed = (await response.json()) as unknown;
527− if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
528− return {};
529− }
530−
531− return parsed as Record<string, unknown>;
532− } catch {
533− return {};
534− }
535−};
536−
537−const resolveTokenEndpointFromAuthorizationServer = async (
538− authorizationServer: string,
539−): Promise<string | null> => {
540− const normalized = normalizeString(authorizationServer);
541− if (!normalized) {
542− return null;
543− }
544−
545− try {
546− const endpoint = new URL("/.well-known/oauth-authorization-server", normalized);
547− const response = await fetch(endpoint.toString(), {
548− method: "GET",
549− headers: {
550− accept: "application/json",
551− },
552− });
553−
554− if (!response.ok) {
555− return null;
556− }
557−
558− const payload = await readJsonResponse(response);
559− const tokenEndpoint = normalizeString(payload.token_endpoint);
560− return tokenEndpoint ?? null;
561− } catch {
562− return null;
563− }
564−};
565−
566−const refreshTokenFromOAuthEndpoint = async (
567− oauthState: OAuthState,
568− refreshConfig: OAuthRefreshConfig,
569−): Promise<
570− | {
571− ok: true;
572− accessToken: string;
573− refreshToken: string | null;
574− expiresAt: number | null;
575− scope: string | null;
576− issuer: string | null;
577− tokenType: string | null;
578− }
579− | {
580− ok: false;
581− errorClass: OAuthRefreshErrorClass;
582− message: string;
583− }
584−> => {
585− const refreshTokenCiphertext = oauthState.refreshTokenCiphertext;
586− if (!refreshTokenCiphertext) {
587− return {
588− ok: false,
589− errorClass: "unsupported",
590− message: "Refresh token is not available",
591− };
592− }
593−
594− const refreshToken = await decryptSecretValue(refreshTokenCiphertext);
595− const clientId = normalizeString(refreshConfig.clientId);
596− if (!clientId) {
597− return {
598− ok: false,
599− errorClass: "unsupported",
600− message: "OAuth client id is required to refresh token",
601− };
602− }
603−
604− const tokenEndpoint = normalizeString(refreshConfig.tokenEndpoint)
605− ?? (refreshConfig.authorizationServer
606− ? await resolveTokenEndpointFromAuthorizationServer(refreshConfig.authorizationServer)
607− : null);
608−
609− if (!tokenEndpoint) {
610− return {
611− ok: false,
612− errorClass: "unsupported",
613− message: "OAuth token endpoint is not configured",
614− };
615− }
616−
617− const body = new URLSearchParams();
618− body.set("grant_type", "refresh_token");
619− body.set("refresh_token", refreshToken);
620− body.set("client_id", clientId);
621−
622− const clientSecretCiphertext = normalizeString(refreshConfig.clientSecretCiphertext);
623− if (clientSecretCiphertext) {
624− const decryptedClientSecret = await decryptSecretValue(clientSecretCiphertext);
625− if (decryptedClientSecret.trim().length > 0) {
626− body.set("client_secret", decryptedClientSecret.trim());
627− }
628− }
629−
630− let response: Response;
631− try {
632− response = await fetch(tokenEndpoint, {
633− method: "POST",
634− headers: {
635− "content-type": "application/x-www-form-urlencoded",
636− accept: "application/json",
637− },
638− body: body.toString(),
639− });
640− } catch (error) {
641− return {
642− ok: false,
643− errorClass: "transient",
644− message: error instanceof Error ? error.message : "OAuth refresh request failed",
645− };
646− }
647−
648− const payload = await readJsonResponse(response);
649−
650− if (!response.ok) {
651− const errorClass = classifyOAuthRefreshFailure(response.status, payload);
652− const message = normalizeString(payload.error_description)
653− ?? normalizeString(payload.error)
654− ?? `OAuth refresh failed (${response.status})`;
655− return {
656− ok: false,
657− errorClass,
658− message,
659− };
660− }
661−
662− const accessToken = normalizeString(payload.access_token);
663− if (!accessToken) {
664− return {
665− ok: false,
666− errorClass: "unknown",
667− message: "OAuth refresh response is missing access_token",
668− };
669− }
670−
671− const maybeExpiresIn = payload.expires_in;
672− const expiresAt = typeof maybeExpiresIn === "number" && Number.isFinite(maybeExpiresIn)
673− ? Date.now() + Math.max(0, Math.floor(maybeExpiresIn)) * 1000
674− : null;
675−
676− const refreshTokenFromResponse = normalizeString(payload.refresh_token);
677− const scope = normalizeString(payload.scope) ?? oauthState.scope;
678− const tokenType = normalizeString(payload.token_type) ?? oauthState.tokenType;
679−
680− return {
681− ok: true,
682− accessToken,
683− refreshToken: refreshTokenFromResponse,
684− expiresAt,
685− scope,
686− issuer: oauthState.issuer,
687− tokenType,
688− };
689−};
690−
691−const insertAuthAuditEvent = async (
692− ctx: MutationCtx,
693− input: {
694− organizationId: string;
695− connectionId: string;
696− sourceId: string | null;
697− eventType: "created" | "updated" | "bound" | "unbound" | "refresh_success" | "refresh_failed" | "reauth_required" | "revoked" | "deleted";
698− actorType: "system" | "account";
699− actorId?: string | null;
700− outcome: string;
701− reasonCode?: string | null;
702− detailsJson?: string | null;
703− correlationId?: string | null;
704− },
705−): Promise<void> => {
706− const now = Date.now();
707− await ctx.db.insert("authAuditEvents", {
708− id: `auth_event_${crypto.randomUUID()}`,
709− organizationId: input.organizationId,
710− connectionId: input.connectionId,
711− sourceId: input.sourceId,
712− eventType: input.eventType,
713− actorType: input.actorType,
714− actorId: input.actorId ?? null,
715− outcome: input.outcome,
716− reasonCode: input.reasonCode ?? null,
717− detailsJson: input.detailsJson ?? null,
718− correlationId: input.correlationId ?? null,
719− createdAt: now,
720− });
721−};
722−
723−const loadAuthConnectionById = async (
724− ctx: QueryCtx | MutationCtx,
725− connectionId: string,
726−): Promise<AuthConnection | null> => {
727− const row = await ctx.db
728− .query("authConnections")
729− .withIndex("by_domainId", (q) => q.eq("id", connectionId))
730− .unique();
731−
732− if (!row) {
733− return null;
734− }
735−
736− return toAuthConnection(row as unknown as Record<string, unknown>);
737−};
738−
739−const loadOAuthStateByConnectionId = async (
740− ctx: QueryCtx | MutationCtx,
741− connectionId: string,
742−): Promise<OAuthState | null> => {
743− const row = await ctx.db
744− .query("oauthState")
745− .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId))
746− .unique();
747−
748− if (!row) {
749− return null;
750− }
751−
752− return toOAuthState(row as unknown as Record<string, unknown>);
753−};
754−
755−const loadAuthMaterialByConnectionId = async (
756− ctx: QueryCtx | MutationCtx,
757− connectionId: string,
758−): Promise<AuthMaterial | null> => {
759− const row = await ctx.db
760− .query("authMaterials")
761− .withIndex("by_connectionId", (q) => q.eq("connectionId", connectionId))
762− .unique();
763−
764− if (!row) {
765− return null;
766− }
767−
768− return toAuthMaterial(row as unknown as Record<string, unknown>);
769−};
770−
771−const listBindingsForWorkspace = async (
772− ctx: QueryCtx,
773− input: {
774− workspaceId: string;
775− organizationId: string;
776− },
777−): Promise<Array<SourceAuthBinding>> => {
778− const workspaceRows = await ctx.db
779− .query("sourceAuthBindings")
780− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", input.workspaceId))
781− .collect();
782−
783− const organizationRows = await ctx.db
784− .query("sourceAuthBindings")
785− .withIndex("by_organizationId", (q) => q.eq("organizationId", input.organizationId))
786− .collect();
787−
788− const bindings = [...workspaceRows, ...organizationRows]
789− .map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>))
790− .filter((binding) =>
791− binding.workspaceId === input.workspaceId
792− || (binding.workspaceId === null && binding.organizationId === input.organizationId)
793− );
794−
795− return Array.from(new Map(bindings.map((binding) => [binding.id, binding])).values());
796−};
797−
798−export const listCredentialBindings = internalQuery({
799− args: {
800− workspaceId: v.string(),
801− },
802− handler: async (ctx, args): Promise<Array<SourceCredentialBinding>> => {
803− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
804− const bindings = await listBindingsForWorkspace(ctx, {
805− workspaceId: args.workspaceId,
806− organizationId,
807− });
808−
809− const compatBindings: Array<SourceCredentialBinding> = [];
810−
811− for (const binding of bindings) {
812− const connection = await loadAuthConnectionById(ctx, binding.connectionId);
813− if (!connection) {
814− continue;
815− }
816−
817− compatBindings.push(toCompatSourceCredentialBinding(binding, connection));
818− }
819−
820− return sortCompatBindings(compatBindings);
821− },
822−});
823−
824−export const resolveSourceAuthSelection = internalQuery({
825− args: {
826− workspaceId: v.string(),
827− sourceId: v.string(),
828− accountId: v.optional(v.union(v.string(), v.null())),
829− },
830− handler: async (
831− ctx,
832− args,
833− ): Promise<{
834− binding: SourceAuthBinding | null;
835− connection: AuthConnection | null;
836− oauthState: OAuthState | null;
837− }> => {
838− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
839−
840− const rows = await ctx.db
841− .query("sourceAuthBindings")
842− .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId))
843− .collect();
844−
845− const binding = selectBestBinding(
846− rows.map((row) => toSourceAuthBinding(row as unknown as Record<string, unknown>)),
847− {
848− workspaceId: args.workspaceId,
849− organizationId,
850− accountId: args.accountId ?? null,
851− },
852− );
853−
854− if (!binding) {
855− return {
856− binding: null,
857− connection: null,
858− oauthState: null,
859− };
860− }
861−
862− const connection = await loadAuthConnectionById(ctx, binding.connectionId);
863− if (!connection) {
864− return {
865− binding: null,
866− connection: null,
867− oauthState: null,
868− };
869− }
870−
871− const oauthState = connection.strategy === "oauth2"
872− ? await loadOAuthStateByConnectionId(ctx, connection.id)
873− : null;
874−
875− return {
876− binding,
877− connection,
878− oauthState,
879− };
880− },
881−});
882−
883−export const beginOAuthRefresh = internalMutation({
884− args: {
885− connectionId: v.string(),
886− leaseHolder: v.string(),
887− leaseMs: v.number(),
888− },
889− handler: async (
890− ctx,
891− args,
892− ): Promise<
893− | {
894− kind: "missing";
895− }
896− | {
897− kind: "locked";
898− leaseExpiresAt: number | null;
899− }
900− | {
901− kind: "ready";
902− oauthState: OAuthState;
903− connection: AuthConnection;
904− }
905− > => {
906− const stateRow = await ctx.db
907− .query("oauthState")
908− .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
909− .unique();
910− const connectionRow = await ctx.db
911− .query("authConnections")
912− .withIndex("by_domainId", (q) => q.eq("id", args.connectionId))
913− .unique();
914−
915− if (!stateRow || !connectionRow) {
916− return { kind: "missing" };
917− }
918−
919− const state = toOAuthState(stateRow as unknown as Record<string, unknown>);
920− const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>);
921− const now = Date.now();
922− const leaseExpiresAt = state.leaseExpiresAt;
923− const leaseActive =
924− state.leaseHolder !== null
925− && leaseExpiresAt !== null
926− && leaseExpiresAt > now
927− && state.leaseHolder !== args.leaseHolder;
928−
929− if (leaseActive) {
930− return {
931− kind: "locked",
932− leaseExpiresAt,
933− };
934− }
935−
936− await ctx.db.patch(stateRow._id, {
937− leaseHolder: args.leaseHolder,
938− leaseExpiresAt: now + Math.max(1_000, Math.floor(args.leaseMs)),
939− leaseFence: state.leaseFence + 1,
940− lastRefreshErrorClass: null,
941− lastRefreshError: null,
942− updatedAt: now,
943− });
944−
945− const nextState = await ctx.db
946− .query("oauthState")
947− .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
948− .unique();
949−
950− if (!nextState) {
951− return { kind: "missing" };
952− }
953−
954− return {
955− kind: "ready",
956− oauthState: toOAuthState(nextState as unknown as Record<string, unknown>),
957− connection,
958− };
959− },
960−});
961−
962−export const finalizeOAuthRefreshSuccess = internalMutation({
963− args: {
964− connectionId: v.string(),
965− leaseHolder: v.string(),
966− expectedTokenVersion: v.number(),
967− expectedLeaseFence: v.number(),
968− accessTokenCiphertext: v.string(),
969− refreshTokenCiphertext: v.optional(v.union(v.string(), v.null())),
970− expiresAt: v.optional(v.union(v.number(), v.null())),
971− scope: v.optional(v.union(v.string(), v.null())),
972− tokenType: v.optional(v.union(v.string(), v.null())),
973− issuer: v.optional(v.union(v.string(), v.null())),
974− },
975− handler: async (ctx, args): Promise<{ committed: boolean }> => {
976− const stateRow = await ctx.db
977− .query("oauthState")
978− .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
979− .unique();
980− const connectionRow = await ctx.db
981− .query("authConnections")
982− .withIndex("by_domainId", (q) => q.eq("id", args.connectionId))
983− .unique();
984−
985− if (!stateRow || !connectionRow) {
986− return { committed: false };
987− }
988−
989− const state = toOAuthState(stateRow as unknown as Record<string, unknown>);
990− const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>);
991−
992− if (state.leaseHolder !== args.leaseHolder) {
993− return { committed: false };
994− }
995−
996− if (state.tokenVersion !== args.expectedTokenVersion) {
997− return { committed: false };
998− }
999−
1000− if (state.leaseFence !== args.expectedLeaseFence) {
1001− return { committed: false };
1002− }
1003−
1004− const now = Date.now();
1005− const nextRefreshToken = args.refreshTokenCiphertext === undefined
1006− ? state.refreshTokenCiphertext
1007− : args.refreshTokenCiphertext;
1008−
1009− await ctx.db.patch(stateRow._id, {
1010− accessTokenCiphertext: args.accessTokenCiphertext,
1011− refreshTokenCiphertext: nextRefreshToken,
1012− expiresAt: args.expiresAt !== undefined ? args.expiresAt : state.expiresAt,
1013− scope: args.scope !== undefined ? args.scope : state.scope,
1014− tokenType: args.tokenType !== undefined ? args.tokenType : state.tokenType,
1015− issuer: args.issuer !== undefined ? args.issuer : state.issuer,
1016− tokenVersion: state.tokenVersion + 1,
1017− leaseHolder: null,
1018− leaseExpiresAt: null,
1019− lastRefreshAt: now,
1020− lastRefreshErrorClass: null,
1021− lastRefreshError: null,
1022− reauthRequiredAt: null,
1023− updatedAt: now,
1024− });
1025−
1026− await ctx.db.patch(connectionRow._id, {
1027− status: "active",
1028− statusReason: null,
1029− lastAuthErrorClass: null,
1030− lastUsedAt: now,
1031− updatedAt: now,
1032− });
1033−
1034− await insertAuthAuditEvent(ctx, {
1035− organizationId: connection.organizationId,
1036− connectionId: connection.id,
1037− sourceId: null,
1038− eventType: "refresh_success",
1039− actorType: "system",
1040− outcome: "success",
1041− });
1042−
1043− return { committed: true };
1044− },
1045−});
1046−
1047−export const finalizeOAuthRefreshFailure = internalMutation({
1048− args: {
1049− connectionId: v.string(),
1050− leaseHolder: v.string(),
1051− errorClass: v.string(),
1052− message: v.string(),
1053− reauthRequired: v.boolean(),
1054− },
1055− handler: async (ctx, args): Promise<void> => {
1056− const stateRow = await ctx.db
1057− .query("oauthState")
1058− .withIndex("by_connectionId", (q) => q.eq("connectionId", args.connectionId))
1059− .unique();
1060− const connectionRow = await ctx.db
1061− .query("authConnections")
1062− .withIndex("by_domainId", (q) => q.eq("id", args.connectionId))
1063− .unique();
1064−
1065− if (!stateRow || !connectionRow) {
1066− return;
1067− }
1068−
1069− const state = toOAuthState(stateRow as unknown as Record<string, unknown>);
1070− const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>);
1071−
1072− if (state.leaseHolder !== args.leaseHolder) {
1073− return;
1074− }
1075−
1076− const now = Date.now();
1077− await ctx.db.patch(stateRow._id, {
1078− leaseHolder: null,
1079− leaseExpiresAt: null,
1080− lastRefreshErrorClass: args.errorClass,
1081− lastRefreshError: args.message,
1082− reauthRequiredAt: args.reauthRequired ? now : state.reauthRequiredAt,
1083− updatedAt: now,
1084− });
1085−
1086− await ctx.db.patch(connectionRow._id, {
1087− status: args.reauthRequired ? "reauth_required" : "error",
1088− statusReason: args.message,
1089− lastAuthErrorClass: args.errorClass,
1090− updatedAt: now,
1091− });
1092−
1093− await insertAuthAuditEvent(ctx, {
1094− organizationId: connection.organizationId,
1095− connectionId: connection.id,
1096− sourceId: null,
1097− eventType: args.reauthRequired ? "reauth_required" : "refresh_failed",
1098− actorType: "system",
1099− outcome: "failure",
1100− reasonCode: args.errorClass,
1101− detailsJson: JSON.stringify({ message: args.message }),
1102− });
1103− },
1104−});
1105−
1106−const refreshOAuthStateIfNeeded = async (
1107− ctx: any,
1108− input: {
1109− connectionId: string;
1110− forceRefresh: boolean;
1111− oauthState: OAuthState;
1112− },
1113−): Promise<OAuthState | null> => {
1114− const now = Date.now();
1115− const isExpiring =
1116− input.oauthState.expiresAt !== null
1117− && input.oauthState.expiresAt <= now + oauthRefreshWindowMs;
1118− const shouldAttemptRefresh = input.forceRefresh || isExpiring;
1119−
1120− if (!shouldAttemptRefresh) {
1121− return input.oauthState;
1122− }
1123−
1124− const leaseHolder = `oauth_refresh_${crypto.randomUUID()}`;
1125− const begin = await ctx.runMutation(
1126− runtimeInternal.control_plane.credentials.beginOAuthRefresh,
1127− {
1128− connectionId: input.connectionId,
1129− leaseHolder,
1130− leaseMs: oauthRefreshLeaseMs,
1131− },
1132− );
1133−
1134− if (begin.kind === "missing") {
1135− return null;
1136− }
1137−
1138− if (begin.kind === "locked") {
1139− const fallback = await ctx.runQuery(
1140− runtimeInternal.control_plane.credentials.getOAuthStateByConnection,
1141− {
1142− connectionId: input.connectionId,
1143− },
1144− );
1145− return fallback;
1146− }
1147−
1148− const state = begin.oauthState;
1149− const refreshConfig = parseOAuthRefreshConfig(state.refreshConfigJson);
1150−
1151− const refreshed = await refreshTokenFromOAuthEndpoint(state, refreshConfig);
1152− if (!refreshed.ok) {
1153− await ctx.runMutation(
1154− runtimeInternal.control_plane.credentials.finalizeOAuthRefreshFailure,
1155− {
1156− connectionId: input.connectionId,
1157− leaseHolder,
1158− errorClass: refreshed.errorClass,
1159− message: refreshed.message,
1160− reauthRequired:
1161− refreshed.errorClass === "invalid_grant"
1162− || refreshed.errorClass === "invalid_client"
1163− || refreshed.errorClass === "unsupported",
1164− },
1165− );
1166−
1167− const fallback = await ctx.runQuery(
1168− runtimeInternal.control_plane.credentials.getOAuthStateByConnection,
1169− {
1170− connectionId: input.connectionId,
1171− },
1172− );
1173− return fallback;
1174− }
1175−
1176− const accessTokenCiphertext = await encryptSecretValue(refreshed.accessToken);
1177− const refreshTokenCiphertext = refreshed.refreshToken
1178− ? await encryptSecretValue(refreshed.refreshToken)
1179− : undefined;
1180−
1181− await ctx.runMutation(
1182− runtimeInternal.control_plane.credentials.finalizeOAuthRefreshSuccess,
1183− {
1184− connectionId: input.connectionId,
1185− leaseHolder,
1186− expectedTokenVersion: state.tokenVersion,
1187− expectedLeaseFence: state.leaseFence,
1188− accessTokenCiphertext,
1189− ...(refreshTokenCiphertext !== undefined
1190− ? { refreshTokenCiphertext }
1191− : {}),
1192− ...(refreshed.expiresAt !== null ? { expiresAt: refreshed.expiresAt } : {}),
1193− ...(refreshed.scope !== null ? { scope: refreshed.scope } : {}),
1194− ...(refreshed.tokenType !== null ? { tokenType: refreshed.tokenType } : {}),
1195− ...(refreshed.issuer !== null ? { issuer: refreshed.issuer } : {}),
1196− },
1197− );
1198−
1199− return await ctx.runQuery(
1200− runtimeInternal.control_plane.credentials.getOAuthStateByConnection,
1201− {
1202− connectionId: input.connectionId,
1203− },
1204− );
1205−};
1206−
1207−export const getOAuthStateByConnection = internalQuery({
1208− args: {
1209− connectionId: v.string(),
1210− },
1211− handler: async (ctx, args): Promise<OAuthState | null> =>
1212− await loadOAuthStateByConnectionId(ctx, args.connectionId),
1213−});
1214−
1215−export const resolveSourceCredentialHeaders = internalAction({
1216− args: {
1217− workspaceId: v.string(),
1218− sourceId: v.string(),
1219− accountId: v.optional(v.union(v.string(), v.null())),
1220− forceRefresh: v.optional(v.boolean()),
1221− },
1222− handler: async (
1223− ctx,
1224− args,
1225− ): Promise<{
1226− headers: Record<string, string>;
1227− oauthConnectionId: string | null;
1228− }> => {
1229− const selected = await ctx.runQuery(
1230− runtimeInternal.control_plane.credentials.resolveSourceAuthSelection,
1231− {
1232− workspaceId: args.workspaceId,
1233− sourceId: args.sourceId,
1234− accountId: args.accountId ?? null,
1235− },
1236− );
1237−
1238− if (!selected.binding || !selected.connection) {
1239− return {
1240− headers: {},
1241− oauthConnectionId: null,
1242− };
1243− }
1244−
1245− const connection = selected.connection;
1246− const connectionStatus = normalizeAuthConnectionStatus(connection);
1247− if (connectionStatus !== "active") {
1248− return {
1249− headers: {},
1250− oauthConnectionId: connection.strategy === "oauth2" ? connection.id : null,
1251− };
1252− }
1253−
1254− const metadata = parseConnectionMetadata(connection.metadataJson);
1255− const additionalHeaders = parseAdditionalHeaders(connection.additionalHeadersJson);
1256−
1257− if (connection.strategy === "oauth2") {
1258− let oauthState = selected.oauthState;
1259− if (!oauthState) {
1260− return {
1261− headers: additionalHeaders,
1262− oauthConnectionId: connection.id,
1263− };
1264− }
1265−
1266− oauthState = await refreshOAuthStateIfNeeded(ctx, {
1267− connectionId: connection.id,
1268− forceRefresh: args.forceRefresh === true,
1269− oauthState,
1270− });
1271−
1272− if (!oauthState) {
1273− return {
1274− headers: additionalHeaders,
1275− oauthConnectionId: connection.id,
1276− };
1277− }
1278−
1279− const now = Date.now();
1280− if (oauthState.expiresAt !== null && oauthState.expiresAt <= now) {
1281− return {
1282− headers: additionalHeaders,
1283− oauthConnectionId: connection.id,
1284− };
1285− }
1286−
1287− const accessToken = await decryptSecretValue(oauthState.accessTokenCiphertext);
1288− const oauthHeaders = accessToken.trim().length > 0
1289− ? ({ Authorization: `Bearer ${accessToken}` } as Record<string, string>)
1290− : {};
1291−
1292− return {
1293− headers: mergeHeaders(oauthHeaders, additionalHeaders),
1294− oauthConnectionId: connection.id,
1295− };
1296− }
1297−
1298− const material = await ctx.runQuery(
1299− runtimeInternal.control_plane.credentials.getAuthMaterialByConnection,
1300− {
1301− connectionId: connection.id,
1302− },
1303− );
1304−
1305− if (!material) {
1306− return {
1307− headers: additionalHeaders,
1308− oauthConnectionId: null,
1309− };
1310− }
1311−
1312− const secret = await decryptSecretValue(material.ciphertext);
1313− const strategyHeaders = buildSecretHeaders(connection.strategy, secret, metadata);
1314−
1315− return {
1316− headers: mergeHeaders(strategyHeaders, additionalHeaders),
1317− oauthConnectionId: null,
1318− };
1319− },
1320−});
1321−
1322−export const getAuthMaterialByConnection = internalQuery({
1323− args: {
1324− connectionId: v.string(),
1325− },
1326− handler: async (ctx, args): Promise<AuthMaterial | null> =>
1327− await loadAuthMaterialByConnectionId(ctx, args.connectionId),
1328−});
1329−
1330−const coerceScopeOwnerType = (
1331− scopeType: SourceCredentialBinding["scopeType"],
1332−): AuthConnection["ownerType"] => {
1333− if (scopeType === "organization") return "organization";
1334− if (scopeType === "account") return "account";
1335− return "workspace";
1336−};
1337−
1338−const buildOAuthRefreshConfigFromPayload = (
1339− payload: ExtendedUpsertCredentialBindingPayload,
1340− existing: OAuthRefreshConfig,
1341− encryptedClientSecret: string | null,
1342−): OAuthRefreshConfig => ({
1343− tokenEndpoint:
1344− normalizeString(payload.oauthTokenEndpoint)
1345− ?? existing.tokenEndpoint,
1346− authorizationServer:
1347− normalizeString(payload.oauthAuthorizationServer)
1348− ?? existing.authorizationServer,
1349− clientId: normalizeString(payload.oauthClientId) ?? existing.clientId,
1350− clientSecretCiphertext:
1351− encryptedClientSecret
1352− ?? existing.clientSecretCiphertext,
1353− sourceUrl: normalizeString(payload.oauthSourceUrl) ?? existing.sourceUrl,
1354− clientInformationJson:
1355− normalizeString(payload.oauthClientInformationJson)
1356− ?? existing.clientInformationJson,
1357−});
1358−
1359−export const upsertCredentialBindingRecord = internalMutation({
1360− args: {
1361− workspaceId: v.string(),
1362− payload: sourceCredentialBindingPayloadValidator,
1363− secretCiphertext: v.string(),
1364− oauthRefreshCiphertext: v.optional(v.union(v.string(), v.null())),
1365− oauthClientSecretCiphertext: v.optional(v.union(v.string(), v.null())),
1366− },
1367− handler: async (ctx, args): Promise<SourceCredentialBinding> => {
1368− const payload = args.payload as ExtendedUpsertCredentialBindingPayload;
1369− const now = Date.now();
1370− const sourceId = sourceIdFromSourceKey(payload.sourceKey);
1371− if (!sourceId) {
1372− throw new Error("Credentials require sourceKey in the form 'source:<id>'");
1373− }
1374−
1375− if (payload.scopeType === "account" && payload.accountId === null) {
1376− throw new Error("Account scope credentials require accountId");
1377− }
1378−
1379− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
1380−
1381− const existingBindingRow = payload.id
1382− ? await ctx.db
1383− .query("sourceAuthBindings")
1384− .withIndex("by_domainId", (q) => q.eq("id", payload.id!))
1385− .unique()
1386− : null;
1387− const existingBinding = existingBindingRow
1388− ? toSourceAuthBinding(existingBindingRow as unknown as Record<string, unknown>)
1389− : null;
1390−
1391− const scopeWorkspaceId = payload.scopeType === "workspace" ? args.workspaceId : null;
1392− const scopeAccountId = payload.scopeType === "account" ? (payload.accountId ?? null) : null;
1393−
1394− const resolvedBindingId = existingBinding?.id
1395− ?? payload.id
1396− ?? (() => {
1397− return `auth_binding_${crypto.randomUUID()}`;
1398− })();
1399−
1400− const requestedConnectionId = normalizeString(payload.credentialId)
1401− ?? existingBinding?.connectionId
1402− ?? `conn_${crypto.randomUUID()}`;
1403−
1404− const existingConnectionRow = await ctx.db
1405− .query("authConnections")
1406− .withIndex("by_domainId", (q) => q.eq("id", requestedConnectionId))
1407− .unique();
1408− const existingConnection = existingConnectionRow
1409− ? toAuthConnection(existingConnectionRow as unknown as Record<string, unknown>)
1410− : null;
1411−
1412− if (existingConnection && existingConnection.organizationId !== organizationId) {
1413− throw new Error("Connection id belongs to another organization");
1414− }
1415−
1416− const nextConnection = decodeAuthConnection({
1417− id: requestedConnectionId,
1418− organizationId,
1419− workspaceId: scopeWorkspaceId,
1420− accountId: scopeAccountId,
1421− ownerType: coerceScopeOwnerType(payload.scopeType),
1422− strategy: strategyFromProvider(payload.provider),
1423− displayName:
1424− normalizeString(existingConnection?.displayName)
1425− ?? sourceKeyFromSourceId(sourceId),
1426− status: "active",
1427− statusReason: null,
1428− lastAuthErrorClass: null,
1429− metadataJson: existingConnection?.metadataJson ?? null,
1430− additionalHeadersJson:
1431− payload.additionalHeadersJson !== undefined
1432− ? payload.additionalHeadersJson
1433− : existingConnection?.additionalHeadersJson ?? null,
1434− createdByAccountId: existingConnection?.createdByAccountId ?? null,
1435− createdAt: existingConnection?.createdAt ?? now,
1436− updatedAt: now,
1437− lastUsedAt: existingConnection?.lastUsedAt ?? null,
1438− });
1439−
1440− if (existingConnectionRow) {
1441− await ctx.db.patch(existingConnectionRow._id, nextConnection);
1442− } else {
1443− await ctx.db.insert("authConnections", nextConnection);
1444− }
1445−
1446− const nextBinding = decodeSourceAuthBinding({
1447− id: resolvedBindingId,
1448− sourceId,
1449− connectionId: requestedConnectionId,
1450− organizationId,
1451− workspaceId: scopeWorkspaceId,
1452− accountId: scopeAccountId,
1453− scopeType: payload.scopeType,
1454− selector: existingBinding?.selector ?? null,
1455− enabled: true,
1456− createdAt: existingBinding?.createdAt ?? now,
1457− updatedAt: now,
1458− });
1459−
1460− if (existingBindingRow) {
1461− await ctx.db.patch(existingBindingRow._id, nextBinding);
1462− } else {
1463− await ctx.db.insert("sourceAuthBindings", nextBinding);
1464− }
1465−
1466− if (nextConnection.strategy === "oauth2") {
1467− const existingOAuthRow = await ctx.db
1468− .query("oauthState")
1469− .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId))
1470− .unique();
1471− const existingOAuth = existingOAuthRow
1472− ? toOAuthState(existingOAuthRow as unknown as Record<string, unknown>)
1473− : null;
1474−
1475− const existingRefreshConfig = existingOAuth
1476− ? parseOAuthRefreshConfig(existingOAuth.refreshConfigJson)
1477− : {};
1478− const refreshConfig = buildOAuthRefreshConfigFromPayload(
1479− payload,
1480− existingRefreshConfig,
1481− args.oauthClientSecretCiphertext ?? null,
1482− );
1483− const refreshConfigJson = encodeOAuthRefreshConfig(refreshConfig);
1484−
1485− const oauthState = decodeOAuthState({
1486− id: existingOAuth?.id ?? `oauth_state_${crypto.randomUUID()}`,
1487− connectionId: requestedConnectionId,
1488− accessTokenCiphertext: args.secretCiphertext,
1489− refreshTokenCiphertext:
1490− args.oauthRefreshCiphertext !== undefined
1491− ? args.oauthRefreshCiphertext
1492− : existingOAuth?.refreshTokenCiphertext ?? null,
1493− keyVersion: currentCredentialEncryptionKeyVersion(),
1494− expiresAt:
1495− payload.oauthExpiresAt !== undefined
1496− ? payload.oauthExpiresAt
1497− : existingOAuth?.expiresAt ?? null,
1498− scope:
1499− payload.oauthScope !== undefined
1500− ? payload.oauthScope
1501− : existingOAuth?.scope ?? null,
1502− tokenType: existingOAuth?.tokenType ?? "Bearer",
1503− issuer:
1504− payload.oauthIssuer !== undefined
1505− ? payload.oauthIssuer
1506− : existingOAuth?.issuer ?? null,
1507− refreshConfigJson,
1508− tokenVersion: (existingOAuth?.tokenVersion ?? 0) + 1,
1509− leaseHolder: null,
1510− leaseExpiresAt: null,
1511− leaseFence: existingOAuth?.leaseFence ?? 0,
1512− lastRefreshAt: existingOAuth?.lastRefreshAt ?? null,
1513− lastRefreshErrorClass: null,
1514− lastRefreshError: null,
1515− reauthRequiredAt: null,
1516− createdAt: existingOAuth?.createdAt ?? now,
1517− updatedAt: now,
1518− });
1519−
1520− if (existingOAuthRow) {
1521− await ctx.db.patch(existingOAuthRow._id, oauthState);
1522− } else {
1523− await ctx.db.insert("oauthState", oauthState);
1524− }
1525−
1526− const existingMaterialRow = await ctx.db
1527− .query("authMaterials")
1528− .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId))
1529− .unique();
1530− if (existingMaterialRow) {
1531− await ctx.db.delete(existingMaterialRow._id);
1532− }
1533− } else {
1534− const existingMaterialRow = await ctx.db
1535− .query("authMaterials")
1536− .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId))
1537− .unique();
1538− const existingMaterial = existingMaterialRow
1539− ? toAuthMaterial(existingMaterialRow as unknown as Record<string, unknown>)
1540− : null;
1541−
1542− const material = decodeAuthMaterial({
1543− id: existingMaterial?.id ?? `auth_material_${crypto.randomUUID()}`,
1544− connectionId: requestedConnectionId,
1545− ciphertext: args.secretCiphertext,
1546− keyVersion: currentCredentialEncryptionKeyVersion(),
1547− createdAt: existingMaterial?.createdAt ?? now,
1548− updatedAt: now,
1549− });
1550−
1551− if (existingMaterialRow) {
1552− await ctx.db.patch(existingMaterialRow._id, material);
1553− } else {
1554− await ctx.db.insert("authMaterials", material);
1555− }
1556−
1557− const existingOAuthRow = await ctx.db
1558− .query("oauthState")
1559− .withIndex("by_connectionId", (q) => q.eq("connectionId", requestedConnectionId))
1560− .unique();
1561− if (existingOAuthRow) {
1562− await ctx.db.delete(existingOAuthRow._id);
1563− }
1564− }
1565−
1566− await insertAuthAuditEvent(ctx, {
1567− organizationId,
1568− connectionId: requestedConnectionId,
1569− sourceId,
1570− eventType: existingConnection ? "updated" : "created",
1571− actorType: "system",
1572− outcome: "success",
1573− });
1574−
1575− await insertAuthAuditEvent(ctx, {
1576− organizationId,
1577− connectionId: requestedConnectionId,
1578− sourceId,
1579− eventType: "bound",
1580− actorType: "system",
1581− outcome: "success",
1582− });
1583−
1584− return toCompatSourceCredentialBinding(nextBinding, nextConnection);
1585− },
1586−});
1587−
1588−export const upsertCredentialBinding = internalAction({
1589− args: {
1590− workspaceId: v.string(),
1591− payload: sourceCredentialBindingPayloadValidator,
1592− },
1593− handler: async (ctx, args): Promise<SourceCredentialBinding> => {
1594− const payload = args.payload as ExtendedUpsertCredentialBindingPayload;
1595− const requestedSecretRef = payload.secretRef.trim();
1596− if (requestedSecretRef.length === 0) {
1597− throw new Error("Credential secret is required");
1598− }
1599−
1600− const encryptedSecretRef = await encryptSecretValue(requestedSecretRef);
1601− const refreshToken = normalizeString(payload.oauthRefreshToken);
1602− const encryptedOauthRefreshToken = refreshToken
1603− ? await encryptSecretValue(refreshToken)
1604− : payload.oauthRefreshToken !== undefined
1605− ? null
1606− : undefined;
1607−
1608− const clientSecret = normalizeString(payload.oauthClientSecret);
1609− const encryptedOauthClientSecret = clientSecret
1610− ? await encryptSecretValue(clientSecret)
1611− : payload.oauthClientSecret !== undefined
1612− ? null
1613− : undefined;
1614−
1615− return await ctx.runMutation(
1616− runtimeInternal.control_plane.credentials.upsertCredentialBindingRecord,
1617− {
1618− workspaceId: args.workspaceId,
1619− payload,
1620− secretCiphertext: encryptedSecretRef,
1621− ...(encryptedOauthRefreshToken !== undefined
1622− ? { oauthRefreshCiphertext: encryptedOauthRefreshToken }
1623− : {}),
1624− ...(encryptedOauthClientSecret !== undefined
1625− ? { oauthClientSecretCiphertext: encryptedOauthClientSecret }
1626− : {}),
1627− },
1628− );
1629− },
1630−});
1631−
1632−export const removeCredentialBinding = internalMutation({
1633− args: {
1634− workspaceId: v.string(),
1635− credentialBindingId: v.string(),
1636− },
1637− handler: async (
1638− ctx,
1639− args,
1640− ): Promise<{
1641− removed: boolean;
1642− }> => {
1643− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
1644−
1645− const bindingRow = await ctx.db
1646− .query("sourceAuthBindings")
1647− .withIndex("by_domainId", (q) => q.eq("id", args.credentialBindingId))
1648− .unique();
1649−
1650− if (!bindingRow) {
1651− return { removed: false };
1652− }
1653−
1654− const binding = toSourceAuthBinding(bindingRow as unknown as Record<string, unknown>);
1655− if (
1656− binding.workspaceId !== args.workspaceId
1657− && !(binding.workspaceId === null && binding.organizationId === organizationId)
1658− ) {
1659− return { removed: false };
1660− }
1661−
1662− await ctx.db.delete(bindingRow._id);
1663−
1664− const remainingBindings = await ctx.db
1665− .query("sourceAuthBindings")
1666− .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1667− .collect();
1668−
1669− const connectionRow = await ctx.db
1670− .query("authConnections")
1671− .withIndex("by_domainId", (q) => q.eq("id", binding.connectionId))
1672− .unique();
1673−
1674− if (remainingBindings.length === 0) {
1675− const materialRow = await ctx.db
1676− .query("authMaterials")
1677− .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1678− .unique();
1679− if (materialRow) {
1680− await ctx.db.delete(materialRow._id);
1681− }
1682−
1683− const oauthRow = await ctx.db
1684− .query("oauthState")
1685− .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1686− .unique();
1687− if (oauthRow) {
1688− await ctx.db.delete(oauthRow._id);
1689− }
1690−
1691− if (connectionRow) {
1692− await ctx.db.delete(connectionRow._id);
1693− }
1694− }
1695−
1696− if (connectionRow) {
1697− const connection = toAuthConnection(connectionRow as unknown as Record<string, unknown>);
1698− await insertAuthAuditEvent(ctx, {
1699− organizationId: connection.organizationId,
1700− connectionId: connection.id,
1701− sourceId: binding.sourceId,
1702− eventType: "unbound",
1703− actorType: "system",
1704− outcome: "success",
1705− });
1706−
1707− if (remainingBindings.length === 0) {
1708− await insertAuthAuditEvent(ctx, {
1709− organizationId: connection.organizationId,
1710− connectionId: connection.id,
1711− sourceId: null,
1712− eventType: "deleted",
1713− actorType: "system",
1714− outcome: "success",
1715− });
1716− }
1717− }
1718−
1719− return { removed: true };
1720− },
1721−});
1722−
1723−export const removeSourceAuthBindings = internalMutation({
1724− args: {
1725− sourceId: v.string(),
1726− },
1727− handler: async (ctx, args): Promise<void> => {
1728− const rows = await ctx.db
1729− .query("sourceAuthBindings")
1730− .withIndex("by_sourceId", (q) => q.eq("sourceId", args.sourceId))
1731− .collect();
1732−
1733− const bindingIds = rows.map((row) => (row as unknown as { id: string }).id);
1734−
1735− for (const bindingId of bindingIds) {
1736− const row = await ctx.db
1737− .query("sourceAuthBindings")
1738− .withIndex("by_domainId", (q) => q.eq("id", bindingId))
1739− .unique();
1740− if (!row) {
1741− continue;
1742− }
1743−
1744− const binding = toSourceAuthBinding(row as unknown as Record<string, unknown>);
1745− await ctx.db.delete(row._id);
1746−
1747− const remaining = await ctx.db
1748− .query("sourceAuthBindings")
1749− .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1750− .collect();
1751− if (remaining.length > 0) {
1752− continue;
1753− }
1754−
1755− const material = await ctx.db
1756− .query("authMaterials")
1757− .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1758− .unique();
1759− if (material) {
1760− await ctx.db.delete(material._id);
1761− }
1762−
1763− const oauth = await ctx.db
1764− .query("oauthState")
1765− .withIndex("by_connectionId", (q) => q.eq("connectionId", binding.connectionId))
1766− .unique();
1767− if (oauth) {
1768− await ctx.db.delete(oauth._id);
1769− }
1770−
1771− const connection = await ctx.db
1772− .query("authConnections")
1773− .withIndex("by_domainId", (q) => q.eq("id", binding.connectionId))
1774− .unique();
1775− if (connection) {
1776− await ctx.db.delete(connection._id);
1777− }
1778− }
1779− },
1780−});
apps/convex/convex/control_plane/errors.ts+0−82
@@ -1,82 +0,0 @@
1−import { SourceStoreError } from "@executor-v2/persistence-ports";
2−
3−type ControlPlaneErrorCode =
4− | "bad_request"
5− | "unauthorized"
6− | "forbidden"
7− | "not_found"
8− | "method_not_allowed"
9− | "internal";
10−
11−type ControlPlaneErrorResponse = {
12− ok: false;
13− error: {
14− code: ControlPlaneErrorCode;
15− message: string;
16− details: string | null;
17− };
18−};
19−
20−const renderCause = (cause: unknown): string => String(cause);
21−
22−export const toSourceStoreError = (
23− operation: string,
24− cause: unknown,
25−): SourceStoreError =>
26− new SourceStoreError({
27− operation,
28− backend: "convex",
29− location: "control-plane",
30− message: "Convex control plane call failed",
31− reason: null,
32− details: renderCause(cause),
33− });
34−
35−const toErrorCode = (status: number): ControlPlaneErrorCode => {
36− switch (status) {
37− case 400:
38− return "bad_request";
39− case 401:
40− return "unauthorized";
41− case 403:
42− return "forbidden";
43− case 404:
44− return "not_found";
45− case 405:
46− return "method_not_allowed";
47− default:
48− return "internal";
49− }
50−};
51−
52−export const controlPlaneErrorResponse = (
53− status: number,
54− message: string,
55− details: string | null,
56−): Response =>
57− Response.json(
58− {
59− ok: false,
60− error: {
61− code: toErrorCode(status),
62− message,
63− details,
64− },
65− } satisfies ControlPlaneErrorResponse,
66− { status },
67− );
68−
69−const isJsonResponse = (response: Response): boolean =>
70− response.headers.get("content-type")?.toLowerCase().includes("application/json") ?? false;
71−
72−export const normalizeControlPlaneErrorResponse = (response: Response): Response => {
73− if (response.ok || isJsonResponse(response)) {
74− return response;
75− }
76−
77− return controlPlaneErrorResponse(
78− response.status,
79− "Control plane request failed",
80− response.statusText || null,
81− );
82−};
apps/convex/convex/control_plane/http.ts+0−121
@@ -1,121 +0,0 @@
1−import {
2− ControlPlaneService,
3− controlPlaneOpenApiSpec,
4− makeControlPlaneWebHandler,
5−} from "@executor-v2/management-api";
6−import * as Effect from "effect/Effect";
7−import * as Layer from "effect/Layer";
8−
9−import { httpAction, type ActionCtx } from "../_generated/server";
10−import { ConvexControlPlaneActorLive } from "./actor";
11−import {
12− controlPlaneErrorResponse,
13− normalizeControlPlaneErrorResponse,
14− toSourceStoreError,
15−} from "./errors";
16−import { makeConvexControlPlaneService } from "./service";
17−
18−const isOpenApiRequest = (request: Request): boolean =>
19− new URL(request.url).pathname === "/v1/openapi.json";
20−
21−const controlPlaneCorsHeaders = (request: Request): Headers => {
22− const origin = request.headers.get("origin");
23− const requestedHeaders = request.headers.get("access-control-request-headers");
24− const headers = new Headers();
25−
26− headers.set("access-control-allow-origin", origin && origin.length > 0 ? origin : "*");
27− headers.set("vary", "origin");
28− headers.set("access-control-allow-methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
29− if (requestedHeaders && requestedHeaders.trim().length > 0) {
30− headers.set("access-control-allow-headers", requestedHeaders);
31− } else {
32− headers.set(
33− "access-control-allow-headers",
34− [
35− "content-type",
36− "authorization",
37− "traceparent",
38− "b3",
39− ].join(", "),
40− );
41− }
42− headers.set("access-control-max-age", "86400");
43−
44− return headers;
45−};
46−
47−const withControlPlaneCors = (request: Request, response: Response): Response => {
48− const headers = new Headers(response.headers);
49− const corsHeaders = controlPlaneCorsHeaders(request);
50−
51− corsHeaders.forEach((value, name) => {
52− headers.set(name, value);
53− });
54−
55− return new Response(response.body, {
56− status: response.status,
57− statusText: response.statusText,
58− headers,
59− });
60−};
61−
62−const handleControlPlaneRequest = (
63− ctx: ActionCtx,
64− request: Request,
65−): Effect.Effect<Response, never> =>
66− Effect.scoped(
67− Effect.gen(function* () {
68− if (request.method === "OPTIONS") {
69− return withControlPlaneCors(request, new Response(null, { status: 204 }));
70− }
71−
72− if (isOpenApiRequest(request)) {
73− return withControlPlaneCors(
74− request,
75− Response.json(controlPlaneOpenApiSpec, { status: 200 }),
76− );
77− }
78−
79− const webHandler = makeControlPlaneWebHandler(
80− Layer.succeed(ControlPlaneService, makeConvexControlPlaneService(ctx)),
81− ConvexControlPlaneActorLive(ctx),
82− );
83−
84− yield* Effect.addFinalizer(() =>
85− Effect.tryPromise({
86− try: () => webHandler.dispose(),
87− catch: (cause) => toSourceStoreError("controlPlane.dispose", cause),
88− }).pipe(
89− Effect.asVoid,
90− Effect.catchAll(() => Effect.void),
91− ),
92− );
93−
94− const response = yield* Effect.tryPromise({
95− try: () => webHandler.handler(request),
96− catch: (cause) => toSourceStoreError("controlPlane.http", cause),
97− });
98−
99− return withControlPlaneCors(
100− request,
101− normalizeControlPlaneErrorResponse(response),
102− );
103− }),
104− ).pipe(
105− Effect.catchAll((error) =>
106− Effect.succeed(
107− withControlPlaneCors(
108− request,
109− controlPlaneErrorResponse(
110− 500,
111− "Control plane request failed",
112− error.details,
113− ),
114− ),
115− ),
116− ),
117− );
118−
119−export const controlPlaneHttpHandler = httpAction((ctx, request) =>
120− Effect.runPromise(handleControlPlaneRequest(ctx, request)),
121−);
apps/convex/convex/control_plane/openapi_ingest.ts+0−1538
@@ -1,1538 +0,0 @@
1−"use node";
2−
3−import { createHash } from "node:crypto";
4−
5−import { type Source } from "@executor-v2/schema";
6−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7−import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
8−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9−import { v } from "convex/values";
10−import * as Effect from "effect/Effect";
11−import * as Schema from "effect/Schema";
12−
13−import { internal } from "../_generated/api";
14−import { internalAction } from "../_generated/server";
15−
16−const runtimeInternal = internal as any;
17−
18−const graphqlExtractorVersion = "graphql_v1";
19−const mcpExtractorVersion = "mcp_v1";
20−const writeBatchSize = 500;
21−const stagedArtifactBatchSize = 500;
22−
23−const graphqlSchemaRootsQuery = `query SchemaRoots {
24− __schema {
25− queryType { name }
26− mutationType { name }
27− }
28−}`;
29−
30−const graphqlRootFieldsQuery = `query RootFields($name: String!) {
31− __type(name: $name) {
32− name
33− fields {
34− name
35− description
36− args {
37− name
38− description
39− defaultValue
40− type {
41− kind
42− name
43− ofType {
44− kind
45− name
46− ofType {
47− kind
48− name
49− }
50− }
51− }
52− }
53− }
54− }
55−}`;
56−
57−type GraphqlIngestTool = {
58− toolId: string;
59− name: string;
60− description: string | null;
61− operationType: string;
62− fieldName: string;
63− operationHash: string;
64− invocationJson: string;
65−};
66−
67−type McpIngestTool = {
68− toolId: string;
69− name: string;
70− description: string | null;
71− toolName: string;
72− operationHash: string;
73− invocationJson: string;
74−};
75−
76−type McpTransport = "sse" | "streamable-http";
77−
78−type McpConnection = {
79− client: Client;
80− close: () => Promise<void>;
81− transport: McpTransport;
82−};
83−
84−const SourceIngestStatusSchema = Schema.Union(
85− Schema.Literal("error"),
86− Schema.Literal("auth_required"),
87−);
88−
89−type SourceIngestStatus = typeof SourceIngestStatusSchema.Type;
90−
91−class SourceIngestError extends Schema.TaggedError<SourceIngestError>()(
92− "SourceIngestError",
93− {
94− status: SourceIngestStatusSchema,
95− message: Schema.String,
96− },
97−) {}
98−
99−type SourceIngestPrepared = {
100− protocol: "openapi" | "graphql" | "mcp";
101− artifactId: string;
102− sourceHash: string;
103− toolCount: number;
104− namespace: string;
105− refHintTableJson: string | null;
106− artifactBatchCount: number;
107−};
108−
109−const isUnknownRecord = (value: unknown): value is Record<string, unknown> =>
110− typeof value === "object" && value !== null && !Array.isArray(value);
111−
112−const normalizeString = (value: unknown): string | null => {
113− if (typeof value !== "string") {
114− return null;
115− }
116−
117− const trimmed = value.trim();
118− return trimmed.length > 0 ? trimmed : null;
119−};
120−
121−const normalizeHttpUrl = (value: string): string | null => {
122− const trimmed = value.trim();
123− if (trimmed.length === 0) {
124− return null;
125− }
126−
127− try {
128− const parsed = new URL(trimmed);
129− if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
130− return null;
131− }
132−
133− return parsed.toString();
134− } catch {
135− return null;
136− }
137−};
138−
139−const stableStringify = (value: unknown): string => {
140− if (Array.isArray(value)) {
141− return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
142− }
143−
144− if (isUnknownRecord(value)) {
145− const keys = Object.keys(value).sort();
146− return `{${keys
147− .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
148− .join(",")}}`;
149− }
150−
151− return JSON.stringify(value);
152−};
153−
154−const hashUnknown = (value: unknown): string =>
155− createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 16);
156−
157−const sourceSlug = (name: string): string => {
158− const slug = name
159− .toLowerCase()
160− .replaceAll(/[^a-z0-9]+/g, "_")
161− .replaceAll(/^_+|_+$/g, "");
162−
163− return slug.length > 0 ? slug : "graphql";
164−};
165−
166−const toolSegment = (value: string): string => {
167− const segment = value
168− .toLowerCase()
169− .replaceAll(/[^a-z0-9]+/g, "_")
170− .replaceAll(/^_+|_+$/g, "");
171−
172− return segment.length > 0 ? segment : "field";
173−};
174−
175−const normalizeNamespacePart = (value: string): string => {
176− const normalized = value
177− .trim()
178− .toLowerCase()
179− .replace(/[^a-z0-9]+/g, "_")
180− .replace(/^_+|_+$/g, "");
181−
182− return normalized.length > 0 ? normalized : "source";
183−};
184−
185−const sourceNamespace = (source: Source): string => {
186− const sourceIdSuffix = source.id.slice(-6).toLowerCase();
187− return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`;
188−};
189−
190−const sourceToolPath = (source: Source, toolId: string): string =>
191− `${sourceNamespace(source)}.${toolId}`;
192−
193−const normalizedSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
194− parts
195− .map((part) => (typeof part === "string" ? part.trim().toLowerCase() : ""))
196− .filter((part) => part.length > 0)
197− .join(" ");
198−
199−const metadataSearchTerms = (metadataJson: string | null | undefined): ReadonlyArray<string> => {
200− if (!metadataJson) {
201− return [];
202− }
203−
204− const parsed = parseJsonObject(metadataJson);
205− const keys = ["method", "path", "operationType", "fieldName", "toolName"] as const;
206− const terms: Array<string> = [];
207−
208− for (const key of keys) {
209− const value = parsed[key];
210− if (typeof value === "string" && value.trim().length > 0) {
211− terms.push(value);
212− }
213− }
214−
215− return terms;
216−};
217−
218−const toWorkspaceToolMethod = (protocol: string, metadataJson: string | null | undefined): string => {
219− if (protocol !== "openapi") {
220− return "post";
221− }
222−
223− const parsed = metadataJson ? parseJsonObject(metadataJson) : {};
224− const method = typeof parsed.method === "string" ? parsed.method.trim().toLowerCase() : "";
225−
226− if (
227− method === "get" ||
228− method === "post" ||
229− method === "put" ||
230− method === "patch" ||
231− method === "delete" ||
232− method === "head" ||
233− method === "options" ||
234− method === "trace"
235− ) {
236− return method;
237− }
238−
239− return "get";
240−};
241−
242−const parseJsonObject = (value: string): Record<string, unknown> => {
243− try {
244− const parsed = JSON.parse(value) as unknown;
245− return isUnknownRecord(parsed) ? parsed : {};
246− } catch {
247− return {};
248− }
249−};
250−
251−const safeJsonStringify = (value: unknown): string | null => {
252− try {
253− return JSON.stringify(value);
254− } catch {
255− return null;
256− }
257−};
258−
259−const chunkArray = <A>(values: ReadonlyArray<A>, size: number): Array<Array<A>> => {
260− const chunkSize = Math.max(1, Math.floor(size));
261− const chunks: Array<Array<A>> = [];
262−
263− for (let index = 0; index < values.length; index += chunkSize) {
264− chunks.push(values.slice(index, index + chunkSize));
265− }
266−
267− return chunks;
268−};
269−
270−const extractMcpSchemas = (
271− invocationJson: string,
272−): { inputSchemaJson: string | null; outputSchemaJson: string | null } => {
273− const parsed = parseJsonObject(invocationJson);
274− return {
275− inputSchemaJson: safeJsonStringify(parsed.inputSchema) ?? null,
276− outputSchemaJson: safeJsonStringify(parsed.outputSchema) ?? null,
277− };
278−};
279−
280−const typeRefToLabel = (typeRef: unknown): string => {
281− if (!isUnknownRecord(typeRef)) {
282− return "Unknown";
283− }
284−
285− const kind = normalizeString(typeRef.kind);
286− const name = normalizeString(typeRef.name);
287− const ofType = typeRef.ofType;
288−
289− if (kind === "NON_NULL") {
290− return `${typeRefToLabel(ofType)}!`;
291− }
292−
293− if (kind === "LIST") {
294− return `[${typeRefToLabel(ofType)}]`;
295− }
296−
297− if (name) {
298− return name;
299− }
300−
301− return kind ?? "Unknown";
302−};
303−
304−const isRequiredType = (typeRef: unknown): boolean =>
305− isUnknownRecord(typeRef) && normalizeString(typeRef.kind) === "NON_NULL";
306−
307−const parseSourceConfig = (configJson: string): Record<string, unknown> => {
308− try {
309− const parsed = JSON.parse(configJson) as unknown;
310− return isUnknownRecord(parsed) ? parsed : {};
311− } catch {
312− return {};
313− }
314−};
315−
316−const collectConfiguredHeadersFromSourceConfig = (config: Record<string, unknown>): Record<string, string> => {
317− const headers: Record<string, string> = {};
318− const configuredHeaders = config.headers;
319−
320− if (!isUnknownRecord(configuredHeaders)) {
321− return headers;
322− }
323−
324− for (const [headerKey, headerValue] of Object.entries(configuredHeaders)) {
325− const key = normalizeString(headerKey);
326− const value = normalizeString(headerValue);
327− if (key && value) {
328− headers[key] = value;
329− }
330− }
331−
332− return headers;
333−};
334−
335−const collectGraphqlHeadersFromSourceConfig = (source: Source): Record<string, string> => {
336− const config = parseSourceConfig(source.configJson);
337− return {
338− "content-type": "application/json",
339− ...collectConfiguredHeadersFromSourceConfig(config),
340− };
341−};
342−
343−const collectMcpHeadersFromSourceConfig = (source: Source): Record<string, string> => {
344− const config = parseSourceConfig(source.configJson);
345− return {
346− ...collectConfiguredHeadersFromSourceConfig(config),
347− };
348−};
349−
350−const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => {
351− const merged: Record<string, string> = {};
352− const keyByLower = new Map<string, string>();
353−
354− for (const headers of sets) {
355− for (const [rawKey, rawValue] of Object.entries(headers)) {
356− const key = rawKey.trim();
357− const value = rawValue.trim();
358− if (key.length === 0 || value.length === 0) {
359− continue;
360− }
361−
362− const normalizedKey = key.toLowerCase();
363− const existingKey = keyByLower.get(normalizedKey);
364− if (existingKey && existingKey !== key) {
365− delete merged[existingKey];
366− }
367−
368− keyByLower.set(normalizedKey, key);
369− merged[key] = value;
370− }
371− }
372−
373− return merged;
374−};
375−
376−const graphqlAuthErrorCodes = new Set(["unauthenticated", "forbidden", "unauthorized"]);
377−
378−const payloadHasGraphqlAuthError = (payload: unknown): boolean => {
379− if (!isUnknownRecord(payload) || !Array.isArray(payload.errors)) {
380− return false;
381− }
382−
383− return payload.errors.some((entry) => {
384− if (!isUnknownRecord(entry)) {
385− return false;
386− }
387−
388− const message = normalizeString(entry.message)?.toLowerCase();
389− if (message && (message.includes("unauthor") || message.includes("forbidden") || message.includes("auth"))) {
390− return true;
391− }
392−
393− if (!isUnknownRecord(entry.extensions)) {
394− return false;
395− }
396−
397− const code = normalizeString(entry.extensions.code)?.toLowerCase();
398− return code ? graphqlAuthErrorCodes.has(code) : false;
399− });
400−};
401−
402−const looksLikeAuthErrorMessage = (value: string): boolean => {
403− const message = value.toLowerCase();
404− return message.includes("unauthor") || message.includes("forbidden") || message.includes("auth required");
405−};
406−
407−const sourceIngestStatusFromCause = (cause: unknown): SourceIngestStatus => {
408− if (cause instanceof SourceIngestError) {
409− return cause.status;
410− }
411−
412− const message = formatError(cause);
413− return looksLikeAuthErrorMessage(message) ? "auth_required" : "error";
414−};
415−
416−const extractGraphqlErrorMessage = (payload: unknown, status: number): string => {
417− if (isUnknownRecord(payload) && Array.isArray(payload.errors) && payload.errors.length > 0) {
418− const firstError = payload.errors[0];
419− if (isUnknownRecord(firstError)) {
420− const message = normalizeString(firstError.message);
421− if (message) {
422− return message;
423− }
424− }
425− }
426−
427− return `GraphQL schema request failed (${status})`;
428−};
429−
430−const fetchGraphqlPayload = (
431− source: Source,
432− headers: Record<string, string>,
433− body: Record<string, unknown>,
434−): Effect.Effect<Record<string, unknown>, SourceIngestError> =>
435− Effect.gen(function* () {
436− const response = yield* Effect.tryPromise({
437− try: () =>
438− fetch(source.endpoint, {
439− method: "POST",
440− headers,
441− body: JSON.stringify(body),
442− cache: "no-store",
443− }),
444− catch: (cause) =>
445− SourceIngestError.make({
446− status: "error",
447− message: `GraphQL schema request failed: ${formatError(cause)}`,
448− }),
449− });
450−
451− const payload = yield* Effect.tryPromise({
452− try: () => response.json().catch(() => null),
453− catch: (cause) =>
454− SourceIngestError.make({
455− status: "error",
456− message: `GraphQL schema response parse failed: ${formatError(cause)}`,
457− }),
458− });
459−
460− if (!response.ok) {
461− const message = extractGraphqlErrorMessage(payload, response.status);
462− const authRequired =
463− response.status === 401 || response.status === 403 || payloadHasGraphqlAuthError(payload);
464−
465− return yield* SourceIngestError.make({
466− status: authRequired ? "auth_required" : "error",
467− message,
468− });
469− }
470−
471− if (!isUnknownRecord(payload)) {
472− return yield* SourceIngestError.make({
473− status: "error",
474− message: "GraphQL response is not an object",
475− });
476− }
477−
478− return payload;
479− });
480−
481−const loadGraphqlSchemaRoots = (
482− source: Source,
483− headers: Record<string, string>,
484−): Effect.Effect<
485− {
486− queryTypeName: string | null;
487− mutationTypeName: string | null;
488− },
489− SourceIngestError
490−> =>
491− Effect.gen(function* () {
492− const payload = yield* fetchGraphqlPayload(source, headers, {
493− query: graphqlSchemaRootsQuery,
494− });
495−
496− const data = payload.data;
497− const schema = isUnknownRecord(data) && isUnknownRecord(data.__schema)
498− ? data.__schema
499− : null;
500−
501− if (!schema) {
502− return yield* SourceIngestError.make({
503− status: "error",
504− message: "GraphQL schema roots payload is missing __schema",
505− });
506− }
507−
508− return {
509− queryTypeName: isUnknownRecord(schema.queryType) ? normalizeString(schema.queryType.name) : null,
510− mutationTypeName: isUnknownRecord(schema.mutationType)
511− ? normalizeString(schema.mutationType.name)
512− : null,
513− };
514− });
515−
516−const loadGraphqlRootFields = (
517− source: Source,
518− headers: Record<string, string>,
519− rootTypeName: string | null,
520−): Effect.Effect<Array<Record<string, unknown>>, SourceIngestError> =>
521− Effect.gen(function* () {
522− if (!rootTypeName) {
523− return [];
524− }
525−
526− const payload = yield* fetchGraphqlPayload(source, headers, {
527− query: graphqlRootFieldsQuery,
528− variables: {
529− name: rootTypeName,
530− },
531− });
532−
533− const data = payload.data;
534− const rootType = isUnknownRecord(data) && isUnknownRecord(data.__type)
535− ? data.__type
536− : null;
537−
538− if (!rootType) {
539− return [];
540− }
541−
542− const fields = rootType.fields;
543− if (!Array.isArray(fields)) {
544− return [];
545− }
546−
547− return fields.filter((field): field is Record<string, unknown> => isUnknownRecord(field));
548− });
549−
550−const ensureUniqueToolIds = (tools: Array<GraphqlIngestTool>): Array<GraphqlIngestTool> => {
551− const counts = new Map<string, number>();
552−
553− return tools.map((tool) => {
554− const count = (counts.get(tool.toolId) ?? 0) + 1;
555− counts.set(tool.toolId, count);
556−
557− if (count === 1) {
558− return tool;
559− }
560−
561− return {
562− ...tool,
563− toolId: `${tool.toolId}_${count}`,
564− };
565− });
566−};
567−
568−const extractGraphqlTools = (
569− source: Source,
570− roots: {
571− queryTypeName: string | null;
572− mutationTypeName: string | null;
573− },
574− queryFields: ReadonlyArray<Record<string, unknown>>,
575− mutationFields: ReadonlyArray<Record<string, unknown>>,
576−): {
577− schemaHash: string;
578− tools: Array<GraphqlIngestTool>;
579−} => {
580− const slug = sourceSlug(source.name);
581−
582− const rawInvocation = {
583− kind: "graphql_raw",
584− endpoint: source.endpoint,
585− };
586−
587− const tools: Array<GraphqlIngestTool> = [
588− {
589− toolId: `${slug}.graphql`,
590− name: `${source.name} GraphQL`,
591− description: `Run raw GraphQL queries against ${source.name}.`,
592− operationType: "raw",
593− fieldName: "graphql",
594− operationHash: hashUnknown(rawInvocation),
595− invocationJson: JSON.stringify(rawInvocation),
596− },
597− ];
598−
599− const fieldSignatures: Array<unknown> = [];
600−
601− const addOperationTools = (
602− operationType: "query" | "mutation",
603− fields: ReadonlyArray<Record<string, unknown>>,
604− ): void => {
605− for (const field of fields) {
606− const fieldName = normalizeString(field.name);
607− if (!fieldName) {
608− continue;
609− }
610−
611− const args = Array.isArray(field.args)
612− ? field.args
613− .filter((arg): arg is Record<string, unknown> => isUnknownRecord(arg))
614− .map((arg) => ({
615− name: normalizeString(arg.name) ?? "arg",
616− description: normalizeString(arg.description),
617− required: isRequiredType(arg.type),
618− type: typeRefToLabel(arg.type),
619− defaultValue: normalizeString(arg.defaultValue),
620− }))
621− : [];
622−
623− const invocation = {
624− kind: "graphql_field",
625− endpoint: source.endpoint,
626− operationType,
627− fieldName,
628− args,
629− };
630−
631− tools.push({
632− toolId: `${slug}.${operationType}.${toolSegment(fieldName)}`,
633− name: fieldName,
634− description: normalizeString(field.description),
635− operationType,
636− fieldName,
637− operationHash: hashUnknown(invocation),
638− invocationJson: JSON.stringify(invocation),
639− });
640−
641− fieldSignatures.push({
642− operationType,
643− fieldName,
644− args: args.map((arg) => ({
645− name: arg.name,
646− required: arg.required,
647− type: arg.type,
648− })),
649− });
650− }
651− };
652−
653− addOperationTools("query", queryFields);
654− addOperationTools("mutation", mutationFields);
655−
656− return {
657− schemaHash: hashUnknown({
658− queryTypeName: roots.queryTypeName,
659− mutationTypeName: roots.mutationTypeName,
660− fieldSignatures,
661− }),
662− tools: ensureUniqueToolIds(tools),
663− };
664−};
665−
666−const parseMcpTransportPreference = (source: Source): McpTransport | null => {
667− const config = parseSourceConfig(source.configJson);
668− const transport = normalizeString(config.transport)?.toLowerCase();
669−
670− if (transport === "sse") {
671− return "sse";
672− }
673−
674− if (transport === "streamable-http" || transport === "streamable_http") {
675− return "streamable-http";
676− }
677−
678− return null;
679−};
680−
681−const parseMcpQueryParams = (source: Source): Record<string, string> => {
682− const config = parseSourceConfig(source.configJson);
683− const queryParams = config.queryParams;
684−
685− if (!isUnknownRecord(queryParams)) {
686− return {};
687− }
688−
689− const normalized: Record<string, string> = {};
690− for (const [rawKey, rawValue] of Object.entries(queryParams)) {
691− const key = normalizeString(rawKey);
692− const value = normalizeString(rawValue);
693− if (key && value) {
694− normalized[key] = value;
695− }
696− }
697−
698− return normalized;
699−};
700−
701−const withMcpHeaders = (
702− input: RequestInfo | URL,
703− init: RequestInit | undefined,
704− headers: Record<string, string>,
705−): Promise<Response> => {
706− const mergedHeaders = new Headers(init?.headers ?? {});
707− for (const [key, value] of Object.entries(headers)) {
708− mergedHeaders.set(key, value);
709− }
710−
711− return fetch(input, {
712− ...init,
713− headers: mergedHeaders,
714− });
715−};
716−
717−const connectMcp = (
718− source: Source,
719− headers: Record<string, string>,
720−): Effect.Effect<McpConnection, SourceIngestError> =>
721− Effect.gen(function* () {
722− const endpoint = yield* Effect.try({
723− try: () => new URL(source.endpoint),
724− catch: () =>
725− SourceIngestError.make({
726− status: "error",
727− message: `Invalid MCP endpoint URL: ${source.endpoint}`,
728− }),
729− });
730−
731− const queryParams = parseMcpQueryParams(source);
732− for (const [key, value] of Object.entries(queryParams)) {
733− endpoint.searchParams.set(key, value);
734− }
735−
736− const connectWithTransport = (
737− transport: McpTransport,
738− ): Effect.Effect<McpConnection, SourceIngestError> =>
739− Effect.tryPromise({
740− try: async () => {
741− const client = new Client(
742− {
743− name: "executor-v2-control-plane",
744− version: "0.1.0",
745− },
746− { capabilities: {} },
747− );
748−
749− if (transport === "streamable-http") {
750− await client.connect(new StreamableHTTPClientTransport(endpoint, {
751− requestInit: { headers },
752− }));
753− } else {
754− await client.connect(new SSEClientTransport(endpoint, {
755− requestInit: { headers },
756− eventSourceInit: {
757− fetch: (input, init) => withMcpHeaders(input, init, headers),
758− },
759− }));
760− }
761−
762− return {
763− client,
764− close: () => client.close(),
765− transport,
766− };
767− },
768− catch: (cause) =>
769− SourceIngestError.make({
770− status: sourceIngestStatusFromCause(cause),
771− message: `Failed to connect to MCP source: ${formatError(cause)}`,
772− }),
773− });
774−
775− const preferred = parseMcpTransportPreference(source);
776− if (preferred === "streamable-http") {
777− return yield* connectWithTransport("streamable-http");
778− }
779−
780− if (preferred === "sse") {
781− return yield* connectWithTransport("sse");
782− }
783−
784− const streamableAttempt = yield* connectWithTransport("streamable-http").pipe(Effect.either);
785− if (streamableAttempt._tag === "Right") {
786− return streamableAttempt.right;
787− }
788−
789− return yield* connectWithTransport("sse");
790− });
791−
792−const normalizeJsonSchema = (value: unknown): Record<string, unknown> => {
793− if (value === true) {
794− return {};
795− }
796−
797− if (value === false) {
798− return { not: {} };
799− }
800−
801− return isUnknownRecord(value) ? value : {};
802−};
803−
804−const extractMcpListedToolRows = (value: unknown): Array<Record<string, unknown>> => {
805− if (!isUnknownRecord(value) || !Array.isArray(value.tools)) {
806− return [];
807− }
808−
809− return value.tools.filter((tool): tool is Record<string, unknown> => isUnknownRecord(tool));
810−};
811−
812−const ensureUniqueMcpToolIds = (tools: Array<McpIngestTool>): Array<McpIngestTool> => {
813− const counts = new Map<string, number>();
814−
815− return tools.map((tool) => {
816− const count = (counts.get(tool.toolId) ?? 0) + 1;
817− counts.set(tool.toolId, count);
818−
819− if (count === 1) {
820− return tool;
821− }
822−
823− return {
824− ...tool,
825− toolId: `${tool.toolId}_${count}`,
826− };
827− });
828−};
829−
830−const extractMcpTools = (
831− source: Source,
832− transport: McpTransport,
833− listedTools: ReadonlyArray<Record<string, unknown>>,
834−): {
835− sourceHash: string;
836− tools: Array<McpIngestTool>;
837−} => {
838− const slug = sourceSlug(source.name);
839− const queryParams = parseMcpQueryParams(source);
840−
841− const tools: Array<McpIngestTool> = [];
842− const toolSignatures: Array<unknown> = [];
843−
844− for (const tool of listedTools) {
845− const toolName = normalizeString(tool.name);
846− if (!toolName) {
847− continue;
848− }
849−
850− const inputSchema = normalizeJsonSchema(tool.inputSchema);
851− const outputSchema = normalizeJsonSchema(tool.outputSchema);
852− const invocation = {
853− kind: "mcp_tool",
854− endpoint: source.endpoint,
855− transport,
856− queryParams,
857− toolName,
858− };
859−
860− tools.push({
861− toolId: `${slug}.mcp.${toolSegment(toolName)}`,
862− name: toolName,
863− description: normalizeString(tool.description),
864− toolName,
865− operationHash: hashUnknown({ invocation, inputSchema, outputSchema }),
866− invocationJson: JSON.stringify({
867− ...invocation,
868− inputSchema,
869− outputSchema,
870− }),
871− });
872−
873− toolSignatures.push({ toolName, inputSchema, outputSchema });
874− }
875−
876− return {
877− sourceHash: hashUnknown({
878− endpoint: source.endpoint,
879− transport,
880− queryParams,
881− toolSignatures,
882− }),
883− tools: ensureUniqueMcpToolIds(tools),
884− };
885−};
886−
887−const formatError = (cause: unknown): string =>
888− cause instanceof Error ? cause.message : String(cause);
889−
890−const trySourceIngestPromise = <A>(
891− promise: () => Promise<A>,
892− message: string,
893−): Effect.Effect<A, SourceIngestError> =>
894− Effect.tryPromise({
895− try: promise,
896− catch: (cause) =>
897− SourceIngestError.make({
898− status: "error",
899− message: `${message}: ${formatError(cause)}`,
900− }),
901− });
902−
903−const openApiParserUrl = process.env.OPENAPI_PARSE_API_URL?.trim() ?? "";
904−const openApiParserToken = process.env.OPENAPI_PARSE_API_TOKEN?.trim() ?? "";
905−
906−type OpenApiParserIngestedPayload = {
907− ok?: boolean;
908− mode?: string;
909− openApiSpec?: unknown;
910− artifactId?: string;
911− sourceHash?: string;
912− toolCount?: number;
913− namespace?: string;
914− refHintTableJson?: string | null;
915− artifactBatchCount?: number;
916− error?: string;
917−};
918−
919−const requestOpenApiParser = async (
920− payload: Record<string, unknown>,
921−): Promise<OpenApiParserIngestedPayload> => {
922− if (openApiParserUrl.length === 0) {
923− throw new Error("OPENAPI_PARSE_API_URL is required for OpenAPI ingestion");
924− }
925−
926− const response = await fetch(openApiParserUrl, {
927− method: "POST",
928− headers: {
929− "content-type": "application/json",
930− ...(openApiParserToken.length > 0
931− ? { "x-openapi-parse-token": openApiParserToken }
932− : {}),
933− },
934− body: JSON.stringify(payload),
935− });
936−
937− if (!response.ok) {
938− const details = await response.text().catch(() => "");
939− throw new Error(
940− `Parser endpoint failed (${response.status}): ${
941− details.trim().length > 0 ? details.trim() : response.statusText
942− }`,
943− );
944− }
945−
946− return (await response.json()) as OpenApiParserIngestedPayload;
947−};
948−
949−const withOpenApiServerVariables = (
950− template: string,
951− variables: unknown,
952−): string => {
953− if (!isUnknownRecord(variables)) {
954− return template;
955− }
956−
957− return template.replaceAll(/\{([^}]+)\}/g, (_token, name: string) => {
958− const variable = variables[name];
959− if (!isUnknownRecord(variable)) {
960− return `{${name}}`;
961− }
962−
963− return normalizeString(variable.default) ?? `{${name}}`;
964− });
965−};
966−
967−const resolveOpenApiServerUrl = (
968− rawServerUrl: string,
969− variables: unknown,
970− specUrl: string,
971−): string | null => {
972− const resolvedTemplate = withOpenApiServerVariables(rawServerUrl, variables);
973− if (resolvedTemplate.includes("{")) {
974− return null;
975− }
976−
977− try {
978− const resolved = new URL(resolvedTemplate, specUrl).toString();
979− return normalizeHttpUrl(resolved);
980− } catch {
981− return null;
982− }
983−};
984−
985−const deriveOpenApiBaseUrlFromSpec = (
986− openApiSpec: unknown,
987− specUrl: string,
988−): string | null => {
989− if (!isUnknownRecord(openApiSpec)) {
990− return null;
991− }
992−
993− const servers = openApiSpec.servers;
994− if (Array.isArray(servers)) {
995− for (const server of servers) {
996− if (!isUnknownRecord(server)) {
997− continue;
998− }
999−
1000− const rawServerUrl = normalizeString(server.url);
1001− if (!rawServerUrl) {
1002− continue;
1003− }
1004−
1005− const resolved = resolveOpenApiServerUrl(rawServerUrl, server.variables, specUrl);
1006− if (resolved) {
1007− return resolved;
1008− }
1009− }
1010− }
1011−
1012− const host = normalizeString(openApiSpec.host);
1013− if (host) {
1014− const schemes = Array.isArray(openApiSpec.schemes) ? openApiSpec.schemes : [];
1015− const firstScheme = normalizeString(schemes[0])?.toLowerCase();
1016− const scheme = firstScheme === "http" || firstScheme === "https" ? firstScheme : "https";
1017− const basePath = normalizeString(openApiSpec.basePath) ?? "";
1018− const normalizedPath = basePath.length === 0
1019− ? ""
1020− : basePath.startsWith("/")
1021− ? basePath
1022− : `/${basePath}`;
1023− return normalizeHttpUrl(`${scheme}://${host}${normalizedPath}`);
1024− }
1025−
1026− return null;
1027−};
1028−
1029−const fetchOpenApiBaseUrlFromSpec = async (specUrl: string): Promise<string> => {
1030− const payload = await requestOpenApiParser({ specUrl });
1031− if (payload.ok !== true) {
1032− throw new Error(
1033− payload.error && payload.error.trim().length > 0
1034− ? payload.error
1035− : "Parser endpoint returned an invalid payload",
1036− );
1037− }
1038−
1039− if (payload.mode !== "parsed") {
1040− throw new Error("Parser endpoint must return mode='parsed' when deriving OpenAPI baseUrl");
1041− }
1042−
1043− const baseUrl = deriveOpenApiBaseUrlFromSpec(payload.openApiSpec, specUrl);
1044− if (!baseUrl) {
1045− throw new Error("OpenAPI source requires spec.servers (or Swagger host/schemes) to derive baseUrl");
1046− }
1047−
1048− return baseUrl;
1049−};
1050−
1051−const fetchOpenApiDocumentForIngest = async (
1052− source: Source,
1053−): Promise<{
1054− artifactId: string;
1055− sourceHash: string;
1056− toolCount: number;
1057− namespace: string;
1058− refHintTableJson: string | null;
1059− artifactBatchCount: number;
1060−}> => {
1061− const payload = await requestOpenApiParser({
1062− specUrl: source.endpoint,
1063− workspaceId: source.workspaceId,
1064− sourceId: source.id,
1065− sourceName: source.name,
1066− sourceEnabled: source.enabled,
1067− });
1068−
1069− if (payload.ok !== true) {
1070− throw new Error(
1071− payload.error && payload.error.trim().length > 0
1072− ? payload.error
1073− : "Parser endpoint returned an invalid payload",
1074− );
1075− }
1076−
1077− if (payload.mode !== "ingested") {
1078− throw new Error("Parser endpoint must return mode='ingested' for OpenAPI ingestion");
1079− }
1080−
1081− const artifactId = normalizeString(payload.artifactId);
1082− const sourceHash = normalizeString(payload.sourceHash);
1083− const namespace = normalizeString(payload.namespace);
1084− const toolCount = Number.isFinite(payload.toolCount)
1085− ? Math.max(0, Math.floor(payload.toolCount ?? 0))
1086− : null;
1087− const artifactBatchCount = Number.isFinite(payload.artifactBatchCount)
1088− ? Math.max(0, Math.floor(payload.artifactBatchCount ?? 0))
1089− : 0;
1090−
1091− if (!artifactId || !sourceHash || !namespace || toolCount === null) {
1092− throw new Error("Parser endpoint returned an invalid ingested payload");
1093− }
1094−
1095− return {
1096− artifactId,
1097− sourceHash,
1098− toolCount,
1099− namespace,
1100− refHintTableJson:
1101− typeof payload.refHintTableJson === "string" ? payload.refHintTableJson : null,
1102− artifactBatchCount,
1103− };
1104−};
1105−
1106−export const deriveOpenApiBaseUrl = internalAction({
1107− args: {
1108− specUrl: v.string(),
1109− },
1110− handler: async (_ctx, args): Promise<{ baseUrl: string }> => {
1111− const specUrl = args.specUrl.trim();
1112− if (specUrl.length === 0) {
1113− throw new Error("specUrl is required");
1114− }
1115−
1116− const baseUrl = await fetchOpenApiBaseUrlFromSpec(specUrl);
1117− return { baseUrl };
1118− },
1119−});
1120−
1121−type IngestOptions = {
1122− rebuildIndex: boolean;
1123− stageArtifactWrites: boolean;
1124−};
1125−
1126−const ingestOpenApiSource = (
1127− ctx: any,
1128− source: Source,
1129−): Effect.Effect<SourceIngestPrepared, SourceIngestError> =>
1130− Effect.gen(function* () {
1131− const ingested = yield* trySourceIngestPromise(
1132− () => fetchOpenApiDocumentForIngest(source),
1133− "Failed to fetch OpenAPI document",
1134− );
1135−
1136− return {
1137− protocol: "openapi",
1138− artifactId: ingested.artifactId,
1139− sourceHash: ingested.sourceHash,
1140− toolCount: ingested.toolCount,
1141− namespace: ingested.namespace,
1142− refHintTableJson: ingested.refHintTableJson,
1143− artifactBatchCount: ingested.artifactBatchCount,
1144− };
1145− });
1146−
1147−const ingestGraphqlSource = (
1148− ctx: any,
1149− source: Source,
1150− options: IngestOptions,
1151−): Effect.Effect<SourceIngestPrepared, SourceIngestError> =>
1152− Effect.gen(function* () {
1153− const credentialHeadersResult = yield* trySourceIngestPromise<{
1154− headers: Record<string, string>;
1155− }>(
1156− () =>
1157− ctx.runAction(runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, {
1158− workspaceId: source.workspaceId,
1159− sourceId: source.id,
1160− }),
1161− "Failed to resolve source credentials for GraphQL ingest",
1162− );
1163−
1164− const headers = mergeHeaders(
1165− collectGraphqlHeadersFromSourceConfig(source),
1166− credentialHeadersResult.headers,
1167− );
1168−
1169− const roots = yield* loadGraphqlSchemaRoots(source, headers);
1170− const queryFields = yield* loadGraphqlRootFields(source, headers, roots.queryTypeName);
1171− const mutationFields = yield* loadGraphqlRootFields(source, headers, roots.mutationTypeName);
1172− const extracted = extractGraphqlTools(source, roots, queryFields, mutationFields);
1173−
1174− const artifactMeta = yield* trySourceIngestPromise<{
1175− artifactId: string;
1176− created: boolean;
1177− }>(
1178− () =>
1179− ctx.runMutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, {
1180− protocol: "graphql",
1181− contentHash: extracted.schemaHash,
1182− extractorVersion: graphqlExtractorVersion,
1183− toolCount: extracted.tools.length,
1184− }),
1185− "Failed to upsert GraphQL artifact metadata",
1186− );
1187−
1188− if (artifactMeta.created) {
1189− const artifactRows = extracted.tools.map((tool) => ({
1190− toolId: tool.toolId,
1191− name: tool.name,
1192− description: tool.description,
1193− canonicalPath: `${tool.operationType}.${tool.fieldName}`,
1194− operationHash: tool.operationHash,
1195− invocationJson: tool.invocationJson,
1196− metadataJson: safeJsonStringify({
1197− operationType: tool.operationType,
1198− fieldName: tool.fieldName,
1199− }),
1200− }));
1201−
1202− if (options.stageArtifactWrites) {
1203− const serializedBatches = chunkArray(artifactRows, stagedArtifactBatchSize).map((batch) =>
1204− JSON.stringify(batch),
1205− );
1206−
1207− yield* trySourceIngestPromise(
1208− () =>
1209− ctx.runMutation(
1210− runtimeInternal.control_plane.tool_registry.replaceSourceIngestArtifactBatches,
1211− {
1212− workspaceId: source.workspaceId,
1213− sourceId: source.id,
1214− artifactId: artifactMeta.artifactId,
1215− protocol: "graphql",
1216− batches: serializedBatches,
1217− },
1218− ),
1219− "Failed to stage GraphQL artifact tool batches",
1220− );
1221− } else {
1222− for (let index = 0; index < artifactRows.length; index += writeBatchSize) {
1223− const batch = artifactRows.slice(index, index + writeBatchSize);
1224− yield* trySourceIngestPromise(
1225− () =>
1226− ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
1227− artifactId: artifactMeta.artifactId,
1228− protocol: "graphql",
1229− insertOnly: true,
1230− tools: batch,
1231− }),
1232− "Failed to write GraphQL artifact tools",
1233− );
1234− }
1235− }
1236− }
1237−
1238− yield* trySourceIngestPromise(
1239− () =>
1240− ctx.runMutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
1241− workspaceId: source.workspaceId,
1242− sourceId: source.id,
1243− artifactId: artifactMeta.artifactId,
1244− }),
1245− "Failed to bind source to GraphQL artifact",
1246− );
1247−
1248− const namespace = sourceNamespace(source);
1249− const indexRows = extracted.tools.map((tool) => ({
1250− toolId: tool.toolId,
1251− protocol: "graphql",
1252− method: "post",
1253− path: sourceToolPath(source, tool.toolId),
1254− operationPath: null,
1255− name: tool.name,
1256− description: tool.description,
1257− searchText: normalizedSearchText(
1258− source.name,
1259− source.endpoint,
1260− namespace,
1261− tool.toolId,
1262− tool.name,
1263− tool.description,
1264− tool.operationType,
1265− tool.fieldName,
1266− ),
1267− operationHash: tool.operationHash,
1268− approvalMode: "auto",
1269− status: source.enabled ? "active" : "disabled",
1270− }));
1271−
1272− if (options.rebuildIndex) {
1273− yield* trySourceIngestPromise(
1274− () =>
1275− ctx.runMutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, {
1276− workspaceId: source.workspaceId,
1277− sourceId: source.id,
1278− sourceName: source.name,
1279− sourceKind: source.kind,
1280− artifactId: artifactMeta.artifactId,
1281− namespace,
1282− refHintTableJson: null,
1283− rows: indexRows,
1284− }),
1285− "Failed to rebuild workspace tool index for GraphQL source",
1286− );
1287− }
1288−
1289− return {
1290− protocol: "graphql",
1291− artifactId: artifactMeta.artifactId,
1292− sourceHash: extracted.schemaHash,
1293− toolCount: extracted.tools.length,
1294− namespace,
1295− refHintTableJson: null,
1296− artifactBatchCount: artifactMeta.created && options.stageArtifactWrites
1297− ? Math.ceil(extracted.tools.length / stagedArtifactBatchSize)
1298− : 0,
1299− };
1300− });
1301−
1302−const ingestMcpSource = (
1303− ctx: any,
1304− source: Source,
1305− options: IngestOptions,
1306−): Effect.Effect<SourceIngestPrepared, SourceIngestError> =>
1307− Effect.gen(function* () {
1308− const credentialHeadersResult = yield* trySourceIngestPromise<{
1309− headers: Record<string, string>;
1310− }>(
1311− () =>
1312− ctx.runAction(runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, {
1313− workspaceId: source.workspaceId,
1314− sourceId: source.id,
1315− }),
1316− "Failed to resolve source credentials for MCP ingest",
1317− );
1318−
1319− const headers = mergeHeaders(
1320− collectMcpHeadersFromSourceConfig(source),
1321− credentialHeadersResult.headers,
1322− );
1323−
1324− const connection = yield* connectMcp(source, headers);
1325−
1326− const listedResult = yield* Effect.tryPromise({
1327− try: () => connection.client.listTools(),
1328− catch: (cause) =>
1329− SourceIngestError.make({
1330− status: sourceIngestStatusFromCause(cause),
1331− message: `Failed to list MCP tools: ${formatError(cause)}`,
1332− }),
1333− }).pipe(
1334− Effect.ensuring(
1335− Effect.promise(() => connection.close().catch(() => undefined)),
1336− ),
1337− );
1338−
1339− const listedTools = extractMcpListedToolRows(listedResult);
1340− const extracted = extractMcpTools(source, connection.transport, listedTools);
1341−
1342− const artifactMeta = yield* trySourceIngestPromise<{
1343− artifactId: string;
1344− created: boolean;
1345− }>(
1346− () =>
1347− ctx.runMutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, {
1348− protocol: "mcp",
1349− contentHash: extracted.sourceHash,
1350− extractorVersion: mcpExtractorVersion,
1351− toolCount: extracted.tools.length,
1352− }),
1353− "Failed to upsert MCP artifact metadata",
1354− );
1355−
1356− if (artifactMeta.created) {
1357− const artifactRows = extracted.tools.map((tool) => {
1358− const schemas = extractMcpSchemas(tool.invocationJson);
1359− return {
1360− toolId: tool.toolId,
1361− name: tool.name,
1362− description: tool.description,
1363− canonicalPath: tool.toolName,
1364− operationHash: tool.operationHash,
1365− invocationJson: tool.invocationJson,
1366− inputSchemaJson: schemas.inputSchemaJson,
1367− outputSchemaJson: schemas.outputSchemaJson,
1368− metadataJson: safeJsonStringify({
1369− toolName: tool.toolName,
1370− }),
1371− };
1372− });
1373−
1374− if (options.stageArtifactWrites) {
1375− const serializedBatches = chunkArray(artifactRows, stagedArtifactBatchSize).map((batch) =>
1376− JSON.stringify(batch),
1377− );
1378−
1379− yield* trySourceIngestPromise(
1380− () =>
1381− ctx.runMutation(
1382− runtimeInternal.control_plane.tool_registry.replaceSourceIngestArtifactBatches,
1383− {
1384− workspaceId: source.workspaceId,
1385− sourceId: source.id,
1386− artifactId: artifactMeta.artifactId,
1387− protocol: "mcp",
1388− batches: serializedBatches,
1389− },
1390− ),
1391− "Failed to stage MCP artifact tool batches",
1392− );
1393− } else {
1394− for (let index = 0; index < artifactRows.length; index += writeBatchSize) {
1395− const batch = artifactRows.slice(index, index + writeBatchSize);
1396− yield* trySourceIngestPromise(
1397− () =>
1398− ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
1399− artifactId: artifactMeta.artifactId,
1400− protocol: "mcp",
1401− insertOnly: true,
1402− tools: batch,
1403− }),
1404− "Failed to write MCP artifact tools",
1405− );
1406− }
1407− }
1408− }
1409−
1410− yield* trySourceIngestPromise(
1411− () =>
1412− ctx.runMutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
1413− workspaceId: source.workspaceId,
1414− sourceId: source.id,
1415− artifactId: artifactMeta.artifactId,
1416− }),
1417− "Failed to bind source to MCP artifact",
1418− );
1419−
1420− const namespace = sourceNamespace(source);
1421− const indexRows = extracted.tools.map((tool) => {
1422− return {
1423− toolId: tool.toolId,
1424− protocol: "mcp",
1425− method: "post",
1426− path: sourceToolPath(source, tool.toolId),
1427− operationPath: null,
1428− name: tool.name,
1429− description: tool.description,
1430− searchText: normalizedSearchText(
1431− source.name,
1432− source.endpoint,
1433− namespace,
1434− tool.toolId,
1435− tool.name,
1436− tool.description,
1437− tool.toolName,
1438− ),
1439− operationHash: tool.operationHash,
1440− approvalMode: "auto",
1441− status: source.enabled ? "active" : "disabled",
1442− };
1443− });
1444−
1445− if (options.rebuildIndex) {
1446− yield* trySourceIngestPromise(
1447− () =>
1448− ctx.runMutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, {
1449− workspaceId: source.workspaceId,
1450− sourceId: source.id,
1451− sourceName: source.name,
1452− sourceKind: source.kind,
1453− artifactId: artifactMeta.artifactId,
1454− namespace,
1455− refHintTableJson: null,
1456− rows: indexRows,
1457− }),
1458− "Failed to rebuild workspace tool index for MCP source",
1459− );
1460− }
1461−
1462− return {
1463− protocol: "mcp",
1464− artifactId: artifactMeta.artifactId,
1465− sourceHash: extracted.sourceHash,
1466− toolCount: extracted.tools.length,
1467− namespace,
1468− refHintTableJson: null,
1469− artifactBatchCount: artifactMeta.created && options.stageArtifactWrites
1470− ? Math.ceil(extracted.tools.length / stagedArtifactBatchSize)
1471− : 0,
1472− };
1473− });
1474−
1475−export const ingestSourceArtifact = internalAction({
1476− args: {
1477− workspaceId: v.string(),
1478− sourceId: v.string(),
1479− },
1480− handler: async (ctx, args): Promise<void> => {
1481− const source = await ctx.runQuery(runtimeInternal.control_plane.sources.getSourceForIngest, {
1482− sourceId: args.sourceId,
1483− });
1484−
1485− if (!source || source.workspaceId !== args.workspaceId) {
1486− return;
1487− }
1488−
1489− await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, {
1490− sourceId: source.id,
1491− status: "probing",
1492− lastError: null,
1493− });
1494−
1495− const ingestEffect = source.kind === "openapi"
1496− ? ingestOpenApiSource(ctx, source)
1497− : source.kind === "graphql"
1498− ? ingestGraphqlSource(ctx, source, {
1499− rebuildIndex: true,
1500− stageArtifactWrites: false,
1501− })
1502− : source.kind === "mcp"
1503− ? ingestMcpSource(ctx, source, {
1504− rebuildIndex: true,
1505− stageArtifactWrites: false,
1506− })
1507− : Effect.fail(
1508− SourceIngestError.make({
1509− status: "error",
1510− message: `Source ingest for kind '${source.kind}' is not implemented yet`,
1511− }),
1512− );
1513−
1514− const result = await Effect.runPromise(
1515− ingestEffect.pipe(
1516− Effect.match({
1517− onFailure: (error) => ({
1518− status: error.status,
1519− sourceHash: null as string | null,
1520− lastError: error.message,
1521− }),
1522− onSuccess: (prepared) => ({
1523− status: "connected" as const,
1524− sourceHash: prepared.sourceHash,
1525− lastError: null as string | null,
1526− }),
1527− }),
1528− ),
1529− );
1530−
1531− await ctx.runMutation(runtimeInternal.control_plane.sources.setSourceIngestState, {
1532− sourceId: source.id,
1533− status: result.status,
1534− sourceHash: result.sourceHash,
1535− lastError: result.lastError,
1536− });
1537− },
1538−});
apps/convex/convex/control_plane/organizations.ts+0−124
@@ -1,124 +0,0 @@
1−import { type UpsertOrganizationPayload } from "@executor-v2/management-api";
2−import {
3− OrganizationSchema,
4− type Organization,
5− type OrganizationMembership,
6−} from "@executor-v2/schema";
7−import { v } from "convex/values";
8−import * as Schema from "effect/Schema";
9−
10−import { internalMutation, internalQuery } from "../_generated/server";
11−
12−const decodeOrganization = Schema.decodeUnknownSync(OrganizationSchema);
13−
14−const stripConvexSystemFields = (
15− value: Record<string, unknown>,
16−): Record<string, unknown> => {
17− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
18− return rest;
19−};
20−
21−const toOrganization = (document: Record<string, unknown>): Organization =>
22− decodeOrganization(stripConvexSystemFields(document));
23−
24−const organizationStatusValidator = v.union(
25− v.literal("active"),
26− v.literal("suspended"),
27− v.literal("archived"),
28−);
29−
30−const sortOrganizations = (
31− organizations: ReadonlyArray<Organization>,
32−): Array<Organization> =>
33− [...organizations].sort((left, right) => {
34− const leftName = left.name.toLowerCase();
35− const rightName = right.name.toLowerCase();
36−
37− if (leftName === rightName) {
38− return left.id.localeCompare(right.id);
39− }
40−
41− return leftName.localeCompare(rightName);
42− });
43−
44−export const listOrganizations = internalQuery({
45− args: {},
46− handler: async (ctx): Promise<Array<Organization>> => {
47− const rows = await ctx.db.query("organizations").collect();
48−
49− return sortOrganizations(
50− rows.map((row) => toOrganization(row as unknown as Record<string, unknown>)),
51− );
52− },
53−});
54−
55−export const upsertOrganization = internalMutation({
56− args: {
57− payload: v.object({
58− id: v.optional(v.string()),
59− slug: v.string(),
60− name: v.string(),
61− status: v.optional(organizationStatusValidator),
62− createdByAccountId: v.optional(v.union(v.string(), v.null())),
63− }),
64− },
65− handler: async (ctx, args): Promise<Organization> => {
66− const payload = args.payload as UpsertOrganizationPayload;
67− const now = Date.now();
68− const organizationId = payload.id ?? `org_${crypto.randomUUID()}`;
69−
70− const existing = await ctx.db
71− .query("organizations")
72− .withIndex("by_domainId", (q) => q.eq("id", organizationId))
73− .unique();
74−
75− const nextOrganization = decodeOrganization({
76− id: organizationId,
77− slug: payload.slug,
78− name: payload.name,
79− status: payload.status ?? existing?.status ?? "active",
80− createdByAccountId:
81− existing?.createdByAccountId
82− ?? payload.createdByAccountId
83− ?? null,
84− createdAt: existing?.createdAt ?? now,
85− updatedAt: now,
86− });
87−
88− if (existing) {
89− await ctx.db.patch(existing._id, nextOrganization);
90− } else {
91− await ctx.db.insert("organizations", nextOrganization);
92−
93− if (nextOrganization.createdByAccountId !== null) {
94− const existingMembership = await ctx.db
95− .query("organizationMemberships")
96− .withIndex("by_organizationId_accountId", (q) =>
97− q
98− .eq("organizationId", nextOrganization.id)
99− .eq("accountId", nextOrganization.createdByAccountId as string)
100− )
101− .first();
102−
103− if (!existingMembership) {
104− const membership: OrganizationMembership = {
105− id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"],
106− organizationId: nextOrganization.id,
107− accountId: nextOrganization.createdByAccountId,
108− role: "owner",
109− status: "active",
110− billable: false,
111− invitedByAccountId: null,
112− joinedAt: now,
113− createdAt: now,
114− updatedAt: now,
115− };
116−
117− await ctx.db.insert("organizationMemberships", membership);
118− }
119− }
120− }
121−
122− return nextOrganization;
123− },
124−});
apps/convex/convex/control_plane/policies.ts+0−120
@@ -1,120 +0,0 @@
1−import { type UpsertPolicyPayload } from "@executor-v2/management-api";
2−import { PolicySchema, type Policy } from "@executor-v2/schema";
3−import { v } from "convex/values";
4−import * as Schema from "effect/Schema";
5−
6−import { internalMutation, internalQuery } from "../_generated/server";
7−
8−const decodePolicy = Schema.decodeUnknownSync(PolicySchema);
9−
10−const stripConvexSystemFields = (
11− value: Record<string, unknown>,
12−): Record<string, unknown> => {
13− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
14− return rest;
15−};
16−
17−const toPolicy = (document: Record<string, unknown>): Policy =>
18− decodePolicy(stripConvexSystemFields(document));
19−
20−const policyDecisionValidator = v.union(
21− v.literal("allow"),
22− v.literal("require_approval"),
23− v.literal("deny"),
24−);
25−
26−const sortPolicies = (policies: ReadonlyArray<Policy>): Array<Policy> =>
27− [...policies].sort((left, right) => {
28− const leftPattern = left.toolPathPattern.toLowerCase();
29− const rightPattern = right.toolPathPattern.toLowerCase();
30−
31− if (leftPattern === rightPattern) {
32− return left.id.localeCompare(right.id);
33− }
34−
35− return leftPattern.localeCompare(rightPattern);
36− });
37−
38−export const listPolicies = internalQuery({
39− args: {
40− workspaceId: v.string(),
41− },
42− handler: async (ctx, args): Promise<Array<Policy>> => {
43− const rows = await ctx.db
44− .query("policies")
45− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
46− .collect();
47−
48− return sortPolicies(
49− rows.map((row) => toPolicy(row as unknown as Record<string, unknown>)),
50− );
51− },
52−});
53−
54−export const upsertPolicy = internalMutation({
55− args: {
56− workspaceId: v.string(),
57− payload: v.object({
58− id: v.optional(v.string()),
59− toolPathPattern: v.string(),
60− decision: policyDecisionValidator,
61− }),
62− },
63− handler: async (ctx, args): Promise<Policy> => {
64− const payload = args.payload as UpsertPolicyPayload;
65− const now = Date.now();
66− const policyId = payload.id ?? `pol_${crypto.randomUUID()}`;
67−
68− const existing = await ctx.db
69− .query("policies")
70− .withIndex("by_domainId", (q) => q.eq("id", policyId))
71− .unique();
72−
73− if (existing && existing.workspaceId !== args.workspaceId) {
74− throw new Error(`Policy not found: ${policyId}`);
75− }
76−
77− const nextPolicy = decodePolicy({
78− id: policyId,
79− workspaceId: args.workspaceId,
80− toolPathPattern: payload.toolPathPattern,
81− decision: payload.decision,
82− createdAt: existing?.createdAt ?? now,
83− updatedAt: now,
84− });
85−
86− if (existing) {
87− await ctx.db.patch(existing._id, nextPolicy);
88− } else {
89− await ctx.db.insert("policies", nextPolicy);
90− }
91−
92− return nextPolicy;
93− },
94−});
95−
96−export const removePolicy = internalMutation({
97− args: {
98− workspaceId: v.string(),
99− policyId: v.string(),
100− },
101− handler: async (
102− ctx,
103− args,
104− ): Promise<{
105− removed: boolean;
106− }> => {
107− const existing = await ctx.db
108− .query("policies")
109− .withIndex("by_domainId", (q) => q.eq("id", args.policyId))
110− .unique();
111−
112− if (!existing || existing.workspaceId !== args.workspaceId) {
113− return { removed: false };
114− }
115−
116− await ctx.db.delete(existing._id);
117−
118− return { removed: true };
119− },
120−});
apps/convex/convex/control_plane/service.ts+0−283
@@ -1,283 +0,0 @@
1−import {
2− ControlPlaneService,
3− type ControlPlaneServiceShape,
4−} from "@executor-v2/management-api";
5−import type { SourceToolSummary } from "@executor-v2/management-api/tools/api";
6−import * as Effect from "effect/Effect";
7−
8−import { internal } from "../_generated/api";
9−import type { ActionCtx } from "../_generated/server";
10−import { toSourceStoreError } from "./errors";
11−
12−const runtimeInternal = internal as any;
13−runtimeInternal.controlPlane = runtimeInternal.controlPlane ?? runtimeInternal.control_plane;
14−const toolPageSize = 250;
15−
16−const collectWorkspaceToolSummaries = async (
17− ctx: ActionCtx,
18− input: {
19− workspaceId: string;
20− sourceId?: string;
21− },
22−): Promise<Array<SourceToolSummary>> => {
23− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
24− workspaceId: input.workspaceId,
25− ...(input.sourceId ? { sourceId: input.sourceId } : {}),
26− });
27−
28− const summaries: Array<SourceToolSummary> = [];
29− let cursor: string | null = null;
30−
31− do {
32− const page: {
33− summaries: Array<SourceToolSummary>;
34− continueCursor: string | null;
35− isDone: boolean;
36− } = await ctx.runQuery(runtimeInternal.control_plane.tools.listWorkspaceToolSummaryPage, {
37− workspaceId: input.workspaceId,
38− ...(input.sourceId ? { sourceId: input.sourceId } : {}),
39− cursor,
40− pageSize: toolPageSize,
41− });
42−
43− summaries.push(...page.summaries);
44− cursor = page.isDone ? null : page.continueCursor;
45− } while (cursor);
46−
47− return summaries;
48−};
49−
50−export const makeConvexControlPlaneService = (
51− ctx: ActionCtx,
52−): ControlPlaneServiceShape =>
53− ControlPlaneService.of({
54− listSources: (workspaceId) =>
55− Effect.tryPromise({
56− try: () =>
57− ctx.runQuery(runtimeInternal.controlPlane.listSources, {
58− workspaceId,
59− }),
60− catch: (cause) => toSourceStoreError("controlPlane.listSources", cause),
61− }),
62− upsertSource: (input) =>
63− Effect.tryPromise({
64− try: () =>
65− ctx.runAction(runtimeInternal.controlPlane.upsertSource, {
66− workspaceId: input.workspaceId,
67− payload: input.payload,
68− }),
69− catch: (cause) => toSourceStoreError("controlPlane.upsertSource", cause),
70− }),
71− removeSource: (input) =>
72− Effect.tryPromise({
73− try: () =>
74− ctx.runMutation(runtimeInternal.controlPlane.removeSource, {
75− workspaceId: input.workspaceId,
76− sourceId: input.sourceId,
77− }),
78− catch: (cause) => toSourceStoreError("controlPlane.removeSource", cause),
79− }),
80− listCredentialBindings: (workspaceId) =>
81− Effect.tryPromise({
82− try: () =>
83− ctx.runQuery(runtimeInternal.controlPlane.listCredentialBindings, {
84− workspaceId,
85− }),
86− catch: (cause) =>
87− toSourceStoreError("controlPlane.listCredentialBindings", cause),
88− }),
89− upsertCredentialBinding: (input) =>
90− Effect.tryPromise({
91− try: () =>
92− ctx.runAction(runtimeInternal.controlPlane.upsertCredentialBinding, {
93− workspaceId: input.workspaceId,
94− payload: input.payload,
95− }),
96− catch: (cause) =>
97− toSourceStoreError("controlPlane.upsertCredentialBinding", cause),
98− }),
99− removeCredentialBinding: (input) =>
100− Effect.tryPromise({
101− try: () =>
102− ctx.runMutation(runtimeInternal.controlPlane.removeCredentialBinding, {
103− workspaceId: input.workspaceId,
104− credentialBindingId: input.credentialBindingId,
105− }),
106− catch: (cause) =>
107− toSourceStoreError("controlPlane.removeCredentialBinding", cause),
108− }),
109− listPolicies: (workspaceId) =>
110− Effect.tryPromise({
111− try: () =>
112− ctx.runQuery(runtimeInternal.controlPlane.listPolicies, {
113− workspaceId,
114− }),
115− catch: (cause) => toSourceStoreError("controlPlane.listPolicies", cause),
116− }),
117− upsertPolicy: (input) =>
118− Effect.tryPromise({
119− try: () =>
120− ctx.runMutation(runtimeInternal.controlPlane.upsertPolicy, {
121− workspaceId: input.workspaceId,
122− payload: input.payload,
123− }),
124− catch: (cause) => toSourceStoreError("controlPlane.upsertPolicy", cause),
125− }),
126− removePolicy: (input) =>
127− Effect.tryPromise({
128− try: () =>
129− ctx.runMutation(runtimeInternal.controlPlane.removePolicy, {
130− workspaceId: input.workspaceId,
131− policyId: input.policyId,
132− }),
133− catch: (cause) => toSourceStoreError("controlPlane.removePolicy", cause),
134− }),
135− listOrganizations: () =>
136− Effect.tryPromise({
137− try: () => ctx.runQuery(runtimeInternal.controlPlane.listOrganizations, {}),
138− catch: (cause) => toSourceStoreError("controlPlane.listOrganizations", cause),
139− }),
140− upsertOrganization: (input) =>
141− Effect.tryPromise({
142− try: () =>
143− ctx.runMutation(runtimeInternal.controlPlane.upsertOrganization, {
144− payload: input.payload,
145− }),
146− catch: (cause) => toSourceStoreError("controlPlane.upsertOrganization", cause),
147− }),
148− listWorkspaces: () =>
149− Effect.tryPromise({
150− try: () => ctx.runQuery(runtimeInternal.controlPlane.listWorkspaces, {}),
151− catch: (cause) => toSourceStoreError("controlPlane.listWorkspaces", cause),
152− }),
153− upsertWorkspace: (input) =>
154− Effect.tryPromise({
155− try: () =>
156− ctx.runMutation(runtimeInternal.controlPlane.upsertWorkspace, {
157− payload: input.payload,
158− }),
159− catch: (cause) => toSourceStoreError("controlPlane.upsertWorkspace", cause),
160− }),
161− listWorkspaceTools: (workspaceId) =>
162− Effect.tryPromise({
163− try: () => collectWorkspaceToolSummaries(ctx, { workspaceId }),
164− catch: (cause) => toSourceStoreError("controlPlane.listWorkspaceTools", cause),
165− }),
166− listSourceTools: (input) =>
167− Effect.tryPromise({
168− try: () =>
169− collectWorkspaceToolSummaries(ctx, {
170− workspaceId: input.workspaceId,
171− sourceId: input.sourceId,
172− }),
173− catch: (cause) => toSourceStoreError("controlPlane.listSourceTools", cause),
174− }),
175− getToolDetail: (input) =>
176− Effect.tryPromise({
177− try: () =>
178− ctx.runQuery(runtimeInternal.controlPlane.getToolDetail, {
179− workspaceId: input.workspaceId,
180− sourceId: input.sourceId,
181− operationHash: input.operationHash,
182− }),
183− catch: (cause) => toSourceStoreError("controlPlane.getToolDetail", cause),
184− }),
185− listStorageInstances: (workspaceId) =>
186− Effect.tryPromise({
187− try: () =>
188− ctx.runQuery(runtimeInternal.controlPlane.listStorageInstances, {
189− workspaceId,
190− }),
191− catch: (cause) =>
192− toSourceStoreError("controlPlane.listStorageInstances", cause),
193− }),
194− openStorageInstance: (input) =>
195− Effect.tryPromise({
196− try: () =>
197− ctx.runMutation(runtimeInternal.controlPlane.openStorageInstance, {
198− workspaceId: input.workspaceId,
199− payload: input.payload,
200− }),
201− catch: (cause) =>
202− toSourceStoreError("controlPlane.openStorageInstance", cause),
203− }),
204− closeStorageInstance: (input) =>
205− Effect.tryPromise({
206− try: () =>
207− ctx.runMutation(runtimeInternal.controlPlane.closeStorageInstance, {
208− workspaceId: input.workspaceId,
209− storageInstanceId: input.storageInstanceId,
210− }),
211− catch: (cause) =>
212− toSourceStoreError("controlPlane.closeStorageInstance", cause),
213− }),
214− removeStorageInstance: (input) =>
215− Effect.tryPromise({
216− try: () =>
217− ctx.runMutation(runtimeInternal.controlPlane.removeStorageInstance, {
218− workspaceId: input.workspaceId,
219− storageInstanceId: input.storageInstanceId,
220− }),
221− catch: (cause) =>
222− toSourceStoreError("controlPlane.removeStorageInstance", cause),
223− }),
224− listStorageDirectory: (input) =>
225− Effect.tryPromise({
226− try: () =>
227− ctx.runQuery(runtimeInternal.controlPlane.listStorageDirectory, {
228− workspaceId: input.workspaceId,
229− storageInstanceId: input.storageInstanceId,
230− payload: input.payload,
231− }),
232− catch: (cause) =>
233− toSourceStoreError("controlPlane.listStorageDirectory", cause),
234− }),
235− readStorageFile: (input) =>
236− Effect.tryPromise({
237− try: () =>
238− ctx.runQuery(runtimeInternal.controlPlane.readStorageFile, {
239− workspaceId: input.workspaceId,
240− storageInstanceId: input.storageInstanceId,
241− payload: input.payload,
242− }),
243− catch: (cause) => toSourceStoreError("controlPlane.readStorageFile", cause),
244− }),
245− listStorageKv: (input) =>
246− Effect.tryPromise({
247− try: () =>
248− ctx.runQuery(runtimeInternal.controlPlane.listStorageKv, {
249− workspaceId: input.workspaceId,
250− storageInstanceId: input.storageInstanceId,
251− payload: input.payload,
252− }),
253− catch: (cause) => toSourceStoreError("controlPlane.listStorageKv", cause),
254− }),
255− queryStorageSql: (input) =>
256− Effect.tryPromise({
257− try: () =>
258− ctx.runMutation(runtimeInternal.controlPlane.queryStorageSql, {
259− workspaceId: input.workspaceId,
260− storageInstanceId: input.storageInstanceId,
261− payload: input.payload,
262− }),
263− catch: (cause) => toSourceStoreError("controlPlane.queryStorageSql", cause),
264− }),
265− listApprovals: (workspaceId) =>
266− Effect.tryPromise({
267− try: () =>
268− ctx.runQuery(runtimeInternal.controlPlane.listApprovals, {
269− workspaceId,
270− }),
271− catch: (cause) => toSourceStoreError("controlPlane.listApprovals", cause),
272− }),
273− resolveApproval: (input) =>
274− Effect.tryPromise({
275− try: () =>
276− ctx.runMutation(runtimeInternal.controlPlane.resolveApproval, {
277− workspaceId: input.workspaceId,
278− approvalId: input.approvalId,
279− payload: input.payload,
280− }),
281− catch: (cause) => toSourceStoreError("controlPlane.resolveApproval", cause),
282− }),
283− });
apps/convex/convex/control_plane/sources.ts+0−395
@@ -1,395 +0,0 @@
1−import { type UpsertSourcePayload } from "@executor-v2/management-api";
2−import { SourceSchema, type Source } from "@executor-v2/schema";
3−import { v } from "convex/values";
4−import * as Schema from "effect/Schema";
5−
6−import { internal } from "../_generated/api";
7−import {
8− internalAction,
9− internalMutation,
10− internalQuery,
11−} from "../_generated/server";
12−
13−const runtimeInternal = internal as any;
14−
15−const decodeSource = Schema.decodeUnknownSync(SourceSchema);
16−
17−const sourceStoreKey = (source: Source): string => `${source.workspaceId}:${source.id}`;
18−
19−const isRecord = (value: unknown): value is Record<string, unknown> =>
20− Boolean(value) && typeof value === "object" && !Array.isArray(value);
21−
22−const parseConfigJson = (configJson: string | undefined): Record<string, unknown> => {
23− if (typeof configJson !== "string") {
24− return {};
25− }
26−
27− try {
28− const parsed = JSON.parse(configJson) as unknown;
29− return isRecord(parsed) ? parsed : {};
30− } catch {
31− return {};
32− }
33−};
34−
35−const protectedHeaderNames = new Set([
36− "authorization",
37− "proxy-authorization",
38− "x-api-key",
39−]);
40−
41−const validateSourceConfigSecurity = (config: Record<string, unknown>): void => {
42− if (Object.prototype.hasOwnProperty.call(config, "auth")) {
43− throw new Error("Source config must not include auth; configure credentials via connections");
44− }
45−
46− if (Object.prototype.hasOwnProperty.call(config, "useCredentialedFetch")) {
47− throw new Error("Source config must not include useCredentialedFetch");
48− }
49−
50− const headers = config.headers;
51− if (!isRecord(headers)) {
52− return;
53− }
54−
55− for (const headerName of Object.keys(headers)) {
56− const normalized = headerName.trim().toLowerCase();
57− if (protectedHeaderNames.has(normalized)) {
58− throw new Error(
59− `Source config headers must not include '${headerName}'; use credential connections instead`,
60− );
61− }
62− }
63−};
64−
65−const normalizeHttpUrl = (value: string): string | null => {
66− const trimmed = value.trim();
67− if (trimmed.length === 0) {
68− return null;
69− }
70−
71− try {
72− const parsed = new URL(trimmed);
73− if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
74− return null;
75− }
76−
77− return parsed.toString();
78− } catch {
79− return null;
80− }
81−};
82−
83−const requireHttpUrl = (value: unknown, errorMessage: string): string => {
84− if (typeof value !== "string") {
85− throw new Error(errorMessage);
86− }
87−
88− const normalized = normalizeHttpUrl(value);
89− if (!normalized) {
90− throw new Error(errorMessage);
91− }
92−
93− return normalized;
94−};
95−
96−const buildOpenApiConfigJson = (
97− payload: UpsertSourcePayload,
98− baseUrl: string,
99−): string => {
100− const config = parseConfigJson(payload.configJson);
101− config.baseUrl = baseUrl;
102−
103− const specUrl = typeof config.specUrl === "string" ? config.specUrl.trim() : "";
104− if (specUrl.length === 0) {
105− config.specUrl = payload.endpoint;
106− }
107−
108− return JSON.stringify(config);
109−};
110−
111−const sortSources = (sources: ReadonlyArray<Source>): Array<Source> =>
112− [...sources].sort((left, right) => {
113− const leftName = left.name.toLowerCase();
114− const rightName = right.name.toLowerCase();
115−
116− if (leftName === rightName) {
117− return sourceStoreKey(left).localeCompare(sourceStoreKey(right));
118− }
119−
120− return leftName.localeCompare(rightName);
121− });
122−
123−const toSource = (document: Record<string, unknown>): Source => {
124− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...source } = document;
125− return decodeSource(source);
126−};
127−
128−const sourceKindValidator = v.union(
129− v.literal("mcp"),
130− v.literal("openapi"),
131− v.literal("graphql"),
132− v.literal("internal"),
133−);
134−
135−const sourceStatusValidator = v.union(
136− v.literal("draft"),
137− v.literal("probing"),
138− v.literal("auth_required"),
139− v.literal("connected"),
140− v.literal("error"),
141−);
142−
143−export const listSources = internalQuery({
144− args: {
145− workspaceId: v.string(),
146− },
147− handler: async (ctx, args): Promise<Array<Source>> => {
148− const documents = await ctx.db
149− .query("sources")
150− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
151− .collect();
152−
153− return sortSources(
154− documents.map((document) =>
155− toSource(document as unknown as Record<string, unknown>),
156− ),
157− );
158− },
159−});
160−
161−export const upsertSourceRecord = internalMutation({
162− args: {
163− workspaceId: v.string(),
164− payload: v.object({
165− id: v.optional(v.string()),
166− name: v.string(),
167− kind: sourceKindValidator,
168− endpoint: v.string(),
169− status: v.optional(sourceStatusValidator),
170− enabled: v.optional(v.boolean()),
171− configJson: v.optional(v.string()),
172− sourceHash: v.optional(v.union(v.string(), v.null())),
173− lastError: v.optional(v.union(v.string(), v.null())),
174− }),
175− },
176− handler: async (ctx, args): Promise<Source> => {
177− const now = Date.now();
178− const sourceId = args.payload.id ?? `src_${crypto.randomUUID()}`;
179−
180− const existing = await ctx.db
181− .query("sources")
182− .withIndex("by_domainId", (q) => q.eq("id", sourceId))
183− .unique();
184−
185− const existingInWorkspace =
186− existing && existing.workspaceId === args.workspaceId ? existing : null;
187−
188− const payload = args.payload as UpsertSourcePayload;
189−
190− const configJson = (() => {
191− const parsedConfig = parseConfigJson(payload.configJson);
192− validateSourceConfigSecurity(parsedConfig);
193−
194− if (payload.kind !== "openapi") {
195− return JSON.stringify(parsedConfig);
196− }
197−
198− const configuredBaseUrl = requireHttpUrl(
199− parsedConfig.baseUrl,
200− "OpenAPI source requires configJson.baseUrl",
201− );
202−
203− return buildOpenApiConfigJson(
204− {
205− ...payload,
206− configJson: JSON.stringify(parsedConfig),
207− },
208− configuredBaseUrl,
209− );
210− })();
211−
212− const source = decodeSource({
213− id: sourceId,
214− workspaceId: args.workspaceId,
215− name: payload.name,
216− kind: payload.kind,
217− endpoint: payload.endpoint,
218− status: payload.status ?? "draft",
219− enabled: payload.enabled ?? true,
220− configJson,
221− sourceHash: payload.sourceHash ?? null,
222− lastError: payload.lastError ?? null,
223− createdAt: existingInWorkspace?.createdAt ?? now,
224− updatedAt: now,
225− });
226−
227− if (existingInWorkspace) {
228− await ctx.db.patch(existingInWorkspace._id, source);
229− } else {
230− await ctx.db.insert("sources", source);
231− }
232−
233− return source;
234− },
235−});
236−
237−export const upsertSource = internalAction({
238− args: {
239− workspaceId: v.string(),
240− payload: v.object({
241− id: v.optional(v.string()),
242− name: v.string(),
243− kind: sourceKindValidator,
244− endpoint: v.string(),
245− status: v.optional(sourceStatusValidator),
246− enabled: v.optional(v.boolean()),
247− configJson: v.optional(v.string()),
248− sourceHash: v.optional(v.union(v.string(), v.null())),
249− lastError: v.optional(v.union(v.string(), v.null())),
250− }),
251− },
252− handler: async (ctx, args): Promise<Source> => {
253− const shouldIngest = args.payload.status !== "draft";
254− let payload = args.payload as UpsertSourcePayload;
255−
256− if (payload.kind === "openapi") {
257− const config = parseConfigJson(payload.configJson);
258− validateSourceConfigSecurity(config);
259− let resolvedBaseUrl = typeof config.baseUrl === "string"
260− ? normalizeHttpUrl(config.baseUrl)
261− : null;
262−
263− if (!resolvedBaseUrl) {
264− const derived = await ctx.runAction(
265− runtimeInternal.control_plane.openapi_ingest.deriveOpenApiBaseUrl,
266− {
267− specUrl: payload.endpoint,
268− },
269− );
270− resolvedBaseUrl = requireHttpUrl(
271− derived?.baseUrl,
272− "OpenAPI source requires configJson.baseUrl",
273− );
274− }
275− const baseUrl = requireHttpUrl(
276− resolvedBaseUrl,
277− "OpenAPI source requires configJson.baseUrl",
278− );
279−
280− payload = {
281− ...payload,
282− configJson: buildOpenApiConfigJson(payload, baseUrl),
283− };
284− } else {
285− validateSourceConfigSecurity(parseConfigJson(payload.configJson));
286− }
287−
288− const source = await ctx.runMutation(runtimeInternal.control_plane.sources.upsertSourceRecord, {
289− workspaceId: args.workspaceId,
290− payload: {
291− ...payload,
292− status: payload.status ?? (shouldIngest ? "probing" : "draft"),
293− },
294− });
295−
296− if (shouldIngest && source.kind !== "internal") {
297− if (source.kind === "openapi") {
298− await ctx.runAction(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {
299− workspaceId: source.workspaceId,
300− sourceId: source.id,
301− });
302− } else {
303− await ctx.scheduler.runAfter(
304− 0,
305− runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact,
306− {
307− workspaceId: source.workspaceId,
308− sourceId: source.id,
309− },
310− );
311− }
312− }
313−
314− return source;
315− },
316−});
317−
318−export const getSourceForIngest = internalQuery({
319− args: {
320− sourceId: v.string(),
321− },
322− handler: async (ctx, args): Promise<Source | null> => {
323− const sourceRow = await ctx.db
324− .query("sources")
325− .withIndex("by_domainId", (q) => q.eq("id", args.sourceId))
326− .unique();
327−
328− if (!sourceRow) {
329− return null;
330− }
331−
332− return toSource(sourceRow as unknown as Record<string, unknown>);
333− },
334−});
335−
336−export const setSourceIngestState = internalMutation({
337− args: {
338− sourceId: v.string(),
339− status: sourceStatusValidator,
340− sourceHash: v.optional(v.union(v.string(), v.null())),
341− lastError: v.optional(v.union(v.string(), v.null())),
342− },
343− handler: async (ctx, args): Promise<void> => {
344− const sourceRow = await ctx.db
345− .query("sources")
346− .withIndex("by_domainId", (q) => q.eq("id", args.sourceId))
347− .unique();
348−
349− if (!sourceRow) {
350− return;
351− }
352−
353− await ctx.db.patch(sourceRow._id, {
354− status: args.status,
355− sourceHash: args.sourceHash !== undefined ? args.sourceHash : sourceRow.sourceHash,
356− lastError: args.lastError !== undefined ? args.lastError : sourceRow.lastError,
357− updatedAt: Date.now(),
358− });
359− },
360−});
361−
362−export const removeSource = internalMutation({
363− args: {
364− workspaceId: v.string(),
365− sourceId: v.string(),
366− },
367− handler: async (
368− ctx,
369− args,
370− ): Promise<{
371− removed: boolean;
372− }> => {
373− const existing = await ctx.db
374− .query("sources")
375− .withIndex("by_domainId", (q) => q.eq("id", args.sourceId))
376− .unique();
377−
378− if (!existing || existing.workspaceId !== args.workspaceId) {
379− return { removed: false };
380− }
381−
382− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.removeSourceBindingsAndIndex, {
383− workspaceId: args.workspaceId,
384− sourceId: args.sourceId,
385− });
386−
387− await ctx.runMutation(runtimeInternal.control_plane.credentials.removeSourceAuthBindings, {
388− sourceId: args.sourceId,
389− });
390−
391− await ctx.db.delete(existing._id);
392−
393− return { removed: true };
394− },
395−});
apps/convex/convex/control_plane/storage.ts+0−1068
@@ -1,1068 +0,0 @@
1−import {
2− type ListStorageDirectoryResult,
3− type ListStorageKvResult,
4− type OpenStorageInstancePayload,
5− type QueryStorageSqlResult,
6− type ReadStorageFileResult,
7−} from "@executor-v2/management-api";
8−import { StorageInstanceSchema, type StorageInstance } from "@executor-v2/schema";
9−import { v } from "convex/values";
10−import * as Schema from "effect/Schema";
11−
12−import {
13− internalQuery,
14− internalMutation,
15− type MutationCtx,
16− type QueryCtx,
17−} from "../_generated/server";
18−
19−const decodeStorageInstance = Schema.decodeUnknownSync(StorageInstanceSchema);
20−
21−const StorageFileEntrySchema = Schema.Struct({
22− id: Schema.String,
23− storageInstanceId: Schema.String,
24− path: Schema.String,
25− contentBase64: Schema.String,
26− sizeBytes: Schema.Number,
27− updatedAt: Schema.Number,
28−});
29−
30−type StorageFileEntry = typeof StorageFileEntrySchema.Type;
31−const decodeStorageFileEntry = Schema.decodeUnknownSync(StorageFileEntrySchema);
32−
33−const StorageKvEntrySchema = Schema.Struct({
34− id: Schema.String,
35− storageInstanceId: Schema.String,
36− key: Schema.String,
37− valueJson: Schema.String,
38− updatedAt: Schema.Number,
39−});
40−
41−type StorageKvEntry = typeof StorageKvEntrySchema.Type;
42−const decodeStorageKvEntry = Schema.decodeUnknownSync(StorageKvEntrySchema);
43−
44−const StorageSqlKvEntrySchema = Schema.Struct({
45− id: Schema.String,
46− storageInstanceId: Schema.String,
47− key: Schema.String,
48− value: Schema.String,
49− updatedAt: Schema.Number,
50−});
51−
52−type StorageSqlKvEntry = typeof StorageSqlKvEntrySchema.Type;
53−const decodeStorageSqlKvEntry = Schema.decodeUnknownSync(StorageSqlKvEntrySchema);
54−
55−const DEFAULT_KV_LIMIT = 100;
56−const MAX_KV_LIMIT = 1000;
57−const DEFAULT_SQL_MAX_ROWS = 200;
58−const MAX_SQL_MAX_ROWS = 5000;
59−
60−const stripConvexSystemFields = (
61− value: Record<string, unknown>,
62−): Record<string, unknown> => {
63− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
64− return rest;
65−};
66−
67−const toStorageInstance = (document: Record<string, unknown>): StorageInstance =>
68− decodeStorageInstance(stripConvexSystemFields(document));
69−
70−const toStorageFileEntry = (document: Record<string, unknown>): StorageFileEntry =>
71− decodeStorageFileEntry(stripConvexSystemFields(document));
72−
73−const toStorageKvEntry = (document: Record<string, unknown>): StorageKvEntry =>
74− decodeStorageKvEntry(stripConvexSystemFields(document));
75−
76−const toStorageSqlKvEntry = (document: Record<string, unknown>): StorageSqlKvEntry =>
77− decodeStorageSqlKvEntry(stripConvexSystemFields(document));
78−
79−const storageScopeTypeValidator = v.union(
80− v.literal("scratch"),
81− v.literal("account"),
82− v.literal("workspace"),
83− v.literal("organization"),
84−);
85−
86−const storageDurabilityValidator = v.union(
87− v.literal("ephemeral"),
88− v.literal("durable"),
89−);
90−
91−const storageProviderValidator = v.union(
92− v.literal("agentfs-local"),
93− v.literal("agentfs-cloudflare"),
94−);
95−
96−const readStorageEncodingValidator = v.union(
97− v.literal("utf8"),
98− v.literal("base64"),
99−);
100−
101−const sortStorageInstances = (
102− storageInstances: ReadonlyArray<StorageInstance>,
103−): Array<StorageInstance> =>
104− [...storageInstances].sort((left, right) => {
105− if (left.updatedAt !== right.updatedAt) {
106− return right.updatedAt - left.updatedAt;
107− }
108−
109− return left.id.localeCompare(right.id);
110− });
111−
112−const resolveWorkspaceOrganizationId = async (
113− ctx: QueryCtx | MutationCtx,
114− workspaceId: string,
115−): Promise<string> => {
116− const workspace = await ctx.db
117− .query("workspaces")
118− .withIndex("by_domainId", (q) => q.eq("id", workspaceId))
119− .unique();
120−
121− if (!workspace) {
122− throw new Error(`Workspace not found: ${workspaceId}`);
123− }
124−
125− return workspace.organizationId;
126−};
127−
128−const canAccessStorageInstance = (
129− storageInstance: StorageInstance,
130− input: {
131− workspaceId: string;
132− organizationId: string;
133− },
134−): boolean =>
135− storageInstance.workspaceId === input.workspaceId
136− || (
137− storageInstance.workspaceId === null
138− && storageInstance.organizationId === input.organizationId
139− );
140−
141−const normalizeStoragePath = (pathValue: string): string => {
142− const trimmed = pathValue.trim().replaceAll("\\", "/");
143− const prefixed = trimmed.length > 0
144− ? (trimmed.startsWith("/") ? trimmed : `/${trimmed}`)
145− : "/";
146−
147− const parts = prefixed.split("/");
148− const normalized: Array<string> = [];
149−
150− for (const part of parts) {
151− if (part.length === 0 || part === ".") {
152− continue;
153− }
154−
155− if (part === "..") {
156− if (normalized.length === 0) {
157− throw new Error("Path escapes storage root");
158− }
159− normalized.pop();
160− continue;
161− }
162−
163− normalized.push(part);
164− }
165−
166− if (normalized.length === 0) {
167− return "/";
168− }
169−
170− return `/${normalized.join("/")}`;
171−};
172−
173−const encodeUtf8Base64 = (value: string): string => {
174− if (typeof Buffer !== "undefined") {
175− return Buffer.from(value, "utf8").toString("base64");
176− }
177−
178− const bytes = new TextEncoder().encode(value);
179− let binary = "";
180− for (const byte of bytes) {
181− binary += String.fromCharCode(byte);
182− }
183− return btoa(binary);
184−};
185−
186−const decodeBase64Utf8 = (value: string): string => {
187− if (typeof Buffer !== "undefined") {
188− return Buffer.from(value, "base64").toString("utf8");
189− }
190−
191− const binary = atob(value);
192− const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
193− return new TextDecoder().decode(bytes);
194−};
195−
196−const parseJsonOrString = (value: string): unknown => {
197− try {
198− return JSON.parse(value) as unknown;
199− } catch {
200− return value;
201− }
202−};
203−
204−const resolveAccessibleStorageInstance = async (
205− ctx: QueryCtx | MutationCtx,
206− input: {
207− workspaceId: string;
208− storageInstanceId: string;
209− },
210−): Promise<StorageInstance | null> => {
211− const organizationId = await resolveWorkspaceOrganizationId(ctx, input.workspaceId);
212− const storageInstanceRow = await ctx.db
213− .query("storageInstances")
214− .withIndex("by_domainId", (q) => q.eq("id", input.storageInstanceId))
215− .unique();
216−
217− if (!storageInstanceRow) {
218− return null;
219− }
220−
221− const storageInstance = toStorageInstance(
222− storageInstanceRow as unknown as Record<string, unknown>,
223− );
224−
225− if (
226− !canAccessStorageInstance(storageInstance, {
227− workspaceId: input.workspaceId,
228− organizationId,
229− })
230− ) {
231− return null;
232− }
233−
234− return storageInstance;
235−};
236−
237−export const upsertStorageFileEntry = internalMutation({
238− args: {
239− workspaceId: v.string(),
240− storageInstanceId: v.string(),
241− payload: v.object({
242− path: v.string(),
243− content: v.string(),
244− }),
245− },
246− handler: async (ctx, args): Promise<StorageFileEntry> => {
247− const storageInstance = await resolveAccessibleStorageInstance(ctx, {
248− workspaceId: args.workspaceId,
249− storageInstanceId: args.storageInstanceId,
250− });
251−
252− if (!storageInstance) {
253− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
254− }
255−
256− const normalizedPath = normalizeStoragePath(args.payload.path);
257− const now = Date.now();
258− const contentBase64 = encodeUtf8Base64(args.payload.content);
259− const sizeBytes = args.payload.content.length;
260−
261− const existingRow = await ctx.db
262− .query("storageFileEntries")
263− .withIndex("by_storageInstanceId_path", (q) =>
264− q.eq("storageInstanceId", args.storageInstanceId).eq("path", normalizedPath)
265− )
266− .unique();
267−
268− const nextEntry = decodeStorageFileEntry({
269− id:
270− (existingRow
271− ? toStorageFileEntry(existingRow as unknown as Record<string, unknown>).id
272− : `storage_file_${crypto.randomUUID()}`),
273− storageInstanceId: args.storageInstanceId,
274− path: normalizedPath,
275− contentBase64,
276− sizeBytes,
277− updatedAt: now,
278− });
279−
280− if (existingRow) {
281− await ctx.db.patch(existingRow._id, nextEntry);
282− } else {
283− await ctx.db.insert("storageFileEntries", nextEntry);
284− }
285−
286− return nextEntry;
287− },
288−});
289−
290−export const upsertStorageKvEntry = internalMutation({
291− args: {
292− workspaceId: v.string(),
293− storageInstanceId: v.string(),
294− payload: v.object({
295− key: v.string(),
296− valueJson: v.string(),
297− }),
298− },
299− handler: async (ctx, args): Promise<StorageKvEntry> => {
300− const storageInstance = await resolveAccessibleStorageInstance(ctx, {
301− workspaceId: args.workspaceId,
302− storageInstanceId: args.storageInstanceId,
303− });
304−
305− if (!storageInstance) {
306− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
307− }
308−
309− const now = Date.now();
310−
311− const existingRow = await ctx.db
312− .query("storageKvEntries")
313− .withIndex("by_storageInstanceId_key", (q) =>
314− q.eq("storageInstanceId", args.storageInstanceId).eq("key", args.payload.key)
315− )
316− .unique();
317−
318− const nextEntry = decodeStorageKvEntry({
319− id:
320− (existingRow
321− ? toStorageKvEntry(existingRow as unknown as Record<string, unknown>).id
322− : `storage_kv_${crypto.randomUUID()}`),
323− storageInstanceId: args.storageInstanceId,
324− key: args.payload.key,
325− valueJson: args.payload.valueJson,
326− updatedAt: now,
327− });
328−
329− if (existingRow) {
330− await ctx.db.patch(existingRow._id, nextEntry);
331− } else {
332− await ctx.db.insert("storageKvEntries", nextEntry);
333− }
334−
335− return nextEntry;
336− },
337−});
338−
339−const decodeSqlStringLiteral = (value: string): string => value.replaceAll("''", "'");
340−
341−const parseSqlWhere = (
342− whereClause: string | undefined,
343−): ((key: string) => boolean) => {
344− if (whereClause === undefined) {
345− return () => true;
346− }
347−
348− const equalsMatch = /^key\s*=\s*'((?:''|[^'])*)'$/i.exec(whereClause.trim());
349− if (equalsMatch) {
350− const expectedKey = decodeSqlStringLiteral(equalsMatch[1] ?? "");
351− return (key) => key === expectedKey;
352− }
353−
354− const likeMatch = /^key\s+like\s+'((?:''|[^'])*)'$/i.exec(whereClause.trim());
355− if (likeMatch) {
356− const pattern = decodeSqlStringLiteral(likeMatch[1] ?? "");
357− if (!pattern.endsWith("%") || pattern.slice(0, -1).includes("%")) {
358− throw new Error("Only trailing % LIKE patterns are supported");
359− }
360−
361− const prefix = pattern.slice(0, -1);
362− return (key) => key.startsWith(prefix);
363− }
364−
365− throw new Error("Unsupported SQL WHERE clause for kv_store");
366−};
367−
368−const parseSelectColumns = (columnsClause: string): Array<"key" | "value"> => {
369− const normalized = columnsClause.trim().toLowerCase();
370− if (normalized === "*") {
371− return ["key", "value"];
372− }
373−
374− const columns = normalized
375− .split(",")
376− .map((column) => column.trim())
377− .filter((column) => column.length > 0);
378−
379− if (columns.length === 0) {
380− throw new Error("SQL SELECT columns are required");
381− }
382−
383− const parsedColumns: Array<"key" | "value"> = [];
384−
385− for (const column of columns) {
386− if (column !== "key" && column !== "value") {
387− throw new Error("Only key/value columns are supported for kv_store");
388− }
389−
390− if (!parsedColumns.includes(column)) {
391− parsedColumns.push(column);
392− }
393− }
394−
395− return parsedColumns;
396−};
397−
398−type SqlWrite =
399− | {
400− type: "upsert";
401− key: string;
402− value: string;
403− insertOnly: boolean;
404− }
405− | {
406− type: "delete";
407− key: string;
408− };
409−
410−const executeSqlForStorage = (
411− sqlText: string,
412− sqlRows: ReadonlyArray<StorageSqlKvEntry>,
413− maxRows: number,
414−): {
415− result: QueryStorageSqlResult;
416− writes: Array<SqlWrite>;
417−} => {
418− const trimmed = sqlText.trim();
419−
420− if (/^create\s+table\s+if\s+not\s+exists\s+kv_store\b/i.test(trimmed)) {
421− return {
422− result: {
423− rows: [],
424− columns: [],
425− rowCount: 0,
426− },
427− writes: [],
428− };
429− }
430−
431− if (/^(begin|commit|rollback)\b/i.test(trimmed)) {
432− return {
433− result: {
434− rows: [],
435− columns: [],
436− rowCount: 0,
437− },
438− writes: [],
439− };
440− }
441−
442− const sortedRows = [...sqlRows].sort((left, right) => left.key.localeCompare(right.key));
443−
444− const selectMatch = /^select\s+(.+?)\s+from\s+kv_store(?:\s+where\s+(.+?))?(?:\s+limit\s+(\d+))?\s*;?$/i.exec(trimmed);
445− if (selectMatch) {
446− const columns = parseSelectColumns(selectMatch[1] ?? "");
447− const where = parseSqlWhere(selectMatch[2]);
448− const sqlLimit =
449− selectMatch[3] !== undefined
450− ? Math.max(1, Math.min(maxRows, Number.parseInt(selectMatch[3], 10)))
451− : maxRows;
452−
453− const rows = sortedRows
454− .filter((row) => where(row.key))
455− .slice(0, sqlLimit)
456− .map((row) => {
457− const resultRow: Record<string, unknown> = {};
458−
459− for (const column of columns) {
460− if (column === "key") {
461− resultRow.key = row.key;
462− } else {
463− resultRow.value = row.value;
464− }
465− }
466−
467− return resultRow;
468− });
469−
470− return {
471− result: {
472− rows,
473− columns,
474− rowCount: rows.length,
475− },
476− writes: [],
477− };
478− }
479−
480− const insertMatch = /^insert\s+into\s+kv_store\s*\(\s*key\s*,\s*value\s*\)\s*values\s*\(\s*'((?:''|[^'])*)'\s*,\s*'((?:''|[^'])*)'\s*\)\s*;?$/i.exec(trimmed);
481− if (insertMatch) {
482− return {
483− result: {
484− rows: [],
485− columns: [],
486− rowCount: 0,
487− },
488− writes: [{
489− type: "upsert",
490− key: decodeSqlStringLiteral(insertMatch[1] ?? ""),
491− value: decodeSqlStringLiteral(insertMatch[2] ?? ""),
492− insertOnly: true,
493− }],
494− };
495− }
496−
497− const replaceMatch = /^replace\s+into\s+kv_store\s*\(\s*key\s*,\s*value\s*\)\s*values\s*\(\s*'((?:''|[^'])*)'\s*,\s*'((?:''|[^'])*)'\s*\)\s*;?$/i.exec(trimmed);
498− if (replaceMatch) {
499− return {
500− result: {
501− rows: [],
502− columns: [],
503− rowCount: 0,
504− },
505− writes: [{
506− type: "upsert",
507− key: decodeSqlStringLiteral(replaceMatch[1] ?? ""),
508− value: decodeSqlStringLiteral(replaceMatch[2] ?? ""),
509− insertOnly: false,
510− }],
511− };
512− }
513−
514− const updateMatch = /^update\s+kv_store\s+set\s+value\s*=\s*'((?:''|[^'])*)'\s+where\s+key\s*=\s*'((?:''|[^'])*)'\s*;?$/i.exec(trimmed);
515− if (updateMatch) {
516− return {
517− result: {
518− rows: [],
519− columns: [],
520− rowCount: 0,
521− },
522− writes: [{
523− type: "upsert",
524− key: decodeSqlStringLiteral(updateMatch[2] ?? ""),
525− value: decodeSqlStringLiteral(updateMatch[1] ?? ""),
526− insertOnly: false,
527− }],
528− };
529− }
530−
531− const deleteMatch = /^delete\s+from\s+kv_store\s+where\s+key\s*=\s*'((?:''|[^'])*)'\s*;?$/i.exec(trimmed);
532− if (deleteMatch) {
533− return {
534− result: {
535− rows: [],
536− columns: [],
537− rowCount: 0,
538− },
539− writes: [{
540− type: "delete",
541− key: decodeSqlStringLiteral(deleteMatch[1] ?? ""),
542− }],
543− };
544− }
545−
546− throw new Error("Unsupported SQL statement for Convex storage inspector");
547−};
548−
549−export const listStorageInstances = internalQuery({
550− args: {
551− workspaceId: v.string(),
552− },
553− handler: async (ctx, args): Promise<Array<StorageInstance>> => {
554− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
555−
556− const workspaceRows = await ctx.db
557− .query("storageInstances")
558− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
559− .collect();
560−
561− const organizationRows = await ctx.db
562− .query("storageInstances")
563− .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId))
564− .collect();
565−
566− const storageInstances = [...workspaceRows, ...organizationRows]
567− .map((row) =>
568− toStorageInstance(row as unknown as Record<string, unknown>),
569− )
570− .filter((storageInstance) =>
571− canAccessStorageInstance(storageInstance, {
572− workspaceId: args.workspaceId,
573− organizationId,
574− })
575− );
576−
577− const uniqueStorageInstances = Array.from(
578− new Map(storageInstances.map((storageInstance) => [storageInstance.id, storageInstance]))
579− .values(),
580− );
581−
582− return sortStorageInstances(uniqueStorageInstances);
583− },
584−});
585−
586−export const openStorageInstance = internalMutation({
587− args: {
588− workspaceId: v.string(),
589− payload: v.object({
590− scopeType: storageScopeTypeValidator,
591− durability: storageDurabilityValidator,
592− provider: v.optional(storageProviderValidator),
593− purpose: v.optional(v.string()),
594− ttlHours: v.optional(v.number()),
595− accountId: v.optional(v.string()),
596− sessionId: v.optional(v.string()),
597− }),
598− },
599− handler: async (ctx, args): Promise<StorageInstance> => {
600− const payload = args.payload as OpenStorageInstancePayload;
601−
602− if (payload.scopeType === "account" && payload.accountId === undefined) {
603− throw new Error("Account scope storage requires accountId");
604− }
605−
606− const organizationId = await resolveWorkspaceOrganizationId(ctx, args.workspaceId);
607− const now = Date.now();
608− const storageInstanceId = `storage_${crypto.randomUUID()}`;
609− const ttlHours =
610− payload.ttlHours !== undefined && Number.isFinite(payload.ttlHours)
611− ? Math.max(1, Math.floor(payload.ttlHours))
612− : 24;
613−
614− const nextStorageInstance = decodeStorageInstance({
615− id: storageInstanceId,
616− scopeType: payload.scopeType,
617− durability: payload.durability,
618− status: "active",
619− provider: payload.provider ?? "agentfs-local",
620− backendKey: `convex:${storageInstanceId}`,
621− organizationId,
622− workspaceId:
623− payload.scopeType === "workspace" || payload.scopeType === "scratch"
624− ? args.workspaceId
625− : null,
626− accountId: payload.scopeType === "account" ? (payload.accountId ?? null) : null,
627− createdByAccountId: payload.accountId ?? null,
628− purpose:
629− payload.purpose !== undefined && payload.purpose.trim().length > 0
630− ? payload.purpose.trim()
631− : null,
632− sizeBytes: null,
633− fileCount: null,
634− createdAt: now,
635− updatedAt: now,
636− lastSeenAt: now,
637− closedAt: null,
638− expiresAt:
639− payload.durability === "ephemeral"
640− ? now + ttlHours * 3_600_000
641− : null,
642− });
643−
644− await ctx.db.insert("storageInstances", nextStorageInstance);
645−
646− return nextStorageInstance;
647− },
648−});
649−
650−export const closeStorageInstance = internalMutation({
651− args: {
652− workspaceId: v.string(),
653− storageInstanceId: v.string(),
654− },
655− handler: async (ctx, args): Promise<StorageInstance> => {
656− const existingStorageInstance = await resolveAccessibleStorageInstance(ctx, {
657− workspaceId: args.workspaceId,
658− storageInstanceId: args.storageInstanceId,
659− });
660−
661− if (!existingStorageInstance) {
662− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
663− }
664−
665− const existingRow = await ctx.db
666− .query("storageInstances")
667− .withIndex("by_domainId", (q) => q.eq("id", args.storageInstanceId))
668− .unique();
669−
670− if (!existingRow) {
671− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
672− }
673−
674− const now = Date.now();
675− const nextStorageInstance = decodeStorageInstance({
676− ...existingStorageInstance,
677− status: "closed",
678− updatedAt: now,
679− lastSeenAt: now,
680− closedAt: existingStorageInstance.closedAt ?? now,
681− });
682−
683− await ctx.db.patch(existingRow._id, nextStorageInstance);
684−
685− return nextStorageInstance;
686− },
687−});
688−
689−export const removeStorageInstance = internalMutation({
690− args: {
691− workspaceId: v.string(),
692− storageInstanceId: v.string(),
693− },
694− handler: async (
695− ctx,
696− args,
697− ): Promise<{
698− removed: boolean;
699− }> => {
700− const existingStorageInstance = await resolveAccessibleStorageInstance(ctx, {
701− workspaceId: args.workspaceId,
702− storageInstanceId: args.storageInstanceId,
703− });
704−
705− if (!existingStorageInstance) {
706− return { removed: false };
707− }
708−
709− const storageInstanceRow = await ctx.db
710− .query("storageInstances")
711− .withIndex("by_domainId", (q) => q.eq("id", args.storageInstanceId))
712− .unique();
713−
714− if (!storageInstanceRow) {
715− return { removed: false };
716− }
717−
718− const storageFileRows = await ctx.db
719− .query("storageFileEntries")
720− .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId))
721− .collect();
722−
723− for (const storageFileRow of storageFileRows) {
724− await ctx.db.delete(storageFileRow._id);
725− }
726−
727− const storageKvRows = await ctx.db
728− .query("storageKvEntries")
729− .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId))
730− .collect();
731−
732− for (const storageKvRow of storageKvRows) {
733− await ctx.db.delete(storageKvRow._id);
734− }
735−
736− const storageSqlKvRows = await ctx.db
737− .query("storageSqlKvEntries")
738− .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId))
739− .collect();
740−
741− for (const storageSqlKvRow of storageSqlKvRows) {
742− await ctx.db.delete(storageSqlKvRow._id);
743− }
744−
745− await ctx.db.delete(storageInstanceRow._id);
746−
747− return { removed: true };
748− },
749−});
750−
751−export const listStorageDirectory = internalQuery({
752− args: {
753− workspaceId: v.string(),
754− storageInstanceId: v.string(),
755− payload: v.object({
756− path: v.string(),
757− }),
758− },
759− handler: async (ctx, args): Promise<ListStorageDirectoryResult> => {
760− const storageInstance = await resolveAccessibleStorageInstance(ctx, {
761− workspaceId: args.workspaceId,
762− storageInstanceId: args.storageInstanceId,
763− });
764−
765− if (!storageInstance) {
766− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
767− }
768−
769− const normalizedPath = normalizeStoragePath(args.payload.path);
770−
771− const storageFileRows = await ctx.db
772− .query("storageFileEntries")
773− .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId))
774− .collect();
775−
776− const storageFiles = storageFileRows.map((row) =>
777− toStorageFileEntry(row as unknown as Record<string, unknown>),
778− );
779−
780− const exactFile = storageFiles.find((storageFile) => storageFile.path === normalizedPath);
781− if (exactFile) {
782− throw new Error(`Cannot list a file path as directory: ${normalizedPath}`);
783− }
784−
785− const prefix = normalizedPath === "/" ? "/" : `${normalizedPath}/`;
786− const directoryByPath = new Map<string, {
787− name: string;
788− path: string;
789− kind: "directory";
790− sizeBytes: null;
791− updatedAt: number;
792− }>();
793− const fileEntries: Array<{
794− name: string;
795− path: string;
796− kind: "file";
797− sizeBytes: number;
798− updatedAt: number;
799− }> = [];
800−
801− for (const storageFile of storageFiles) {
802− if (!storageFile.path.startsWith(prefix)) {
803− continue;
804− }
805−
806− const remainder = storageFile.path.slice(prefix.length);
807− if (remainder.length === 0) {
808− continue;
809− }
810−
811− const separatorIndex = remainder.indexOf("/");
812− if (separatorIndex < 0) {
813− const fileName = remainder;
814− fileEntries.push({
815− name: fileName,
816− path: storageFile.path,
817− kind: "file",
818− sizeBytes: storageFile.sizeBytes,
819− updatedAt: storageFile.updatedAt,
820− });
821− continue;
822− }
823−
824− const directoryName = remainder.slice(0, separatorIndex);
825− const directoryPath = normalizedPath === "/"
826− ? `/${directoryName}`
827− : `${normalizedPath}/${directoryName}`;
828− const existingDirectory = directoryByPath.get(directoryPath);
829−
830− if (!existingDirectory) {
831− directoryByPath.set(directoryPath, {
832− name: directoryName,
833− path: directoryPath,
834− kind: "directory",
835− sizeBytes: null,
836− updatedAt: storageFile.updatedAt,
837− });
838− continue;
839− }
840−
841− if (storageFile.updatedAt > existingDirectory.updatedAt) {
842− directoryByPath.set(directoryPath, {
843− ...existingDirectory,
844− updatedAt: storageFile.updatedAt,
845− });
846− }
847− }
848−
849− if (
850− normalizedPath !== "/"
851− && directoryByPath.size === 0
852− && fileEntries.length === 0
853− ) {
854− throw new Error(`Directory not found: ${normalizedPath}`);
855− }
856−
857− const directoryEntries = [...directoryByPath.values()].sort((left, right) =>
858− left.name.localeCompare(right.name)
859− );
860− const sortedFileEntries = [...fileEntries].sort((left, right) =>
861− left.name.localeCompare(right.name)
862− );
863−
864− return {
865− path: normalizedPath,
866− entries: [...directoryEntries, ...sortedFileEntries],
867− };
868− },
869−});
870−
871−export const readStorageFile = internalQuery({
872− args: {
873− workspaceId: v.string(),
874− storageInstanceId: v.string(),
875− payload: v.object({
876− path: v.string(),
877− encoding: v.optional(readStorageEncodingValidator),
878− }),
879− },
880− handler: async (ctx, args): Promise<ReadStorageFileResult> => {
881− const storageInstance = await resolveAccessibleStorageInstance(ctx, {
882− workspaceId: args.workspaceId,
883− storageInstanceId: args.storageInstanceId,
884− });
885−
886− if (!storageInstance) {
887− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
888− }
889−
890− const normalizedPath = normalizeStoragePath(args.payload.path);
891− const storageFileRow = await ctx.db
892− .query("storageFileEntries")
893− .withIndex("by_storageInstanceId_path", (q) =>
894− q.eq("storageInstanceId", args.storageInstanceId).eq("path", normalizedPath)
895− )
896− .unique();
897−
898− if (!storageFileRow) {
899− throw new Error(`Storage file not found: ${normalizedPath}`);
900− }
901−
902− const storageFile = toStorageFileEntry(storageFileRow as unknown as Record<string, unknown>);
903− const encoding = args.payload.encoding ?? "utf8";
904−
905− return {
906− path: normalizedPath,
907− encoding,
908− content: encoding === "base64"
909− ? storageFile.contentBase64
910− : decodeBase64Utf8(storageFile.contentBase64),
911− bytes: storageFile.sizeBytes,
912− };
913− },
914−});
915−
916−export const listStorageKv = internalQuery({
917− args: {
918− workspaceId: v.string(),
919− storageInstanceId: v.string(),
920− payload: v.object({
921− prefix: v.optional(v.string()),
922− limit: v.optional(v.number()),
923− }),
924− },
925− handler: async (ctx, args): Promise<ListStorageKvResult> => {
926− const storageInstance = await resolveAccessibleStorageInstance(ctx, {
927− workspaceId: args.workspaceId,
928− storageInstanceId: args.storageInstanceId,
929− });
930−
931− if (!storageInstance) {
932− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
933− }
934−
935− const prefix = args.payload.prefix ?? "";
936− const requestedLimit =
937− args.payload.limit !== undefined && Number.isFinite(args.payload.limit)
938− ? Math.floor(args.payload.limit)
939− : DEFAULT_KV_LIMIT;
940− const limit = Math.max(1, Math.min(MAX_KV_LIMIT, requestedLimit));
941−
942− const storageKvRows = await ctx.db
943− .query("storageKvEntries")
944− .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId))
945− .collect();
946−
947− const items = storageKvRows
948− .map((row) => toStorageKvEntry(row as unknown as Record<string, unknown>))
949− .filter((entry) => entry.key.startsWith(prefix))
950− .sort((left, right) => left.key.localeCompare(right.key))
951− .slice(0, limit)
952− .map((entry) => ({
953− key: entry.key,
954− value: parseJsonOrString(entry.valueJson),
955− }));
956−
957− return {
958− items,
959− };
960− },
961−});
962−
963−export const queryStorageSql = internalMutation({
964− args: {
965− workspaceId: v.string(),
966− storageInstanceId: v.string(),
967− payload: v.object({
968− sql: v.string(),
969− maxRows: v.optional(v.number()),
970− }),
971− },
972− handler: async (ctx, args): Promise<QueryStorageSqlResult> => {
973− const storageInstance = await resolveAccessibleStorageInstance(ctx, {
974− workspaceId: args.workspaceId,
975− storageInstanceId: args.storageInstanceId,
976− });
977−
978− if (!storageInstance) {
979− throw new Error(`Storage instance not found: ${args.storageInstanceId}`);
980− }
981−
982− const sqlText = args.payload.sql.trim();
983− if (sqlText.length === 0) {
984− throw new Error("SQL query is required");
985− }
986−
987− const requestedMaxRows =
988− args.payload.maxRows !== undefined && Number.isFinite(args.payload.maxRows)
989− ? Math.floor(args.payload.maxRows)
990− : DEFAULT_SQL_MAX_ROWS;
991− const maxRows = Math.max(1, Math.min(MAX_SQL_MAX_ROWS, requestedMaxRows));
992−
993− const sqlRowsRaw = await ctx.db
994− .query("storageSqlKvEntries")
995− .withIndex("by_storageInstanceId", (q) => q.eq("storageInstanceId", args.storageInstanceId))
996− .collect();
997−
998− const sqlRows = sqlRowsRaw.map((row) =>
999− toStorageSqlKvEntry(row as unknown as Record<string, unknown>),
1000− );
1001− const byKey = new Map<string, {
1002− row: StorageSqlKvEntry;
1003− docId: unknown;
1004− }>();
1005−
1006− for (let index = 0; index < sqlRows.length; index += 1) {
1007− const row = sqlRows[index];
1008− const rawRow = sqlRowsRaw[index];
1009− if (row && rawRow) {
1010− byKey.set(row.key, {
1011− row,
1012− docId: rawRow._id,
1013− });
1014− }
1015− }
1016−
1017− const execution = executeSqlForStorage(sqlText, sqlRows, maxRows);
1018−
1019− for (const write of execution.writes) {
1020− if (write.type === "delete") {
1021− const existing = byKey.get(write.key);
1022− if (existing) {
1023− await ctx.db.delete(existing.docId as any);
1024− byKey.delete(write.key);
1025− }
1026− continue;
1027− }
1028−
1029− const existing = byKey.get(write.key);
1030− if (existing && write.insertOnly) {
1031− throw new Error(`UNIQUE constraint failed: kv_store.key (${write.key})`);
1032− }
1033−
1034− const now = Date.now();
1035−
1036− if (existing) {
1037− const nextRow = decodeStorageSqlKvEntry({
1038− ...existing.row,
1039− value: write.value,
1040− updatedAt: now,
1041− });
1042−
1043− await ctx.db.patch(existing.docId as any, nextRow);
1044− byKey.set(write.key, {
1045− row: nextRow,
1046− docId: existing.docId,
1047− });
1048− continue;
1049− }
1050−
1051− const nextRow = decodeStorageSqlKvEntry({
1052− id: `storage_sql_kv_${crypto.randomUUID()}`,
1053− storageInstanceId: args.storageInstanceId,
1054− key: write.key,
1055− value: write.value,
1056− updatedAt: now,
1057− });
1058−
1059− const insertedId = await ctx.db.insert("storageSqlKvEntries", nextRow);
1060− byKey.set(write.key, {
1061− row: nextRow,
1062− docId: insertedId,
1063− });
1064− }
1065−
1066− return execution.result;
1067− },
1068−});
apps/convex/convex/control_plane/tool_registry.ts+0−1611
@@ -1,1611 +0,0 @@
1−import { ActionCache } from "@convex-dev/action-cache";
2−import { v } from "convex/values";
3−import { components, internal } from "../_generated/api";
4−
5−import { action, internalAction, internalMutation, internalQuery } from "../_generated/server";
6−
7−const defaultSearchLimit = 50;
8−const maxSearchLimit = 5_000;
9−const writeBatchSize = 500;
10−const runtimeInternal = internal as any;
11−const listWorkspaceNamespacesActionCache = new ActionCache((components as any).actionCache, {
12− action: runtimeInternal.control_plane.tool_registry.listWorkspaceNamespacesUncached,
13− name: "workspace_namespaces_v1",
14− ttl: 15_000,
15−});
16−
17−const stripConvexSystemFields = (
18− value: Record<string, unknown>,
19−): Record<string, unknown> => {
20− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
21− return rest;
22−};
23−
24−const normalizePathForLookup = (path: string): string =>
25− path
26− .trim()
27− .toLowerCase()
28− .replace(/[^a-z0-9]+/g, ".")
29− .replace(/\.+/g, ".")
30− .replace(/^\.|\.$/g, "");
31−
32−const normalizeLimit = (value: number | undefined): number => {
33− if (!Number.isFinite(value)) {
34− return defaultSearchLimit;
35− }
36−
37− return Math.max(1, Math.min(maxSearchLimit, Math.floor(value ?? defaultSearchLimit)));
38−};
39−
40−const artifactSchemaRefId = (artifactId: string, refKey: string): string =>
41− `${artifactId}:ref:${encodeURIComponent(refKey)}`;
42−
43−const normalizeNamespacePart = (value: string): string => {
44− const normalized = value
45− .trim()
46− .toLowerCase()
47− .replace(/[^a-z0-9]+/g, "_")
48− .replace(/^_+|_+$/g, "");
49−
50− return normalized.length > 0 ? normalized : "source";
51−};
52−
53−const sourceNamespace = (source: {
54− id: string;
55− name: string;
56−}): string => {
57− const sourceIdSuffix = source.id.slice(-6).toLowerCase();
58− return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`;
59−};
60−
61−const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {
62− if (!value) {
63− return {};
64− }
65−
66− try {
67− const parsed = JSON.parse(value) as unknown;
68− return parsed && typeof parsed === "object" && !Array.isArray(parsed)
69− ? (parsed as Record<string, unknown>)
70− : {};
71− } catch {
72− return {};
73− }
74−};
75−
76−const metadataSearchTerms = (metadataJson: string | null | undefined): ReadonlyArray<string> => {
77− if (!metadataJson) {
78− return [];
79− }
80−
81− const parsed = parseJsonObject(metadataJson);
82− const keys = ["method", "path", "operationType", "fieldName", "toolName"] as const;
83− const terms: Array<string> = [];
84−
85− for (const key of keys) {
86− const value = parsed[key];
87− if (typeof value === "string" && value.trim().length > 0) {
88− terms.push(value);
89− }
90− }
91−
92− return terms;
93−};
94−
95−const normalizedSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
96− parts
97− .map((part) => (typeof part === "string" ? part.trim().toLowerCase() : ""))
98− .filter((part) => part.length > 0)
99− .join(" ");
100−
101−const methodFromCanonicalPath = (canonicalPath: string | null | undefined): string | null => {
102− if (!canonicalPath) {
103− return null;
104− }
105−
106− const candidate = canonicalPath.trim().split(/\s+/)[0]?.toLowerCase() ?? null;
107− if (
108− candidate === "get" ||
109− candidate === "post" ||
110− candidate === "put" ||
111− candidate === "patch" ||
112− candidate === "delete" ||
113− candidate === "head" ||
114− candidate === "options" ||
115− candidate === "trace"
116− ) {
117− return candidate;
118− }
119−
120− return null;
121−};
122−
123−const toWorkspaceToolMethod = (
124− protocol: string | null | undefined,
125− metadataJson: string | null | undefined,
126− canonicalPath: string | null | undefined,
127−): string => {
128− const canonicalMethod = methodFromCanonicalPath(canonicalPath);
129− if (canonicalMethod) {
130− return canonicalMethod;
131− }
132−
133− const normalizedProtocol = (protocol ?? "").toLowerCase();
134− if (normalizedProtocol !== "openapi") {
135− return "post";
136− }
137−
138− const metadata = parseJsonObject(metadataJson);
139− const method = typeof metadata.method === "string" ? metadata.method.trim().toLowerCase() : "";
140−
141− if (
142− method === "get" ||
143− method === "post" ||
144− method === "put" ||
145− method === "patch" ||
146− method === "delete" ||
147− method === "head" ||
148− method === "options" ||
149− method === "trace"
150− ) {
151− return method;
152− }
153−
154− return "get";
155−};
156−
157−const tokenizeSearchQuery = (query: string): Array<string> =>
158− Array.from(
159− new Set(
160− query
161− .toLowerCase()
162− .split(/[\s._:/-]+/)
163− .map((part) => part.trim())
164− .filter((part) => part.length > 0),
165− ),
166− );
167−
168−const scoreSearchRow = (row: Record<string, unknown>, queryLower: string): number => {
169− const path = String(row.path ?? "").toLowerCase();
170− const name = String(row.name ?? "").toLowerCase();
171− const description = String(row.description ?? "").toLowerCase();
172− const sourceName = String(row.sourceName ?? "").toLowerCase();
173− const searchText = String(row.searchText ?? "").toLowerCase();
174−
175− if (path === queryLower) {
176− return 100;
177− }
178−
179− if (path.startsWith(queryLower)) {
180− return 85;
181− }
182−
183− if (name.includes(queryLower)) {
184− return 75;
185− }
186−
187− if (path.includes(queryLower)) {
188− return 70;
189− }
190−
191− if (sourceName.includes(queryLower)) {
192− return 60;
193− }
194−
195− if (description.includes(queryLower)) {
196− return 50;
197− }
198−
199− if (searchText.includes(queryLower)) {
200− return 40;
201− }
202−
203− const tokens = tokenizeSearchQuery(queryLower);
204− if (tokens.length === 0) {
205− return 0;
206− }
207−
208− let pathMatches = 0;
209− let nameMatches = 0;
210− let sourceMatches = 0;
211− let descriptionMatches = 0;
212− let searchTextMatches = 0;
213−
214− for (const token of tokens) {
215− if (path.includes(token)) {
216− pathMatches += 1;
217− continue;
218− }
219−
220− if (name.includes(token)) {
221− nameMatches += 1;
222− continue;
223− }
224−
225− if (sourceName.includes(token)) {
226− sourceMatches += 1;
227− continue;
228− }
229−
230− if (description.includes(token)) {
231− descriptionMatches += 1;
232− continue;
233− }
234−
235− if (searchText.includes(token)) {
236− searchTextMatches += 1;
237− }
238− }
239−
240− const matchedTokens =
241− pathMatches +
242− nameMatches +
243− sourceMatches +
244− descriptionMatches +
245− searchTextMatches;
246−
247− if (matchedTokens === 0) {
248− return 0;
249− }
250−
251− let score =
252− pathMatches * 20 +
253− nameMatches * 16 +
254− sourceMatches * 12 +
255− descriptionMatches * 8 +
256− searchTextMatches * 6;
257−
258− score += matchedTokens === tokens.length ? 20 : 5;
259−
260− return score;
261−};
262−
263−const chunkArray = <A>(values: ReadonlyArray<A>, size: number): Array<Array<A>> => {
264− const chunkSize = Math.max(1, Math.floor(size));
265− const chunks: Array<Array<A>> = [];
266−
267− for (let index = 0; index < values.length; index += chunkSize) {
268− chunks.push(values.slice(index, index + chunkSize));
269− }
270−
271− return chunks;
272−};
273−
274−const workspaceToolPageSize = 250;
275−
276−const listWorkspaceToolIndexRows = async (
277− ctx: any,
278− args: {
279− workspaceId: string;
280− limit: number;
281− sourceId?: string;
282− namespace?: string;
283− includeDisabled?: boolean;
284− },
285−): Promise<Array<Record<string, unknown>>> => {
286− const limit = Math.max(0, args.limit);
287− if (limit === 0) {
288− return [];
289− }
290−
291− const baseQuery: any = (() => {
292− if (args.sourceId) {
293− return ctx.db
294− .query("workspaceToolIndex")
295− .withIndex("by_workspaceId_sourceId", (q: any) =>
296− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
297− );
298− }
299−
300− if (args.namespace) {
301− return ctx.db
302− .query("workspaceToolIndex")
303− .withIndex("by_workspaceId_namespace", (q: any) =>
304− q.eq("workspaceId", args.workspaceId).eq("namespace", args.namespace),
305− );
306− }
307−
308− return ctx.db
309− .query("workspaceToolIndex")
310− .withIndex("by_workspaceId", (q: any) => q.eq("workspaceId", args.workspaceId));
311− })();
312−
313− const rows: Array<Record<string, unknown>> = [];
314− let cursor: string | null = null;
315−
316− do {
317− const page: {
318− page: Array<Record<string, unknown>>;
319− continueCursor: string | null;
320− isDone: boolean;
321− } = await baseQuery.paginate({
322− cursor,
323− numItems: Math.min(workspaceToolPageSize, Math.max(1, limit - rows.length)),
324− });
325−
326− for (const row of page.page) {
327− if (args.includeDisabled !== true && row.status !== "active") {
328− continue;
329− }
330− if (args.sourceId && row.sourceId !== args.sourceId) {
331− continue;
332− }
333− if (args.namespace && row.namespace !== args.namespace) {
334− continue;
335− }
336−
337− rows.push(stripConvexSystemFields(row as unknown as Record<string, unknown>));
338− if (rows.length >= limit) {
339− break;
340− }
341− }
342−
343− if (rows.length >= limit || page.isDone) {
344− break;
345− }
346−
347− cursor = page.continueCursor;
348− } while (cursor !== null);
349−
350− rows.sort((left, right) => String(left.path).localeCompare(String(right.path)));
351− return rows;
352−};
353−
354−export const ingestOpenApiManifest = action({
355− args: {
356− token: v.string(),
357− workspaceId: v.string(),
358− sourceId: v.string(),
359− sourceName: v.string(),
360− sourceEndpoint: v.string(),
361− sourceEnabled: v.boolean(),
362− sourceHash: v.string(),
363− toolCount: v.number(),
364− refs: v.array(
365− v.object({
366− refKey: v.string(),
367− schemaJson: v.string(),
368− }),
369− ),
370− tools: v.array(
371− v.object({
372− toolId: v.string(),
373− name: v.string(),
374− description: v.union(v.string(), v.null()),
375− method: v.string(),
376− path: v.string(),
377− operationHash: v.string(),
378− invocationJson: v.string(),
379− inputSchemaJson: v.union(v.string(), v.null()),
380− outputSchemaJson: v.union(v.string(), v.null()),
381− }),
382− ),
383− },
384− handler: async (ctx, args) => {
385− const expectedToken =
386− process.env.OPENAPI_INGEST_SERVICE_TOKEN?.trim() || null;
387−
388− if (!expectedToken) {
389− throw new Error("OpenAPI ingest token is not configured");
390− }
391−
392− if (args.token.trim() !== expectedToken) {
393− throw new Error("Unauthorized OpenAPI ingest request");
394− }
395−
396− const artifactMeta = await ctx.runMutation(
397− runtimeInternal.control_plane.tool_registry.upsertArtifactMeta,
398− {
399− protocol: "openapi",
400− contentHash: args.sourceHash,
401− extractorVersion: "openapi_v2",
402− toolCount: args.toolCount,
403− refHintTableJson: null,
404− },
405− );
406−
407− if (args.refs.length > 0) {
408− let shouldPersistRefs = artifactMeta.created;
409−
410− if (!shouldPersistRefs) {
411− const existingRefCount = await ctx.runQuery(
412− runtimeInternal.control_plane.tool_registry.countArtifactSchemaRefs,
413− {
414− artifactId: artifactMeta.artifactId,
415− },
416− );
417− shouldPersistRefs = existingRefCount !== args.refs.length;
418− }
419−
420− if (shouldPersistRefs) {
421− await ctx.runMutation(
422− runtimeInternal.control_plane.tool_registry.clearArtifactSchemaRefs,
423− {
424− artifactId: artifactMeta.artifactId,
425− },
426− );
427−
428− for (const batch of chunkArray(args.refs, writeBatchSize)) {
429− await ctx.runMutation(
430− runtimeInternal.control_plane.tool_registry.putArtifactSchemaRefsBatch,
431− {
432− artifactId: artifactMeta.artifactId,
433− refs: batch,
434− },
435− );
436− }
437− }
438− }
439−
440− if (artifactMeta.created && args.tools.length > 0) {
441− const artifactRows = args.tools.map((tool) => ({
442− toolId: tool.toolId,
443− name: tool.name,
444− description: tool.description,
445− canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`,
446− operationHash: tool.operationHash,
447− invocationJson: tool.invocationJson,
448− inputSchemaJson: tool.inputSchemaJson,
449− outputSchemaJson: tool.outputSchemaJson,
450− metadataJson: JSON.stringify({
451− method: tool.method,
452− path: tool.path,
453− }),
454− }));
455−
456− for (const batch of chunkArray(artifactRows, writeBatchSize)) {
457− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
458− artifactId: artifactMeta.artifactId,
459− protocol: "openapi",
460− insertOnly: true,
461− tools: batch,
462− });
463− }
464− }
465−
466− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
467− workspaceId: args.workspaceId,
468− sourceId: args.sourceId,
469− artifactId: artifactMeta.artifactId,
470− });
471−
472− const namespace = sourceNamespace({ id: args.sourceId, name: args.sourceName });
473− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.clearWorkspaceSourceToolIndex, {
474− workspaceId: args.workspaceId,
475− sourceId: args.sourceId,
476− });
477−
478− for (const batch of chunkArray(args.tools, writeBatchSize)) {
479− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.appendWorkspaceSourceToolIndexChunk, {
480− workspaceId: args.workspaceId,
481− sourceId: args.sourceId,
482− sourceName: args.sourceName,
483− sourceKind: "openapi",
484− artifactId: artifactMeta.artifactId,
485− namespace,
486− refHintTableJson: null,
487− insertOnly: true,
488− rows: batch.map((tool) => {
489− const operationPath = `${tool.method.toUpperCase()} ${tool.path}`;
490− const path = `${namespace}.${tool.toolId}`;
491−
492− return {
493− toolId: tool.toolId,
494− protocol: "openapi",
495− method: tool.method.toLowerCase(),
496− path,
497− operationPath,
498− name: tool.name,
499− description: tool.description,
500− searchText: normalizedSearchText(
501− args.sourceName,
502− args.sourceEndpoint,
503− namespace,
504− tool.toolId,
505− tool.name,
506− tool.description,
507− tool.method,
508− tool.path,
509− ),
510− operationHash: tool.operationHash,
511− approvalMode: "auto",
512− status: args.sourceEnabled ? "active" : "disabled",
513− };
514− }),
515− });
516− }
517−
518− return {
519− artifactId: artifactMeta.artifactId,
520− created: artifactMeta.created,
521− sourceHash: args.sourceHash,
522− toolCount: args.toolCount,
523− sourceName: args.sourceName,
524− sourceEndpoint: args.sourceEndpoint,
525− sourceEnabled: args.sourceEnabled,
526− };
527− },
528−});
529−
530−export const upsertArtifactMeta = internalMutation({
531− args: {
532− protocol: v.string(),
533− contentHash: v.string(),
534− extractorVersion: v.string(),
535− toolCount: v.number(),
536− refHintTableJson: v.optional(v.union(v.string(), v.null())),
537− },
538− handler: async (ctx, args) => {
539− const now = Date.now();
540− const existing = await ctx.db
541− .query("artifacts")
542− .withIndex("by_protocol_contentHash_extractorVersion", (q) =>
543− q
544− .eq("protocol", args.protocol)
545− .eq("contentHash", args.contentHash)
546− .eq("extractorVersion", args.extractorVersion),
547− )
548− .unique();
549−
550− if (existing) {
551− await ctx.db.patch(existing._id, {
552− toolCount: args.toolCount,
553− refHintTableJson: args.refHintTableJson ?? null,
554− updatedAt: now,
555− });
556−
557− return {
558− artifactId: existing.id,
559− created: false,
560− };
561− }
562−
563− const artifactId = `art_${args.protocol}_${args.contentHash}_${args.extractorVersion}`;
564− await ctx.db.insert("artifacts", {
565− id: artifactId,
566− protocol: args.protocol,
567− contentHash: args.contentHash,
568− extractorVersion: args.extractorVersion,
569− toolCount: args.toolCount,
570− refHintTableJson: args.refHintTableJson ?? null,
571− createdAt: now,
572− updatedAt: now,
573− });
574−
575− return {
576− artifactId,
577− created: true,
578− };
579− },
580−});
581−
582−export const clearArtifactSchemaRefs = internalMutation({
583− args: {
584− artifactId: v.string(),
585− },
586− handler: async (ctx, args) => {
587− const existing = await ctx.db
588− .query("artifactSchemaRefs")
589− .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId))
590− .collect();
591−
592− for (const row of existing) {
593− await ctx.db.delete(row._id);
594− }
595−
596− return { removedCount: existing.length };
597− },
598−});
599−
600−export const putArtifactSchemaRefsBatch = internalMutation({
601− args: {
602− artifactId: v.string(),
603− refs: v.array(
604− v.object({
605− refKey: v.string(),
606− schemaJson: v.string(),
607− }),
608− ),
609− },
610− handler: async (ctx, args) => {
611− const now = Date.now();
612− let insertedCount = 0;
613−
614− for (const ref of args.refs) {
615− await ctx.db.insert("artifactSchemaRefs", {
616− id: artifactSchemaRefId(args.artifactId, ref.refKey),
617− artifactId: args.artifactId,
618− refKey: ref.refKey,
619− schemaJson: ref.schemaJson,
620− createdAt: now,
621− updatedAt: now,
622− });
623− insertedCount += 1;
624− }
625−
626− return { insertedCount };
627− },
628−});
629−
630−export const countArtifactSchemaRefs = internalQuery({
631− args: {
632− artifactId: v.string(),
633− },
634− handler: async (ctx, args) => {
635− const rows = await ctx.db
636− .query("artifactSchemaRefs")
637− .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId))
638− .collect();
639−
640− return rows.length;
641− },
642−});
643−
644−export const putArtifactToolsBatch = internalMutation({
645− args: {
646− artifactId: v.string(),
647− protocol: v.string(),
648− insertOnly: v.optional(v.boolean()),
649− tools: v.array(
650− v.object({
651− toolId: v.string(),
652− name: v.string(),
653− description: v.union(v.string(), v.null()),
654− canonicalPath: v.string(),
655− operationHash: v.string(),
656− invocationJson: v.string(),
657− inputSchemaJson: v.optional(v.union(v.string(), v.null())),
658− outputSchemaJson: v.optional(v.union(v.string(), v.null())),
659− metadataJson: v.optional(v.union(v.string(), v.null())),
660− }),
661− ),
662− },
663− handler: async (ctx, args) => {
664− const now = Date.now();
665− let insertedCount = 0;
666−
667− if (args.insertOnly === true) {
668− for (const tool of args.tools) {
669− await ctx.db.insert("artifactTools", {
670− id: `${args.artifactId}:${tool.toolId}`,
671− artifactId: args.artifactId,
672− protocol: args.protocol,
673− toolId: tool.toolId,
674− name: tool.name,
675− description: tool.description,
676− canonicalPath: tool.canonicalPath,
677− operationHash: tool.operationHash,
678− invocationJson: tool.invocationJson,
679− inputSchemaJson: tool.inputSchemaJson ?? null,
680− outputSchemaJson: tool.outputSchemaJson ?? null,
681− metadataJson: tool.metadataJson ?? null,
682− createdAt: now,
683− updatedAt: now,
684− });
685− insertedCount += 1;
686− }
687−
688− return { insertedCount };
689− }
690−
691− for (const tool of args.tools) {
692− const existing = await ctx.db
693− .query("artifactTools")
694− .withIndex("by_artifactId_toolId", (q) =>
695− q.eq("artifactId", args.artifactId).eq("toolId", tool.toolId),
696− )
697− .unique();
698−
699− const row = {
700− id: `${args.artifactId}:${tool.toolId}`,
701− artifactId: args.artifactId,
702− protocol: args.protocol,
703− toolId: tool.toolId,
704− name: tool.name,
705− description: tool.description,
706− canonicalPath: tool.canonicalPath,
707− operationHash: tool.operationHash,
708− invocationJson: tool.invocationJson,
709− inputSchemaJson: tool.inputSchemaJson ?? null,
710− outputSchemaJson: tool.outputSchemaJson ?? null,
711− metadataJson: tool.metadataJson ?? null,
712− createdAt: existing?.createdAt ?? now,
713− updatedAt: now,
714− };
715−
716− if (existing) {
717− await ctx.db.patch(existing._id, row);
718− } else {
719− await ctx.db.insert("artifactTools", row);
720− insertedCount += 1;
721− }
722− }
723−
724− return { insertedCount };
725− },
726−});
727−
728−export const bindSourceToArtifact = internalMutation({
729− args: {
730− workspaceId: v.string(),
731− sourceId: v.string(),
732− artifactId: v.string(),
733− },
734− handler: async (ctx, args) => {
735− const existing = await ctx.db
736− .query("sourceArtifactBindings")
737− .withIndex("by_workspaceId_sourceId", (q) =>
738− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
739− )
740− .unique();
741−
742− const row = {
743− id: existing?.id ?? `sab_${args.workspaceId}_${args.sourceId}`,
744− workspaceId: args.workspaceId,
745− sourceId: args.sourceId,
746− artifactId: args.artifactId,
747− updatedAt: Date.now(),
748− };
749−
750− if (existing) {
751− await ctx.db.patch(existing._id, row);
752− } else {
753− await ctx.db.insert("sourceArtifactBindings", row);
754− }
755−
756− return row;
757− },
758−});
759−
760−export const replaceSourceIngestArtifactBatches = internalMutation({
761− args: {
762− workspaceId: v.string(),
763− sourceId: v.string(),
764− artifactId: v.string(),
765− protocol: v.string(),
766− batches: v.array(v.string()),
767− },
768− handler: async (ctx, args) => {
769− const existing = await ctx.db
770− .query("sourceIngestArtifactBatches")
771− .withIndex("by_workspaceId_sourceId", (q) =>
772− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
773− )
774− .collect();
775−
776− for (const row of existing) {
777− await ctx.db.delete(row._id);
778− }
779−
780− const now = Date.now();
781− for (let batchIndex = 0; batchIndex < args.batches.length; batchIndex += 1) {
782− await ctx.db.insert("sourceIngestArtifactBatches", {
783− id: `siab_${args.workspaceId}_${args.sourceId}_${args.artifactId}_${batchIndex}`,
784− workspaceId: args.workspaceId,
785− sourceId: args.sourceId,
786− artifactId: args.artifactId,
787− protocol: args.protocol,
788− batchIndex,
789− toolsJson: args.batches[batchIndex] ?? "[]",
790− updatedAt: now,
791− });
792− }
793−
794− return { batchCount: args.batches.length };
795− },
796−});
797−
798−export const clearSourceIngestArtifactBatches = internalMutation({
799− args: {
800− workspaceId: v.string(),
801− sourceId: v.string(),
802− },
803− handler: async (ctx, args) => {
804− const existing = await ctx.db
805− .query("sourceIngestArtifactBatches")
806− .withIndex("by_workspaceId_sourceId", (q) =>
807− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
808− )
809− .collect();
810−
811− for (const row of existing) {
812− await ctx.db.delete(row._id);
813− }
814−
815− return { removedCount: existing.length };
816− },
817−});
818−
819−export const getSourceIngestArtifactBatch = internalQuery({
820− args: {
821− workspaceId: v.string(),
822− sourceId: v.string(),
823− artifactId: v.string(),
824− batchIndex: v.number(),
825− },
826− handler: async (ctx, args) => {
827− const row = await ctx.db
828− .query("sourceIngestArtifactBatches")
829− .withIndex("by_workspaceId_sourceId_artifactId_batchIndex", (q) =>
830− q
831− .eq("workspaceId", args.workspaceId)
832− .eq("sourceId", args.sourceId)
833− .eq("artifactId", args.artifactId)
834− .eq("batchIndex", Math.max(0, Math.floor(args.batchIndex))),
835− )
836− .unique();
837−
838− return row ? stripConvexSystemFields(row as unknown as Record<string, unknown>) : null;
839− },
840−});
841−
842−export const replaceWorkspaceSourceToolIndex = internalMutation({
843− args: {
844− workspaceId: v.string(),
845− sourceId: v.string(),
846− sourceName: v.string(),
847− sourceKind: v.string(),
848− artifactId: v.string(),
849− namespace: v.string(),
850− refHintTableJson: v.optional(v.union(v.string(), v.null())),
851− rows: v.array(
852− v.object({
853− toolId: v.string(),
854− protocol: v.string(),
855− method: v.string(),
856− path: v.string(),
857− operationPath: v.optional(v.union(v.string(), v.null())),
858− name: v.string(),
859− description: v.union(v.string(), v.null()),
860− searchText: v.string(),
861− operationHash: v.string(),
862− approvalMode: v.string(),
863− status: v.string(),
864− }),
865− ),
866− },
867− handler: async (ctx, args) => {
868− const existingRows = await ctx.db
869− .query("workspaceToolIndex")
870− .withIndex("by_workspaceId_sourceId", (q) =>
871− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
872− )
873− .collect();
874−
875− for (const row of existingRows) {
876− await ctx.db.delete(row._id);
877− }
878−
879− const now = Date.now();
880− let insertedCount = 0;
881−
882− for (const row of args.rows) {
883− await ctx.db.insert("workspaceToolIndex", {
884− id: `wti_${args.workspaceId}_${args.sourceId}_${row.toolId}`,
885− workspaceId: args.workspaceId,
886− sourceId: args.sourceId,
887− sourceName: args.sourceName,
888− sourceKind: args.sourceKind,
889− artifactId: args.artifactId,
890− toolId: row.toolId,
891− protocol: row.protocol,
892− method: row.method,
893− namespace: args.namespace,
894− path: row.path,
895− pathLower: row.path.toLowerCase(),
896− normalizedPath: normalizePathForLookup(row.path),
897− operationPath: row.operationPath ?? null,
898− name: row.name,
899− description: row.description,
900− searchText: row.searchText,
901− operationHash: row.operationHash,
902− approvalMode: row.approvalMode,
903− status: row.status,
904− refHintTableJson: args.refHintTableJson ?? null,
905− updatedAt: now,
906− });
907− insertedCount += 1;
908− }
909−
910− return { insertedCount };
911− },
912−});
913−
914−export const clearWorkspaceSourceToolIndex = internalMutation({
915− args: {
916− workspaceId: v.string(),
917− sourceId: v.string(),
918− },
919− handler: async (ctx, args) => {
920− const existingRows = await ctx.db
921− .query("workspaceToolIndex")
922− .withIndex("by_workspaceId_sourceId", (q) =>
923− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
924− )
925− .collect();
926−
927− for (const row of existingRows) {
928− await ctx.db.delete(row._id);
929− }
930−
931− return { removedCount: existingRows.length };
932− },
933−});
934−
935−export const appendWorkspaceSourceToolIndexChunk = internalMutation({
936− args: {
937− workspaceId: v.string(),
938− sourceId: v.string(),
939− sourceName: v.string(),
940− sourceKind: v.string(),
941− artifactId: v.string(),
942− namespace: v.string(),
943− refHintTableJson: v.optional(v.union(v.string(), v.null())),
944− insertOnly: v.optional(v.boolean()),
945− rows: v.array(
946− v.object({
947− toolId: v.string(),
948− protocol: v.string(),
949− method: v.string(),
950− path: v.string(),
951− operationPath: v.optional(v.union(v.string(), v.null())),
952− name: v.string(),
953− description: v.union(v.string(), v.null()),
954− searchText: v.string(),
955− operationHash: v.string(),
956− approvalMode: v.string(),
957− status: v.string(),
958− }),
959− ),
960− },
961− handler: async (ctx, args) => {
962− const now = Date.now();
963− let insertedCount = 0;
964− let updatedCount = 0;
965−
966− if (args.insertOnly === true) {
967− for (const row of args.rows) {
968− const nextRow = {
969− id: `wti_${args.workspaceId}_${args.sourceId}_${row.toolId}`,
970− workspaceId: args.workspaceId,
971− sourceId: args.sourceId,
972− sourceName: args.sourceName,
973− sourceKind: args.sourceKind,
974− artifactId: args.artifactId,
975− toolId: row.toolId,
976− protocol: row.protocol,
977− method: row.method,
978− namespace: args.namespace,
979− path: row.path,
980− pathLower: row.path.toLowerCase(),
981− normalizedPath: normalizePathForLookup(row.path),
982− operationPath: row.operationPath ?? null,
983− name: row.name,
984− description: row.description,
985− searchText: row.searchText,
986− operationHash: row.operationHash,
987− approvalMode: row.approvalMode,
988− status: row.status,
989− refHintTableJson: args.refHintTableJson ?? null,
990− updatedAt: now,
991− };
992−
993− await ctx.db.insert("workspaceToolIndex", nextRow);
994− insertedCount += 1;
995− }
996−
997− return { insertedCount, updatedCount };
998− }
999−
1000− for (const row of args.rows) {
1001− const id = `wti_${args.workspaceId}_${args.sourceId}_${row.toolId}`;
1002− const existing = await ctx.db
1003− .query("workspaceToolIndex")
1004− .withIndex("by_domainId", (q) => q.eq("id", id))
1005− .unique();
1006−
1007− const nextRow = {
1008− id,
1009− workspaceId: args.workspaceId,
1010− sourceId: args.sourceId,
1011− sourceName: args.sourceName,
1012− sourceKind: args.sourceKind,
1013− artifactId: args.artifactId,
1014− toolId: row.toolId,
1015− protocol: row.protocol,
1016− method: row.method,
1017− namespace: args.namespace,
1018− path: row.path,
1019− pathLower: row.path.toLowerCase(),
1020− normalizedPath: normalizePathForLookup(row.path),
1021− operationPath: row.operationPath ?? null,
1022− name: row.name,
1023− description: row.description,
1024− searchText: row.searchText,
1025− operationHash: row.operationHash,
1026− approvalMode: row.approvalMode,
1027− status: row.status,
1028− refHintTableJson: args.refHintTableJson ?? null,
1029− updatedAt: now,
1030− };
1031−
1032− if (existing) {
1033− await ctx.db.patch(existing._id, nextRow);
1034− updatedCount += 1;
1035− } else {
1036− await ctx.db.insert("workspaceToolIndex", nextRow);
1037− insertedCount += 1;
1038− }
1039− }
1040−
1041− return { insertedCount, updatedCount };
1042− },
1043−});
1044−
1045−export const removeSourceBindingsAndIndex = internalMutation({
1046− args: {
1047− workspaceId: v.string(),
1048− sourceId: v.string(),
1049− },
1050− handler: async (ctx, args) => {
1051− const binding = await ctx.db
1052− .query("sourceArtifactBindings")
1053− .withIndex("by_workspaceId_sourceId", (q) =>
1054− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
1055− )
1056− .unique();
1057−
1058− if (binding) {
1059− await ctx.db.delete(binding._id);
1060− }
1061−
1062− const indexRows = await ctx.db
1063− .query("workspaceToolIndex")
1064− .withIndex("by_workspaceId_sourceId", (q) =>
1065− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
1066− )
1067− .collect();
1068−
1069− for (const row of indexRows) {
1070− await ctx.db.delete(row._id);
1071− }
1072−
1073− return {
1074− removedBinding: Boolean(binding),
1075− removedIndexRows: indexRows.length,
1076− };
1077− },
1078−});
1079−
1080−export const getSourceArtifactBinding = internalQuery({
1081− args: {
1082− workspaceId: v.string(),
1083− sourceId: v.string(),
1084− },
1085− handler: async (ctx, args) => {
1086− const binding = await ctx.db
1087− .query("sourceArtifactBindings")
1088− .withIndex("by_workspaceId_sourceId", (q) =>
1089− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
1090− )
1091− .unique();
1092−
1093− return binding ? stripConvexSystemFields(binding as unknown as Record<string, unknown>) : null;
1094− },
1095−});
1096−
1097−export const listWorkspaceSourceArtifactBindings = internalQuery({
1098− args: {
1099− workspaceId: v.string(),
1100− },
1101− handler: async (ctx, args) => {
1102− const rows = await ctx.db
1103− .query("sourceArtifactBindings")
1104− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
1105− .collect();
1106−
1107− return rows.map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>));
1108− },
1109−});
1110−
1111−export const getArtifactById = internalQuery({
1112− args: {
1113− artifactId: v.string(),
1114− },
1115− handler: async (ctx, args) => {
1116− const artifact = await ctx.db
1117− .query("artifacts")
1118− .withIndex("by_domainId", (q) => q.eq("id", args.artifactId))
1119− .unique();
1120−
1121− return artifact ? stripConvexSystemFields(artifact as unknown as Record<string, unknown>) : null;
1122− },
1123−});
1124−
1125−export const listArtifactTools = internalQuery({
1126− args: {
1127− artifactId: v.string(),
1128− },
1129− handler: async (ctx, args) => {
1130− const rows = await ctx.db
1131− .query("artifactTools")
1132− .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId))
1133− .collect();
1134−
1135− return rows
1136− .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>))
1137− .sort((left, right) => String(left.toolId).localeCompare(String(right.toolId)));
1138− },
1139−});
1140−
1141−export const listArtifactToolsPage = internalQuery({
1142− args: {
1143− artifactId: v.string(),
1144− cursor: v.optional(v.union(v.string(), v.null())),
1145− pageSize: v.optional(v.number()),
1146− },
1147− handler: async (ctx, args) => {
1148− const pageSize = Math.max(1, Math.min(500, Math.floor(args.pageSize ?? 200)));
1149− const page = await ctx.db
1150− .query("artifactTools")
1151− .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId))
1152− .paginate({
1153− cursor: args.cursor ?? null,
1154− numItems: pageSize,
1155− });
1156−
1157− const rows = page.page.map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>));
1158−
1159− return {
1160− rows,
1161− continueCursor: page.continueCursor,
1162− isDone: page.isDone,
1163− };
1164− },
1165−});
1166−
1167−export const hasWorkspaceSourceToolIndexRows = internalQuery({
1168− args: {
1169− workspaceId: v.string(),
1170− sourceId: v.string(),
1171− },
1172− handler: async (ctx, args) => {
1173− const page = await ctx.db
1174− .query("workspaceToolIndex")
1175− .withIndex("by_workspaceId_sourceId", (q) =>
1176− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
1177− )
1178− .paginate({
1179− cursor: null,
1180− numItems: 1,
1181− });
1182−
1183− return page.page.length > 0;
1184− },
1185−});
1186−
1187−export const ensureWorkspaceToolIndexCoverage = internalAction({
1188− args: {
1189− workspaceId: v.string(),
1190− sourceId: v.optional(v.string()),
1191− },
1192− handler: async (ctx, args) => {
1193− const sourceRows = await ctx.runQuery(runtimeInternal.control_plane.sources.listSources, {
1194− workspaceId: args.workspaceId,
1195− });
1196− const sourceById = new Map<string, Record<string, unknown>>(
1197− (sourceRows as Array<Record<string, unknown>>).map((row) => [String(row.id), row] as const),
1198− );
1199−
1200− const bindingRows = await ctx.runQuery(
1201− runtimeInternal.control_plane.tool_registry.listWorkspaceSourceArtifactBindings,
1202− {
1203− workspaceId: args.workspaceId,
1204− },
1205− );
1206−
1207− let rebuiltCount = 0;
1208−
1209− for (const binding of bindingRows) {
1210− const sourceId = String(binding.sourceId ?? "");
1211− if (sourceId.length === 0) {
1212− continue;
1213− }
1214−
1215− if (args.sourceId && sourceId !== args.sourceId) {
1216− continue;
1217− }
1218−
1219− const source = sourceById.get(sourceId);
1220− if (!source) {
1221− continue;
1222− }
1223−
1224− const hasRows = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.hasWorkspaceSourceToolIndexRows, {
1225− workspaceId: args.workspaceId,
1226− sourceId,
1227− });
1228− if (hasRows) {
1229− continue;
1230− }
1231−
1232− const sourceName = String(source.name ?? "");
1233− const sourceKind = String(source.kind ?? "openapi");
1234− const sourceEndpoint = String(source.endpoint ?? "");
1235− const sourceEnabled = source.enabled === true;
1236− const artifactId = String(binding.artifactId ?? "");
1237− if (artifactId.length === 0) {
1238− continue;
1239− }
1240−
1241− const artifact = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.getArtifactById, {
1242− artifactId,
1243− });
1244− const namespace = sourceNamespace({ id: sourceId, name: sourceName });
1245−
1246− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.clearWorkspaceSourceToolIndex, {
1247− workspaceId: args.workspaceId,
1248− sourceId,
1249− });
1250−
1251− let cursor: string | null = null;
1252− do {
1253− const page: {
1254− rows: Array<Record<string, unknown>>;
1255− continueCursor: string | null;
1256− isDone: boolean;
1257− } = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listArtifactToolsPage, {
1258− artifactId,
1259− cursor,
1260− pageSize: 200,
1261− });
1262−
1263− if (page.rows.length > 0) {
1264− await ctx.runMutation(runtimeInternal.control_plane.tool_registry.appendWorkspaceSourceToolIndexChunk, {
1265− workspaceId: args.workspaceId,
1266− sourceId,
1267− sourceName,
1268− sourceKind,
1269− artifactId,
1270− namespace,
1271− refHintTableJson: typeof artifact?.refHintTableJson === "string" ? artifact.refHintTableJson : null,
1272− insertOnly: true,
1273− rows: page.rows.map((row) => {
1274− const toolId = String(row.toolId ?? "");
1275− const protocol = String(row.protocol ?? "openapi");
1276− const canonicalPath =
1277− typeof row.canonicalPath === "string" && row.canonicalPath.trim().length > 0
1278− ? row.canonicalPath.trim()
1279− : null;
1280− const method = toWorkspaceToolMethod(
1281− protocol,
1282− typeof row.metadataJson === "string" ? row.metadataJson : null,
1283− canonicalPath,
1284− );
1285− const path = `${namespace}.${toolId}`;
1286− const description = typeof row.description === "string" ? row.description : null;
1287− const name = String(row.name ?? toolId);
1288−
1289− return {
1290− toolId,
1291− protocol,
1292− method,
1293− path,
1294− operationPath: canonicalPath,
1295− name,
1296− description,
1297− searchText: normalizedSearchText(
1298− sourceName,
1299− sourceEndpoint,
1300− namespace,
1301− toolId,
1302− name,
1303− description,
1304− ...metadataSearchTerms(typeof row.metadataJson === "string" ? row.metadataJson : null),
1305− ),
1306− operationHash: String(row.operationHash ?? ""),
1307− approvalMode: "auto",
1308− status: sourceEnabled ? "active" : "disabled",
1309− };
1310− }),
1311− });
1312− }
1313−
1314− cursor = page.isDone ? null : page.continueCursor;
1315− } while (cursor);
1316−
1317− rebuiltCount += 1;
1318− }
1319−
1320− return { rebuiltCount };
1321− },
1322−});
1323−
1324−export const listArtifactToolIndexSlice = internalQuery({
1325− args: {
1326− artifactId: v.string(),
1327− offset: v.optional(v.number()),
1328− limit: v.optional(v.number()),
1329− },
1330− handler: async (ctx, args) => {
1331− const rows = await ctx.db
1332− .query("artifactTools")
1333− .withIndex("by_artifactId", (q) => q.eq("artifactId", args.artifactId))
1334− .collect();
1335−
1336− const ordered = rows
1337− .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>))
1338− .sort((left, right) => String(left.toolId).localeCompare(String(right.toolId)));
1339−
1340− const offset = Math.max(0, Math.floor(args.offset ?? 0));
1341− const limit = normalizeLimit(args.limit);
1342−
1343− return ordered.slice(offset, offset + limit).map((row) => ({
1344− toolId: String(row.toolId),
1345− protocol: String(row.protocol),
1346− name: String(row.name),
1347− description: typeof row.description === "string" ? row.description : null,
1348− canonicalPath: typeof row.canonicalPath === "string" ? row.canonicalPath : null,
1349− operationHash: String(row.operationHash),
1350− metadataJson: typeof row.metadataJson === "string" ? row.metadataJson : null,
1351− }));
1352− },
1353−});
1354−
1355−export const getArtifactTool = internalQuery({
1356− args: {
1357− artifactId: v.string(),
1358− toolId: v.string(),
1359− },
1360− handler: async (ctx, args) => {
1361− const row = await ctx.db
1362− .query("artifactTools")
1363− .withIndex("by_artifactId_toolId", (q) =>
1364− q.eq("artifactId", args.artifactId).eq("toolId", args.toolId),
1365− )
1366− .unique();
1367−
1368− return row ? stripConvexSystemFields(row as unknown as Record<string, unknown>) : null;
1369− },
1370−});
1371−
1372−export const searchWorkspaceTools = internalQuery({
1373− args: {
1374− workspaceId: v.string(),
1375− query: v.string(),
1376− limit: v.optional(v.number()),
1377− sourceId: v.optional(v.string()),
1378− namespace: v.optional(v.string()),
1379− includeDisabled: v.optional(v.boolean()),
1380− },
1381− handler: async (ctx, args) => {
1382− const trimmedQuery = args.query.trim();
1383− const limit = normalizeLimit(args.limit);
1384− if (trimmedQuery.length === 0) {
1385− return [];
1386− }
1387−
1388− const searchQuery: any = ctx.db
1389− .query("workspaceToolIndex")
1390− .withSearchIndex("search_text", (q: any) => {
1391− let scoped = q.search("searchText", trimmedQuery).eq("workspaceId", args.workspaceId);
1392− if (args.sourceId) {
1393− scoped = scoped.eq("sourceId", args.sourceId);
1394− }
1395− if (args.namespace) {
1396− scoped = scoped.eq("namespace", args.namespace);
1397− }
1398− if (args.includeDisabled !== true) {
1399− scoped = scoped.eq("status", "active");
1400− }
1401− return scoped;
1402− });
1403− const searchFetchLimit = Math.max(limit, Math.min(maxSearchLimit, Math.max(limit * 4, 200)));
1404− const rows = (await searchQuery.take(searchFetchLimit)).map((row: Record<string, unknown>) =>
1405− stripConvexSystemFields(row),
1406− );
1407−
1408− const queryLower = trimmedQuery.toLowerCase();
1409− const ranked = rows
1410− .map((row: Record<string, unknown>) => ({
1411− row,
1412− score: scoreSearchRow(row, queryLower),
1413− }))
1414− .filter((entry: { row: Record<string, unknown>; score: number }) => entry.score > 0)
1415− .sort((left: { row: Record<string, unknown>; score: number }, right: { row: Record<string, unknown>; score: number }) => {
1416− if (left.score !== right.score) {
1417− return right.score - left.score;
1418− }
1419−
1420− return String(left.row.path).localeCompare(String(right.row.path));
1421− });
1422−
1423− return ranked.slice(0, limit).map((entry: { row: Record<string, unknown> }) => entry.row);
1424− },
1425−});
1426−
1427−export const listWorkspaceTools = internalQuery({
1428− args: {
1429− workspaceId: v.string(),
1430− limit: v.optional(v.number()),
1431− sourceId: v.optional(v.string()),
1432− namespace: v.optional(v.string()),
1433− includeDisabled: v.optional(v.boolean()),
1434− },
1435− handler: async (ctx, args) => {
1436− const rows = await listWorkspaceToolIndexRows(ctx, {
1437− workspaceId: args.workspaceId,
1438− limit: normalizeLimit(args.limit),
1439− sourceId: args.sourceId,
1440− namespace: args.namespace,
1441− includeDisabled: args.includeDisabled,
1442− });
1443−
1444− return rows;
1445− },
1446−});
1447−
1448−export const getWorkspaceToolByPath = internalQuery({
1449− args: {
1450− workspaceId: v.string(),
1451− pathLower: v.string(),
1452− },
1453− handler: async (ctx, args) => {
1454− const row = await ctx.db
1455− .query("workspaceToolIndex")
1456− .withIndex("by_workspaceId_pathLower", (q) =>
1457− q.eq("workspaceId", args.workspaceId).eq("pathLower", args.pathLower),
1458− )
1459− .first();
1460−
1461− return row ? stripConvexSystemFields(row as unknown as Record<string, unknown>) : null;
1462− },
1463−});
1464−
1465−export const listWorkspaceToolsByNormalizedPath = internalQuery({
1466− args: {
1467− workspaceId: v.string(),
1468− normalizedPath: v.string(),
1469− limit: v.optional(v.number()),
1470− },
1471− handler: async (ctx, args) => {
1472− const limit = normalizeLimit(args.limit);
1473− const page = await ctx.db
1474− .query("workspaceToolIndex")
1475− .withIndex("by_workspaceId_normalizedPath", (q) =>
1476− q.eq("workspaceId", args.workspaceId).eq("normalizedPath", args.normalizedPath),
1477− )
1478− .paginate({
1479− cursor: null,
1480− numItems: limit,
1481− });
1482−
1483− return page.page
1484− .filter((row) => row.status === "active")
1485− .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>));
1486− },
1487−});
1488−
1489−export const listWorkspaceToolIndexPage = internalQuery({
1490− args: {
1491− workspaceId: v.string(),
1492− cursor: v.optional(v.union(v.string(), v.null())),
1493− pageSize: v.optional(v.number()),
1494− },
1495− handler: async (ctx, args) => {
1496− const pageSize = Math.max(1, Math.min(500, Math.floor(args.pageSize ?? 200)));
1497− const page = await ctx.db
1498− .query("workspaceToolIndex")
1499− .withIndex("by_workspaceId", (q) => q.eq("workspaceId", args.workspaceId))
1500− .paginate({
1501− cursor: args.cursor ?? null,
1502− numItems: pageSize,
1503− });
1504−
1505− const rows = page.page
1506− .filter((row) => row.status === "active")
1507− .map((row) => stripConvexSystemFields(row as unknown as Record<string, unknown>));
1508−
1509− return {
1510− rows,
1511− continueCursor: page.continueCursor,
1512− isDone: page.isDone,
1513− };
1514− },
1515−});
1516−
1517−export const listWorkspaceNamespacesUncached = internalAction({
1518− args: {
1519− workspaceId: v.string(),
1520− limit: v.optional(v.number()),
1521− },
1522− handler: async (ctx, args) => {
1523− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
1524− workspaceId: args.workspaceId,
1525− });
1526−
1527− const grouped = new Map<
1528− string,
1529− {
1530− sourceName: string;
1531− sourceId: string;
1532− sourceKind: string;
1533− toolCount: number;
1534− paths: Array<string>;
1535− }
1536− >();
1537−
1538− let cursor: string | null = null;
1539− do {
1540− const page: {
1541− rows: Array<Record<string, unknown>>;
1542− continueCursor: string | null;
1543− isDone: boolean;
1544− } = await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listWorkspaceToolIndexPage, {
1545− workspaceId: args.workspaceId,
1546− cursor,
1547− pageSize: 250,
1548− });
1549−
1550− for (const row of page.rows) {
1551− const namespace = String(row.namespace);
1552− const path = String(row.path);
1553− const existing = grouped.get(namespace);
1554− if (existing) {
1555− existing.toolCount += 1;
1556−
1557− if (existing.paths.length < 3) {
1558− existing.paths.push(path);
1559− existing.paths.sort((left, right) => left.localeCompare(right));
1560− } else if (path.localeCompare(existing.paths[existing.paths.length - 1] ?? path) < 0) {
1561− existing.paths[existing.paths.length - 1] = path;
1562− existing.paths.sort((left, right) => left.localeCompare(right));
1563− }
1564−
1565− continue;
1566− }
1567−
1568− grouped.set(namespace, {
1569− sourceName: String(row.sourceName),
1570− sourceId: String(row.sourceId),
1571− sourceKind: String(row.sourceKind),
1572− toolCount: 1,
1573− paths: [path],
1574− });
1575− }
1576−
1577− cursor = page.isDone ? null : (page.continueCursor as string | null);
1578− } while (cursor);
1579−
1580− const limit = normalizeLimit(args.limit);
1581−
1582− if (limit <= 0 || grouped.size === 0) {
1583− return [];
1584− }
1585−
1586− const namespaces = [...grouped.entries()]
1587− .map(([namespace, value]) => ({
1588− namespace,
1589− source: value.sourceName,
1590− sourceId: value.sourceId,
1591− sourceKind: value.sourceKind,
1592− toolCount: value.toolCount,
1593− samplePaths: value.paths,
1594− }))
1595− .sort((left, right) => left.namespace.localeCompare(right.namespace));
1596−
1597− return namespaces.slice(0, limit);
1598− },
1599−});
1600−
1601−export const listWorkspaceNamespaces = internalAction({
1602− args: {
1603− workspaceId: v.string(),
1604− limit: v.optional(v.number()),
1605− },
1606− handler: async (ctx, args) =>
1607− listWorkspaceNamespacesActionCache.fetch(ctx, {
1608− workspaceId: args.workspaceId,
1609− limit: args.limit,
1610− }),
1611−});
apps/convex/convex/control_plane/tools.ts+0−431
@@ -1,431 +0,0 @@
1−import { type SourceToolDetail, type SourceToolSummary } from "@executor-v2/management-api";
2−import {
3− SourceSchema,
4− type OpenApiHttpMethod,
5− type Source,
6−} from "@executor-v2/schema";
7−import { v } from "convex/values";
8−import * as Schema from "effect/Schema";
9−import { internal } from "../_generated/api";
10−
11−import { internalAction, internalQuery } from "../_generated/server";
12−
13−const decodeSource = Schema.decodeUnknownSync(SourceSchema);
14−const runtimeInternal = internal as any;
15−
16−const stripConvexSystemFields = (
17− value: Record<string, unknown>,
18−): Record<string, unknown> => {
19− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
20− return rest;
21−};
22−
23−const toSource = (document: Record<string, unknown>): Source =>
24− decodeSource(stripConvexSystemFields(document));
25−
26−const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {
27− if (!value) {
28− return {};
29− }
30−
31− try {
32− const parsed = JSON.parse(value) as unknown;
33− return parsed && typeof parsed === "object" && !Array.isArray(parsed)
34− ? (parsed as Record<string, unknown>)
35− : {};
36− } catch {
37− return {};
38− }
39−};
40−
41−const parseJsonValue = (value: string | null | undefined): unknown | null => {
42− if (!value) {
43− return null;
44− }
45−
46− try {
47− return JSON.parse(value) as unknown;
48− } catch {
49− return null;
50− }
51−};
52−
53−const asNullableString = (value: unknown): string | null =>
54− typeof value === "string" && value.trim().length > 0 ? value : null;
55−
56−const normalizeNamespacePart = (value: string): string => {
57− const normalized = value
58− .trim()
59− .toLowerCase()
60− .replace(/[^a-z0-9]+/g, "_")
61− .replace(/^_+|_+$/g, "");
62−
63− return normalized.length > 0 ? normalized : "source";
64−};
65−
66−const sourceNamespace = (source: Source): string => {
67− const sourceIdSuffix = source.id.slice(-6).toLowerCase();
68− return `${normalizeNamespacePart(source.name)}_${sourceIdSuffix}`;
69−};
70−
71−const isMethod = (
72− value: string | null,
73−): value is OpenApiHttpMethod | "post" =>
74− value === "get" ||
75− value === "post" ||
76− value === "put" ||
77− value === "patch" ||
78− value === "delete" ||
79− value === "head" ||
80− value === "options" ||
81− value === "trace";
82−
83−const methodFromCanonicalPath = (canonicalPath: string | null | undefined): OpenApiHttpMethod | "post" | null => {
84− if (!canonicalPath) {
85− return null;
86− }
87−
88− const candidate = canonicalPath.trim().split(/\s+/)[0]?.toLowerCase() ?? null;
89− return isMethod(candidate) ? candidate : null;
90−};
91−
92−const toMethod = (protocol: string, metadataJson: string | null | undefined): OpenApiHttpMethod | "post" => {
93− if (protocol !== "openapi") {
94− return "post";
95− }
96−
97− const metadata = parseJsonObject(metadataJson);
98− const method = asNullableString(metadata.method)?.toLowerCase();
99−
100− if (
101− method === "get" ||
102− method === "post" ||
103− method === "put" ||
104− method === "patch" ||
105− method === "delete" ||
106− method === "head" ||
107− method === "options" ||
108− method === "trace"
109− ) {
110− return method;
111− }
112−
113− return "get";
114−};
115−
116−const collectRefKeys = (value: unknown, refs: Set<string>): void => {
117− if (Array.isArray(value)) {
118− for (const item of value) {
119− collectRefKeys(item, refs);
120− }
121− return;
122− }
123−
124− if (!value || typeof value !== "object") {
125− return;
126− }
127−
128− const record = value as Record<string, unknown>;
129− const refValue = record.$ref;
130− if (typeof refValue === "string" && refValue.startsWith("#/")) {
131− refs.add(refValue);
132− }
133−
134− for (const nestedValue of Object.values(record)) {
135− collectRefKeys(nestedValue, refs);
136− }
137−};
138−
139−const resolveToolRefHintTableJson = async (
140− ctx: any,
141− artifactId: string,
142− inputSchemaJson: string | null | undefined,
143− outputSchemaJson: string | null | undefined,
144−): Promise<string | null> => {
145− const initialRefs = new Set<string>();
146− collectRefKeys(parseJsonValue(inputSchemaJson), initialRefs);
147− collectRefKeys(parseJsonValue(outputSchemaJson), initialRefs);
148−
149− if (initialRefs.size === 0) {
150− return null;
151− }
152−
153− const queue = [...initialRefs];
154− const seen = new Set<string>();
155− const table: Record<string, string> = {};
156− const maxRefs = 5_000;
157−
158− while (queue.length > 0 && seen.size < maxRefs) {
159− const refKey = queue.shift();
160− if (!refKey || seen.has(refKey)) {
161− continue;
162− }
163−
164− seen.add(refKey);
165−
166− const row = await ctx.db
167− .query("artifactSchemaRefs")
168− .withIndex("by_artifactId_refKey", (q: any) =>
169− q.eq("artifactId", artifactId).eq("refKey", refKey),
170− )
171− .unique();
172−
173− if (!row || typeof row.schemaJson !== "string") {
174− continue;
175− }
176−
177− table[refKey] = row.schemaJson;
178−
179− const nestedRefs = new Set<string>();
180− collectRefKeys(parseJsonValue(row.schemaJson), nestedRefs);
181− for (const nestedRef of nestedRefs) {
182− if (!seen.has(nestedRef)) {
183− queue.push(nestedRef);
184− }
185− }
186− }
187−
188− if (Object.keys(table).length === 0) {
189− return null;
190− }
191−
192− return JSON.stringify(table);
193−};
194−
195−const toSummary = (
196− source: Source,
197− toolRow: Record<string, unknown>,
198− metadataJson: string | null | undefined,
199−): SourceToolSummary => {
200− const protocol = asNullableString(toolRow.protocol) ?? "openapi";
201− const canonicalPath = asNullableString(toolRow.canonicalPath);
202− const method = methodFromCanonicalPath(canonicalPath) ?? toMethod(protocol, metadataJson);
203− const toolId = asNullableString(toolRow.toolId) ?? "unknown";
204− const path = `${sourceNamespace(source)}.${toolId}`;
205−
206− return {
207− sourceId: source.id,
208− sourceName: source.name,
209− sourceKind: source.kind,
210− toolId,
211− name: asNullableString(toolRow.name) ?? "Unnamed Tool",
212− description: asNullableString(toolRow.description),
213− method,
214− path,
215− operationPath: canonicalPath,
216− operationHash: asNullableString(toolRow.operationHash) ?? "",
217− };
218−};
219−
220−const summaryFromIndexRow = (row: Record<string, unknown>): SourceToolSummary => {
221− const method = asNullableString(row.method)?.toLowerCase() ?? null;
222− const sourceKind = asNullableString(row.sourceKind);
223− const normalizedSourceKind: Source["kind"] =
224− sourceKind === "graphql" || sourceKind === "mcp" || sourceKind === "internal"
225− ? sourceKind
226− : "openapi";
227−
228− return {
229− sourceId: (asNullableString(row.sourceId) ?? "") as Source["id"],
230− sourceName: asNullableString(row.sourceName) ?? "",
231− sourceKind: normalizedSourceKind,
232− toolId: asNullableString(row.toolId) ?? "unknown",
233− name: asNullableString(row.name) ?? "Unnamed Tool",
234− description: asNullableString(row.description),
235− method: isMethod(method) ? method : "post",
236− path: asNullableString(row.path) ?? "",
237− operationPath: asNullableString(row.operationPath ?? null),
238− operationHash: asNullableString(row.operationHash) ?? "",
239− };
240−};
241−
242−const sortTools = (tools: ReadonlyArray<SourceToolSummary>): Array<SourceToolSummary> =>
243− [...tools].sort((left, right) => {
244− const leftSource = left.sourceName.toLowerCase();
245− const rightSource = right.sourceName.toLowerCase();
246−
247− if (leftSource !== rightSource) {
248− return leftSource.localeCompare(rightSource);
249− }
250−
251− const leftName = left.name.toLowerCase();
252− const rightName = right.name.toLowerCase();
253−
254− if (leftName !== rightName) {
255− return leftName.localeCompare(rightName);
256− }
257−
258− return left.toolId.localeCompare(right.toolId);
259− });
260−
261−const workspaceToolsPageSize = 250;
262−
263−export const listWorkspaceToolSummaryPage = internalQuery({
264− args: {
265− workspaceId: v.string(),
266− sourceId: v.optional(v.string()),
267− cursor: v.optional(v.union(v.string(), v.null())),
268− pageSize: v.optional(v.number()),
269− },
270− handler: async (ctx, args) => {
271− const pageSize = Math.max(1, Math.min(500, Math.floor(args.pageSize ?? workspaceToolsPageSize)));
272− const paginationOptions = {
273− cursor: args.cursor ?? null,
274− numItems: pageSize,
275− };
276−
277− const page = args.sourceId
278− ? await ctx.db
279− .query("workspaceToolIndex")
280− .withIndex("by_workspaceId_sourceId", (q: any) =>
281− q.eq("workspaceId", args.workspaceId).eq("sourceId", args.sourceId),
282− )
283− .paginate(paginationOptions)
284− : await ctx.db
285− .query("workspaceToolIndex")
286− .withIndex("by_workspaceId", (q: any) => q.eq("workspaceId", args.workspaceId))
287− .paginate(paginationOptions);
288−
289− const summaries = page.page
290− .filter((row) => row.status === "active")
291− .map((row) => summaryFromIndexRow(stripConvexSystemFields(row as unknown as Record<string, unknown>)));
292−
293− return {
294− summaries,
295− continueCursor: page.continueCursor,
296− isDone: page.isDone,
297− };
298− },
299−});
300−
301−export const listWorkspaceTools = internalAction({
302− args: {
303− workspaceId: v.string(),
304− },
305− handler: async (ctx, args): Promise<Array<SourceToolSummary>> => {
306− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
307− workspaceId: args.workspaceId,
308− });
309−
310− const summaries: Array<SourceToolSummary> = [];
311− let cursor: string | null = null;
312−
313− do {
314− const page: {
315− summaries: Array<SourceToolSummary>;
316− continueCursor: string | null;
317− isDone: boolean;
318− } = await ctx.runQuery(runtimeInternal.control_plane.tools.listWorkspaceToolSummaryPage, {
319− workspaceId: args.workspaceId,
320− cursor,
321− pageSize: workspaceToolsPageSize,
322− });
323−
324− summaries.push(...page.summaries);
325− cursor = page.isDone ? null : page.continueCursor;
326− } while (cursor);
327−
328− return sortTools(summaries);
329− },
330−});
331−
332−export const listSourceTools = internalAction({
333− args: {
334− workspaceId: v.string(),
335− sourceId: v.string(),
336− },
337− handler: async (ctx, args): Promise<Array<SourceToolSummary>> => {
338− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
339− workspaceId: args.workspaceId,
340− sourceId: args.sourceId,
341− });
342−
343− const summaries: Array<SourceToolSummary> = [];
344− let cursor: string | null = null;
345−
346− do {
347− const page: {
348− summaries: Array<SourceToolSummary>;
349− continueCursor: string | null;
350− isDone: boolean;
351− } = await ctx.runQuery(runtimeInternal.control_plane.tools.listWorkspaceToolSummaryPage, {
352− workspaceId: args.workspaceId,
353− sourceId: args.sourceId,
354− cursor,
355− pageSize: workspaceToolsPageSize,
356− });
357−
358− summaries.push(...page.summaries);
359− cursor = page.isDone ? null : page.continueCursor;
360− } while (cursor);
361−
362− return sortTools(summaries);
363− },
364−});
365−
366−export const getToolDetail = internalQuery({
367− args: {
368− workspaceId: v.string(),
369− sourceId: v.string(),
370− operationHash: v.string(),
371− },
372− handler: async (ctx, args): Promise<SourceToolDetail | null> => {
373− const sourceRow = await ctx.db
374− .query("sources")
375− .withIndex("by_domainId", (q) => q.eq("id", args.sourceId))
376− .unique();
377−
378− if (!sourceRow || sourceRow.workspaceId !== args.workspaceId) {
379− return null;
380− }
381−
382− const source = toSource(sourceRow as unknown as Record<string, unknown>);
383− const indexedRows = await ctx.db
384− .query("workspaceToolIndex")
385− .withIndex("by_workspaceId_operationHash", (q: any) =>
386− q.eq("workspaceId", args.workspaceId).eq("operationHash", args.operationHash),
387− )
388− .collect();
389− const indexedRow = indexedRows.find((row) => row.sourceId === args.sourceId) ?? null;
390− if (!indexedRow) {
391− return null;
392− }
393−
394− const artifactTool = await ctx.db
395− .query("artifactTools")
396− .withIndex("by_artifactId_toolId", (q: any) =>
397− q.eq("artifactId", indexedRow.artifactId).eq("toolId", indexedRow.toolId),
398− )
399− .unique();
400− if (!artifactTool) {
401− return null;
402− }
403−
404− const artifact = await ctx.db
405− .query("artifacts")
406− .withIndex("by_domainId", (q) => q.eq("id", indexedRow.artifactId))
407− .unique();
408−
409− const resolvedRefHintTableJson = source.kind === "openapi"
410− ? await resolveToolRefHintTableJson(
411− ctx,
412− indexedRow.artifactId,
413− artifactTool.inputSchemaJson ?? null,
414− artifactTool.outputSchemaJson ?? null,
415− )
416− : null;
417−
418− const summary = summaryFromIndexRow(
419− stripConvexSystemFields(indexedRow as unknown as Record<string, unknown>),
420− );
421−
422− return {
423− ...summary,
424− inputSchemaJson: artifactTool.inputSchemaJson ?? null,
425− outputSchemaJson: artifactTool.outputSchemaJson ?? null,
426− refHintTableJson: resolvedRefHintTableJson
427− ?? artifact?.refHintTableJson
428− ?? null,
429− };
430− },
431−});
apps/convex/convex/control_plane/workspaces.ts+0−78
@@ -1,78 +0,0 @@
1−import { type UpsertWorkspacePayload } from "@executor-v2/management-api";
2−import { WorkspaceSchema, type Workspace } from "@executor-v2/schema";
3−import { v } from "convex/values";
4−import * as Schema from "effect/Schema";
5−
6−import { internalMutation, internalQuery } from "../_generated/server";
7−
8−const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
9−
10−const stripConvexSystemFields = (
11− value: Record<string, unknown>,
12−): Record<string, unknown> => {
13− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
14− return rest;
15−};
16−
17−const toWorkspace = (document: Record<string, unknown>): Workspace =>
18− decodeWorkspace(stripConvexSystemFields(document));
19−
20−const sortWorkspaces = (workspaces: ReadonlyArray<Workspace>): Array<Workspace> =>
21− [...workspaces].sort((left, right) => {
22− const leftName = left.name.toLowerCase();
23− const rightName = right.name.toLowerCase();
24−
25− if (leftName === rightName) {
26− return left.id.localeCompare(right.id);
27− }
28−
29− return leftName.localeCompare(rightName);
30− });
31−
32−export const listWorkspaces = internalQuery({
33− args: {},
34− handler: async (ctx): Promise<Array<Workspace>> => {
35− const rows = await ctx.db.query("workspaces").collect();
36−
37− return sortWorkspaces(
38− rows.map((row) => toWorkspace(row as unknown as Record<string, unknown>)),
39− );
40− },
41−});
42−
43−export const upsertWorkspace = internalMutation({
44− args: {
45− payload: v.object({
46− id: v.optional(v.string()),
47− organizationId: v.string(),
48− name: v.string(),
49− }),
50− },
51− handler: async (ctx, args): Promise<Workspace> => {
52− const payload = args.payload as UpsertWorkspacePayload;
53− const now = Date.now();
54− const workspaceId = payload.id ?? `ws_${crypto.randomUUID()}`;
55−
56− const existing = await ctx.db
57− .query("workspaces")
58− .withIndex("by_domainId", (q) => q.eq("id", workspaceId))
59− .unique();
60−
61− const nextWorkspace = decodeWorkspace({
62− id: workspaceId,
63− organizationId: payload.organizationId,
64− name: payload.name,
65− createdByAccountId: existing?.createdByAccountId ?? null,
66− createdAt: existing?.createdAt ?? now,
67− updatedAt: now,
68− });
69−
70− if (existing) {
71− await ctx.db.patch(existing._id, nextWorkspace);
72− } else {
73− await ctx.db.insert("workspaces", nextWorkspace);
74− }
75−
76− return nextWorkspace;
77− },
78−});
apps/convex/convex/convex.config.ts+0−9
@@ -1,9 +0,0 @@
1−import actionCache from "@convex-dev/action-cache/convex.config";
2−import workflow from "@convex-dev/workflow/convex.config.js";
3−import { defineApp } from "convex/server";
4−
5−const app = defineApp();
6−app.use(actionCache);
7−app.use(workflow);
8−
9−export default app;
apps/convex/convex/credential_crypto.ts+0−192
@@ -1,192 +0,0 @@
1−const encryptedPrefix = "enc:v1:";
2−
3−const encoder = new TextEncoder();
4−const decoder = new TextDecoder();
5−
6−type EncryptionKeyEntry = {
7− keyVersion: string;
8− passphrase: string;
9−};
10−
11−const bytesToBase64 = (bytes: Uint8Array): string => {
12− let binary = "";
13− for (const byte of bytes) {
14− binary += String.fromCharCode(byte);
15− }
16− return btoa(binary);
17−};
18−
19−const base64ToBytes = (base64: string): Uint8Array => {
20− const binary = atob(base64);
21− const bytes = new Uint8Array(binary.length);
22− for (let i = 0; i < binary.length; i += 1) {
23− bytes[i] = binary.charCodeAt(i);
24− }
25− return bytes;
26−};
27−
28−const normalizeKeyVersion = (value: string): string => {
29− const trimmed = value.trim();
30− return trimmed.length > 0 ? trimmed : "v1";
31−};
32−
33−const parseEncryptionKeyEntries = (): Array<EncryptionKeyEntry> => {
34− const configured = process.env.CREDENTIAL_ENCRYPTION_KEYS?.trim() ?? "";
35− if (configured.length > 0) {
36− const entries = configured
37− .split(",")
38− .map((part) => part.trim())
39− .filter((part) => part.length > 0)
40− .flatMap((part) => {
41− const separatorIndex = part.indexOf("=") >= 0
42− ? part.indexOf("=")
43− : part.indexOf(":");
44− if (separatorIndex <= 0) {
45− return [];
46− }
47−
48− const keyVersion = normalizeKeyVersion(part.slice(0, separatorIndex));
49− const passphrase = part.slice(separatorIndex + 1).trim();
50− if (passphrase.length === 0) {
51− return [];
52− }
53−
54− return [{ keyVersion, passphrase }];
55− });
56−
57− if (entries.length > 0) {
58− return entries;
59− }
60− }
61−
62− const fallback = process.env.CREDENTIAL_ENCRYPTION_KEY?.trim() ?? "";
63− if (fallback.length === 0) {
64− throw new Error(
65− "Encrypted credential storage requires CREDENTIAL_ENCRYPTION_KEY or CREDENTIAL_ENCRYPTION_KEYS",
66− );
67− }
68−
69− return [{ keyVersion: "v1", passphrase: fallback }];
70−};
71−
72−const resolveEncryptionKeyEntries = (): Array<EncryptionKeyEntry> =>
73− parseEncryptionKeyEntries();
74−
75−const keyCache = new Map<string, Promise<CryptoKey>>();
76−
77−const deriveAesKey = async (passphrase: string): Promise<CryptoKey> => {
78− const cacheKey = passphrase;
79− const existing = keyCache.get(cacheKey);
80− if (existing) {
81− return await existing;
82− }
83−
84− const keyPromise = (async () => {
85− const passphraseBytes = encoder.encode(passphrase);
86− const hashBuffer = await crypto.subtle.digest("SHA-256", passphraseBytes);
87− return await crypto.subtle.importKey(
88− "raw",
89− hashBuffer,
90− {
91− name: "AES-GCM",
92− },
93− false,
94− ["encrypt", "decrypt"],
95− );
96− })();
97−
98− keyCache.set(cacheKey, keyPromise);
99− return await keyPromise;
100−};
101−
102−const parseEncryptedValue = (
103− value: string,
104−): { keyVersion: string | null; iv: Uint8Array; cipher: Uint8Array } | null => {
105− if (!value.startsWith(encryptedPrefix)) {
106− return null;
107− }
108−
109− const payload = value.slice(encryptedPrefix.length);
110− const parts = payload.split(":");
111−
112− try {
113− if (parts.length === 2) {
114− return {
115− keyVersion: null,
116− iv: base64ToBytes(parts[0] ?? ""),
117− cipher: base64ToBytes(parts[1] ?? ""),
118− };
119− }
120−
121− if (parts.length === 3) {
122− return {
123− keyVersion: normalizeKeyVersion(parts[0] ?? ""),
124− iv: base64ToBytes(parts[1] ?? ""),
125− cipher: base64ToBytes(parts[2] ?? ""),
126− };
127− }
128−
129− return null;
130− } catch {
131− return null;
132− }
133−};
134−
135−const resolveKeyEntryForDecrypt = (
136− keyVersion: string | null,
137−): EncryptionKeyEntry => {
138− const entries = resolveEncryptionKeyEntries();
139−
140− if (!keyVersion) {
141− return entries[0]!;
142− }
143−
144− const matched = entries.find((entry) => entry.keyVersion === keyVersion);
145− if (matched) {
146− return matched;
147− }
148−
149− throw new Error(`Credential encryption key version is not configured: ${keyVersion}`);
150−};
151−
152−export const currentCredentialEncryptionKeyVersion = (): string =>
153− resolveEncryptionKeyEntries()[0]!.keyVersion;
154−
155−export const isEncryptedSecret = (value: string): boolean =>
156− value.startsWith(encryptedPrefix);
157−
158−export const encryptSecretValue = async (plainText: string): Promise<string> => {
159− const activeKey = resolveEncryptionKeyEntries()[0]!;
160− const key = await deriveAesKey(activeKey.passphrase);
161− const iv = crypto.getRandomValues(new Uint8Array(12));
162− const encrypted = await crypto.subtle.encrypt(
163− {
164− name: "AES-GCM",
165− iv,
166− },
167− key,
168− encoder.encode(plainText),
169− );
170−
171− return `${encryptedPrefix}${activeKey.keyVersion}:${bytesToBase64(iv)}:${bytesToBase64(new Uint8Array(encrypted))}`;
172−};
173−
174−export const decryptSecretValue = async (storedValue: string): Promise<string> => {
175− const parsed = parseEncryptedValue(storedValue);
176− if (parsed === null) {
177− throw new Error("Credential secret is not encrypted");
178− }
179−
180− const keyEntry = resolveKeyEntryForDecrypt(parsed.keyVersion);
181− const key = await deriveAesKey(keyEntry.passphrase);
182− const decrypted = await crypto.subtle.decrypt(
183− {
184− name: "AES-GCM",
185− iv: parsed.iv as unknown as BufferSource,
186− },
187− key,
188− parsed.cipher as unknown as BufferSource,
189− );
190−
191− return decoder.decode(decrypted);
192−};
apps/convex/convex/executor.test.ts+0−1782
@@ -1,1782 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { extractOpenApiManifest } from "@executor-v2/management-api";
3−import { convexTest } from "convex-test";
4−import * as Effect from "effect/Effect";
5−
6−import { internal } from "./_generated/api";
7−import { executeRunImpl } from "./executor";
8−import schema from "./schema";
9−
10−const runtimeInternal = internal as any;
11−const api = {
12− controlPlane: runtimeInternal.controlPlane ?? runtimeInternal.control_plane,
13−} as any;
14−
15−const setup = () =>
16− convexTest(schema, {
17− "./http.ts": () => import("./http"),
18− "./mcp.ts": () => import("./mcp"),
19− "./executor.ts": () => import("./executor"),
20− "./runtimeCallbacks.ts": () => import("./runtimeCallbacks"),
21− "./source_tool_registry.ts": () => import("./source_tool_registry"),
22− "./task_runs.ts": () => import("./task_runs"),
23− "./control_plane/storage.ts": () => import("./control_plane/storage"),
24− "./control_plane/credentials.ts": () => import("./control_plane/credentials"),
25− "./control_plane/sources.ts": () => import("./control_plane/sources"),
26− "./control_plane/tool_registry.ts": () => import("./control_plane/tool_registry"),
27− "./control_plane/tools.ts": () => import("./control_plane/tools"),
28− "./control_plane/openapi_ingest.ts": () => import("./control_plane/openapi_ingest"),
29− "./workflow.ts": () => import("./workflow"),
30− "./controlPlane.ts": () => import("./controlPlane"),
31− "./_generated/api.js": () => import("./_generated/api.js"),
32− });
33−
34−const ensureWorkspace = async (
35− t: ReturnType<typeof setup>,
36− workspaceId: string,
37− organizationId: string,
38−): Promise<void> => {
39− await t.mutation(api.controlPlane.upsertWorkspace, {
40− payload: {
41− id: workspaceId,
42− organizationId,
43− name: workspaceId,
44− },
45− });
46−};
47−
48−describe("Convex executor and control-plane", () => {
49− it.effect("executes code via executeRunImpl", () =>
50− Effect.gen(function* () {
51− const originalEnv = {
52− runUrl: process.env.CLOUDFLARE_SANDBOX_RUN_URL,
53− authToken: process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN,
54− callbackUrl: process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL,
55− callbackSecret: process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET,
56− };
57− const originalFetch = globalThis.fetch;
58−
59− try {
60− process.env.CLOUDFLARE_SANDBOX_RUN_URL = "https://sandbox.local/run";
61− process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = "sandbox-token";
62− process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = "https://convex.local/callback";
63− process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = "sandbox-callback-secret";
64−
65− globalThis.fetch = (async (
66− input: RequestInfo | URL,
67− init?: RequestInit,
68− ) => {
69− const url = typeof input === "string" ? input : input.toString();
70− if (url !== "https://sandbox.local/run") {
71− throw new Error(`Unexpected URL: ${url}`);
72− }
73−
74− expect(init?.method).toBe("POST");
75− const headers = new Headers(init?.headers as HeadersInit | undefined);
76− expect(headers.get("authorization")).toBe(
77− "Bearer sandbox-token",
78− );
79−
80− return new Response(
81− JSON.stringify({
82− status: "completed",
83− result: 42,
84− }),
85− {
86− status: 200,
87− headers: {
88− "content-type": "application/json",
89− },
90− },
91− );
92− }) as unknown as typeof fetch;
93−
94− const result = yield* executeRunImpl({
95− code: "return 6 * 7;",
96− });
97−
98− expect(result.status).toBe("completed");
99− expect(result.result).toBe(42);
100− } finally {
101− globalThis.fetch = originalFetch;
102−
103− if (originalEnv.runUrl === undefined) {
104− delete process.env.CLOUDFLARE_SANDBOX_RUN_URL;
105− } else {
106− process.env.CLOUDFLARE_SANDBOX_RUN_URL = originalEnv.runUrl;
107− }
108−
109− if (originalEnv.authToken === undefined) {
110− delete process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN;
111− } else {
112− process.env.CLOUDFLARE_SANDBOX_AUTH_TOKEN = originalEnv.authToken;
113− }
114−
115− if (originalEnv.callbackUrl === undefined) {
116− delete process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL;
117− } else {
118− process.env.CLOUDFLARE_SANDBOX_CALLBACK_URL = originalEnv.callbackUrl;
119− }
120−
121− if (originalEnv.callbackSecret === undefined) {
122− delete process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET;
123− } else {
124− process.env.CLOUDFLARE_SANDBOX_CALLBACK_SECRET = originalEnv.callbackSecret;
125− }
126− }
127− }),
128− );
129−
130− it.effect("upserts, lists, and removes sources", () =>
131− Effect.gen(function* () {
132− const t = setup();
133−
134− const added = (yield* Effect.tryPromise(() =>
135− t.action(api.controlPlane.upsertSource as any, {
136− workspaceId: "ws_1",
137− payload: {
138− id: "src_1",
139− name: "Weather",
140− kind: "openapi",
141− endpoint: "https://example.com/openapi.json",
142− enabled: true,
143− configJson: "{\"baseUrl\":\"https://example.com\"}",
144− status: "draft",
145− sourceHash: null,
146− lastError: null,
147− },
148− }),
149− )) as {
150− id: string;
151− workspaceId: string;
152− name: string;
153− };
154−
155− yield* Effect.tryPromise(() => t.finishAllScheduledFunctions(() => {}));
156−
157− expect(added.id).toBe("src_1");
158− expect(added.workspaceId).toBe("ws_1");
159− expect(added.name).toBe("Weather");
160−
161− const listed = (yield* Effect.tryPromise(() =>
162− t.query(api.controlPlane.listSources, {
163− workspaceId: "ws_1",
164− }),
165− )) as Array<{
166− id: string;
167− }>;
168−
169− expect(listed).toHaveLength(1);
170− expect(listed[0]?.id).toBe("src_1");
171−
172− const removed = (yield* Effect.tryPromise(() =>
173− t.mutation(api.controlPlane.removeSource, {
174− workspaceId: "ws_1",
175− sourceId: "src_1",
176− }),
177− )) as {
178− removed: boolean;
179− };
180−
181− expect(removed.removed).toBe(true);
182−
183− const listedAfterRemove = (yield* Effect.tryPromise(() =>
184− t.query(api.controlPlane.listSources, {
185− workspaceId: "ws_1",
186− }),
187− )) as Array<unknown>;
188−
189− expect(listedAfterRemove).toHaveLength(0);
190− }),
191− );
192−
193− it.effect("upserts, lists, and removes credentials, policies, and storage", () =>
194− Effect.gen(function* () {
195− const t = setup();
196−
197− yield* Effect.tryPromise(() => ensureWorkspace(t, "ws_1", "org_1"));
198−
199− const addedCredential = (yield* Effect.tryPromise(() =>
200− t.action(api.controlPlane.upsertCredentialBinding, {
201− workspaceId: "ws_1",
202− payload: {
203− id: "auth_binding_1",
204− credentialId: "conn_1",
205− scopeType: "workspace",
206− sourceKey: "source:github",
207− provider: "bearer",
208− secretRef: "secret://github/token",
209− accountId: null,
210− additionalHeadersJson: null,
211− boundAuthFingerprint: null,
212− },
213− }),
214− )) as {
215− id: string;
216− workspaceId: string | null;
217− };
218−
219− expect(addedCredential.id).toBe("auth_binding_1");
220− expect(addedCredential.workspaceId).toBe("ws_1");
221−
222− const listedCredentials = (yield* Effect.tryPromise(() =>
223− t.query(api.controlPlane.listCredentialBindings, {
224− workspaceId: "ws_1",
225− }),
226− )) as Array<{
227− id: string;
228− }>;
229−
230− expect(listedCredentials).toHaveLength(1);
231− expect(listedCredentials[0]?.id).toBe("auth_binding_1");
232−
233− const removedCredential = (yield* Effect.tryPromise(() =>
234− t.mutation(api.controlPlane.removeCredentialBinding, {
235− workspaceId: "ws_1",
236− credentialBindingId: "auth_binding_1",
237− }),
238− )) as {
239− removed: boolean;
240− };
241−
242− expect(removedCredential.removed).toBe(true);
243−
244− const listedCredentialsAfterRemove = (yield* Effect.tryPromise(() =>
245− t.query(api.controlPlane.listCredentialBindings, {
246− workspaceId: "ws_1",
247− }),
248− )) as Array<unknown>;
249−
250− expect(listedCredentialsAfterRemove).toHaveLength(0);
251−
252− const addedPolicy = (yield* Effect.tryPromise(() =>
253− t.mutation(api.controlPlane.upsertPolicy, {
254− workspaceId: "ws_1",
255− payload: {
256− id: "pol_1",
257− toolPathPattern: "github.repos.*",
258− decision: "require_approval",
259− },
260− }),
261− )) as {
262− id: string;
263− workspaceId: string;
264− };
265−
266− expect(addedPolicy.id).toBe("pol_1");
267− expect(addedPolicy.workspaceId).toBe("ws_1");
268−
269− const listedPolicies = (yield* Effect.tryPromise(() =>
270− t.query(api.controlPlane.listPolicies, {
271− workspaceId: "ws_1",
272− }),
273− )) as Array<{
274− id: string;
275− }>;
276−
277− expect(listedPolicies).toHaveLength(1);
278− expect(listedPolicies[0]?.id).toBe("pol_1");
279−
280− const removedPolicy = (yield* Effect.tryPromise(() =>
281− t.mutation(api.controlPlane.removePolicy, {
282− workspaceId: "ws_1",
283− policyId: "pol_1",
284− }),
285− )) as {
286− removed: boolean;
287− };
288−
289− expect(removedPolicy.removed).toBe(true);
290−
291− const listedPoliciesAfterRemove = (yield* Effect.tryPromise(() =>
292− t.query(api.controlPlane.listPolicies, {
293− workspaceId: "ws_1",
294− }),
295− )) as Array<unknown>;
296−
297− expect(listedPoliciesAfterRemove).toHaveLength(0);
298−
299− const openedStorage = (yield* Effect.tryPromise(() =>
300− t.mutation(api.controlPlane.openStorageInstance, {
301− workspaceId: "ws_1",
302− payload: {
303− scopeType: "workspace",
304− durability: "ephemeral",
305− provider: "agentfs-local",
306− purpose: "test storage",
307− ttlHours: 1,
308− sessionId: "session_1",
309− },
310− }),
311− )) as {
312− id: string;
313− status: string;
314− };
315−
316− expect(openedStorage.status).toBe("active");
317−
318− const listedStorage = (yield* Effect.tryPromise(() =>
319− t.query(api.controlPlane.listStorageInstances, {
320− workspaceId: "ws_1",
321− }),
322− )) as Array<{
323− id: string;
324− }>;
325−
326− expect(listedStorage).toHaveLength(1);
327− expect(listedStorage[0]?.id).toBe(openedStorage.id);
328−
329− yield* Effect.tryPromise(() =>
330− t.mutation(runtimeInternal.control_plane.storage.upsertStorageFileEntry, {
331− workspaceId: "ws_1",
332− storageInstanceId: openedStorage.id,
333− payload: {
334− path: "/notes/today.txt",
335− content: "hello convex storage",
336− },
337− }),
338− );
339−
340− const listedDirectory = (yield* Effect.tryPromise(() =>
341− t.query(api.controlPlane.listStorageDirectory, {
342− workspaceId: "ws_1",
343− storageInstanceId: openedStorage.id,
344− payload: {
345− path: "/",
346− },
347− }),
348− )) as {
349− path: string;
350− entries: ReadonlyArray<{
351− name: string;
352− path: string;
353− kind: "file" | "directory";
354− }>;
355− };
356−
357− expect(listedDirectory.path).toBe("/");
358− expect(listedDirectory.entries).toHaveLength(1);
359− expect(listedDirectory.entries[0]?.name).toBe("notes");
360− expect(listedDirectory.entries[0]?.kind).toBe("directory");
361−
362− const listedNestedDirectory = (yield* Effect.tryPromise(() =>
363− t.query(api.controlPlane.listStorageDirectory, {
364− workspaceId: "ws_1",
365− storageInstanceId: openedStorage.id,
366− payload: {
367− path: "/notes",
368− },
369− }),
370− )) as {
371− path: string;
372− entries: ReadonlyArray<{
373− name: string;
374− path: string;
375− kind: "file" | "directory";
376− }>;
377− };
378−
379− expect(listedNestedDirectory.path).toBe("/notes");
380− expect(listedNestedDirectory.entries).toHaveLength(1);
381− expect(listedNestedDirectory.entries[0]?.name).toBe("today.txt");
382− expect(listedNestedDirectory.entries[0]?.kind).toBe("file");
383−
384− const readStorageFile = (yield* Effect.tryPromise(() =>
385− t.query(api.controlPlane.readStorageFile, {
386− workspaceId: "ws_1",
387− storageInstanceId: openedStorage.id,
388− payload: {
389− path: "/notes/today.txt",
390− encoding: "utf8",
391− },
392− }),
393− )) as {
394− content: string;
395− };
396−
397− expect(readStorageFile.content).toBe("hello convex storage");
398−
399− yield* Effect.tryPromise(() =>
400− t.mutation(runtimeInternal.control_plane.storage.upsertStorageKvEntry, {
401− workspaceId: "ws_1",
402− storageInstanceId: openedStorage.id,
403− payload: {
404− key: "feature.enabled",
405− valueJson: "true",
406− },
407− }),
408− );
409−
410− const listedKv = (yield* Effect.tryPromise(() =>
411− t.query(api.controlPlane.listStorageKv, {
412− workspaceId: "ws_1",
413− storageInstanceId: openedStorage.id,
414− payload: {
415− prefix: "feature.",
416− },
417− }),
418− )) as {
419− items: Array<{
420− key: string;
421− value: unknown;
422− }>;
423− };
424−
425− expect(listedKv.items).toHaveLength(1);
426− expect(listedKv.items[0]?.key).toBe("feature.enabled");
427− expect(listedKv.items[0]?.value).toBe(true);
428−
429− yield* Effect.tryPromise(() =>
430− t.mutation(api.controlPlane.queryStorageSql, {
431− workspaceId: "ws_1",
432− storageInstanceId: openedStorage.id,
433− payload: {
434− sql: "CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
435− },
436− }),
437− );
438−
439− yield* Effect.tryPromise(() =>
440− t.mutation(api.controlPlane.queryStorageSql, {
441− workspaceId: "ws_1",
442− storageInstanceId: openedStorage.id,
443− payload: {
444− sql: "INSERT INTO kv_store (key, value) VALUES ('hello', 'world');",
445− },
446− }),
447− );
448−
449− const queriedSql = (yield* Effect.tryPromise(() =>
450− t.mutation(api.controlPlane.queryStorageSql, {
451− workspaceId: "ws_1",
452− storageInstanceId: openedStorage.id,
453− payload: {
454− sql: "SELECT key, value FROM kv_store LIMIT 10;",
455− },
456− }),
457− )) as {
458− rowCount: number;
459− columns: Array<string>;
460− rows: Array<Record<string, unknown>>;
461− };
462−
463− expect(queriedSql.rowCount).toBe(1);
464− expect(queriedSql.columns).toEqual(["key", "value"]);
465− expect(queriedSql.rows[0]).toEqual({ key: "hello", value: "world" });
466−
467− const closedStorage = (yield* Effect.tryPromise(() =>
468− t.mutation(api.controlPlane.closeStorageInstance, {
469− workspaceId: "ws_1",
470− storageInstanceId: openedStorage.id,
471− }),
472− )) as {
473− id: string;
474− status: string;
475− };
476−
477− expect(closedStorage.id).toBe(openedStorage.id);
478− expect(closedStorage.status).toBe("closed");
479−
480− const removedStorage = (yield* Effect.tryPromise(() =>
481− t.mutation(api.controlPlane.removeStorageInstance, {
482− workspaceId: "ws_1",
483− storageInstanceId: openedStorage.id,
484− }),
485− )) as {
486− removed: boolean;
487− };
488−
489− expect(removedStorage.removed).toBe(true);
490−
491− const listedStorageAfterRemove = (yield* Effect.tryPromise(() =>
492− t.query(api.controlPlane.listStorageInstances, {
493− workspaceId: "ws_1",
494− }),
495− )) as Array<unknown>;
496−
497− expect(listedStorageAfterRemove).toHaveLength(0);
498− }),
499− );
500−
501− it.effect("upserts and lists organizations/workspaces and tool views", () =>
502− Effect.gen(function* () {
503− const t = setup();
504−
505− const organization = (yield* Effect.tryPromise(() =>
506− t.mutation(api.controlPlane.upsertOrganization, {
507− payload: {
508− id: "org_1",
509− slug: "acme",
510− name: "Acme Inc",
511− status: "active",
512− },
513− }),
514− )) as {
515− id: string;
516− name: string;
517− };
518−
519− expect(organization.id).toBe("org_1");
520− expect(organization.name).toBe("Acme Inc");
521−
522− const organizations = (yield* Effect.tryPromise(() =>
523− t.query(api.controlPlane.listOrganizations, {}),
524− )) as Array<{
525− id: string;
526− }>;
527−
528− expect(organizations).toHaveLength(1);
529− expect(organizations[0]?.id).toBe("org_1");
530−
531− const workspace = (yield* Effect.tryPromise(() =>
532− t.mutation(api.controlPlane.upsertWorkspace, {
533− payload: {
534− id: "ws_1",
535− organizationId: "org_1",
536− name: "Primary Workspace",
537− },
538− }),
539− )) as {
540− id: string;
541− organizationId: string;
542− };
543−
544− expect(workspace.id).toBe("ws_1");
545− expect(workspace.organizationId).toBe("org_1");
546−
547− const workspaces = (yield* Effect.tryPromise(() =>
548− t.query(api.controlPlane.listWorkspaces, {}),
549− )) as Array<{
550− id: string;
551− }>;
552−
553− expect(workspaces).toHaveLength(1);
554− expect(workspaces[0]?.id).toBe("ws_1");
555−
556− const nowForOpenApiSource = Date.now();
557− yield* Effect.tryPromise(() =>
558− t.run(async (ctx) => {
559− await ctx.db.insert("sources", {
560− id: "src_1",
561− workspaceId: "ws_1",
562− name: "Weather API",
563− kind: "openapi",
564− endpoint: "https://example.com/openapi.json",
565− enabled: true,
566− configJson: "{\"baseUrl\":\"https://example.com\"}",
567− status: "draft",
568− sourceHash: null,
569− lastError: null,
570− createdAt: nowForOpenApiSource,
571− updatedAt: nowForOpenApiSource,
572− });
573− }),
574− );
575−
576− const manifest = yield* extractOpenApiManifest("Weather API", {
577− openapi: "3.0.0",
578− info: {
579− title: "Weather API",
580− version: "1.0.0",
581− },
582− paths: {
583− "/weather": {
584− get: {
585− operationId: "getWeather",
586− responses: {
587− "200": {
588− description: "ok",
589− },
590− },
591− },
592− },
593− },
594− });
595−
596− const now = Date.now();
597− const openApiArtifactMeta = (yield* Effect.tryPromise(() =>
598− t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, {
599− protocol: "openapi",
600− contentHash: manifest.sourceHash,
601− extractorVersion: "openapi_v2",
602− toolCount: manifest.tools.length,
603− refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null,
604− }),
605− )) as {
606− artifactId: string;
607− created: boolean;
608− };
609−
610− yield* Effect.tryPromise(() =>
611− t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
612− artifactId: openApiArtifactMeta.artifactId,
613− protocol: "openapi",
614− insertOnly: true,
615− tools: manifest.tools.map((tool) => ({
616− toolId: tool.toolId,
617− name: tool.name,
618− description: tool.description,
619− canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`,
620− operationHash: tool.operationHash,
621− invocationJson: JSON.stringify(tool.invocation),
622− inputSchemaJson: tool.typing?.inputSchemaJson ?? null,
623− outputSchemaJson: tool.typing?.outputSchemaJson ?? null,
624− metadataJson: JSON.stringify({
625− method: tool.method,
626− path: tool.path,
627− }),
628− })),
629− }),
630− );
631−
632− yield* Effect.tryPromise(() =>
633− t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
634− workspaceId: "ws_1",
635− sourceId: "src_1",
636− artifactId: openApiArtifactMeta.artifactId,
637− }),
638− );
639−
640− yield* Effect.tryPromise(() =>
641− t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, {
642− workspaceId: "ws_1",
643− sourceId: "src_1",
644− sourceName: "Weather API",
645− sourceKind: "openapi",
646− artifactId: openApiArtifactMeta.artifactId,
647− namespace: "weather_api_src_1",
648− refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null,
649− rows: manifest.tools.map((tool) => ({
650− toolId: tool.toolId,
651− protocol: "openapi",
652− method: tool.method,
653− path: `weather_api_src_1.${tool.toolId}`,
654− name: tool.name,
655− description: tool.description,
656− searchText: `weather api ${tool.name.toLowerCase()} ${tool.path.toLowerCase()} ${tool.method.toLowerCase()}`,
657− operationHash: tool.operationHash,
658− approvalMode: "auto",
659− status: "active",
660− })),
661− }),
662− );
663−
664− const workspaceTools = (yield* Effect.tryPromise(() =>
665− t.action(api.controlPlane.listWorkspaceTools, {
666− workspaceId: "ws_1",
667− }),
668− )) as Array<{
669− sourceId: string;
670− }>;
671−
672− expect(workspaceTools).toHaveLength(1);
673− expect(workspaceTools[0]?.sourceId).toBe("src_1");
674−
675− const sourceTools = (yield* Effect.tryPromise(() =>
676− t.action(api.controlPlane.listSourceTools, {
677− workspaceId: "ws_1",
678− sourceId: "src_1",
679− }),
680− )) as Array<{
681− sourceId: string;
682− }>;
683−
684− expect(sourceTools).toHaveLength(1);
685− expect(sourceTools[0]?.sourceId).toBe("src_1");
686−
687− const nowForGraphqlSource = Date.now();
688− yield* Effect.tryPromise(() =>
689− t.run(async (ctx) => {
690− await ctx.db.insert("sources", {
691− id: "src_graphql_1",
692− workspaceId: "ws_1",
693− name: "Linear API",
694− kind: "graphql",
695− endpoint: "https://api.linear.app/graphql",
696− enabled: true,
697− configJson: "{}",
698− status: "draft",
699− sourceHash: null,
700− lastError: null,
701− createdAt: nowForGraphqlSource,
702− updatedAt: nowForGraphqlSource,
703− });
704− }),
705− );
706−
707− const graphqlArtifactMeta = (yield* Effect.tryPromise(() =>
708− t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, {
709− protocol: "graphql",
710− contentHash: "graphql_schema_hash_1",
711− extractorVersion: "graphql_v1",
712− toolCount: 2,
713− refHintTableJson: null,
714− }),
715− )) as {
716− artifactId: string;
717− created: boolean;
718− };
719−
720− expect(graphqlArtifactMeta.created).toBe(true);
721−
722− yield* Effect.tryPromise(() =>
723− t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
724− artifactId: graphqlArtifactMeta.artifactId,
725− protocol: "graphql",
726− insertOnly: true,
727− tools: [
728− {
729− toolId: "linear.graphql",
730− name: "Linear GraphQL",
731− description: "Run raw GraphQL queries against Linear",
732− canonicalPath: "raw.graphql",
733− operationHash: "raw_hash",
734− invocationJson: "{}",
735− metadataJson: JSON.stringify({
736− operationType: "raw",
737− fieldName: "graphql",
738− }),
739− },
740− {
741− toolId: "linear.query.viewer",
742− name: "viewer",
743− description: "Current viewer",
744− canonicalPath: "query.viewer",
745− operationHash: "viewer_hash",
746− invocationJson: "{}",
747− metadataJson: JSON.stringify({
748− operationType: "query",
749− fieldName: "viewer",
750− }),
751− },
752− ],
753− }),
754− );
755−
756− yield* Effect.tryPromise(() =>
757− t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
758− workspaceId: "ws_1",
759− sourceId: "src_graphql_1",
760− artifactId: graphqlArtifactMeta.artifactId,
761− }),
762− );
763−
764− yield* Effect.tryPromise(() =>
765− t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, {
766− workspaceId: "ws_1",
767− sourceId: "src_graphql_1",
768− sourceName: "Linear API",
769− sourceKind: "graphql",
770− artifactId: graphqlArtifactMeta.artifactId,
771− namespace: "linear_api_phql_1",
772− refHintTableJson: null,
773− rows: [
774− {
775− toolId: "linear.graphql",
776− protocol: "graphql",
777− method: "post",
778− path: "linear_api_phql_1.linear.graphql",
779− name: "Linear GraphQL",
780− description: "Run raw GraphQL queries against Linear",
781− searchText: "linear api graphql linear.graphql",
782− operationHash: "raw_hash",
783− approvalMode: "auto",
784− status: "active",
785− },
786− {
787− toolId: "linear.query.viewer",
788− protocol: "graphql",
789− method: "post",
790− path: "linear_api_phql_1.linear.query.viewer",
791− name: "viewer",
792− description: "Current viewer",
793− searchText: "linear api graphql viewer linear.query.viewer",
794− operationHash: "viewer_hash",
795− approvalMode: "auto",
796− status: "active",
797− },
798− ],
799− }),
800− );
801−
802− const graphqlSourceTools = (yield* Effect.tryPromise(() =>
803− t.action(api.controlPlane.listSourceTools, {
804− workspaceId: "ws_1",
805− sourceId: "src_graphql_1",
806− }),
807− )) as Array<{
808− sourceId: string;
809− toolId: string;
810− method: string;
811− }>;
812−
813− expect(graphqlSourceTools).toHaveLength(2);
814− expect(graphqlSourceTools[0]?.sourceId).toBe("src_graphql_1");
815− expect(graphqlSourceTools[0]?.method).toBe("post");
816−
817− const workspaceToolsWithGraphql = (yield* Effect.tryPromise(() =>
818− t.action(api.controlPlane.listWorkspaceTools, {
819− workspaceId: "ws_1",
820− }),
821− )) as Array<{
822− sourceId: string;
823− }>;
824−
825− expect(workspaceToolsWithGraphql.some((tool) => tool.sourceId === "src_graphql_1")).toBe(
826− true,
827− );
828−
829− const nowForMcpSource = Date.now();
830−
831− yield* Effect.tryPromise(() =>
832− t.run(async (ctx) => {
833− await ctx.db.insert("sources", {
834− id: "src_mcp_1",
835− workspaceId: "ws_1",
836− name: "Deepwiki",
837− kind: "mcp",
838− endpoint: "https://example.com/mcp",
839− enabled: true,
840− configJson: "{}",
841− status: "draft",
842− sourceHash: null,
843− lastError: null,
844− createdAt: nowForMcpSource,
845− updatedAt: nowForMcpSource,
846− });
847− }),
848− );
849−
850− const mcpArtifactMeta = (yield* Effect.tryPromise(() =>
851− t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, {
852− protocol: "mcp",
853− contentHash: "mcp_source_hash_1",
854− extractorVersion: "mcp_v1",
855− toolCount: 1,
856− refHintTableJson: null,
857− }),
858− )) as {
859− artifactId: string;
860− created: boolean;
861− };
862−
863− expect(mcpArtifactMeta.created).toBe(true);
864−
865− yield* Effect.tryPromise(() =>
866− t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
867− artifactId: mcpArtifactMeta.artifactId,
868− protocol: "mcp",
869− insertOnly: true,
870− tools: [
871− {
872− toolId: "deepwiki.mcp.search_docs",
873− name: "search_docs",
874− description: "Search docs",
875− canonicalPath: "search_docs",
876− operationHash: "mcp_hash_1",
877− invocationJson: "{}",
878− metadataJson: JSON.stringify({
879− toolName: "search_docs",
880− }),
881− },
882− ],
883− }),
884− );
885−
886− yield* Effect.tryPromise(() =>
887− t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
888− workspaceId: "ws_1",
889− sourceId: "src_mcp_1",
890− artifactId: mcpArtifactMeta.artifactId,
891− }),
892− );
893−
894− yield* Effect.tryPromise(() =>
895− t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, {
896− workspaceId: "ws_1",
897− sourceId: "src_mcp_1",
898− sourceName: "Deepwiki",
899− sourceKind: "mcp",
900− artifactId: mcpArtifactMeta.artifactId,
901− namespace: "deepwiki_mcp_1",
902− refHintTableJson: null,
903− rows: [
904− {
905− toolId: "deepwiki.mcp.search_docs",
906− protocol: "mcp",
907− method: "post",
908− path: "deepwiki_mcp_1.deepwiki.mcp.search_docs",
909− name: "search_docs",
910− description: "Search docs",
911− searchText: "deepwiki mcp search docs",
912− operationHash: "mcp_hash_1",
913− approvalMode: "auto",
914− status: "active",
915− },
916− ],
917− }),
918− );
919−
920− const mcpSourceTools = (yield* Effect.tryPromise(() =>
921− t.action(api.controlPlane.listSourceTools, {
922− workspaceId: "ws_1",
923− sourceId: "src_mcp_1",
924− }),
925− )) as Array<{
926− sourceId: string;
927− path: string;
928− }>;
929−
930− expect(mcpSourceTools).toHaveLength(1);
931− expect(mcpSourceTools[0]?.sourceId).toBe("src_mcp_1");
932− expect(mcpSourceTools[0]?.path).toContain("deepwiki.mcp.search_docs");
933−
934− const workspaceToolsWithMcp = (yield* Effect.tryPromise(() =>
935− t.action(api.controlPlane.listWorkspaceTools, {
936− workspaceId: "ws_1",
937− }),
938− )) as Array<{
939− sourceId: string;
940− }>;
941−
942− expect(workspaceToolsWithMcp.some((tool) => tool.sourceId === "src_mcp_1")).toBe(true);
943−
944− const sourceToolsFromWrongWorkspace = (yield* Effect.tryPromise(() =>
945− t.action(api.controlPlane.listSourceTools, {
946− workspaceId: "ws_2",
947− sourceId: "src_1",
948− }),
949− )) as Array<unknown>;
950−
951− expect(sourceToolsFromWrongWorkspace).toHaveLength(0);
952− }),
953− );
954−
955− it.effect("marks graphql sources auth_required when introspection is unauthorized", () =>
956− Effect.gen(function* () {
957− const t = setup();
958− const originalFetch = globalThis.fetch;
959−
960− yield* Effect.tryPromise(() => ensureWorkspace(t, "ws_auth", "org_auth"));
961−
962− globalThis.fetch = (async () =>
963− new Response(
964− JSON.stringify({
965− errors: [
966− {
967− message: "Unauthorized",
968− },
969− ],
970− }),
971− {
972− status: 401,
973− headers: {
974− "content-type": "application/json",
975− },
976− },
977− )) as unknown as typeof fetch;
978−
979− try {
980− const now = Date.now();
981−
982− yield* Effect.tryPromise(() =>
983− t.run(async (ctx) => {
984− await ctx.db.insert("sources", {
985− id: "src_graphql_auth_1",
986− workspaceId: "ws_auth",
987− name: "Linear API",
988− kind: "graphql",
989− endpoint: "https://api.linear.app/graphql",
990− enabled: true,
991− configJson: "{}",
992− status: "draft",
993− sourceHash: null,
994− lastError: null,
995− createdAt: now,
996− updatedAt: now,
997− });
998− }),
999− );
1000−
1001− yield* Effect.tryPromise(() =>
1002− t.action(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {
1003− workspaceId: "ws_auth",
1004− sourceId: "src_graphql_auth_1",
1005− }),
1006− );
1007−
1008− const sources = (yield* Effect.tryPromise(() =>
1009− t.query(api.controlPlane.listSources, {
1010− workspaceId: "ws_auth",
1011− }),
1012− )) as Array<{
1013− status: string;
1014− lastError: string | null;
1015− }>;
1016−
1017− expect(sources).toHaveLength(1);
1018− expect(sources[0]?.status).toBe("auth_required");
1019− expect(sources[0]?.lastError?.toLowerCase()).toContain("unauthor");
1020− } finally {
1021− globalThis.fetch = originalFetch;
1022− }
1023− }),
1024− );
1025−
1026− it.effect("uses credential bindings when ingesting graphql sources", () =>
1027− Effect.gen(function* () {
1028− const t = setup();
1029− const originalFetch = globalThis.fetch;
1030−
1031− yield* Effect.tryPromise(() => ensureWorkspace(t, "ws_cred", "org_cred"));
1032−
1033− globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
1034− const authHeader = new Headers(init?.headers).get("authorization");
1035− if (authHeader !== "Bearer linear_token") {
1036− return new Response(
1037− JSON.stringify({
1038− errors: [
1039− {
1040− message: "Unauthorized",
1041− },
1042− ],
1043− }),
1044− {
1045− status: 401,
1046− headers: {
1047− "content-type": "application/json",
1048− },
1049− },
1050− );
1051− }
1052−
1053− const bodyText = typeof init?.body === "string" ? init.body : "{}";
1054− const payload = JSON.parse(bodyText) as {
1055− query?: string;
1056− variables?: {
1057− name?: string;
1058− };
1059− };
1060−
1061− if (payload.query?.includes("SchemaRoots")) {
1062− return new Response(
1063− JSON.stringify({
1064− data: {
1065− __schema: {
1066− queryType: { name: "Query" },
1067− mutationType: null,
1068− },
1069− },
1070− }),
1071− {
1072− status: 200,
1073− headers: {
1074− "content-type": "application/json",
1075− },
1076− },
1077− );
1078− }
1079−
1080− if (payload.query?.includes("RootFields") && payload.variables?.name === "Query") {
1081− return new Response(
1082− JSON.stringify({
1083− data: {
1084− __type: {
1085− name: "Query",
1086− fields: [
1087− {
1088− name: "viewer",
1089− description: "Current viewer",
1090− args: [],
1091− },
1092− ],
1093− },
1094− },
1095− }),
1096− {
1097− status: 200,
1098− headers: {
1099− "content-type": "application/json",
1100− },
1101− },
1102− );
1103− }
1104−
1105− return new Response(
1106− JSON.stringify({
1107− data: {
1108− __type: {
1109− name: payload.variables?.name ?? "Unknown",
1110− fields: [],
1111− },
1112− },
1113− }),
1114− {
1115− status: 200,
1116− headers: {
1117− "content-type": "application/json",
1118− },
1119− },
1120− );
1121− }) as unknown as typeof fetch;
1122−
1123− try {
1124− const now = Date.now();
1125−
1126− yield* Effect.tryPromise(() =>
1127− t.action(api.controlPlane.upsertCredentialBinding, {
1128− workspaceId: "ws_cred",
1129− payload: {
1130− id: "auth_binding_linear",
1131− credentialId: "conn_linear",
1132− scopeType: "workspace",
1133− sourceKey: "source:src_graphql_cred_1",
1134− provider: "bearer",
1135− secretRef: "linear_token",
1136− accountId: null,
1137− additionalHeadersJson: null,
1138− boundAuthFingerprint: null,
1139− },
1140− }),
1141− );
1142−
1143− yield* Effect.tryPromise(() =>
1144− t.run(async (ctx) => {
1145− await ctx.db.insert("sources", {
1146− id: "src_graphql_cred_1",
1147− workspaceId: "ws_cred",
1148− name: "Linear API",
1149− kind: "graphql",
1150− endpoint: "https://api.linear.app/graphql",
1151− enabled: true,
1152− configJson: "{}",
1153− status: "draft",
1154− sourceHash: null,
1155− lastError: null,
1156− createdAt: now,
1157− updatedAt: now,
1158− });
1159− }),
1160− );
1161−
1162− yield* Effect.tryPromise(() =>
1163− t.action(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {
1164− workspaceId: "ws_cred",
1165− sourceId: "src_graphql_cred_1",
1166− }),
1167− );
1168−
1169− const sources = (yield* Effect.tryPromise(() =>
1170− t.query(api.controlPlane.listSources, {
1171− workspaceId: "ws_cred",
1172− }),
1173− )) as Array<{
1174− status: string;
1175− sourceHash: string | null;
1176− }>;
1177−
1178− expect(sources).toHaveLength(1);
1179− expect(sources[0]?.status).toBe("connected");
1180− expect(sources[0]?.sourceHash).toBeTypeOf("string");
1181−
1182− const sourceTools = (yield* Effect.tryPromise(() =>
1183− t.action(api.controlPlane.listSourceTools, {
1184− workspaceId: "ws_cred",
1185− sourceId: "src_graphql_cred_1",
1186− }),
1187− )) as Array<{
1188− toolId: string;
1189− }>;
1190−
1191− expect(sourceTools.length).toBeGreaterThan(0);
1192− } finally {
1193− globalThis.fetch = originalFetch;
1194− }
1195− }),
1196− );
1197−
1198− it.effect("ingests mcp sources with credential headers", () =>
1199− Effect.gen(function* () {
1200− const t = setup();
1201− const originalFetch = globalThis.fetch;
1202−
1203− yield* Effect.tryPromise(() =>
1204− ensureWorkspace(t, "ws_mcp_ingest", "org_mcp_ingest")
1205− );
1206−
1207− globalThis.fetch = (async (_input: unknown, init?: RequestInit) => {
1208− const authHeader = new Headers(init?.headers).get("authorization");
1209− if (authHeader !== "Bearer mcp_token") {
1210− return new Response(
1211− JSON.stringify({
1212− error: {
1213− code: 401,
1214− message: "Unauthorized",
1215− },
1216− }),
1217− {
1218− status: 401,
1219− headers: {
1220− "content-type": "application/json",
1221− },
1222− },
1223− );
1224− }
1225−
1226− const bodyText = typeof init?.body === "string" ? init.body : "{}";
1227− const payload = JSON.parse(bodyText) as {
1228− id?: string | number;
1229− method?: string;
1230− };
1231−
1232− if (payload.method === "initialize") {
1233− return new Response(
1234− JSON.stringify({
1235− jsonrpc: "2.0",
1236− id: payload.id,
1237− result: {
1238− protocolVersion: "2025-03-26",
1239− capabilities: {
1240− tools: {},
1241− },
1242− serverInfo: {
1243− name: "fake-mcp",
1244− version: "0.1.0",
1245− },
1246− },
1247− }),
1248− {
1249− status: 200,
1250− headers: {
1251− "content-type": "application/json",
1252− },
1253− },
1254− );
1255− }
1256−
1257− if (payload.method === "notifications/initialized") {
1258− return new Response(null, {
1259− status: 204,
1260− });
1261− }
1262−
1263− if (payload.method === "tools/list") {
1264− return new Response(
1265− JSON.stringify({
1266− jsonrpc: "2.0",
1267− id: payload.id,
1268− result: {
1269− tools: [
1270− {
1271− name: "search_docs",
1272− description: "Search docs",
1273− inputSchema: {
1274− type: "object",
1275− properties: {},
1276− },
1277− outputSchema: {
1278− type: "object",
1279− properties: {
1280− results: {
1281− type: "array",
1282− },
1283− },
1284− },
1285− },
1286− ],
1287− },
1288− }),
1289− {
1290− status: 200,
1291− headers: {
1292− "content-type": "application/json",
1293− },
1294− },
1295− );
1296− }
1297−
1298− return new Response(
1299− JSON.stringify({
1300− jsonrpc: "2.0",
1301− id: payload.id,
1302− error: {
1303− code: -32601,
1304− message: "Method not found",
1305− },
1306− }),
1307− {
1308− status: 200,
1309− headers: {
1310− "content-type": "application/json",
1311− },
1312− },
1313− );
1314− }) as unknown as typeof fetch;
1315−
1316− try {
1317− const now = Date.now();
1318−
1319− yield* Effect.tryPromise(() =>
1320− t.action(api.controlPlane.upsertCredentialBinding, {
1321− workspaceId: "ws_mcp_ingest",
1322− payload: {
1323− id: "auth_binding_mcp",
1324− credentialId: "conn_mcp",
1325− scopeType: "workspace",
1326− sourceKey: "source:src_mcp_ingest_1",
1327− provider: "bearer",
1328− secretRef: "mcp_token",
1329− accountId: null,
1330− additionalHeadersJson: null,
1331− boundAuthFingerprint: null,
1332− },
1333− }),
1334− );
1335−
1336− yield* Effect.tryPromise(() =>
1337− t.run(async (ctx) => {
1338− await ctx.db.insert("sources", {
1339− id: "src_mcp_ingest_1",
1340− workspaceId: "ws_mcp_ingest",
1341− name: "Deepwiki",
1342− kind: "mcp",
1343− endpoint: "https://example.com/mcp",
1344− enabled: true,
1345− configJson: "{}",
1346− status: "draft",
1347− sourceHash: null,
1348− lastError: null,
1349− createdAt: now,
1350− updatedAt: now,
1351− });
1352− }),
1353− );
1354−
1355− yield* Effect.tryPromise(() =>
1356− t.action(runtimeInternal.control_plane.openapi_ingest.ingestSourceArtifact, {
1357− workspaceId: "ws_mcp_ingest",
1358− sourceId: "src_mcp_ingest_1",
1359− }),
1360− );
1361−
1362− const sources = (yield* Effect.tryPromise(() =>
1363− t.query(api.controlPlane.listSources, {
1364− workspaceId: "ws_mcp_ingest",
1365− }),
1366− )) as Array<{
1367− status: string;
1368− sourceHash: string | null;
1369− }>;
1370−
1371− expect(sources).toHaveLength(1);
1372− expect(sources[0]?.status).toBe("connected");
1373− expect(sources[0]?.sourceHash).toBeTypeOf("string");
1374−
1375− const sourceTools = (yield* Effect.tryPromise(() =>
1376− t.action(api.controlPlane.listSourceTools, {
1377− workspaceId: "ws_mcp_ingest",
1378− sourceId: "src_mcp_ingest_1",
1379− }),
1380− )) as Array<{
1381− sourceId: string;
1382− path: string;
1383− }>;
1384−
1385− expect(sourceTools).toHaveLength(1);
1386− expect(sourceTools[0]?.sourceId).toBe("src_mcp_ingest_1");
1387− expect(sourceTools[0]?.path).toContain("deepwiki.mcp.search_docs");
1388− } finally {
1389− globalThis.fetch = originalFetch;
1390− }
1391− }),
1392− );
1393−
1394− it.effect("retries OpenAPI invocation once after oauth invalid_token", () =>
1395− Effect.gen(function* () {
1396− const t = setup();
1397− const originalFetch = globalThis.fetch;
1398−
1399− yield* Effect.tryPromise(() =>
1400− ensureWorkspace(t, "ws_oauth_runtime", "org_oauth_runtime")
1401− );
1402−
1403− const observedAuthorizationHeaders: Array<string | null> = [];
1404− let refreshCallCount = 0;
1405−
1406− globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
1407− const url =
1408− typeof input === "string"
1409− ? input
1410− : input instanceof URL
1411− ? input.toString()
1412− : input.url;
1413−
1414− if (url === "https://oauth.example.com/token") {
1415− refreshCallCount += 1;
1416− const bodyText = typeof init?.body === "string" ? init.body : "";
1417− if (!bodyText.includes("grant_type=refresh_token")) {
1418− return new Response(
1419− JSON.stringify({ error: "invalid_request" }),
1420− {
1421− status: 400,
1422− headers: {
1423− "content-type": "application/json",
1424− },
1425− },
1426− );
1427− }
1428−
1429− return new Response(
1430− JSON.stringify({
1431− access_token: "fresh_token",
1432− token_type: "Bearer",
1433− expires_in: 3600,
1434− }),
1435− {
1436− status: 200,
1437− headers: {
1438− "content-type": "application/json",
1439− },
1440− },
1441− );
1442− }
1443−
1444− if (url === "https://api.example.com/weather") {
1445− const authHeader = new Headers(init?.headers).get("authorization");
1446− observedAuthorizationHeaders.push(authHeader);
1447−
1448− if (authHeader === "Bearer stale_token") {
1449− return new Response(
1450− JSON.stringify({
1451− error: "invalid_token",
1452− }),
1453− {
1454− status: 401,
1455− headers: {
1456− "content-type": "application/json",
1457− "www-authenticate": 'Bearer error="invalid_token"',
1458− },
1459− },
1460− );
1461− }
1462−
1463− if (authHeader === "Bearer fresh_token") {
1464− return new Response(
1465− JSON.stringify({
1466− temperature: 72,
1467− }),
1468− {
1469− status: 200,
1470− headers: {
1471− "content-type": "application/json",
1472− },
1473− },
1474− );
1475− }
1476−
1477− return new Response(
1478− JSON.stringify({
1479− error: "unauthorized",
1480− }),
1481− {
1482− status: 401,
1483− headers: {
1484− "content-type": "application/json",
1485− },
1486− },
1487− );
1488− }
1489−
1490− throw new Error(`Unexpected URL: ${url}`);
1491− }) as unknown as typeof fetch;
1492−
1493− try {
1494− const sourceId = "src_openapi_oauth_runtime_1";
1495− const workspaceId = "ws_oauth_runtime";
1496− const accountId = "acct_oauth_runtime";
1497− const runId = "run_oauth_runtime_1";
1498− const callId = "call_oauth_runtime_1";
1499− const now = Date.now();
1500−
1501− yield* Effect.tryPromise(() =>
1502− t.run(async (ctx) => {
1503− await ctx.db.insert("sources", {
1504− id: sourceId,
1505− workspaceId,
1506− name: "Weather API",
1507− kind: "openapi",
1508− endpoint: "https://api.example.com/openapi.json",
1509− enabled: true,
1510− configJson: JSON.stringify({
1511− baseUrl: "https://api.example.com",
1512− }),
1513− status: "draft",
1514− sourceHash: null,
1515− lastError: null,
1516− createdAt: now,
1517− updatedAt: now,
1518− });
1519− }),
1520− );
1521−
1522− const manifest = yield* extractOpenApiManifest("Weather API", {
1523− openapi: "3.0.0",
1524− info: {
1525− title: "Weather API",
1526− version: "1.0.0",
1527− },
1528− paths: {
1529− "/weather": {
1530− get: {
1531− operationId: "getWeather",
1532− responses: {
1533− "200": {
1534− description: "ok",
1535− },
1536− },
1537− },
1538− },
1539− },
1540− });
1541−
1542− const artifactMeta = (yield* Effect.tryPromise(() =>
1543− t.mutation(runtimeInternal.control_plane.tool_registry.upsertArtifactMeta, {
1544− protocol: "openapi",
1545− contentHash: manifest.sourceHash,
1546− extractorVersion: "openapi_v2",
1547− toolCount: manifest.tools.length,
1548− refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null,
1549− }),
1550− )) as {
1551− artifactId: string;
1552− created: boolean;
1553− };
1554−
1555− yield* Effect.tryPromise(() =>
1556− t.mutation(runtimeInternal.control_plane.tool_registry.putArtifactToolsBatch, {
1557− artifactId: artifactMeta.artifactId,
1558− protocol: "openapi",
1559− insertOnly: true,
1560− tools: manifest.tools.map((tool) => ({
1561− toolId: tool.toolId,
1562− name: tool.name,
1563− description: tool.description,
1564− canonicalPath: `${tool.method.toUpperCase()} ${tool.path}`,
1565− operationHash: tool.operationHash,
1566− invocationJson: JSON.stringify(tool.invocation),
1567− inputSchemaJson: tool.typing?.inputSchemaJson ?? null,
1568− outputSchemaJson: tool.typing?.outputSchemaJson ?? null,
1569− metadataJson: JSON.stringify({
1570− method: tool.method,
1571− path: tool.path,
1572− }),
1573− })),
1574− }),
1575− );
1576−
1577− yield* Effect.tryPromise(() =>
1578− t.mutation(runtimeInternal.control_plane.tool_registry.bindSourceToArtifact, {
1579− workspaceId,
1580− sourceId,
1581− artifactId: artifactMeta.artifactId,
1582− }),
1583− );
1584−
1585− const namespace = "weather_api_oauth_runtime_1";
1586−
1587− yield* Effect.tryPromise(() =>
1588− t.mutation(runtimeInternal.control_plane.tool_registry.replaceWorkspaceSourceToolIndex, {
1589− workspaceId,
1590− sourceId,
1591− sourceName: "Weather API",
1592− sourceKind: "openapi",
1593− artifactId: artifactMeta.artifactId,
1594− namespace,
1595− refHintTableJson: manifest.refHintTable ? JSON.stringify(manifest.refHintTable) : null,
1596− rows: manifest.tools.map((tool) => ({
1597− toolId: tool.toolId,
1598− protocol: "openapi",
1599− method: tool.method,
1600− path: `${namespace}.${tool.toolId}`,
1601− name: tool.name,
1602− description: tool.description,
1603− searchText: `weather api ${tool.name.toLowerCase()} ${tool.path.toLowerCase()} ${tool.method.toLowerCase()}`,
1604− operationHash: tool.operationHash,
1605− approvalMode: "auto",
1606− status: "active",
1607− })),
1608− }),
1609− );
1610−
1611− yield* Effect.tryPromise(() =>
1612− t.action(api.controlPlane.upsertCredentialBinding, {
1613− workspaceId,
1614− payload: {
1615− id: "auth_binding_oauth_runtime_1",
1616− credentialId: "conn_oauth_runtime_1",
1617− scopeType: "workspace",
1618− sourceKey: `source:${sourceId}`,
1619− provider: "oauth2",
1620− secretRef: "stale_token",
1621− oauthRefreshToken: "refresh_token_1",
1622− oauthTokenEndpoint: "https://oauth.example.com/token",
1623− oauthClientId: "client_oauth_runtime_1",
1624− accountId: null,
1625− additionalHeadersJson: null,
1626− boundAuthFingerprint: null,
1627− },
1628− }),
1629− );
1630−
1631− yield* Effect.tryPromise(() =>
1632− t.mutation(runtimeInternal.task_runs.startTaskRun, {
1633− workspaceId,
1634− runId,
1635− accountId,
1636− }),
1637− );
1638−
1639− const callResult = (yield* Effect.tryPromise(() =>
1640− t.action(runtimeInternal.source_tool_registry.invokeWorkspaceTool, {
1641− workspaceId,
1642− accountId,
1643− runId,
1644− callId,
1645− toolPath: `${namespace}.${manifest.tools[0]!.toolId}`,
1646− input: {},
1647− }),
1648− )) as {
1649− ok: boolean;
1650− value?: {
1651− status?: number;
1652− body?: unknown;
1653− };
1654− error?: string;
1655− };
1656−
1657− expect(callResult.ok).toBe(true);
1658− expect(callResult.value?.status).toBe(200);
1659− expect(callResult.value?.body).toEqual({
1660− temperature: 72,
1661− });
1662−
1663− expect(refreshCallCount).toBe(1);
1664− expect(observedAuthorizationHeaders).toEqual([
1665− "Bearer stale_token",
1666− "Bearer fresh_token",
1667− ]);
1668− } finally {
1669− globalThis.fetch = originalFetch;
1670− }
1671− }),
1672− );
1673−
1674− it.effect("persists approval state for runtime tool calls", () =>
1675− Effect.gen(function* () {
1676− const t = setup();
1677−
1678− const missingRunDecision = (yield* Effect.tryPromise(() =>
1679− t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, {
1680− workspaceId: "ws_1",
1681− runId: "run_approval_1",
1682− callId: "call_approval_1",
1683− toolPath: "github.repos.delete",
1684− inputPreviewJson: "{}",
1685− defaultMode: "auto",
1686− requireApprovals: true,
1687− retryAfterMs: 333,
1688− }),
1689− )) as {
1690− kind: "approved" | "pending" | "denied";
1691− error?: string;
1692− };
1693−
1694− expect(missingRunDecision.kind).toBe("denied");
1695− expect(missingRunDecision.error).toContain("Unknown run for approval request");
1696−
1697− yield* Effect.tryPromise(() =>
1698− t.mutation(runtimeInternal.task_runs.startTaskRun, {
1699− workspaceId: "ws_1",
1700− runId: "run_approval_1",
1701− accountId: "acct_1",
1702− }),
1703− );
1704−
1705− // First evaluation writes a pending approval row when this runId/callId is unseen.
1706− const firstDecision = (yield* Effect.tryPromise(() =>
1707− t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, {
1708− workspaceId: "ws_1",
1709− runId: "run_approval_1",
1710− callId: "call_approval_1",
1711− toolPath: "github.repos.delete",
1712− inputPreviewJson: "{}",
1713− defaultMode: "auto",
1714− requireApprovals: true,
1715− retryAfterMs: 333,
1716− }),
1717− )) as {
1718− kind: "approved" | "pending" | "denied";
1719− approvalId?: string;
1720− retryAfterMs?: number;
1721− };
1722−
1723− expect(firstDecision.kind).toBe("pending");
1724− expect(firstDecision.retryAfterMs).toBe(333);
1725− expect(firstDecision.approvalId).toBeTypeOf("string");
1726−
1727− const secondDecision = (yield* Effect.tryPromise(() =>
1728− t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, {
1729− workspaceId: "ws_1",
1730− runId: "run_approval_1",
1731− callId: "call_approval_1",
1732− toolPath: "github.repos.delete",
1733− inputPreviewJson: "{}",
1734− defaultMode: "auto",
1735− requireApprovals: true,
1736− retryAfterMs: 333,
1737− }),
1738− )) as {
1739− kind: "approved" | "pending" | "denied";
1740− approvalId?: string;
1741− };
1742−
1743− expect(secondDecision.kind).toBe("pending");
1744− expect(secondDecision.approvalId).toBe(firstDecision.approvalId);
1745−
1746− const approvalId = firstDecision.approvalId;
1747− if (!approvalId) {
1748− throw new Error("expected approval id");
1749− }
1750−
1751− yield* Effect.tryPromise(() =>
1752− t.mutation(api.controlPlane.resolveApproval, {
1753− workspaceId: "ws_1",
1754− approvalId,
1755− payload: {
1756− status: "approved",
1757− reason: "approved by test",
1758− },
1759− }),
1760− );
1761−
1762− const resolvedDecision = (yield* Effect.tryPromise(() =>
1763− t.mutation(runtimeInternal.source_tool_registry.evaluateToolApproval, {
1764− workspaceId: "ws_1",
1765− runId: "run_approval_1",
1766− callId: "call_approval_1",
1767− toolPath: "github.repos.delete",
1768− inputPreviewJson: "{}",
1769− defaultMode: "auto",
1770− requireApprovals: true,
1771− retryAfterMs: 333,
1772− }),
1773− )) as {
1774− kind: "approved" | "pending" | "denied";
1775− };
1776−
1777− expect(resolvedDecision).toEqual({
1778− kind: "approved",
1779− });
1780− }),
1781− );
1782−});
apps/convex/convex/executor.ts+0−29
@@ -1,29 +0,0 @@
1−import {
2− createRunExecutor,
3− type ToolRegistry,
4−} from "@executor-v2/engine";
5−import type { ExecuteRunInput, ExecuteRunResult } from "@executor-v2/sdk";
6−import * as Effect from "effect/Effect";
7−
8−import { createExecuteRuntimeRunInConvex } from "./runtime_execution_port";
9−
10−export type ExecuteRunImplOptions = {
11− toolRegistry?: ToolRegistry;
12− makeRunId?: () => string;
13−};
14−
15−export const executeRunImpl = (
16− input: ExecuteRunInput,
17− options: ExecuteRunImplOptions = {},
18−): Effect.Effect<ExecuteRunResult> => {
19− const runExecutor = createRunExecutor(
20− createExecuteRuntimeRunInConvex({
21− toolRegistry: options.toolRegistry,
22− }),
23− {
24− makeRunId: options.makeRunId,
25− },
26− );
27−
28− return runExecutor.executeRun(input);
29−};
apps/convex/convex/http.ts+0−62
@@ -1,62 +0,0 @@
1−import { httpRouter } from "convex/server";
2−
3−import { controlPlaneHttpHandler } from "./controlPlane";
4−import { mcpHandler } from "./mcp";
5−import {
6− oauthAuthorizationServerHandler,
7− oauthProtectedResourceHandler,
8−} from "./mcp_auth";
9−import { handleToolCallHttp } from "./runtimeCallbacks";
10−
11−const http = httpRouter();
12−
13−http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler });
14−http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler });
15−http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler });
16−http.route({
17− path: "/.well-known/oauth-protected-resource",
18− method: "GET",
19− handler: oauthProtectedResourceHandler,
20−});
21−http.route({
22− path: "/.well-known/oauth-authorization-server",
23− method: "GET",
24− handler: oauthAuthorizationServerHandler,
25−});
26−http.route({
27− path: "/v1/runtime/tool-call",
28− method: "POST",
29− handler: handleToolCallHttp,
30−});
31−http.route({
32− pathPrefix: "/v1/",
33− method: "GET",
34− handler: controlPlaneHttpHandler,
35−});
36−http.route({
37− pathPrefix: "/v1/",
38− method: "POST",
39− handler: controlPlaneHttpHandler,
40−});
41−http.route({
42− pathPrefix: "/v1/",
43− method: "PUT",
44− handler: controlPlaneHttpHandler,
45−});
46−http.route({
47− pathPrefix: "/v1/",
48− method: "PATCH",
49− handler: controlPlaneHttpHandler,
50−});
51−http.route({
52− pathPrefix: "/v1/",
53− method: "DELETE",
54− handler: controlPlaneHttpHandler,
55−});
56−http.route({
57− pathPrefix: "/v1/",
58− method: "OPTIONS",
59− handler: controlPlaneHttpHandler,
60−});
61−
62−export default http;
apps/convex/convex/http_security.ts+0−69
@@ -1,69 +0,0 @@
1−type RequestDenyOptions = {
2− status: number;
3− message: string;
4−};
5−
6−type InternalSecretOptions = {
7− context: string;
8− envVarNames: ReadonlyArray<string>;
9− headerName?: string;
10−};
11−
12−const trim = (value: string | undefined): string | undefined => {
13− const candidate = value?.trim();
14− return candidate && candidate.length > 0 ? candidate : undefined;
15−};
16−
17−const denyRequest = (options: RequestDenyOptions): Response =>
18− Response.json(
19− {
20− error: options.message,
21− },
22− { status: options.status },
23− );
24−
25−const readEnvValue = (name: string): string | undefined => trim(process.env[name]);
26−
27−const readConfiguredSecret = (envVarNames: ReadonlyArray<string>): string | null => {
28− for (const name of envVarNames) {
29− const value = readEnvValue(name);
30− if (value) {
31− return value;
32− }
33− }
34−
35− return null;
36−};
37−
38−const readRequestHeader = (request: Request, name: string): string | null =>
39− trim(request.headers.get(name) ?? undefined) ?? null;
40−
41−export const enforceInternalSecret = (
42− request: Request,
43− options: InternalSecretOptions,
44−): Response | null => {
45− const expectedSecret = readConfiguredSecret(options.envVarNames);
46− if (expectedSecret === null) {
47− return denyRequest({
48− status: 503,
49− message: `${options.context} authentication secret is not configured`,
50− });
51− }
52−
53− const providedSecret = readRequestHeader(request, options.headerName ?? "x-internal-secret");
54− if (providedSecret === null) {
55− return denyRequest({
56− status: 401,
57− message: `${options.context} authentication is required`,
58− });
59− }
60−
61− if (providedSecret !== expectedSecret) {
62− return denyRequest({
63− status: 403,
64− message: `${options.context} authentication failed`,
65− });
66− }
67−
68− return null;
69−};
apps/convex/convex/mcp.ts+0−192
@@ -1,192 +0,0 @@
1−import {
2− buildExecuteToolDescription,
3− defaultExecuteToolDescription,
4− defaultExecuteToolExposureMode,
5− parseExecuteToolExposureMode,
6− type ExecuteToolExposureMode,
7−} from "@executor-v2/engine";
8−import { handleMcpHttpRequest } from "@executor-v2/mcp-gateway";
9−import { createExecutorRunClient } from "@executor-v2/sdk";
10−import * as Effect from "effect/Effect";
11−
12−import { internal } from "./_generated/api";
13−import { httpAction, type ActionCtx } from "./_generated/server";
14−import { executeRunImpl } from "./executor";
15−import {
16− getMcpAuthConfig,
17− unauthorizedMcpResponse,
18− verifyMcpToken,
19−} from "./mcp_auth";
20−import { createConvexSourceToolRegistry } from "./source_tool_registry";
21−
22−const readWorkspaceIdFromRequest = (request: Request): string | null => {
23− const value = new URL(request.url).searchParams.get("workspaceId")?.trim();
24− return value && value.length > 0 ? value : null;
25−};
26−
27−const readToolExposureModeFromRequest = (
28− request: Request,
29− fallbackMode: ExecuteToolExposureMode,
30−): ExecuteToolExposureMode => {
31− const url = new URL(request.url);
32− const rawMode =
33− url.searchParams.get("toolExposureMode") ??
34− url.searchParams.get("toolContextMode") ??
35− undefined;
36−
37− return parseExecuteToolExposureMode(rawMode ?? undefined) ?? fallbackMode;
38−};
39−
40−const resolveExecuteToolDescription = async (
41− mode: ExecuteToolExposureMode,
42− toolRegistry: ReturnType<typeof createConvexSourceToolRegistry>,
43−): Promise<string> => {
44− try {
45− return await Effect.runPromise(
46− buildExecuteToolDescription({
47− toolRegistry,
48− mode,
49− }),
50− );
51− } catch {
52− return defaultExecuteToolDescription;
53− }
54−};
55−
56−const hasWorkspaceAccess = async (
57− ctx: ActionCtx,
58− workspaceId: string,
59− accountId: string,
60−): Promise<boolean> => {
61− try {
62− const workspace = (await ctx.runQuery(
63− runtimeInternal.control_plane.actor.getWorkspaceForActor,
64− { workspaceId },
65− )) as {
66− organizationId: string;
67− createdByAccountId: string | null;
68− } | null;
69−
70− if (!workspace) {
71− return false;
72− }
73−
74− if (workspace.createdByAccountId === accountId) {
75− return true;
76− }
77−
78− const memberships = (await ctx.runQuery(
79− runtimeInternal.control_plane.actor.listOrganizationMembershipsForActor,
80− { accountId },
81− )) as Array<{
82− organizationId: string;
83− }>;
84−
85− return memberships.some((membership) => membership.organizationId === workspace.organizationId);
86− } catch {
87− return false;
88− }
89−};
90−
91−const defaultToolExposureMode =
92− parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ??
93− defaultExecuteToolExposureMode;
94−const runtimeInternal = internal as any;
95−
96−export const mcpHandler = httpAction(async (ctx, request) => {
97− const mcpAuthConfig = getMcpAuthConfig();
98− const workspaceId = readWorkspaceIdFromRequest(request);
99−
100− if (!mcpAuthConfig.enabled) {
101− return Response.json(
102− {
103− error: "MCP OAuth must be configured",
104− },
105− { status: 503 },
106− );
107− }
108−
109− const auth = await verifyMcpToken(request, mcpAuthConfig);
110− if (!auth) {
111− return unauthorizedMcpResponse(request, "No valid bearer token provided.");
112− }
113−
114− if (!workspaceId) {
115− return Response.json(
116− {
117− error: "workspaceId query parameter is required",
118− },
119− { status: 400 },
120− );
121− }
122−
123− const accountId = auth.subject;
124−
125− const authorized = await hasWorkspaceAccess(ctx, workspaceId, accountId);
126− if (!authorized) {
127− return Response.json(
128− {
129− error: "Workspace authorization failed",
130− },
131− { status: 403 },
132− );
133− }
134−
135− const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId, {
136− accountId,
137− });
138− const toolExposureMode = readToolExposureModeFromRequest(
139− request,
140− defaultToolExposureMode,
141− );
142− const executeToolDescription = await resolveExecuteToolDescription(
143− toolExposureMode,
144− toolRegistry,
145− );
146−
147− const runClient = createExecutorRunClient(async (input) => {
148− const runId = `run_${crypto.randomUUID()}`;
149−
150− await ctx.runMutation(runtimeInternal.task_runs.startTaskRun, {
151− workspaceId,
152− runId,
153− accountId,
154− sessionId: "session_mcp",
155− runtimeId: "runtime_local_inproc",
156− codeHash: `code_length_${input.code.length}`,
157− });
158−
159− try {
160− const result = await Effect.runPromise(
161− executeRunImpl(input, {
162− toolRegistry,
163− makeRunId: () => runId,
164− }),
165− );
166−
167− await ctx.runMutation(runtimeInternal.task_runs.finishTaskRun, {
168− workspaceId,
169− runId,
170− status: result.status,
171− error: result.error ?? null,
172− });
173−
174− return result;
175− } catch (cause) {
176− await ctx.runMutation(runtimeInternal.task_runs.finishTaskRun, {
177− workspaceId,
178− runId,
179− status: "failed",
180− error: String(cause),
181− });
182− throw cause;
183− }
184− });
185−
186− return handleMcpHttpRequest(request, {
187− serverName: "executor-v2-convex",
188− serverVersion: "0.0.0",
189− runClient,
190− executeToolDescription,
191− });
192−});
apps/convex/convex/mcp_auth.ts+0−229
@@ -1,229 +0,0 @@
1−import { createRemoteJWKSet, jwtVerify } from "jose";
2−
3−import { httpAction } from "./_generated/server";
4−
5−export const MCP_PATH = "/v1/mcp";
6−
7−type McpAuthConfig = {
8− required: boolean;
9− enabled: boolean;
10− authorizationServer: string | null;
11− jwks: ReturnType<typeof createRemoteJWKSet> | null;
12−};
13−
14−type VerifiedMcpToken = {
15− provider: "workos";
16− subject: string;
17−};
18−
19−const isTruthyEnvValue = (value: string | undefined): boolean => {
20− const normalized = value?.trim().toLowerCase();
21− return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
22−};
23−
24−const parseOptionalBooleanEnv = (
25− value: string | undefined,
26−): boolean | null => {
27− const normalized = value?.trim().toLowerCase();
28− if (!normalized) {
29− return null;
30− }
31−
32− if (["1", "true", "yes", "on"].includes(normalized)) {
33− return true;
34− }
35−
36− if (["0", "false", "no", "off"].includes(normalized)) {
37− return false;
38− }
39−
40− return null;
41−};
42−
43−const mcpAuthRequired = (): boolean => {
44− if (isTruthyEnvValue(process.env.EXECUTOR_ALLOW_UNAUTHENTICATED_MCP)) {
45− return false;
46− }
47−
48− const explicitEnforcement = parseOptionalBooleanEnv(
49− process.env.EXECUTOR_ENFORCE_MCP_AUTH,
50− );
51−
52− if (explicitEnforcement !== null) {
53− return explicitEnforcement;
54− }
55−
56− return true;
57−};
58−
59−const getMcpAuthorizationServer = (): string | null =>
60− process.env.MCP_AUTHORIZATION_SERVER
61− ?? process.env.MCP_AUTHORIZATION_SERVER_URL
62− ?? process.env.WORKOS_AUTHKIT_ISSUER
63− ?? process.env.WORKOS_AUTHKIT_DOMAIN
64− ?? null;
65−
66−export const getMcpAuthConfig = (): McpAuthConfig => {
67− const required = mcpAuthRequired();
68− const authorizationServer = getMcpAuthorizationServer();
69−
70− if (!authorizationServer) {
71− return {
72− required,
73− enabled: false,
74− authorizationServer: null,
75− jwks: null,
76− };
77− }
78−
79− return {
80− required,
81− enabled: true,
82− authorizationServer,
83− jwks: createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)),
84− };
85−};
86−
87−const parseBearerToken = (request: Request): string | null => {
88− const header = request.headers.get("authorization");
89− if (!header || !header.startsWith("Bearer ")) {
90− return null;
91− }
92−
93− const token = header.slice("Bearer ".length).trim();
94− return token.length > 0 ? token : null;
95−};
96−
97−const resourceMetadataUrl = (request: Request): string => {
98− const url = new URL(request.url);
99− const metadata = new URL("/.well-known/oauth-protected-resource", url.origin);
100− metadata.search = url.search;
101−
102− const resource = new URL(url.pathname, url.origin);
103− resource.search = url.search;
104− metadata.searchParams.set("resource", resource.toString());
105−
106− return metadata.toString();
107−};
108−
109−export const unauthorizedMcpResponse = (request: Request, message: string): Response => {
110− const challenge = [
111− 'Bearer error="unauthorized"',
112− 'error_description="Authorization needed"',
113− `resource_metadata="${resourceMetadataUrl(request)}"`,
114− ].join(", ");
115−
116− return Response.json(
117− { error: message },
118− {
119− status: 401,
120− headers: {
121− "WWW-Authenticate": challenge,
122− },
123− },
124− );
125−};
126−
127−export const verifyMcpToken = async (
128− request: Request,
129− config: McpAuthConfig,
130−): Promise<VerifiedMcpToken | null> => {
131− if (!config.enabled || !config.jwks || !config.authorizationServer) {
132− return null;
133− }
134−
135− const token = parseBearerToken(request);
136− if (!token) {
137− return null;
138− }
139−
140− try {
141− const { payload } = await jwtVerify(token, config.jwks, {
142− issuer: config.authorizationServer,
143− });
144−
145− if (typeof payload.sub !== "string" || payload.sub.length === 0) {
146− return null;
147− }
148−
149− const providerClaim = typeof payload.provider === "string" ? payload.provider : undefined;
150− if (providerClaim === "anonymous") {
151− return null;
152− }
153−
154− return {
155− provider: "workos",
156− subject: payload.sub,
157− };
158− } catch (error) {
159− console.error("MCP token verification failed:", error instanceof Error ? error.message : String(error));
160− return null;
161− }
162−};
163−
164−const errorResponseForMissingConfig = (config: McpAuthConfig): Response =>
165− Response.json(
166− {
167− error: config.required
168− ? "MCP OAuth must be configured for cloud deployments"
169− : "MCP OAuth is not configured",
170− },
171− { status: config.required ? 503 : 404 },
172− );
173−
174−export const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => {
175− const config = getMcpAuthConfig();
176− if (!config.enabled || !config.authorizationServer) {
177− return errorResponseForMissingConfig(config);
178− }
179−
180− const url = new URL(request.url);
181− let resource = new URL(MCP_PATH, url.origin);
182− resource.search = url.search;
183−
184− const resourceHint = url.searchParams.get("resource");
185− if (resourceHint) {
186− try {
187− const parsed = new URL(resourceHint);
188− if (parsed.origin !== url.origin) {
189− return Response.json({ error: "resource hint origin must match this server" }, { status: 400 });
190− }
191−
192− if (parsed.pathname === MCP_PATH) {
193− resource = new URL(MCP_PATH, url.origin);
194− resource.search = parsed.search;
195− }
196− } catch {
197− return Response.json({ error: "Invalid resource hint" }, { status: 400 });
198− }
199− }
200−
201− return Response.json({
202− resource: resource.toString(),
203− authorization_servers: [config.authorizationServer],
204− bearer_methods_supported: ["header"],
205− });
206−});
207−
208−export const oauthAuthorizationServerHandler = httpAction(async (_ctx, _request) => {
209− const config = getMcpAuthConfig();
210− if (!config.enabled || !config.authorizationServer) {
211− return errorResponseForMissingConfig(config);
212− }
213−
214− const upstream = new URL("/.well-known/oauth-authorization-server", config.authorizationServer);
215− const response = await fetch(upstream.toString(), {
216− headers: {
217− accept: "application/json",
218− },
219− });
220−
221− const body = await response.text();
222−
223− return new Response(body, {
224− status: response.status,
225− headers: {
226− "content-type": response.headers.get("content-type") ?? "application/json",
227− },
228− });
229−});
apps/convex/convex/run_executor.ts+0−5
@@ -1,5 +0,0 @@
1−import { type ExecuteRunInput } from "@executor-v2/sdk";
2−
3−import { executeRunImpl } from "./executor";
4−
5−export const executeRun = (input: ExecuteRunInput) => executeRunImpl(input);
apps/convex/convex/runtimeCallbacks.ts+0−165
@@ -1,165 +0,0 @@
1−import {
2− RuntimeAdapterError,
3− invokeRuntimeToolCallResult,
4−} from "@executor-v2/engine";
5−import type {
6− RuntimeToolCallRequest,
7− RuntimeToolCallResult,
8−} from "@executor-v2/sdk";
9−import * as Data from "effect/Data";
10−import * as Effect from "effect/Effect";
11−import * as ParseResult from "effect/ParseResult";
12−import * as Schema from "effect/Schema";
13−
14−import { internal } from "./_generated/api";
15−import { httpAction, type ActionCtx } from "./_generated/server";
16−import { enforceInternalSecret } from "./http_security";
17−import { createConvexSourceToolRegistry } from "./source_tool_registry";
18−
19−class RuntimeToolCallBadRequestError extends Data.TaggedError(
20− "RuntimeToolCallBadRequestError",
21−)<{
22− message: string;
23− details: string;
24−}> {}
25−
26−const RuntimeToolCallCredentialContextSchema = Schema.Struct({
27− workspaceId: Schema.String,
28− sourceKey: Schema.String,
29− organizationId: Schema.optional(Schema.NullOr(Schema.String)),
30− accountId: Schema.optional(Schema.NullOr(Schema.String)),
31−});
32−
33−const RuntimeToolCallRequestSchema = Schema.Struct({
34− runId: Schema.String,
35− callId: Schema.String,
36− toolPath: Schema.String,
37− input: Schema.optional(
38− Schema.Record({
39− key: Schema.String,
40− value: Schema.Unknown,
41− }),
42− ),
43− credentialContext: Schema.optional(RuntimeToolCallCredentialContextSchema),
44−});
45−
46−const decodeRuntimeToolCallRequest = Schema.decodeUnknown(RuntimeToolCallRequestSchema);
47−
48−const badRequest = (message: string): Response =>
49− Response.json(
50− {
51− ok: false,
52− kind: "failed",
53− error: message,
54− } satisfies RuntimeToolCallResult,
55− { status: 400 },
56− );
57−
58−const formatBadRequestMessage = (error: RuntimeToolCallBadRequestError): string =>
59− error.details.length > 0 ? `${error.message}: ${error.details}` : error.message;
60−
61−const formatRuntimeAdapterError = (error: RuntimeAdapterError): string =>
62− error.details ? `${error.message}: ${error.details}` : error.message;
63−
64−const formatUnknownDetails = (cause: unknown): string => String(cause);
65−
66−const decodeToolCallRequest = (request: Request): Effect.Effect<RuntimeToolCallRequest, RuntimeToolCallBadRequestError> =>
67− Effect.gen(function* () {
68− const body = yield* Effect.tryPromise({
69− try: () => request.json(),
70− catch: (cause) =>
71− new RuntimeToolCallBadRequestError({
72− message: "Invalid runtime callback request body",
73− details: formatUnknownDetails(cause),
74− }),
75− });
76−
77− return yield* decodeRuntimeToolCallRequest(body).pipe(
78− Effect.mapError(
79− (cause) =>
80− new RuntimeToolCallBadRequestError({
81− message: "Runtime callback request body is invalid",
82− details: ParseResult.TreeFormatter.formatErrorSync(cause),
83− }),
84− ),
85− );
86− });
87−
88−const resolveWorkspaceIdForToolCall = (
89− ctx: ActionCtx,
90− input: RuntimeToolCallRequest,
91−): Effect.Effect<{ workspaceId: string; accountId: string }, RuntimeToolCallBadRequestError> =>
92− Effect.gen(function* () {
93− const runContext = yield* Effect.tryPromise({
94− try: () =>
95− ctx.runQuery(internal.task_runs.getTaskRunContext, {
96− runId: input.runId,
97− }),
98− catch: (cause) =>
99− new RuntimeToolCallBadRequestError({
100− message: "Unable to resolve runtime callback workspace",
101− details: formatUnknownDetails(cause),
102− }),
103− });
104−
105− if (runContext && runContext.workspaceId.trim().length > 0) {
106− const accountId = runContext.accountId.trim();
107− if (accountId.length === 0) {
108− return yield* new RuntimeToolCallBadRequestError({
109− message: "Runtime callback run is missing account identity",
110− details: `runId=${input.runId}`,
111− });
112− }
113−
114− return {
115− workspaceId: runContext.workspaceId.trim(),
116− accountId,
117− };
118− }
119−
120− return yield* new RuntimeToolCallBadRequestError({
121− message: "Runtime callback run is not associated with a workspace",
122− details: `runId=${input.runId}`,
123− });
124− });
125−
126−const handleToolCallHttpEffect = (
127− ctx: ActionCtx,
128− request: Request,
129−): Effect.Effect<Response, never> =>
130− Effect.gen(function* () {
131− const blockedBySecret = enforceInternalSecret(request, {
132− context: "Runtime callback",
133− envVarNames: ["CLOUDFLARE_SANDBOX_CALLBACK_SECRET"],
134− });
135− if (blockedBySecret) {
136− return blockedBySecret;
137− }
138−
139− const input = yield* decodeToolCallRequest(request);
140− const runContext = yield* resolveWorkspaceIdForToolCall(ctx, input);
141−
142− const toolRegistry = createConvexSourceToolRegistry(ctx, runContext.workspaceId, {
143− accountId: runContext.accountId,
144− });
145−
146− const result = yield* invokeRuntimeToolCallResult(toolRegistry, input).pipe(
147− Effect.catchTag("RuntimeAdapterError", (error) =>
148− Effect.succeed<RuntimeToolCallResult>({
149− ok: false,
150− kind: "failed",
151− error: formatRuntimeAdapterError(error),
152− }),
153− ),
154− );
155−
156− return Response.json(result, { status: 200 });
157− }).pipe(
158− Effect.catchTag("RuntimeToolCallBadRequestError", (error) =>
159− Effect.succeed(badRequest(formatBadRequestMessage(error))),
160− ),
161− );
162−
163−export const handleToolCallHttp = httpAction((ctx, request) =>
164− Effect.runPromise(handleToolCallHttpEffect(ctx, request)),
165−);
apps/convex/convex/runtime_adapter.ts+0−10
@@ -1,10 +0,0 @@
1−import {
2− type RuntimeAdapter,
3− type RuntimeExecuteError,
4− type RuntimeExecuteInput,
5−} from "@executor-v2/engine";
6−import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";
7−
8−export type { RuntimeAdapter, RuntimeExecuteError, RuntimeExecuteInput };
9−
10−export { makeCloudflareWorkerLoaderRuntimeAdapter };
apps/convex/convex/runtime_execution_port.ts+0−74
@@ -1,74 +0,0 @@
1−import {
2− RuntimeExecutionPortError,
3− createRuntimeToolCallService,
4− createStaticToolRegistry,
5− type ExecuteRuntimeRun,
6− type ExecuteRuntimeRunInput,
7− type ToolRegistry,
8− makeRuntimeAdapterRegistry,
9− type RuntimeExecuteError,
10−} from "@executor-v2/engine";
11−import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";
12−import * as Effect from "effect/Effect";
13−
14−export type ConvexRuntimeExecutionPortOptions = {
15− toolRegistry?: ToolRegistry;
16−};
17−
18−export const createExecuteRuntimeRunInConvex = (
19− options: ConvexRuntimeExecutionPortOptions = {},
20−): ExecuteRuntimeRun => {
21− const convexRuntimeAdapter = makeCloudflareWorkerLoaderRuntimeAdapter();
22− const runtimeAdapters = makeRuntimeAdapterRegistry([convexRuntimeAdapter]);
23−
24− const toolRegistry =
25− options.toolRegistry ??
26− createStaticToolRegistry({
27− tools: {},
28− });
29− const toolCallService = createRuntimeToolCallService(toolRegistry);
30−
31− return (input: ExecuteRuntimeRunInput) =>
32− Effect.gen(function* () {
33− const runtimeAdapter = yield* runtimeAdapters.get(convexRuntimeAdapter.kind).pipe(
34− Effect.mapError(
35− (error) =>
36− new RuntimeExecutionPortError({
37− operation: "resolve_runtime_adapter",
38− message: error.message,
39− details: null,
40− }),
41− ),
42− );
43−
44− const isAvailable = yield* runtimeAdapter.isAvailable();
45− if (!isAvailable) {
46− return yield* new RuntimeExecutionPortError({
47− operation: "runtime_available",
48− message: `Runtime '${convexRuntimeAdapter.kind}' is not configured in this convex process.`,
49− details:
50− "Set CLOUDFLARE_SANDBOX_RUN_URL, CLOUDFLARE_SANDBOX_AUTH_TOKEN, CLOUDFLARE_SANDBOX_CALLBACK_URL, and CLOUDFLARE_SANDBOX_CALLBACK_SECRET.",
51− });
52− }
53−
54− return yield* runtimeAdapter
55− .execute({
56− runId: input.runId,
57− code: input.code,
58− timeoutMs: input.timeoutMs,
59− toolCallService,
60− })
61− .pipe(
62− Effect.mapError(
63− (error: RuntimeExecuteError) =>
64− new RuntimeExecutionPortError({
65− operation: "runtime_execute",
66− message: error.message,
67− details: error.details,
68− }),
69− ),
70− );
71− });
72−};
73−
74−export const executeRuntimeRunInConvex = createExecuteRuntimeRunInConvex();
apps/convex/convex/schema.ts+0−253
@@ -1,253 +0,0 @@
1−import { defineSchema, defineTable, type TablesFromSchemaDefinition } from "@executor-v2/confect";
2−import {
3− ApprovalSchema,
4− AuthAuditEventSchema,
5− AuthConnectionSchema,
6− AuthMaterialSchema,
7− EventEnvelopeSchema,
8− OAuthStateSchema,
9− OrganizationMembershipSchema,
10− OrganizationSchema,
11− PolicySchema,
12− ProfileSchema,
13− SourceAuthBindingSchema,
14− SourceSchema,
15− StorageInstanceSchema,
16− SyncStateSchema,
17− TaskRunSchema,
18− WorkspaceSchema,
19−} from "@executor-v2/schema";
20−import * as Schema from "effect/Schema";
21−
22−const StorageFileEntrySchema = Schema.Struct({
23− id: Schema.String,
24− storageInstanceId: Schema.String,
25− path: Schema.String,
26− contentBase64: Schema.String,
27− sizeBytes: Schema.Number,
28− updatedAt: Schema.Number,
29−});
30−
31−const StorageKvEntrySchema = Schema.Struct({
32− id: Schema.String,
33− storageInstanceId: Schema.String,
34− key: Schema.String,
35− valueJson: Schema.String,
36− updatedAt: Schema.Number,
37−});
38−
39−const StorageSqlKvEntrySchema = Schema.Struct({
40− id: Schema.String,
41− storageInstanceId: Schema.String,
42− key: Schema.String,
43− value: Schema.String,
44− updatedAt: Schema.Number,
45−});
46−
47−const ArtifactSchema = Schema.Struct({
48− id: Schema.String,
49− protocol: Schema.String,
50− contentHash: Schema.String,
51− extractorVersion: Schema.String,
52− toolCount: Schema.Number,
53− refHintTableJson: Schema.optional(Schema.NullOr(Schema.String)),
54− createdAt: Schema.Number,
55− updatedAt: Schema.Number,
56−});
57−
58−const ArtifactToolSchema = Schema.Struct({
59− id: Schema.String,
60− artifactId: Schema.String,
61− protocol: Schema.String,
62− toolId: Schema.String,
63− name: Schema.String,
64− description: Schema.NullOr(Schema.String),
65− canonicalPath: Schema.String,
66− operationHash: Schema.String,
67− invocationJson: Schema.String,
68− inputSchemaJson: Schema.optional(Schema.NullOr(Schema.String)),
69− outputSchemaJson: Schema.optional(Schema.NullOr(Schema.String)),
70− metadataJson: Schema.optional(Schema.NullOr(Schema.String)),
71− createdAt: Schema.Number,
72− updatedAt: Schema.Number,
73−});
74−
75−const ArtifactSchemaRefSchema = Schema.Struct({
76− id: Schema.String,
77− artifactId: Schema.String,
78− refKey: Schema.String,
79− schemaJson: Schema.String,
80− createdAt: Schema.Number,
81− updatedAt: Schema.Number,
82−});
83−
84−const SourceArtifactBindingSchema = Schema.Struct({
85− id: Schema.String,
86− workspaceId: Schema.String,
87− sourceId: Schema.String,
88− artifactId: Schema.String,
89− updatedAt: Schema.Number,
90−});
91−
92−const SourceIngestArtifactBatchSchema = Schema.Struct({
93− id: Schema.String,
94− workspaceId: Schema.String,
95− sourceId: Schema.String,
96− artifactId: Schema.String,
97− protocol: Schema.String,
98− batchIndex: Schema.Number,
99− toolsJson: Schema.String,
100− updatedAt: Schema.Number,
101−});
102−
103−const WorkspaceToolIndexSchema = Schema.Struct({
104− id: Schema.String,
105− workspaceId: Schema.String,
106− sourceId: Schema.String,
107− sourceName: Schema.String,
108− sourceKind: Schema.String,
109− artifactId: Schema.String,
110− toolId: Schema.String,
111− protocol: Schema.String,
112− method: Schema.String,
113− namespace: Schema.String,
114− path: Schema.String,
115− pathLower: Schema.String,
116− normalizedPath: Schema.String,
117− operationPath: Schema.optional(Schema.NullOr(Schema.String)),
118− name: Schema.String,
119− description: Schema.NullOr(Schema.String),
120− searchText: Schema.String,
121− operationHash: Schema.String,
122− approvalMode: Schema.String,
123− status: Schema.String,
124− refHintTableJson: Schema.optional(Schema.NullOr(Schema.String)),
125− updatedAt: Schema.Number,
126−});
127−
128−export const executorConfectSchema = defineSchema({
129− profiles: defineTable(ProfileSchema).index("by_domainId", ["id"]),
130− organizations: defineTable(OrganizationSchema)
131− .index("by_domainId", ["id"])
132− .index("by_slug", ["slug"]),
133− organizationMemberships: defineTable(OrganizationMembershipSchema)
134− .index("by_domainId", ["id"])
135− .index("by_organizationId", ["organizationId"])
136− .index("by_accountId", ["accountId"])
137− .index("by_organizationId_accountId", ["organizationId", "accountId"]),
138− workspaces: defineTable(WorkspaceSchema)
139− .index("by_domainId", ["id"])
140− .index("by_organizationId", ["organizationId"])
141− .index("by_createdByAccountId", ["createdByAccountId"]),
142− sources: defineTable(SourceSchema)
143− .index("by_domainId", ["id"])
144− .index("by_workspaceId", ["workspaceId"]),
145− artifacts: defineTable(ArtifactSchema)
146− .index("by_domainId", ["id"])
147− .index("by_protocol_contentHash_extractorVersion", [
148− "protocol",
149− "contentHash",
150− "extractorVersion",
151− ]),
152− artifactTools: defineTable(ArtifactToolSchema)
153− .index("by_domainId", ["id"])
154− .index("by_artifactId", ["artifactId"])
155− .index("by_artifactId_toolId", ["artifactId", "toolId"]),
156− artifactSchemaRefs: defineTable(ArtifactSchemaRefSchema)
157− .index("by_domainId", ["id"])
158− .index("by_artifactId", ["artifactId"])
159− .index("by_artifactId_refKey", ["artifactId", "refKey"]),
160− sourceArtifactBindings: defineTable(SourceArtifactBindingSchema)
161− .index("by_domainId", ["id"])
162− .index("by_workspaceId", ["workspaceId"])
163− .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"])
164− .index("by_artifactId", ["artifactId"]),
165− sourceIngestArtifactBatches: defineTable(SourceIngestArtifactBatchSchema)
166− .index("by_domainId", ["id"])
167− .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"])
168− .index("by_workspaceId_sourceId_artifactId_batchIndex", [
169− "workspaceId",
170− "sourceId",
171− "artifactId",
172− "batchIndex",
173− ])
174− .index("by_artifactId", ["artifactId"]),
175− workspaceToolIndex: defineTable(WorkspaceToolIndexSchema)
176− .index("by_domainId", ["id"])
177− .index("by_workspaceId", ["workspaceId"])
178− .index("by_workspaceId_sourceId", ["workspaceId", "sourceId"])
179− .index("by_workspaceId_namespace", ["workspaceId", "namespace"])
180− .index("by_workspaceId_pathLower", ["workspaceId", "pathLower"])
181− .index("by_workspaceId_normalizedPath", ["workspaceId", "normalizedPath"])
182− .index("by_workspaceId_operationHash", ["workspaceId", "operationHash"])
183− .searchIndex("search_text", {
184− searchField: "searchText",
185− filterFields: ["workspaceId", "sourceId", "status", "namespace"],
186− }),
187− authConnections: defineTable(AuthConnectionSchema)
188− .index("by_domainId", ["id"])
189− .index("by_organizationId", ["organizationId"])
190− .index("by_workspaceId", ["workspaceId"])
191− .index("by_accountId", ["accountId"])
192− .index("by_status", ["status"])
193− .index("by_strategy", ["strategy"]),
194− sourceAuthBindings: defineTable(SourceAuthBindingSchema)
195− .index("by_domainId", ["id"])
196− .index("by_sourceId", ["sourceId"])
197− .index("by_connectionId", ["connectionId"])
198− .index("by_workspaceId", ["workspaceId"])
199− .index("by_organizationId", ["organizationId"])
200− .index("by_accountId", ["accountId"]),
201− authMaterials: defineTable(AuthMaterialSchema)
202− .index("by_domainId", ["id"])
203− .index("by_connectionId", ["connectionId"]),
204− oauthState: defineTable(OAuthStateSchema)
205− .index("by_domainId", ["id"])
206− .index("by_connectionId", ["connectionId"])
207− .index("by_expiresAt", ["expiresAt"])
208− .index("by_reauthRequiredAt", ["reauthRequiredAt"]),
209− authAuditEvents: defineTable(AuthAuditEventSchema)
210− .index("by_domainId", ["id"])
211− .index("by_organizationId", ["organizationId"])
212− .index("by_connectionId", ["connectionId"])
213− .index("by_sourceId", ["sourceId"]),
214− policies: defineTable(PolicySchema)
215− .index("by_domainId", ["id"])
216− .index("by_workspaceId", ["workspaceId"]),
217− storageInstances: defineTable(StorageInstanceSchema)
218− .index("by_domainId", ["id"])
219− .index("by_workspaceId", ["workspaceId"])
220− .index("by_organizationId", ["organizationId"]),
221− storageFileEntries: defineTable(StorageFileEntrySchema)
222− .index("by_domainId", ["id"])
223− .index("by_storageInstanceId", ["storageInstanceId"])
224− .index("by_storageInstanceId_path", ["storageInstanceId", "path"]),
225− storageKvEntries: defineTable(StorageKvEntrySchema)
226− .index("by_domainId", ["id"])
227− .index("by_storageInstanceId", ["storageInstanceId"])
228− .index("by_storageInstanceId_key", ["storageInstanceId", "key"]),
229− storageSqlKvEntries: defineTable(StorageSqlKvEntrySchema)
230− .index("by_domainId", ["id"])
231− .index("by_storageInstanceId", ["storageInstanceId"])
232− .index("by_storageInstanceId_key", ["storageInstanceId", "key"]),
233− approvals: defineTable(ApprovalSchema)
234− .index("by_domainId", ["id"])
235− .index("by_workspaceId", ["workspaceId"])
236− .index("by_taskRunId", ["taskRunId"])
237− .index("by_taskRunId_callId", ["taskRunId", "callId"]),
238− taskRuns: defineTable(TaskRunSchema)
239− .index("by_domainId", ["id"])
240− .index("by_workspaceId", ["workspaceId"])
241− .index("by_sessionId", ["sessionId"]),
242− syncStates: defineTable(SyncStateSchema)
243− .index("by_domainId", ["id"])
244− .index("by_workspaceId", ["workspaceId"]),
245− events: defineTable(EventEnvelopeSchema)
246− .index("by_domainId", ["id"])
247− .index("by_workspaceId", ["workspaceId"])
248− .index("by_workspaceId_sequence", ["workspaceId", "sequence"]),
249−});
250−
251−export type ExecutorConfectTables = TablesFromSchemaDefinition<typeof executorConfectSchema>;
252−
253−export default executorConfectSchema.convexSchemaDefinition;
apps/convex/convex/source_tool_registry.ts+0−2128
@@ -1,2128 +0,0 @@
1−import {
2− PersistentToolApprovalPolicyStoreError,
3− RuntimeAdapterError,
4− ToolProviderError,
5− createPersistentToolApprovalPolicy,
6− makeOpenApiToolProvider,
7− makeToolProviderRegistry,
8− type PersistentToolApprovalRecord,
9− type PersistentToolApprovalStore,
10− type ToolApprovalPolicy,
11− type ToolRegistry,
12− type ToolRegistryCatalogNamespacesInput,
13− type ToolRegistryCatalogNamespacesOutput,
14− type ToolRegistryCatalogToolsInput,
15− type ToolRegistryCatalogToolsOutput,
16− type ToolRegistryDiscoverDepth,
17− type ToolRegistryDiscoverInput,
18− type ToolRegistryDiscoverOutput,
19− type ToolRegistryDiscoverQueryResult,
20− type ToolRegistryToolSummary,
21−} from "@executor-v2/engine";
22−import {
23− ApprovalSchema,
24− OpenApiInvocationPayloadSchema,
25− type Approval,
26− type CanonicalToolDescriptor,
27− type Source,
28−} from "@executor-v2/schema";
29−import type { RuntimeToolCallResult } from "@executor-v2/sdk";
30−import { v } from "convex/values";
31−import * as Effect from "effect/Effect";
32−import * as ParseResult from "effect/ParseResult";
33−import * as Schema from "effect/Schema";
34−
35−import { internal } from "./_generated/api";
36−import { internalAction, internalMutation, type ActionCtx } from "./_generated/server";
37−
38−const runtimeInternal = internal as any;
39−
40−const decodeApproval = Schema.decodeUnknownSync(ApprovalSchema);
41−const decodeOpenApiInvocationPayload = Schema.decodeUnknownSync(OpenApiInvocationPayloadSchema);
42−
43−const sourceToolRegistryRuntimeKind = "source-tool-registry";
44−const defaultPendingRetryAfterMs = 1_000;
45−const maxCatalogNamespacesLimit = 5_000;
46−const maxCatalogToolsLimit = 50_000;
47−const maxUnknownToolSuggestions = 3;
48−
49−const readBooleanFlag = (value: string | undefined): boolean => {
50− const normalized = value?.trim().toLowerCase();
51− return normalized === "1" || normalized === "true" || normalized === "yes";
52−};
53−
54−const requireToolApprovalsByDefault = readBooleanFlag(
55− process.env.CONVEX_REQUIRE_TOOL_APPROVALS,
56−);
57−
58−const stripConvexSystemFields = (
59− value: Record<string, unknown>,
60−): Record<string, unknown> => {
61− const { _id: _ignoredId, _creationTime: _ignoredCreationTime, ...rest } = value;
62− return rest;
63−};
64−
65−const toRuntimeAdapterError = (
66− operation: string,
67− message: string,
68− details: string | null,
69−): RuntimeAdapterError =>
70− new RuntimeAdapterError({
71− operation,
72− runtimeKind: sourceToolRegistryRuntimeKind,
73− message,
74− details,
75− });
76−
77−const normalizePendingRetryAfterMs = (value: number | undefined): number => {
78− if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
79− return defaultPendingRetryAfterMs;
80− }
81−
82− return Math.round(value);
83−};
84−
85−const normalizeDeniedError = (value: string | undefined, toolPath: string): string => {
86− const normalized = value?.trim();
87− if (normalized && normalized.length > 0) {
88− return normalized;
89− }
90−
91− return `Tool call denied: ${toolPath}`;
92−};
93−
94−const toToolCallResultFromDecision = (
95− decision:
96− | { kind: "approved" }
97− | { kind: "pending"; approvalId: string; retryAfterMs?: number; error?: string }
98− | { kind: "denied"; error: string },
99− toolPath: string,
100−): RuntimeToolCallResult => {
101− if (decision.kind === "approved") {
102− return {
103− ok: true,
104− value: undefined,
105− };
106− }
107−
108− if (decision.kind === "pending") {
109− return {
110− ok: false,
111− kind: "pending",
112− approvalId: decision.approvalId,
113− retryAfterMs: normalizePendingRetryAfterMs(decision.retryAfterMs),
114− error: decision.error,
115− };
116− }
117−
118− return {
119− ok: false,
120− kind: "denied",
121− error: normalizeDeniedError(decision.error, toolPath),
122− };
123−};
124−
125−const serializeInputPreview = (input: Record<string, unknown> | undefined): string => {
126− try {
127− return JSON.stringify(input ?? {});
128− } catch {
129− return "{}";
130− }
131−};
132−
133−const toPersistentApprovalStoreError = (
134− operation: string,
135− message: string,
136− details: string | null,
137−): PersistentToolApprovalPolicyStoreError =>
138− new PersistentToolApprovalPolicyStoreError({
139− operation,
140− message,
141− details,
142− });
143−
144−const toPersistentApprovalRecord = (
145− approval: Approval,
146−): PersistentToolApprovalRecord => ({
147− approvalId: approval.id,
148− workspaceId: approval.workspaceId,
149− runId: approval.taskRunId,
150− callId: approval.callId,
151− toolPath: approval.toolPath,
152− status: approval.status,
153− reason: approval.reason,
154−});
155−
156−const normalizeToolPathForLookup = (path: string): string =>
157− path
158− .trim()
159− .toLowerCase()
160− .replace(/[^a-z0-9]+/g, ".")
161− .replace(/\.+/g, ".")
162− .replace(/^\.|\.$/g, "");
163−
164−const safeJsonParse = (value: string): unknown => {
165− try {
166− return JSON.parse(value);
167− } catch {
168− return null;
169− }
170−};
171−
172−const jsonObjectFromUnknown = (value: unknown): Record<string, unknown> => {
173− if (!value || typeof value !== "object" || Array.isArray(value)) {
174− return {};
175− }
176−
177− return value as Record<string, unknown>;
178−};
179−
180−const normalizeHeaderString = (value: unknown): string | null => {
181− if (typeof value !== "string") {
182− return null;
183− }
184−
185− const trimmed = value.trim();
186− return trimmed.length > 0 ? trimmed : null;
187−};
188−
189−const toStringRecord = (value: Record<string, unknown>): Record<string, string> => {
190− const normalized: Record<string, string> = {};
191−
192− for (const [rawKey, rawValue] of Object.entries(value)) {
193− const key = normalizeHeaderString(rawKey);
194− const headerValue = normalizeHeaderString(rawValue);
195− if (key && headerValue) {
196− normalized[key] = headerValue;
197− }
198− }
199−
200− return normalized;
201−};
202−
203−const parseSourceConfig = (source: Source): Record<string, unknown> =>
204− jsonObjectFromUnknown(safeJsonParse(source.configJson));
205−
206−const collectConfiguredHeadersFromSourceConfig = (
207− config: Record<string, unknown>,
208−): Record<string, string> => {
209− const headers = jsonObjectFromUnknown(config.headers);
210− const nextHeaders: Record<string, string> = {};
211−
212− for (const [rawKey, rawValue] of Object.entries(headers)) {
213− const key = normalizeHeaderString(rawKey);
214− const value = normalizeHeaderString(rawValue);
215− if (key && value) {
216− nextHeaders[key] = value;
217− }
218− }
219−
220− return nextHeaders;
221−};
222−
223−const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => {
224− const merged: Record<string, string> = {};
225− const keyByLower = new Map<string, string>();
226−
227− for (const headers of sets) {
228− for (const [rawKey, rawValue] of Object.entries(headers)) {
229− const key = rawKey.trim();
230− const value = rawValue.trim();
231− if (key.length === 0 || value.length === 0) {
232− continue;
233− }
234−
235− const normalizedKey = key.toLowerCase();
236− const existingKey = keyByLower.get(normalizedKey);
237− if (existingKey && existingKey !== key) {
238− delete merged[existingKey];
239− }
240−
241− keyByLower.set(normalizedKey, key);
242− merged[key] = value;
243− }
244− }
245−
246− return merged;
247−};
248−
249−const resolveSourceCredentialHeadersForInvocation = async (
250− ctx: ActionCtx,
251− source: Source,
252− accountId: string,
253− options?: {
254− forceRefresh?: boolean;
255− },
256−): Promise<{
257− headers: Record<string, string>;
258− oauthConnectionId: string | null;
259−}> => {
260− try {
261− const resolved = (await ctx.runAction(
262− runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders,
263− {
264− workspaceId: source.workspaceId,
265− sourceId: source.id,
266− accountId,
267− ...(options?.forceRefresh === true ? { forceRefresh: true } : {}),
268− },
269− )) as { headers?: unknown; oauthConnectionId?: unknown };
270−
271− const headers = jsonObjectFromUnknown(resolved.headers);
272− const normalized: Record<string, string> = {};
273−
274− for (const [rawKey, rawValue] of Object.entries(headers)) {
275− const key = normalizeHeaderString(rawKey);
276− const value = normalizeHeaderString(rawValue);
277− if (key && value) {
278− normalized[key] = value;
279− }
280− }
281−
282− return {
283− headers: normalized,
284− oauthConnectionId:
285− typeof resolved.oauthConnectionId === "string" && resolved.oauthConnectionId.trim().length > 0
286− ? resolved.oauthConnectionId
287− : null,
288− };
289− } catch {
290− return {
291− headers: {},
292− oauthConnectionId: null,
293− };
294− }
295−};
296−
297−const resolveSourceHeadersForInvocation = async (
298− ctx: ActionCtx,
299− source: Source,
300− accountId: string,
301− options?: {
302− forceRefresh?: boolean;
303− },
304−): Promise<{
305− headers: Record<string, string>;
306− oauthConnectionId: string | null;
307−}> => {
308− const config = parseSourceConfig(source);
309− const credentialResolved = await resolveSourceCredentialHeadersForInvocation(
310− ctx,
311− source,
312− accountId,
313− options,
314− );
315−
316− return {
317− headers: mergeHeaders(
318− collectConfiguredHeadersFromSourceConfig(config),
319− credentialResolved.headers,
320− ),
321− oauthConnectionId: credentialResolved.oauthConnectionId,
322− };
323−};
324−
325−const sourceWithInvocationHeaders = (
326− source: Source,
327− invocationHeaders: Record<string, string>,
328−): Source => {
329− const config = parseSourceConfig(source);
330− const mergedHeaders = mergeHeaders(
331− collectConfiguredHeadersFromSourceConfig(config),
332− invocationHeaders,
333− );
334−
335− const nextConfig: Record<string, unknown> = {
336− ...config,
337− };
338−
339− if (Object.keys(mergedHeaders).length > 0) {
340− nextConfig.headers = mergedHeaders;
341− } else {
342− delete nextConfig.headers;
343− }
344−
345− return {
346− ...source,
347− configJson: JSON.stringify(nextConfig),
348− };
349−};
350−
351−const readHeaderCaseInsensitive = (
352− headers: Record<string, string>,
353− name: string,
354−): string | null => {
355− const target = name.toLowerCase();
356−
357− for (const [key, value] of Object.entries(headers)) {
358− if (key.toLowerCase() === target) {
359− return value;
360− }
361− }
362−
363− return null;
364−};
365−
366−const textHasInvalidTokenMarker = (value: string): boolean => {
367− const normalized = value.toLowerCase();
368− return normalized.includes("invalid_token") || normalized.includes("invalid token");
369−};
370−
371−const valueHasInvalidTokenMarker = (value: unknown): boolean => {
372− if (typeof value === "string") {
373− return textHasInvalidTokenMarker(value);
374− }
375−
376− if (Array.isArray(value)) {
377− return value.some((entry) => valueHasInvalidTokenMarker(entry));
378− }
379−
380− if (!value || typeof value !== "object") {
381− return false;
382− }
383−
384− const record = value as Record<string, unknown>;
385− if (valueHasInvalidTokenMarker(record.error)) {
386− return true;
387− }
388−
389− if (valueHasInvalidTokenMarker(record.error_description)) {
390− return true;
391− }
392−
393− if (valueHasInvalidTokenMarker(record.message)) {
394− return true;
395− }
396−
397− if (valueHasInvalidTokenMarker(record.code)) {
398− return true;
399− }
400−
401− if (Array.isArray(record.errors) && record.errors.some((entry) => valueHasInvalidTokenMarker(entry))) {
402− return true;
403− }
404−
405− return Object.values(record).some((entry) => valueHasInvalidTokenMarker(entry));
406−};
407−
408−type HttpInvocationEnvelope = {
409− status: number | null;
410− headers: Record<string, string>;
411− body: unknown;
412−};
413−
414−const toHttpInvocationEnvelope = (output: unknown): HttpInvocationEnvelope => {
415− if (!output || typeof output !== "object" || Array.isArray(output)) {
416− return {
417− status: null,
418− headers: {},
419− body: null,
420− };
421− }
422−
423− const record = output as Record<string, unknown>;
424− const rawStatus = record.status;
425− const rawHeaders = record.headers;
426−
427− return {
428− status:
429− typeof rawStatus === "number" && Number.isFinite(rawStatus)
430− ? Math.floor(rawStatus)
431− : null,
432− headers:
433− rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders)
434− ? toStringRecord(rawHeaders as Record<string, unknown>)
435− : {},
436− body: record.body,
437− };
438−};
439−
440−const responseHasInvalidTokenSignal = (envelope: HttpInvocationEnvelope): boolean => {
441− if (envelope.status !== 401) {
442− return false;
443− }
444−
445− const wwwAuthenticate = readHeaderCaseInsensitive(envelope.headers, "www-authenticate");
446− if (wwwAuthenticate && textHasInvalidTokenMarker(wwwAuthenticate)) {
447− return true;
448− }
449−
450− return valueHasInvalidTokenMarker(envelope.body);
451−};
452−
453−const shouldRetryOAuthAfterUnauthorized = (
454− oauthConnectionId: string | null,
455− output: unknown,
456−): boolean => {
457− if (!oauthConnectionId) {
458− return false;
459− }
460−
461− return responseHasInvalidTokenSignal(toHttpInvocationEnvelope(output));
462−};
463−
464−const headersToRecord = (headers: Headers): Record<string, string> => {
465− const record: Record<string, string> = {};
466− headers.forEach((value, key) => {
467− record[key] = value;
468− });
469− return record;
470−};
471−
472−const GraphqlInvocationArgSchema = Schema.Struct({
473− name: Schema.String,
474− description: Schema.NullOr(Schema.String),
475− required: Schema.Boolean,
476− type: Schema.String,
477− defaultValue: Schema.NullOr(Schema.String),
478−});
479−
480−const GraphqlRawInvocationSchema = Schema.Struct({
481− kind: Schema.Literal("graphql_raw"),
482− endpoint: Schema.String,
483−});
484−
485−const GraphqlFieldInvocationSchema = Schema.Struct({
486− kind: Schema.Literal("graphql_field"),
487− endpoint: Schema.String,
488− operationType: Schema.Literal("query", "mutation"),
489− fieldName: Schema.String,
490− args: Schema.Array(GraphqlInvocationArgSchema),
491−});
492−
493−const GraphqlInvocationPayloadSchema = Schema.Union(
494− GraphqlRawInvocationSchema,
495− GraphqlFieldInvocationSchema,
496−);
497−
498−type GraphqlInvocationPayload = typeof GraphqlInvocationPayloadSchema.Type;
499−
500−const decodeGraphqlInvocationPayload = Schema.decodeUnknownSync(GraphqlInvocationPayloadSchema);
501−
502−const McpInvocationPayloadSchema = Schema.Struct({
503− kind: Schema.Literal("mcp_tool"),
504− endpoint: Schema.String,
505− transport: Schema.Literal("streamable-http", "sse"),
506− queryParams: Schema.Record({
507− key: Schema.String,
508− value: Schema.String,
509− }),
510− toolName: Schema.String,
511− inputSchema: Schema.optional(Schema.Unknown),
512− outputSchema: Schema.optional(Schema.Unknown),
513−});
514−
515−type McpInvocationPayload = typeof McpInvocationPayloadSchema.Type;
516−
517−const decodeMcpInvocationPayload = Schema.decodeUnknownSync(McpInvocationPayloadSchema);
518−
519−const asOptionalString = (value: unknown): string | null =>
520− typeof value === "string" && value.trim().length > 0 ? value : null;
521−
522−const normalizeInvokeInput = (input: unknown): Record<string, unknown> => {
523− if (!input || typeof input !== "object" || Array.isArray(input)) {
524− return {};
525− }
526−
527− return input as Record<string, unknown>;
528−};
529−
530−const createGraphqlOperationQuery = (
531− payload: Extract<GraphqlInvocationPayload, { kind: "graphql_field" }>,
532− args: Record<string, unknown>,
533−): {
534− query: string;
535− variables: Record<string, unknown>;
536−} => {
537− const definitions: Array<string> = [];
538− const callArgs: Array<string> = [];
539− const variables: Record<string, unknown> = {};
540−
541− for (const arg of payload.args) {
542− const value = args[arg.name];
543−
544− if (value === undefined || value === null) {
545− if (arg.required) {
546− throw new Error(`Missing required GraphQL argument: ${arg.name}`);
547− }
548−
549− continue;
550− }
551−
552− const varType = arg.type.trim().length > 0 ? arg.type : "String";
553− definitions.push(`$${arg.name}: ${varType}`);
554− callArgs.push(`${arg.name}: $${arg.name}`);
555− variables[arg.name] = value;
556− }
557−
558− const variableDefs = definitions.length > 0 ? `(${definitions.join(", ")})` : "";
559− const fieldArgs = callArgs.length > 0 ? `(${callArgs.join(", ")})` : "";
560− const selectionSet = asOptionalString(args.selectionSet);
561− const fieldSelection =
562− selectionSet && selectionSet.length > 0
563− ? `${payload.fieldName}${fieldArgs} { ${selectionSet} }`
564− : `${payload.fieldName}${fieldArgs}`;
565− const query = `${payload.operationType}${variableDefs} { ${fieldSelection} }`;
566−
567− return { query, variables };
568−};
569−
570−const graphqlResponseBody = async (response: Response): Promise<unknown> => {
571− const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
572− if (contentType.includes("application/json")) {
573− return await response.json();
574− }
575−
576− return await response.text();
577−};
578−
579−const createMcpEndpointUrl = (payload: McpInvocationPayload): URL => {
580− const url = new URL(payload.endpoint);
581−
582− for (const [key, value] of Object.entries(payload.queryParams)) {
583− url.searchParams.set(key, value);
584− }
585−
586− return url;
587−};
588−
589−const postMcpJsonRpc = async (
590− endpoint: URL,
591− body: unknown,
592− sessionId: string | null,
593− sourceHeaders: Record<string, string>,
594−): Promise<Response> => {
595− const headers = new Headers({
596− "content-type": "application/json",
597− accept: "application/json, text/event-stream",
598− });
599−
600− for (const [key, value] of Object.entries(sourceHeaders)) {
601− headers.set(key, value);
602− }
603−
604− if (sessionId && sessionId.trim().length > 0) {
605− headers.set("mcp-session-id", sessionId);
606− }
607−
608− return await fetch(endpoint, {
609− method: "POST",
610− headers,
611− body: JSON.stringify(body),
612− });
613−};
614−
615−const decodeMcpJsonResponse = async (response: Response): Promise<Record<string, unknown>> => {
616− const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
617−
618− if (!contentType.includes("application/json")) {
619− const text = await response.text();
620− return { raw: text };
621− }
622−
623− return jsonObjectFromUnknown(await response.json());
624−};
625−
626−const describeOutput = (value: unknown): string => {
627− if (typeof value === "string") {
628− return value;
629− }
630−
631− try {
632− return JSON.stringify(value);
633− } catch {
634− return String(value);
635− }
636−};
637−
638−const deriveHintFromSchemaJson = (
639− schemaJson: string | null | undefined,
640− fallback: string,
641−): string => {
642− if (!schemaJson) {
643− return fallback;
644− }
645−
646− const schema = safeJsonParse(schemaJson);
647− if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
648− return fallback;
649− }
650−
651− const schemaRecord = schema as Record<string, unknown>;
652− const title = schemaRecord.title;
653− if (typeof title === "string" && title.trim().length > 0) {
654− return title.trim();
655− }
656−
657− const type = schemaRecord.type;
658− if (type === "object") {
659− const properties = schemaRecord.properties;
660− if (properties && typeof properties === "object" && !Array.isArray(properties)) {
661− const keys = Object.keys(properties);
662− if (keys.length > 0) {
663− const shown = keys.slice(0, 3).join(", ");
664− return keys.length <= 3 ? `object { ${shown} }` : `object { ${shown}, ... }`;
665− }
666− }
667−
668− return "object";
669− }
670−
671− if (type === "array") {
672− return "array";
673− }
674−
675− if (typeof type === "string") {
676− return type;
677− }
678−
679− return fallback;
680−};
681−
682−type WorkspaceToolIndexRow = {
683− workspaceId: string;
684− sourceId: string;
685− sourceName: string;
686− sourceKind: string;
687− artifactId: string;
688− toolId: string;
689− protocol: string;
690− namespace: string;
691− path: string;
692− pathLower: string;
693− normalizedPath: string;
694− name: string;
695− description: string | null;
696− operationHash: string;
697− approvalMode: string;
698− status: string;
699− refHintTableJson?: string | null;
700−};
701−
702−type ArtifactToolRow = {
703− artifactId: string;
704− protocol: string;
705− toolId: string;
706− name: string;
707− description: string | null;
708− canonicalPath: string;
709− operationHash: string;
710− invocationJson: string;
711− inputSchemaJson?: string | null;
712− outputSchemaJson?: string | null;
713− metadataJson?: string | null;
714−};
715−
716−const toToolProviderDetails = (cause: unknown): string =>
717− ParseResult.isParseError(cause)
718− ? ParseResult.TreeFormatter.formatErrorSync(cause)
719− : String(cause);
720−
721−const toToolProviderError = (
722− providerKind: "openapi" | "graphql" | "mcp",
723− operation: string,
724− message: string,
725− cause: unknown,
726−): ToolProviderError =>
727− new ToolProviderError({
728− providerKind,
729− operation,
730− message,
731− details: toToolProviderDetails(cause),
732− });
733−
734−const tokenizeSearchQuery = (query: string): Array<string> =>
735− Array.from(
736− new Set(
737− query
738− .toLowerCase()
739− .split(/[\s._:/-]+/)
740− .map((part) => part.trim())
741− .filter((part) => part.length > 0),
742− ),
743− );
744−
745−const normalizeDiscoverDepth = (value: unknown): ToolRegistryDiscoverDepth => {
746− if (typeof value !== "number" || !Number.isFinite(value)) {
747− return 1;
748− }
749−
750− if (value <= 0) {
751− return 0;
752− }
753−
754− if (value >= 2) {
755− return 2;
756− }
757−
758− return 1;
759−};
760−
761−type NormalizedDiscoverQuery = {
762− text: string;
763− lowerText: string;
764− depth: ToolRegistryDiscoverDepth;
765−};
766−
767−const normalizeDiscoverQueries = (
768− input: ToolRegistryDiscoverInput,
769−): Array<NormalizedDiscoverQuery> => {
770− const normalized = (input.queries ?? []).map((query) => {
771− const text = query.text.trim();
772− return {
773− text,
774− lowerText: text.toLowerCase(),
775− depth: normalizeDiscoverDepth(query.depth),
776− };
777− });
778−
779− if (normalized.length > 0) {
780− return normalized;
781− }
782−
783− const fallbackText = (input.query ?? "").trim();
784− return [
785− {
786− text: fallbackText,
787− lowerText: fallbackText.toLowerCase(),
788− depth: 1,
789− },
790− ];
791−};
792−
793−const scoreSummary = (
794− summary: ToolRegistryToolSummary,
795− query: string,
796− depth: ToolRegistryDiscoverDepth = 1,
797−): number => {
798− if (query.length === 0) {
799− return 1;
800− }
801−
802− const lowerQuery = query.toLowerCase();
803− const lowerPath = summary.path.toLowerCase();
804− const lowerSource = (summary.source ?? "").toLowerCase();
805− const lowerDescription = (summary.description ?? "").toLowerCase();
806−
807− if (lowerPath === lowerQuery) {
808− return 100;
809− }
810−
811− if (lowerPath.startsWith(lowerQuery)) {
812− return 80;
813− }
814−
815− if (lowerPath.includes(lowerQuery)) {
816− return 60;
817− }
818−
819− if (lowerSource.includes(lowerQuery)) {
820− return 40;
821− }
822−
823− if (lowerDescription.includes(lowerQuery)) {
824− return 30;
825− }
826−
827− if (depth === 0) {
828− return 0;
829− }
830−
831− const tokens = tokenizeSearchQuery(lowerQuery);
832− if (tokens.length === 0) {
833− return 0;
834− }
835−
836− let pathMatches = 0;
837− let sourceMatches = 0;
838− let descriptionMatches = 0;
839−
840− for (const token of tokens) {
841− if (lowerPath.includes(token)) {
842− pathMatches += 1;
843− continue;
844− }
845−
846− if (lowerSource.includes(token)) {
847− sourceMatches += 1;
848− continue;
849− }
850−
851− if (depth >= 2 && lowerDescription.includes(token)) {
852− descriptionMatches += 1;
853− }
854− }
855−
856− const matchedTokens = pathMatches + sourceMatches + descriptionMatches;
857− if (matchedTokens === 0) {
858− return 0;
859− }
860−
861− let score =
862− pathMatches * 18 +
863− sourceMatches * 12 +
864− descriptionMatches * 8;
865−
866− score += matchedTokens === tokens.length ? 20 : 5;
867−
868− return score;
869−};
870−
871−const levenshteinDistance = (left: string, right: string): number => {
872− if (left === right) {
873− return 0;
874− }
875−
876− if (left.length === 0) {
877− return right.length;
878− }
879−
880− if (right.length === 0) {
881− return left.length;
882− }
883−
884− const previous = new Array<number>(right.length + 1);
885− const current = new Array<number>(right.length + 1);
886−
887− for (let column = 0; column <= right.length; column += 1) {
888− previous[column] = column;
889− }
890−
891− for (let row = 1; row <= left.length; row += 1) {
892− current[0] = row;
893−
894− for (let column = 1; column <= right.length; column += 1) {
895− const substitutionCost = left[row - 1] === right[column - 1] ? 0 : 1;
896− current[column] = Math.min(
897− previous[column] + 1,
898− current[column - 1] + 1,
899− previous[column - 1] + substitutionCost,
900− );
901− }
902−
903− for (let column = 0; column <= right.length; column += 1) {
904− previous[column] = current[column];
905− }
906− }
907−
908− return previous[right.length] ?? right.length;
909−};
910−
911−const uniquePaths = (paths: ReadonlyArray<string>): Array<string> => {
912− const seen = new Set<string>();
913− const ordered: Array<string> = [];
914−
915− for (const path of paths) {
916− if (!seen.has(path)) {
917− seen.add(path);
918− ordered.push(path);
919− }
920−
921− if (ordered.length >= maxUnknownToolSuggestions) {
922− break;
923− }
924− }
925−
926− return ordered;
927−};
928−
929−const toToolSummary = (
930− row: WorkspaceToolIndexRow,
931− options: {
932− includeSchemas: boolean;
933− compact: boolean;
934− artifactTool?: ArtifactToolRow | null;
935− },
936−): ToolRegistryToolSummary => {
937− const inputSchemaJson = options.artifactTool?.inputSchemaJson ?? undefined;
938− const outputSchemaJson = options.artifactTool?.outputSchemaJson ?? undefined;
939−
940− return {
941− path: row.path,
942− source: row.sourceName,
943− approval: row.approvalMode === "required" ? "required" : "auto",
944− description: options.compact ? undefined : row.description ?? undefined,
945− inputHint: options.compact
946− ? undefined
947− : options.artifactTool
948− ? deriveHintFromSchemaJson(inputSchemaJson ?? null, "input")
949− : undefined,
950− outputHint: options.compact
951− ? undefined
952− : options.artifactTool
953− ? deriveHintFromSchemaJson(outputSchemaJson ?? null, "output")
954− : undefined,
955− typing:
956− options.includeSchemas && (inputSchemaJson || outputSchemaJson)
957− ? {
958− inputSchemaJson,
959− outputSchemaJson,
960− refHintKeys: [],
961− }
962− : undefined,
963− };
964−};
965−
966−const toSearchScoringSummary = (row: WorkspaceToolIndexRow): ToolRegistryToolSummary => ({
967− path: row.path,
968− source: row.sourceName,
969− approval: row.approvalMode === "required" ? "required" : "auto",
970− description: row.description ?? undefined,
971−});
972−
973−const hydrateRowsWithArtifactTools = (
974− ctx: ActionCtx,
975− rows: ReadonlyArray<WorkspaceToolIndexRow>,
976−): Effect.Effect<Array<{ row: WorkspaceToolIndexRow; artifactTool: ArtifactToolRow | null }>, RuntimeAdapterError> =>
977− Effect.forEach(
978− rows,
979− (row) =>
980− getArtifactToolEffect(ctx, row.artifactId, row.toolId).pipe(
981− Effect.map((artifactTool) => ({
982− row,
983− artifactTool,
984− })),
985− ),
986− { concurrency: 8 },
987− );
988−
989−const parseRefHintTableJson = (value: string | null | undefined): Record<string, string> | null => {
990− if (!value) {
991− return null;
992− }
993−
994− const parsed = safeJsonParse(value);
995− if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
996− return null;
997− }
998−
999− const next: Record<string, string> = {};
1000− for (const [key, raw] of Object.entries(parsed as Record<string, unknown>)) {
1001− if (typeof raw === "string") {
1002− next[key] = raw;
1003− }
1004− }
1005−
1006− return Object.keys(next).length > 0 ? next : null;
1007−};
1008−
1009−const mergeRefHintTables = (
1010− rows: ReadonlyArray<WorkspaceToolIndexRow>,
1011− includeSchemas: boolean,
1012−): Record<string, string> | undefined => {
1013− if (!includeSchemas) {
1014− return undefined;
1015− }
1016−
1017− const merged: Record<string, string> = {};
1018− for (const row of rows) {
1019− const table = parseRefHintTableJson(row.refHintTableJson ?? null);
1020− if (!table) {
1021− continue;
1022− }
1023−
1024− Object.assign(merged, table);
1025− }
1026−
1027− return Object.keys(merged).length > 0 ? merged : undefined;
1028−};
1029−
1030−const listWorkspaceToolsEffect = (
1031− ctx: ActionCtx,
1032− workspaceId: string,
1033− options: {
1034− limit: number;
1035− sourceId?: string;
1036− namespace?: string;
1037− },
1038−): Effect.Effect<Array<WorkspaceToolIndexRow>, RuntimeAdapterError> =>
1039− Effect.tryPromise({
1040− try: async () => {
1041− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
1042− workspaceId,
1043− ...(options.sourceId ? { sourceId: options.sourceId } : {}),
1044− });
1045−
1046− return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listWorkspaceTools, {
1047− workspaceId,
1048− limit: options.limit,
1049− sourceId: options.sourceId,
1050− namespace: options.namespace,
1051− includeDisabled: false,
1052− })) as Array<WorkspaceToolIndexRow>;
1053− },
1054− catch: (cause) =>
1055− toRuntimeAdapterError(
1056− "list_workspace_tools",
1057− "Failed to list indexed workspace tools",
1058− String(cause),
1059− ),
1060− });
1061−
1062−const searchWorkspaceToolsEffect = (
1063− ctx: ActionCtx,
1064− workspaceId: string,
1065− options: {
1066− query: string;
1067− limit: number;
1068− sourceId?: string;
1069− namespace?: string;
1070− },
1071−): Effect.Effect<Array<WorkspaceToolIndexRow>, RuntimeAdapterError> =>
1072− Effect.tryPromise({
1073− try: async () => {
1074− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
1075− workspaceId,
1076− ...(options.sourceId ? { sourceId: options.sourceId } : {}),
1077− });
1078−
1079− return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.searchWorkspaceTools, {
1080− workspaceId,
1081− query: options.query,
1082− limit: options.limit,
1083− sourceId: options.sourceId,
1084− namespace: options.namespace,
1085− includeDisabled: false,
1086− })) as Array<WorkspaceToolIndexRow>;
1087− },
1088− catch: (cause) =>
1089− toRuntimeAdapterError(
1090− "search_workspace_tools",
1091− "Failed to search indexed workspace tools",
1092− String(cause),
1093− ),
1094− });
1095−
1096−const getWorkspaceToolByPathEffect = (
1097− ctx: ActionCtx,
1098− workspaceId: string,
1099− toolPath: string,
1100−): Effect.Effect<WorkspaceToolIndexRow | null, RuntimeAdapterError> =>
1101− Effect.tryPromise({
1102− try: async () => {
1103− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
1104− workspaceId,
1105− });
1106−
1107− return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.getWorkspaceToolByPath, {
1108− workspaceId,
1109− pathLower: toolPath.toLowerCase(),
1110− })) as WorkspaceToolIndexRow | null;
1111− },
1112− catch: (cause) =>
1113− toRuntimeAdapterError(
1114− "resolve_tool_path",
1115− "Failed to resolve tool path",
1116− String(cause),
1117− ),
1118− });
1119−
1120−const listWorkspaceToolsByNormalizedPathEffect = (
1121− ctx: ActionCtx,
1122− workspaceId: string,
1123− normalizedPath: string,
1124−): Effect.Effect<Array<WorkspaceToolIndexRow>, RuntimeAdapterError> =>
1125− Effect.tryPromise({
1126− try: async () => {
1127− await ctx.runAction(runtimeInternal.control_plane.tool_registry.ensureWorkspaceToolIndexCoverage, {
1128− workspaceId,
1129− });
1130−
1131− return (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.listWorkspaceToolsByNormalizedPath, {
1132− workspaceId,
1133− normalizedPath,
1134− limit: 25,
1135− })) as Array<WorkspaceToolIndexRow>;
1136− },
1137− catch: (cause) =>
1138− toRuntimeAdapterError(
1139− "resolve_tool_path",
1140− "Failed to resolve normalized tool path",
1141− String(cause),
1142− ),
1143− });
1144−
1145−const getArtifactToolEffect = (
1146− ctx: ActionCtx,
1147− artifactId: string,
1148− toolId: string,
1149−): Effect.Effect<ArtifactToolRow | null, RuntimeAdapterError> =>
1150− Effect.tryPromise({
1151− try: async () =>
1152− (await ctx.runQuery(runtimeInternal.control_plane.tool_registry.getArtifactTool, {
1153− artifactId,
1154− toolId,
1155− })) as ArtifactToolRow | null,
1156− catch: (cause) =>
1157− toRuntimeAdapterError(
1158− "load_artifact_tool",
1159− `Failed to load artifact tool: ${artifactId}:${toolId}`,
1160− String(cause),
1161− ),
1162− });
1163−
1164−const getSourceForInvocationEffect = (
1165− ctx: ActionCtx,
1166− workspaceId: string,
1167− sourceId: string,
1168−): Effect.Effect<Source | null, RuntimeAdapterError> =>
1169− Effect.tryPromise({
1170− try: async () => {
1171− const source = (await ctx.runQuery(runtimeInternal.control_plane.sources.getSourceForIngest, {
1172− sourceId,
1173− })) as Source | null;
1174−
1175− if (!source || source.workspaceId !== workspaceId || !source.enabled) {
1176− return null;
1177− }
1178−
1179− return source;
1180− },
1181− catch: (cause) =>
1182− toRuntimeAdapterError(
1183− "load_source",
1184− `Failed to load source for invocation: ${sourceId}`,
1185− String(cause),
1186− ),
1187− });
1188−
1189−const invokeToolWithRegistry = (
1190− ctx: ActionCtx,
1191− source: Source,
1192− artifactTool: ArtifactToolRow,
1193− args: Record<string, unknown>,
1194− accountId: string,
1195−): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> =>
1196− Effect.gen(function* () {
1197− const baseOpenApiProvider = makeOpenApiToolProvider();
1198−
1199− const openApiProvider = {
1200− kind: "openapi" as const,
1201− invoke: (input: { source: Source | null; tool: CanonicalToolDescriptor; args: unknown }) =>
1202− Effect.gen(function* () {
1203− const sourceForInvoke = input.source;
1204− if (!sourceForInvoke) {
1205− return yield* toToolProviderError(
1206− "openapi",
1207− "invoke_tool",
1208− `OpenAPI invocation failed for tool: ${input.tool.toolId}`,
1209− "OpenAPI provider requires a source",
1210− );
1211− }
1212−
1213− const resolveHeaders = (forceRefresh: boolean) =>
1214− Effect.tryPromise({
1215− try: () =>
1216− resolveSourceHeadersForInvocation(
1217− ctx,
1218− sourceForInvoke,
1219− accountId,
1220− forceRefresh ? { forceRefresh: true } : undefined,
1221− ),
1222− catch: (cause) =>
1223− toToolProviderError(
1224− "openapi",
1225− "resolve_credentials",
1226− `OpenAPI credential resolution failed for tool: ${input.tool.toolId}`,
1227− cause,
1228− ),
1229− });
1230−
1231− const invokeOpenApi = (resolvedHeaders: Record<string, string>) =>
1232− baseOpenApiProvider.invoke({
1233− source: sourceWithInvocationHeaders(sourceForInvoke, resolvedHeaders),
1234− tool: input.tool,
1235− args: input.args,
1236− });
1237−
1238− const sourceHeaders = yield* resolveHeaders(false);
1239− let result = yield* invokeOpenApi(sourceHeaders.headers);
1240−
1241− if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) {
1242− const refreshedHeaders = yield* resolveHeaders(true);
1243− result = yield* invokeOpenApi(refreshedHeaders.headers);
1244− }
1245−
1246− return result;
1247− }),
1248− };
1249−
1250− const graphqlProvider = {
1251− kind: "graphql" as const,
1252− invoke: (input: { source: Source | null; tool: CanonicalToolDescriptor; args: unknown }) =>
1253− Effect.tryPromise({
1254− try: async () => {
1255− if (!input.source) {
1256− throw new Error("GraphQL provider requires a source");
1257− }
1258−
1259− const payload = decodeGraphqlInvocationPayload(input.tool.providerPayload);
1260− const invokeArgs = normalizeInvokeInput(input.args);
1261−
1262− let query: string;
1263− let variables: Record<string, unknown> | undefined;
1264− let operationName: string | undefined;
1265−
1266− if (payload.kind === "graphql_raw") {
1267− const rawQuery = asOptionalString(invokeArgs.query);
1268− if (!rawQuery) {
1269− throw new Error("Missing required GraphQL query string at args.query");
1270− }
1271−
1272− query = rawQuery;
1273− variables = jsonObjectFromUnknown(invokeArgs.variables);
1274− operationName = asOptionalString(invokeArgs.operationName) ?? undefined;
1275− } else {
1276− const built = createGraphqlOperationQuery(payload, invokeArgs);
1277− query = built.query;
1278− variables = built.variables;
1279− operationName = payload.fieldName;
1280− }
1281−
1282− const invokeGraphql = async (
1283− resolvedHeaders: Record<string, string>,
1284− ): Promise<{ output: unknown; isError: boolean }> => {
1285− const response = await fetch(payload.endpoint, {
1286− method: "POST",
1287− headers: mergeHeaders(
1288− {
1289− "content-type": "application/json",
1290− },
1291− resolvedHeaders,
1292− ),
1293− body: JSON.stringify({
1294− query,
1295− variables,
1296− operationName,
1297− }),
1298− });
1299−
1300− const body = await graphqlResponseBody(response);
1301− const bodyRecord = jsonObjectFromUnknown(body);
1302− const hasErrors = Array.isArray(bodyRecord.errors);
1303−
1304− return {
1305− output: {
1306− status: response.status,
1307− headers: headersToRecord(response.headers),
1308− body,
1309− },
1310− isError: response.status >= 400 || hasErrors,
1311− };
1312− };
1313−
1314− const sourceHeaders = await resolveSourceHeadersForInvocation(
1315− ctx,
1316− input.source,
1317− accountId,
1318− );
1319−
1320− let result = await invokeGraphql(sourceHeaders.headers);
1321−
1322− if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) {
1323− const refreshedHeaders = await resolveSourceHeadersForInvocation(
1324− ctx,
1325− input.source,
1326− accountId,
1327− { forceRefresh: true },
1328− );
1329−
1330− result = await invokeGraphql(refreshedHeaders.headers);
1331− }
1332−
1333− return result;
1334− },
1335− catch: (cause) =>
1336− toToolProviderError(
1337− "graphql",
1338− "invoke_tool",
1339− `GraphQL invocation failed for tool: ${input.tool.toolId}`,
1340− cause,
1341− ),
1342− }),
1343− };
1344−
1345− const mcpProvider = {
1346− kind: "mcp" as const,
1347− invoke: (input: { source: Source | null; tool: CanonicalToolDescriptor; args: unknown }) =>
1348− Effect.tryPromise({
1349− try: async () => {
1350− if (!input.source) {
1351− throw new Error("MCP provider requires a source");
1352− }
1353−
1354− const payload = decodeMcpInvocationPayload(input.tool.providerPayload);
1355− const invokeArgs = normalizeInvokeInput(input.args);
1356−
1357− if (payload.transport !== "streamable-http") {
1358− throw new Error(
1359− `Unsupported MCP transport for runtime invocation: ${payload.transport}`,
1360− );
1361− }
1362−
1363− const endpoint = createMcpEndpointUrl(payload);
1364− const invokeMcp = async (
1365− resolvedHeaders: Record<string, string>,
1366− ): Promise<{ output: unknown; isError: boolean }> => {
1367− const initializeResponse = await postMcpJsonRpc(
1368− endpoint,
1369− {
1370− jsonrpc: "2.0",
1371− id: `init_${crypto.randomUUID()}`,
1372− method: "initialize",
1373− params: {
1374− protocolVersion: "2024-11-05",
1375− capabilities: {},
1376− clientInfo: {
1377− name: "executor-v2-runtime",
1378− version: "0.1.0",
1379− },
1380− },
1381− },
1382− null,
1383− resolvedHeaders,
1384− );
1385− const initializeBody = await decodeMcpJsonResponse(initializeResponse);
1386−
1387− const sessionId = initializeResponse.headers.get("mcp-session-id");
1388−
1389− if (initializeResponse.status >= 400 || initializeBody.error !== undefined) {
1390− return {
1391− output: {
1392− status: initializeResponse.status,
1393− headers: headersToRecord(initializeResponse.headers),
1394− body: initializeBody,
1395− },
1396− isError: true,
1397− };
1398− }
1399−
1400− await postMcpJsonRpc(
1401− endpoint,
1402− {
1403− jsonrpc: "2.0",
1404− method: "notifications/initialized",
1405− params: {},
1406− },
1407− sessionId,
1408− resolvedHeaders,
1409− );
1410−
1411− const callResponse = await postMcpJsonRpc(
1412− endpoint,
1413− {
1414− jsonrpc: "2.0",
1415− id: `call_${crypto.randomUUID()}`,
1416− method: "tools/call",
1417− params: {
1418− name: payload.toolName,
1419− arguments: invokeArgs,
1420− },
1421− },
1422− sessionId,
1423− resolvedHeaders,
1424− );
1425− const callBody = await decodeMcpJsonResponse(callResponse);
1426−
1427− return {
1428− output: {
1429− status: callResponse.status,
1430− headers: headersToRecord(callResponse.headers),
1431− body: callBody,
1432− },
1433− isError: callResponse.status >= 400 || callBody.error !== undefined,
1434− };
1435− };
1436−
1437− const sourceHeaders = await resolveSourceHeadersForInvocation(
1438− ctx,
1439− input.source,
1440− accountId,
1441− );
1442−
1443− let result = await invokeMcp(sourceHeaders.headers);
1444−
1445− if (shouldRetryOAuthAfterUnauthorized(sourceHeaders.oauthConnectionId, result.output)) {
1446− const refreshedHeaders = await resolveSourceHeadersForInvocation(
1447− ctx,
1448− input.source,
1449− accountId,
1450− { forceRefresh: true },
1451− );
1452−
1453− result = await invokeMcp(refreshedHeaders.headers);
1454− }
1455−
1456− return result;
1457− },
1458− catch: (cause) =>
1459− toToolProviderError(
1460− "mcp",
1461− "invoke_tool",
1462− `MCP invocation failed for tool: ${input.tool.toolId}`,
1463− cause,
1464− ),
1465− }),
1466− };
1467−
1468− const registry = makeToolProviderRegistry([
1469− openApiProvider,
1470− graphqlProvider,
1471− mcpProvider,
1472− ]);
1473−
1474− const providerKind = artifactTool.protocol;
1475− const invocationPayload = safeJsonParse(artifactTool.invocationJson);
1476−
1477− const descriptor = {
1478− providerKind: providerKind as any,
1479− sourceId: source.id,
1480− workspaceId: source.workspaceId,
1481− toolId: artifactTool.toolId,
1482− name: artifactTool.name,
1483− description: artifactTool.description,
1484− invocationMode:
1485− providerKind === "graphql"
1486− ? "graphql"
1487− : providerKind === "mcp"
1488− ? "mcp"
1489− : "http",
1490− availability: "remote_capable",
1491− providerPayload:
1492− providerKind === "openapi"
1493− ? decodeOpenApiInvocationPayload(invocationPayload)
1494− : providerKind === "graphql"
1495− ? decodeGraphqlInvocationPayload(invocationPayload)
1496− : decodeMcpInvocationPayload(invocationPayload),
1497− } as CanonicalToolDescriptor;
1498−
1499− const invocation = yield* registry
1500− .invoke({
1501− source,
1502− tool: descriptor,
1503− args,
1504− })
1505− .pipe(
1506− Effect.mapError((cause) =>
1507− cause._tag === "ToolProviderError"
1508− ? toRuntimeAdapterError("invoke_tool", cause.message, cause.details)
1509− : toRuntimeAdapterError("invoke_tool", cause.message, null),
1510− ),
1511− );
1512−
1513− return invocation;
1514− });
1515−
1516−const unknownToolPathErrorMessage = (
1517− requestedPath: string,
1518− suggestions: ReadonlyArray<string>,
1519−): string => {
1520− const hintQuery = requestedPath.trim().length > 0 ? requestedPath.trim() : "tool";
1521− const suggestionText =
1522− suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}.` : "";
1523−
1524− return `Unknown tool path: ${requestedPath}.${suggestionText} Use tools.discover({ queries: [{ text: ${JSON.stringify(
1525− hintQuery,
1526− )}, depth: 1 }] }) or tools.catalog.tools({ query: ${JSON.stringify(
1527− hintQuery,
1528− )} }) to find available tool paths.`;
1529−};
1530−
1531−const resolveToolPath = (
1532− ctx: ActionCtx,
1533− workspaceId: string,
1534− requestedPath: string,
1535−): Effect.Effect<
1536− { row: WorkspaceToolIndexRow | null; suggestions: Array<string> },
1537− RuntimeAdapterError
1538−> =>
1539− Effect.gen(function* () {
1540− const trimmedPath = requestedPath.trim();
1541− if (trimmedPath.length === 0) {
1542− return {
1543− row: null,
1544− suggestions: [],
1545− };
1546− }
1547−
1548− const exact = yield* getWorkspaceToolByPathEffect(ctx, workspaceId, trimmedPath);
1549− if (exact && exact.status === "active") {
1550− return {
1551− row: exact,
1552− suggestions: [],
1553− };
1554− }
1555−
1556− const normalizedPath = normalizeToolPathForLookup(trimmedPath);
1557− const normalizedMatches = yield* listWorkspaceToolsByNormalizedPathEffect(
1558− ctx,
1559− workspaceId,
1560− normalizedPath,
1561− );
1562−
1563− if (normalizedMatches.length === 1) {
1564− return {
1565− row: normalizedMatches[0] ?? null,
1566− suggestions: [],
1567− };
1568− }
1569−
1570− if (normalizedMatches.length > 1) {
1571− const preferred = [...normalizedMatches].sort((left, right) => {
1572− if (left.path.length !== right.path.length) {
1573− return left.path.length - right.path.length;
1574− }
1575−
1576− return left.path.localeCompare(right.path);
1577− })[0];
1578−
1579− return {
1580− row: preferred ?? null,
1581− suggestions: [],
1582− };
1583− }
1584−
1585− const candidates = yield* searchWorkspaceToolsEffect(ctx, workspaceId, {
1586− query: trimmedPath,
1587− limit: 20,
1588− });
1589−
1590− if (candidates.length === 0) {
1591− return {
1592− row: null,
1593− suggestions: [],
1594− };
1595− }
1596−
1597− const lowerRequested = trimmedPath.toLowerCase();
1598− const closest = [...candidates]
1599− .map((candidate) => ({
1600− candidate,
1601− distance: levenshteinDistance(candidate.path.toLowerCase(), lowerRequested),
1602− }))
1603− .sort((left, right) => {
1604− if (left.distance !== right.distance) {
1605− return left.distance - right.distance;
1606− }
1607−
1608− return left.candidate.path.localeCompare(right.candidate.path);
1609− });
1610−
1611− const best = closest[0];
1612− const maxDistance = Math.max(2, Math.floor(trimmedPath.length * 0.2));
1613−
1614− if (best && best.distance <= maxDistance) {
1615− return {
1616− row: best.candidate,
1617− suggestions: [],
1618− };
1619− }
1620−
1621− return {
1622− row: null,
1623− suggestions: uniquePaths(closest.map((item) => item.candidate.path)),
1624− };
1625− });
1626−
1627−const approvalModeValidator = v.union(v.literal("auto"), v.literal("required"));
1628−
1629−export const evaluateToolApproval = internalMutation({
1630− args: {
1631− workspaceId: v.string(),
1632− runId: v.string(),
1633− callId: v.string(),
1634− toolPath: v.string(),
1635− inputPreviewJson: v.string(),
1636− defaultMode: approvalModeValidator,
1637− requireApprovals: v.optional(v.boolean()),
1638− retryAfterMs: v.optional(v.number()),
1639− },
1640− handler: async (ctx, args) => {
1641− const taskRunRow = await ctx.db
1642− .query("taskRuns")
1643− .withIndex("by_domainId", (q) => q.eq("id", args.runId))
1644− .unique();
1645−
1646− if (!taskRunRow || taskRunRow.workspaceId !== args.workspaceId) {
1647− return {
1648− kind: "denied" as const,
1649− error: `Unknown run for approval request: ${args.runId}`,
1650− };
1651− }
1652−
1653− const store: PersistentToolApprovalStore = {
1654− findByRunAndCall: (input) =>
1655− Effect.tryPromise({
1656− try: async () => {
1657− const rows = await ctx.db
1658− .query("approvals")
1659− .withIndex("by_taskRunId_callId", (q) =>
1660− q.eq("taskRunId", input.runId).eq("callId", input.callId),
1661− )
1662− .collect();
1663−
1664− const row = rows.find((candidate) => candidate.workspaceId === input.workspaceId) ?? null;
1665− if (!row) {
1666− return null;
1667− }
1668−
1669− const approval = decodeApproval(
1670− stripConvexSystemFields(row as unknown as Record<string, unknown>),
1671− );
1672−
1673− return toPersistentApprovalRecord(approval);
1674− },
1675− catch: (cause) =>
1676− toPersistentApprovalStoreError(
1677− "approvals.find",
1678− "Failed to query approval",
1679− String(cause),
1680− ),
1681− }),
1682−
1683− createPending: (input) =>
1684− Effect.tryPromise({
1685− try: async () => {
1686− const existingRows = await ctx.db
1687− .query("approvals")
1688− .withIndex("by_taskRunId_callId", (q) =>
1689− q.eq("taskRunId", input.runId).eq("callId", input.callId),
1690− )
1691− .collect();
1692−
1693− const existingRow =
1694− existingRows.find((candidate) => candidate.workspaceId === input.workspaceId) ?? null;
1695− if (existingRow) {
1696− return toPersistentApprovalRecord(
1697− decodeApproval(
1698− stripConvexSystemFields(existingRow as unknown as Record<string, unknown>),
1699− ),
1700− );
1701− }
1702−
1703− const approval = {
1704− id: `apr_${crypto.randomUUID()}`,
1705− workspaceId: input.workspaceId,
1706− taskRunId: input.runId,
1707− callId: input.callId,
1708− toolPath: input.toolPath,
1709− status: "pending",
1710− inputPreviewJson: input.inputPreviewJson,
1711− reason: null,
1712− requestedAt: Date.now(),
1713− resolvedAt: null,
1714− } as Approval;
1715−
1716− await ctx.db.insert("approvals", approval);
1717− return toPersistentApprovalRecord(approval);
1718− },
1719− catch: (cause) =>
1720− toPersistentApprovalStoreError(
1721− "approvals.create",
1722− "Failed to create pending approval",
1723− String(cause),
1724− ),
1725− }),
1726− };
1727−
1728− const policy = createPersistentToolApprovalPolicy({
1729− store,
1730− requireApprovals: args.requireApprovals === true,
1731− retryAfterMs: args.retryAfterMs,
1732− serializeInputPreview: () => args.inputPreviewJson,
1733− onStoreError: (error) => ({
1734− kind: "denied",
1735− error:
1736− error.details && error.details.length > 0
1737− ? `${error.message}: ${error.details}`
1738− : error.message,
1739− }),
1740− });
1741−
1742− return await policy.evaluate({
1743− workspaceId: args.workspaceId,
1744− runId: args.runId,
1745− callId: args.callId,
1746− toolPath: args.toolPath,
1747− defaultMode: args.defaultMode,
1748− });
1749− },
1750−});
1751−
1752−const createConvexPersistentToolApprovalPolicy = (
1753− ctx: ActionCtx,
1754− workspaceId: string,
1755− options: {
1756− requireApprovals: boolean;
1757− retryAfterMs: number;
1758− },
1759−): ToolApprovalPolicy => ({
1760− evaluate: (input) =>
1761− ctx.runMutation(runtimeInternal.source_tool_registry.evaluateToolApproval, {
1762− workspaceId,
1763− runId: input.runId,
1764− callId: input.callId,
1765− toolPath: input.toolPath,
1766− inputPreviewJson: serializeInputPreview(input.input),
1767− defaultMode: input.defaultMode,
1768− requireApprovals: options.requireApprovals,
1769− retryAfterMs: options.retryAfterMs,
1770− }),
1771−});
1772−
1773−const evaluateApprovalDecisionEffect = (
1774− policy: ToolApprovalPolicy,
1775− input: {
1776− workspaceId: string;
1777− runId: string;
1778− callId: string;
1779− toolPath: string;
1780− source: string;
1781− defaultMode: "auto" | "required";
1782− args: Record<string, unknown>;
1783− },
1784−): Effect.Effect<
1785− | { kind: "approved" }
1786− | { kind: "pending"; approvalId: string; retryAfterMs?: number; error?: string }
1787− | { kind: "denied"; error: string },
1788− RuntimeAdapterError
1789−> =>
1790− Effect.tryPromise({
1791− try: () =>
1792− Promise.resolve(
1793− policy.evaluate({
1794− workspaceId: input.workspaceId,
1795− runId: input.runId,
1796− callId: input.callId,
1797− toolPath: input.toolPath,
1798− source: input.source,
1799− input: input.args,
1800− defaultMode: input.defaultMode,
1801− }),
1802− ),
1803− catch: (cause) =>
1804− toRuntimeAdapterError(
1805− "evaluate_approval",
1806− `Tool approval evaluation failed: ${input.toolPath}`,
1807− String(cause),
1808− ),
1809− });
1810−
1811−const describeSource = (row: {
1812− sourceName: string;
1813− sourceKind: string;
1814−}): string => `${row.sourceKind.toUpperCase()} source: ${row.sourceName}`;
1815−
1816−export type ConvexSourceToolRegistryOptions = {
1817− accountId: string;
1818− requireToolApprovals?: boolean;
1819− approvalRetryAfterMs?: number;
1820−};
1821−
1822−export const createConvexSourceToolRegistry = (
1823− ctx: ActionCtx,
1824− workspaceId: string,
1825− options: ConvexSourceToolRegistryOptions,
1826−): ToolRegistry => {
1827− const requireApprovals = options.requireToolApprovals ?? requireToolApprovalsByDefault;
1828− const accountId = options.accountId.trim();
1829− if (accountId.length === 0) {
1830− throw new Error("Tool registry accountId is required");
1831− }
1832− const approvalRetryAfterMs =
1833− typeof options.approvalRetryAfterMs === "number" &&
1834− Number.isFinite(options.approvalRetryAfterMs) &&
1835− options.approvalRetryAfterMs >= 0
1836− ? Math.round(options.approvalRetryAfterMs)
1837− : defaultPendingRetryAfterMs;
1838−
1839− const approvalPolicy = createConvexPersistentToolApprovalPolicy(ctx, workspaceId, {
1840− requireApprovals,
1841− retryAfterMs: approvalRetryAfterMs,
1842− });
1843−
1844− return {
1845− callTool: (input) =>
1846− Effect.gen(function* () {
1847− const resolved = yield* resolveToolPath(ctx, workspaceId, input.toolPath);
1848− if (!resolved.row) {
1849− return {
1850− ok: false,
1851− kind: "failed",
1852− error: unknownToolPathErrorMessage(input.toolPath, resolved.suggestions),
1853− } satisfies RuntimeToolCallResult;
1854− }
1855−
1856− const args = normalizeInvokeInput(input.input);
1857− const toolPath = resolved.row.path;
1858− const approval = yield* evaluateApprovalDecisionEffect(approvalPolicy, {
1859− workspaceId,
1860− runId: input.runId,
1861− callId: input.callId,
1862− toolPath,
1863− source: resolved.row.sourceName,
1864− defaultMode:
1865− resolved.row.approvalMode === "required"
1866− ? "required"
1867− : "auto",
1868− args,
1869− });
1870−
1871− if (approval.kind !== "approved") {
1872− return toToolCallResultFromDecision(approval, toolPath);
1873− }
1874−
1875− const artifactTool = yield* getArtifactToolEffect(
1876− ctx,
1877− resolved.row.artifactId,
1878− resolved.row.toolId,
1879− );
1880−
1881− if (!artifactTool) {
1882− return {
1883− ok: false,
1884− kind: "failed",
1885− error: `Missing artifact tool payload for ${resolved.row.path}`,
1886− } satisfies RuntimeToolCallResult;
1887− }
1888−
1889− const source = yield* getSourceForInvocationEffect(ctx, workspaceId, resolved.row.sourceId);
1890− if (!source) {
1891− return {
1892− ok: false,
1893− kind: "failed",
1894− error: `Source unavailable for tool: ${resolved.row.path}`,
1895− } satisfies RuntimeToolCallResult;
1896− }
1897−
1898− const invocation = yield* invokeToolWithRegistry(
1899− ctx,
1900− source,
1901− artifactTool,
1902− args,
1903− accountId,
1904− );
1905− if (invocation.isError) {
1906− return {
1907− ok: false,
1908− kind: "failed",
1909− error: describeOutput(invocation.output),
1910− } satisfies RuntimeToolCallResult;
1911− }
1912−
1913− return {
1914− ok: true,
1915− value: invocation.output,
1916− } satisfies RuntimeToolCallResult;
1917− }),
1918−
1919− discover: (input: ToolRegistryDiscoverInput) =>
1920− Effect.gen(function* () {
1921− const limit = Math.max(1, Math.min(50, input.limit ?? 8));
1922− const compact = input.compact === true;
1923− const includeSchemas = input.includeSchemas === true;
1924− const discoverQueries = normalizeDiscoverQueries(input);
1925− const fetchLimit = Math.max(limit * 6, 50);
1926−
1927− const perQueryData = yield* Effect.forEach(discoverQueries, (query) =>
1928− Effect.gen(function* () {
1929− const rows =
1930− query.text.length > 0
1931− ? yield* searchWorkspaceToolsEffect(ctx, workspaceId, {
1932− query: query.text,
1933− limit: fetchLimit,
1934− })
1935− : yield* listWorkspaceToolsEffect(ctx, workspaceId, {
1936− limit: fetchLimit,
1937− });
1938−
1939− const rankedRows = rows
1940− .map((row) => ({
1941− row,
1942− score: scoreSummary(
1943− toSearchScoringSummary(row),
1944− query.lowerText,
1945− query.depth,
1946− ),
1947− }))
1948− .filter((item) => item.score > 0)
1949− .sort((left, right) => right.score - left.score)
1950− .slice(0, limit);
1951−
1952− const selectedRows = rankedRows.map((item) => item.row);
1953− const summaries = includeSchemas
1954− ? (
1955− yield* hydrateRowsWithArtifactTools(ctx, selectedRows).pipe(
1956− Effect.map((entries) =>
1957− entries.map(({ row, artifactTool }) =>
1958− toToolSummary(row, {
1959− includeSchemas,
1960− compact,
1961− artifactTool,
1962− }),
1963− ),
1964− ),
1965− )
1966− )
1967− : selectedRows.map((row) =>
1968− toToolSummary(row, {
1969− includeSchemas,
1970− compact,
1971− }),
1972− );
1973−
1974− return {
1975− rows,
1976− queryResult: {
1977− text: query.text,
1978− depth: query.depth,
1979− bestPath: summaries[0]?.path ?? null,
1980− results: summaries,
1981− total: summaries.length,
1982− } satisfies ToolRegistryDiscoverQueryResult,
1983− };
1984− }),
1985− );
1986−
1987− const perQuery = perQueryData.map((entry) => entry.queryResult);
1988− const primary = perQuery[0] ?? {
1989− text: "",
1990− depth: 1 as const,
1991− bestPath: null,
1992− results: [] as Array<ToolRegistryToolSummary>,
1993− total: 0,
1994− };
1995− const allRows = perQueryData.flatMap((entry) => entry.rows);
1996−
1997− return {
1998− bestPath: primary.bestPath,
1999− results: primary.results,
2000− total: primary.total,
2001− perQuery,
2002− refHintTable: mergeRefHintTables(allRows, includeSchemas),
2003− } satisfies ToolRegistryDiscoverOutput;
2004− }),
2005−
2006− catalogNamespaces: (input: ToolRegistryCatalogNamespacesInput) =>
2007− Effect.gen(function* () {
2008− const limit = Math.max(1, Math.min(maxCatalogNamespacesLimit, input.limit ?? 50));
2009−
2010− const rows = yield* Effect.tryPromise({
2011− try: async () =>
2012− (await ctx.runAction(runtimeInternal.control_plane.tool_registry.listWorkspaceNamespaces, {
2013− workspaceId,
2014− limit,
2015− })) as Array<{
2016− namespace: string;
2017− source: string;
2018− sourceId: string;
2019− sourceKind: string;
2020− toolCount: number;
2021− samplePaths: Array<string>;
2022− }>,
2023− catch: (cause) =>
2024− toRuntimeAdapterError(
2025− "catalog_namespaces",
2026− "Failed to catalog namespaces",
2027− String(cause),
2028− ),
2029− });
2030−
2031− return {
2032− namespaces: rows.map((row) => ({
2033− namespace: row.namespace,
2034− toolCount: row.toolCount,
2035− samplePaths: row.samplePaths,
2036− source: row.source,
2037− sourceKey: row.sourceId,
2038− description: describeSource({ sourceName: row.source, sourceKind: row.sourceKind }),
2039− })),
2040− total: rows.length,
2041− } satisfies ToolRegistryCatalogNamespacesOutput;
2042− }),
2043−
2044− catalogTools: (input: ToolRegistryCatalogToolsInput) =>
2045− Effect.gen(function* () {
2046− const limit = Math.max(1, Math.min(maxCatalogToolsLimit, input.limit ?? 50));
2047− const queryText = (input.query ?? "").trim();
2048− const compact = input.compact === true;
2049− const includeSchemas = input.includeSchemas === true;
2050− const namespace = input.namespace?.trim();
2051−
2052− const rows =
2053− queryText.length > 0
2054− ? yield* searchWorkspaceToolsEffect(ctx, workspaceId, {
2055− query: queryText,
2056− namespace,
2057− limit: Math.max(limit * 4, 50),
2058− })
2059− : yield* listWorkspaceToolsEffect(ctx, workspaceId, {
2060− namespace,
2061− limit: Math.max(limit * 4, 50),
2062− });
2063−
2064− const rankedRows = rows
2065− .map((row) => ({
2066− row,
2067− score: scoreSummary(toSearchScoringSummary(row), queryText.toLowerCase()),
2068− }))
2069− .filter((item) => item.score > 0)
2070− .sort((left, right) => right.score - left.score)
2071− .slice(0, limit);
2072−
2073− const selectedRows = rankedRows.map((item) => item.row);
2074−
2075− const summaries = includeSchemas
2076− ? (
2077− yield* hydrateRowsWithArtifactTools(ctx, selectedRows).pipe(
2078− Effect.map((entries) =>
2079− entries.map(({ row, artifactTool }) =>
2080− toToolSummary(row, {
2081− includeSchemas,
2082− compact,
2083− artifactTool,
2084− }),
2085− ),
2086− ),
2087− )
2088− )
2089− : selectedRows.map((row) =>
2090− toToolSummary(row, {
2091− includeSchemas,
2092− compact,
2093− }),
2094− );
2095−
2096− return {
2097− results: summaries,
2098− total: summaries.length,
2099− refHintTable: mergeRefHintTables(rows, includeSchemas),
2100− } satisfies ToolRegistryCatalogToolsOutput;
2101− }),
2102− } as ToolRegistry;
2103−};
2104−
2105−export const invokeWorkspaceTool = internalAction({
2106− args: {
2107− workspaceId: v.string(),
2108− accountId: v.string(),
2109− runId: v.string(),
2110− callId: v.string(),
2111− toolPath: v.string(),
2112− input: v.optional(v.any()),
2113− },
2114− handler: async (ctx, args): Promise<RuntimeToolCallResult> => {
2115− const toolRegistry = createConvexSourceToolRegistry(ctx, args.workspaceId, {
2116− accountId: args.accountId,
2117− });
2118−
2119− return await Effect.runPromise(
2120− toolRegistry.callTool({
2121− runId: args.runId,
2122− callId: args.callId,
2123− toolPath: args.toolPath,
2124− input: normalizeInvokeInput(args.input),
2125− }),
2126− );
2127− },
2128−});
apps/convex/convex/task_runs.ts+0−106
@@ -1,106 +0,0 @@
1−import { type TaskRun } from "@executor-v2/schema";
2−import { v } from "convex/values";
3−
4−import { internalMutation, internalQuery } from "./_generated/server";
5−
6−const taskRunTerminalStatusValidator = v.union(
7− v.literal("completed"),
8− v.literal("failed"),
9− v.literal("timed_out"),
10− v.literal("denied"),
11−);
12−
13−export const startTaskRun = internalMutation({
14− args: {
15− workspaceId: v.string(),
16− runId: v.string(),
17− accountId: v.string(),
18− sessionId: v.optional(v.string()),
19− runtimeId: v.optional(v.string()),
20− codeHash: v.optional(v.string()),
21− },
22− handler: async (ctx, args): Promise<void> => {
23− const existing = await ctx.db
24− .query("taskRuns")
25− .withIndex("by_domainId", (q) => q.eq("id", args.runId))
26− .unique();
27−
28− if (existing) {
29− if (existing.workspaceId !== args.workspaceId) {
30− throw new Error(`Task run workspace mismatch: ${args.runId}`);
31− }
32−
33− if (existing.accountId !== args.accountId) {
34− throw new Error(`Task run account mismatch: ${args.runId}`);
35− }
36−
37− return;
38− }
39−
40− const taskRun = {
41− id: args.runId,
42− workspaceId: args.workspaceId,
43− accountId: args.accountId,
44− sessionId: args.sessionId ?? "session_runtime",
45− runtimeId: args.runtimeId ?? "runtime",
46− codeHash: args.codeHash ?? "runtime",
47− status: "running",
48− startedAt: Date.now(),
49− completedAt: null,
50− exitCode: null,
51− error: null,
52− } as TaskRun;
53−
54− await ctx.db.insert("taskRuns", taskRun);
55− },
56−});
57−
58−export const finishTaskRun = internalMutation({
59− args: {
60− workspaceId: v.string(),
61− runId: v.string(),
62− status: taskRunTerminalStatusValidator,
63− error: v.optional(v.union(v.string(), v.null())),
64− },
65− handler: async (ctx, args): Promise<void> => {
66− const existing = await ctx.db
67− .query("taskRuns")
68− .withIndex("by_domainId", (q) => q.eq("id", args.runId))
69− .unique();
70−
71− if (!existing) {
72− return;
73− }
74−
75− if (existing.workspaceId !== args.workspaceId) {
76− throw new Error(`Task run workspace mismatch: ${args.runId}`);
77− }
78−
79− await ctx.db.patch(existing._id, {
80− status: args.status,
81− completedAt: Date.now(),
82− error: args.error ?? null,
83− });
84− },
85−});
86−
87−export const getTaskRunContext = internalQuery({
88− args: {
89− runId: v.string(),
90− },
91− handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string } | null> => {
92− const existing = await ctx.db
93− .query("taskRuns")
94− .withIndex("by_domainId", (q) => q.eq("id", args.runId))
95− .unique();
96−
97− if (!existing) {
98− return null;
99− }
100−
101− return {
102− workspaceId: existing.workspaceId,
103− accountId: existing.accountId,
104− };
105− },
106−});
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 @@
1−import { WorkflowManager } from "@convex-dev/workflow";
2−
3−import { components } from "./_generated/api";
4−
5−export const workflow = new WorkflowManager(components.workflow, {
6− workpoolOptions: {
7− defaultRetryBehavior: {
8− maxAttempts: 3,
9− initialBackoffMs: 250,
10− base: 2,
11− },
12− retryActionsByDefault: true,
13− maxParallelism: 8,
14− },
15−});
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 @@
1−export {};
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
1515 "@executor-v2/domain": "workspace:*",
1616 "@executor-v2/engine": "workspace:*",
1717 "@executor-v2/mcp-gateway": "workspace:*",
18− "@executor-v2/persistence-local": "workspace:*",
18+ "@executor-v2/persistence-ports": "workspace:*",
19+ "@executor-v2/persistence-sql": "workspace:*",
1920 "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*",
2021 "@executor-v2/runtime-deno-subprocess": "workspace:*",
2122 "@executor-v2/runtime-local-inproc": "workspace:*",
apps/pm/src/actor.ts+38−36
@@ -8,17 +8,19 @@ import { · 7 unmodified lines
88 makeActor,
99 makeAllowAllActor,
1010 } from "@executor-v2/domain";
11−import {
12− type LocalStateSnapshot,
13− type LocalStateStore,
14−} from "@executor-v2/persistence-local";
11+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
1512 import {
1613 type OrganizationMembership,
14+ type Workspace,
1715 type WorkspaceMembership,
1816 } from "@executor-v2/schema";
1917 import * as PlatformHeaders from "@effect/platform/Headers";
2018 import * as Effect from "effect/Effect";
21−import * as Option from "effect/Option";
19+
20+type ActorRows = Pick<
21+ SqlControlPlanePersistence["rows"],
22+ "organizationMemberships" | "workspaces"
23+>;
2224
2325 const isTruthy = (value: string | undefined): boolean => {
2426 const normalized = value?.trim().toLowerCase();
@@ -36,19 +38,19 @@ const localAdminFallbackEnabled = (() => { · 11 unmodified lines
3638 })();
3739
3840 const organizationMembershipsForAccount = (
39− snapshot: LocalStateSnapshot,
41+ memberships: ReadonlyArray<OrganizationMembership>,
4042 accountId: OrganizationMembership["accountId"],
4143 ): ReadonlyArray<OrganizationMembership> =>
42− snapshot.organizationMemberships.filter(
44+ memberships.filter(
4345 (membership) => membership.accountId === accountId,
4446 );
4547
4648 const workspaceMembershipsForAccount = (
47− snapshot: LocalStateSnapshot,
49+ workspaces: ReadonlyArray<Workspace>,
4850 accountId: OrganizationMembership["accountId"],
4951 organizationMemberships: ReadonlyArray<OrganizationMembership>,
5052 ): ReadonlyArray<WorkspaceMembership> =>
51− snapshot.workspaces.flatMap((workspace) =>
53+ workspaces.flatMap((workspace) =>
5254 deriveWorkspaceMembershipsForPrincipal({
5355 principalAccountId: accountId,
5456 workspaceId: workspace.id,
@@ -58,13 +60,16 @@ const workspaceMembershipsForAccount = ( · 3 unmodified lines
5860 );
5961
6062 const resolveActorFromSnapshot = (
61− localStateStore: LocalStateStore,
63+ rows: ActorRows,
6264 headers: PlatformHeaders.Headers,
6365 ) =>
6466 Effect.gen(function* () {
6567 const principal = yield* requirePrincipalFromHeaders(headers);
6668
67− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
69+ const [memberships, workspaces] = yield* Effect.all([
70+ rows.organizationMemberships.list(),
71+ rows.workspaces.list(),
72+ ]).pipe(
6873 Effect.mapError(
6974 (error) =>
7075 new ActorUnauthenticatedError({
@@ -73,20 +78,19 @@ const resolveActorFromSnapshot = ( · 2 unmodified lines
7378 ),
7479 );
7580
76− const snapshot = Option.getOrNull(snapshotOption);
77−
78− if (snapshot === null && localAdminFallbackEnabled) {
81+ if (memberships.length === 0 && workspaces.length === 0 && localAdminFallbackEnabled) {
7982 return makeAllowAllActor(principal);
8083 }
8184
82− const organizationMemberships =
83− snapshot === null
84− ? []
85− : organizationMembershipsForAccount(snapshot, principal.accountId);
86− const workspaceMemberships =
87− snapshot === null
88− ? []
89− : workspaceMembershipsForAccount(snapshot, principal.accountId, organizationMemberships);
85+ const organizationMemberships = organizationMembershipsForAccount(
86+ memberships,
87+ principal.accountId,
88+ );
89+ const workspaceMemberships = workspaceMembershipsForAccount(
90+ workspaces,
91+ principal.accountId,
92+ organizationMemberships,
93+ );
9094
9195 return yield* makeActor({
9296 principal,
@@ -95,14 +99,17 @@ const resolveActorFromSnapshot = ( · 2 unmodified lines
9599 });
96100 });
97101
98−export const PmActorLive = (localStateStore: LocalStateStore) =>
102+export const PmActorLive = (rows: ActorRows) =>
99103 ControlPlaneActorResolverLive({
100− resolveActor: (input) => resolveActorFromSnapshot(localStateStore, input.headers),
104+ resolveActor: (input) => resolveActorFromSnapshot(rows, input.headers),
101105 resolveWorkspaceActor: (input) =>
102106 Effect.gen(function* () {
103107 const principal = yield* requirePrincipalFromHeaders(input.headers);
104108
105− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
109+ const [memberships, workspaces] = yield* Effect.all([
110+ rows.organizationMemberships.list(),
111+ rows.workspaces.list(),
112+ ]).pipe(
106113 Effect.mapError(
107114 (error) =>
108115 new ActorUnauthenticatedError({
@@ -111,21 +118,16 @@ export const PmActorLive = (localStateStore: LocalStateStore) => · 2 unmodified lines
111118 ),
112119 );
113120
114− const snapshot = Option.getOrNull(snapshotOption);
115−
116− if (snapshot === null && localAdminFallbackEnabled) {
121+ if (memberships.length === 0 && workspaces.length === 0 && localAdminFallbackEnabled) {
117122 return makeAllowAllActor(principal);
118123 }
119124
120− const organizationMemberships =
121− snapshot === null
122− ? []
123− : organizationMembershipsForAccount(snapshot, principal.accountId);
125+ const organizationMemberships = organizationMembershipsForAccount(
126+ memberships,
127+ principal.accountId,
128+ );
124129
125− const workspace =
126− snapshot === null
127− ? null
128− : snapshot.workspaces.find((item) => item.id === input.workspaceId) ?? null;
130+ const workspace = workspaces.find((item) => item.id === input.workspaceId) ?? null;
129131
130132 const workspaceMemberships = deriveWorkspaceMembershipsForPrincipal({
131133 principalAccountId: principal.accountId,
apps/pm/src/approvals-service.ts+30−87
@@ -6,18 +6,15 @@ import { · 5 unmodified lines
66 type ToolApprovalPolicy,
77 } from "@executor-v2/engine";
88 import { SourceStoreError } from "@executor-v2/persistence-ports";
9−import {
10− type LocalStateSnapshot,
11− type LocalStateStore,
12− type LocalStateStoreError,
13−} from "@executor-v2/persistence-local";
9+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
1410 import {
1511 makeControlPlaneApprovalsService,
1612 type ControlPlaneApprovalsServiceShape,
1713 } from "@executor-v2/management-api";
1814 import { type Approval } from "@executor-v2/schema";
1915 import * as Effect from "effect/Effect";
20−import * as Option from "effect/Option";
16+
17+type ApprovalRows = Pick<SqlControlPlanePersistence["rows"], "approvals">;
2118
2219 const toSourceStoreError = (
2320 operation: string,
@@ -26,16 +23,16 @@ const toSourceStoreError = ( · 2 unmodified lines
2623 ): SourceStoreError =>
2724 new SourceStoreError({
2825 operation,
29− backend: "local-file",
30− location: "snapshot.json",
26+ backend: "sql",
27+ location: "approvals",
3128 message,
3229 reason: null,
3330 details,
3431 });
3532
36−const toSourceStoreErrorFromLocalState = (
33+const toSourceStoreErrorFromRowStore = (
3734 operation: string,
38− error: LocalStateStoreError,
35+ error: { message: string; details: string | null; reason: string | null },
3936 ): SourceStoreError =>
4037 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
4138
@@ -62,9 +59,9 @@ const toPersistentApprovalStoreError = ( · 20 unmodified lines
6259 details,
6360 });
6461
65−const toPersistentApprovalStoreErrorFromLocalState = (
62+const toPersistentApprovalStoreErrorFromRowStore = (
6663 operation: string,
67− error: LocalStateStoreError,
64+ error: { message: string; details: string | null; reason: string | null },
6865 ): PersistentToolApprovalPolicyStoreError =>
6966 toPersistentApprovalStoreError(operation, error.message, error.details ?? error.reason ?? null);
7067
@@ -78,51 +75,24 @@ const toPersistentApprovalRecord = (approval: Approval): PersistentToolApprovalR · 7 unmodified lines
7875 reason: approval.reason,
7976 });
8077
81−const updateApproval = (
82− snapshot: LocalStateSnapshot,
83− index: number,
84− nextApproval: Approval,
85−): LocalStateSnapshot => {
86− const approvals = [...snapshot.approvals];
87− approvals[index] = nextApproval;
88−
89− return {
90− ...snapshot,
91− generatedAt: Date.now(),
92− approvals,
93− };
94−};
95−
9678 export type PmPersistentToolApprovalPolicyOptions = {
9779 requireApprovals?: boolean;
9880 retryAfterMs?: number;
9981 };
10082
10183 export const createPmPersistentToolApprovalPolicy = (
102− localStateStore: LocalStateStore,
84+ rows: ApprovalRows,
10385 options: PmPersistentToolApprovalPolicyOptions = {},
10486 ): ToolApprovalPolicy => {
105− const missingSnapshotError = (operation: string): PersistentToolApprovalPolicyStoreError =>
106− toPersistentApprovalStoreError(
107− operation,
108− "Persistent approvals require an initialized local state snapshot",
109− null,
110− );
111−
11287 const store: PersistentToolApprovalStore = {
11388 findByRunAndCall: (input) =>
114− localStateStore.getSnapshot().pipe(
89+ rows.approvals.list().pipe(
11590 Effect.mapError((error) =>
116− toPersistentApprovalStoreErrorFromLocalState("approvals.read", error),
91+ toPersistentApprovalStoreErrorFromRowStore("approvals.read", error),
11792 ),
118− Effect.flatMap((snapshotOption) => {
119− const snapshot = Option.getOrNull(snapshotOption);
120− if (snapshot === null) {
121− return Effect.fail(missingSnapshotError("approvals.read"));
122− }
123−
93+ Effect.flatMap((approvals) => {
12494 const approval =
125− snapshot.approvals.find(
95+ approvals.find(
12696 (candidate) =>
12797 candidate.workspaceId === input.workspaceId &&
12898 candidate.taskRunId === input.runId &&
@@ -134,16 +104,11 @@ export const createPmPersistentToolApprovalPolicy = ( · 5 unmodified lines
134104 ),
135105
136106 createPending: (input) =>
137− localStateStore.getSnapshot().pipe(
107+ rows.approvals.list().pipe(
138108 Effect.mapError((error) =>
139− toPersistentApprovalStoreErrorFromLocalState("approvals.read", error),
109+ toPersistentApprovalStoreErrorFromRowStore("approvals.read", error),
140110 ),
141− Effect.flatMap((snapshotOption) => {
142− const snapshot = Option.getOrNull(snapshotOption);
143− if (snapshot === null) {
144− return Effect.fail(missingSnapshotError("approvals.create"));
145− }
146−
111+ Effect.flatMap(() => {
147112 const pendingApproval = {
148113 id: `apr_${crypto.randomUUID()}`,
149114 workspaceId: input.workspaceId,
@@ -157,15 +122,9 @@ export const createPmPersistentToolApprovalPolicy = ( · 7 unmodified lines
157122 resolvedAt: null,
158123 } as Approval;
159124
160− const nextSnapshot: LocalStateSnapshot = {
161− ...snapshot,
162− generatedAt: Date.now(),
163− approvals: [...snapshot.approvals, pendingApproval],
164− };
165−
166− return localStateStore.writeSnapshot(nextSnapshot).pipe(
125+ return rows.approvals.upsert(pendingApproval).pipe(
167126 Effect.mapError((error) =>
168− toPersistentApprovalStoreErrorFromLocalState("approvals.write", error),
127+ toPersistentApprovalStoreErrorFromRowStore("approvals.write", error),
169128 ),
170129 Effect.as(toPersistentApprovalRecord(pendingApproval)),
171130 );
@@ -181,48 +140,34 @@ export const createPmPersistentToolApprovalPolicy = ( · 9 unmodified lines
181140 };
182141
183142 export const createPmApprovalsService = (
184− localStateStore: LocalStateStore,
143+ rows: ApprovalRows,
185144 ): ControlPlaneApprovalsServiceShape =>
186145 makeControlPlaneApprovalsService({
187146 listApprovals: (workspaceId) =>
188147 Effect.gen(function* () {
189− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
148+ const approvals = yield* rows.approvals.list().pipe(
190149 Effect.mapError((error) =>
191− toSourceStoreErrorFromLocalState("approvals.list", error),
150+ toSourceStoreErrorFromRowStore("approvals.list", error),
192151 ),
193152 );
194153
195− const snapshot = Option.getOrNull(snapshotOption);
196− if (snapshot === null) {
197− return [];
198− }
199−
200− const approvals = snapshot.approvals.filter(
154+ const scopedApprovals = approvals.filter(
201155 (approval) => approval.workspaceId === workspaceId,
202156 );
203157
204− return sortApprovals(approvals);
158+ return sortApprovals(scopedApprovals);
205159 }),
206160
207161 resolveApproval: (input) =>
208162 Effect.gen(function* () {
209− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
163+ const approvals = yield* rows.approvals.list().pipe(
210164 Effect.mapError((error) =>
211− toSourceStoreErrorFromLocalState("approvals.resolve", error),
165+ toSourceStoreErrorFromRowStore("approvals.resolve", error),
212166 ),
213167 );
214168
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−
224169 const index = findApprovalIndex(
225− snapshot.approvals,
170+ approvals,
226171 input.workspaceId,
227172 input.approvalId,
228173 );
@@ -235,7 +180,7 @@ export const createPmApprovalsService = ( · 6 unmodified lines
235180 );
236181 }
237182
238− const approval = snapshot.approvals[index];
183+ const approval = approvals[index];
239184 if (approval.status !== "pending") {
240185 return yield* toSourceStoreError(
241186 "approvals.resolve",
@@ -251,11 +196,9 @@ export const createPmApprovalsService = ( · 9 unmodified lines
251196 resolvedAt: Date.now(),
252197 };
253198
254− const nextSnapshot = updateApproval(snapshot, index, resolvedApproval);
255−
256− yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
199+ yield* rows.approvals.upsert(resolvedApproval).pipe(
257200 Effect.mapError((error) =>
258− toSourceStoreErrorFromLocalState("approvals.resolve_write", error),
201+ toSourceStoreErrorFromRowStore("approvals.resolve_write", error),
259202 ),
260203 );
261204
apps/pm/src/credential-resolver.ts+35−18
@@ -5,9 +5,27 @@ import { · 4 unmodified lines
55 sourceIdFromSourceKey,
66 type ResolveToolCredentials,
77 } from "@executor-v2/engine";
8−import { type LocalStateStore } from "@executor-v2/persistence-local";
8+import { type RowStoreError } from "@executor-v2/persistence-ports";
9+import {
10+ type AuthConnection,
11+ type AuthMaterial,
12+ type OAuthState,
13+ type SourceAuthBinding,
14+ type Workspace,
15+} from "@executor-v2/schema";
916 import * as Effect from "effect/Effect";
10−import * as Option from "effect/Option";
17+
18+type RowReader<A> = {
19+ list: () => Effect.Effect<ReadonlyArray<A>, RowStoreError>;
20+};
21+
22+type CredentialResolverRows = {
23+ workspaces: RowReader<Workspace>;
24+ sourceAuthBindings: RowReader<SourceAuthBinding>;
25+ authConnections: RowReader<AuthConnection>;
26+ authMaterials: RowReader<AuthMaterial>;
27+ oauthStates: RowReader<OAuthState>;
28+};
1129
1230 const toCredentialResolverError = (
1331 operation: string,
@@ -151,7 +169,7 @@ const buildSecretHeaders = ( · 137 unmodified lines
151169 };
152170
153171 export const createPmResolveToolCredentials = (
154− localStateStore: LocalStateStore,
172+ rows: CredentialResolverRows,
155173 ): ResolveToolCredentials =>
156174 makeCredentialResolver((input) =>
157175 Effect.gen(function* () {
@@ -162,23 +180,22 @@ export const createPmResolveToolCredentials = ( · 4 unmodified lines
162180 };
163181 }
164182
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(
166190 Effect.mapError((error) =>
167191 toCredentialResolverError(
168− "read_local_state_snapshot",
169− "Failed reading local snapshot while resolving credentials",
192+ "read_credential_rows",
193+ "Failed reading credential rows while resolving credentials",
170194 error.details ?? error.message,
171195 ),
172196 ),
173197 );
174198
175− const snapshot = Option.getOrNull(snapshotOption);
176− if (snapshot === null) {
177− return {
178− headers: {},
179− };
180− }
181−
182199 const sourceId = sourceIdFromSourceKey(context.sourceKey);
183200 if (!sourceId) {
184201 return {
@@ -186,12 +203,12 @@ export const createPmResolveToolCredentials = ( · 1 unmodified line
186203 };
187204 }
188205
189− const workspace = snapshot.workspaces.find(
206+ const workspace = workspaces.find(
190207 (item) => item.id === context.workspaceId,
191208 );
192209 const organizationId = context.organizationId ?? workspace?.organizationId ?? null;
193210
194− const binding = snapshot.sourceAuthBindings
211+ const binding = bindings
195212 .filter((candidate) => candidate.sourceId === sourceId)
196213 .map((candidate) => {
197214 let score = -1;
@@ -230,7 +247,7 @@ export const createPmResolveToolCredentials = ( · 32 unmodified lines
230247 };
231248 }
232249
233− const connection = snapshot.authConnections.find(
250+ const connection = connections.find(
234251 (candidate) => candidate.id === binding.connectionId,
235252 );
236253 if (!connection || connection.status !== "active") {
@@ -244,7 +261,7 @@ export const createPmResolveToolCredentials = ( · 7 unmodified lines
244261 );
245262
246263 if (connection.strategy === "oauth2") {
247− const oauthState = snapshot.oauthStates.find(
264+ const oauthState = oauthStates.find(
248265 (candidate) => candidate.connectionId === connection.id,
249266 );
250267
@@ -258,7 +275,7 @@ export const createPmResolveToolCredentials = ( · 7 unmodified lines
258275 };
259276 }
260277
261− const material = snapshot.authMaterials.find(
278+ const material = materials.find(
262279 (candidate) => candidate.connectionId === connection.id,
263280 );
264281
apps/pm/src/credentials-service.ts+134−159
@@ -1,9 +1,5 @@
11 import { SourceStoreError } from "@executor-v2/persistence-ports";
2−import {
3− type LocalStateSnapshot,
4− type LocalStateStore,
5− type LocalStateStoreError,
6−} from "@executor-v2/persistence-local";
2+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
73 import {
84 makeControlPlaneCredentialsService,
95 type ControlPlaneCredentialsServiceShape,
@@ -17,10 +13,19 @@ import { · 7 unmodified lines
1713 type OrganizationId,
1814 type SourceAuthBinding,
1915 type SourceCredentialBinding,
16+ type Workspace,
2017 type WorkspaceId,
2118 } from "@executor-v2/schema";
2219 import * as Effect from "effect/Effect";
23−import * as Option from "effect/Option";
20+
21+type CredentialRows = Pick<
22+ SqlControlPlanePersistence["rows"],
23+ | "workspaces"
24+ | "authConnections"
25+ | "sourceAuthBindings"
26+ | "authMaterials"
27+ | "oauthStates"
28+>;
2429
2530 const toSourceStoreError = (
2631 operation: string,
@@ -29,24 +34,24 @@ const toSourceStoreError = ( · 2 unmodified lines
2934 ): SourceStoreError =>
3035 new SourceStoreError({
3136 operation,
32− backend: "local-file",
33− location: "snapshot.json",
37+ backend: "sql",
38+ location: "credentials",
3439 message,
3540 reason: null,
3641 details,
3742 });
3843
39−const toSourceStoreErrorFromLocalState = (
44+const toSourceStoreErrorFromRowStore = (
4045 operation: string,
41− error: LocalStateStoreError,
46+ error: { message: string; details: string | null; reason: string | null },
4247 ): SourceStoreError =>
4348 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
4449
4550 const resolveWorkspaceOrganizationId = (
46− snapshot: LocalStateSnapshot,
51+ workspaces: ReadonlyArray<Workspace>,
4752 workspaceId: WorkspaceId,
4853 ): OrganizationId => {
49− const workspace = snapshot.workspaces.find((item) => item.id === workspaceId);
54+ const workspace = workspaces.find((item) => item.id === workspaceId);
5055
5156 if (!workspace) {
5257 throw new Error(`Workspace not found: ${workspaceId}`);
@@ -139,21 +144,6 @@ const sortCredentialBindings = ( · 86 unmodified lines
139144 return leftKey.localeCompare(rightKey);
140145 });
141146
142−const upsertById = <A extends { id: string }>(
143− values: ReadonlyArray<A>,
144− nextValue: A,
145−): Array<A> => {
146− const next = [...values];
147− const index = next.findIndex((value) => value.id === nextValue.id);
148− if (index >= 0) {
149− next[index] = nextValue;
150− return next;
151− }
152−
153− next.push(nextValue);
154− return next;
155−};
156−
157147 const parseJsonObject = (value: string | null | undefined): Record<string, unknown> => {
158148 if (!value) {
159149 return {};
@@ -245,88 +235,33 @@ const buildOAuthRefreshConfigFromPayload = ( · 85 unmodified lines
245235 ?? existing.clientInformationJson,
246236 });
247237
248−const removeCredentialBindingForWorkspace = (
249− snapshot: LocalStateSnapshot,
250− workspaceId: WorkspaceId,
251− credentialBindingId: string,
252−): { snapshot: LocalStateSnapshot; removed: boolean } => {
253− const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId);
254− const removeIndex = snapshot.sourceAuthBindings.findIndex(
255− (binding) =>
256− binding.id === credentialBindingId
257− && (
258− binding.workspaceId === workspaceId
259− || (binding.workspaceId === null && binding.organizationId === organizationId)
260− ),
261− );
262−
263− if (removeIndex < 0) {
264− return {
265− snapshot,
266− removed: false,
267− };
268− }
269−
270− const bindingToRemove = snapshot.sourceAuthBindings[removeIndex];
271− if (!bindingToRemove) {
272− return {
273− snapshot,
274− removed: false,
275− };
276− }
277−
278− const nextBindings = [...snapshot.sourceAuthBindings];
279− nextBindings.splice(removeIndex, 1);
280−
281− const hasRemainingBindings = nextBindings.some(
282− (binding) => binding.connectionId === bindingToRemove.connectionId,
283− );
284−
285− return {
286− removed: true,
287− snapshot: {
288− ...snapshot,
289− generatedAt: Date.now(),
290− sourceAuthBindings: nextBindings,
291− authConnections: hasRemainingBindings
292− ? snapshot.authConnections
293− : snapshot.authConnections.filter(
294− (connection) => connection.id !== bindingToRemove.connectionId,
295− ),
296− authMaterials: hasRemainingBindings
297− ? snapshot.authMaterials
298− : snapshot.authMaterials.filter(
299− (material) => material.connectionId !== bindingToRemove.connectionId,
300− ),
301− oauthStates: hasRemainingBindings
302− ? snapshot.oauthStates
303− : snapshot.oauthStates.filter(
304− (state) => state.connectionId !== bindingToRemove.connectionId,
305− ),
306− },
307− };
308−};
309−
310238 export const createPmCredentialsService = (
311− localStateStore: LocalStateStore,
239+ rows: CredentialRows,
312240 ): ControlPlaneCredentialsServiceShape =>
313241 makeControlPlaneCredentialsService({
314242 listCredentialBindings: (workspaceId) =>
315243 Effect.gen(function* () {
316− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
317− Effect.mapError((error) =>
318− toSourceStoreErrorFromLocalState("credentials.list", error),
244+ const [bindings, connections, workspaces] = yield* Effect.all([
245+ rows.sourceAuthBindings.list().pipe(
246+ Effect.mapError((error) =>
247+ toSourceStoreErrorFromRowStore("credentials.bindings.list", error),
248+ ),
319249 ),
320− );
321−
322− const snapshot = Option.getOrNull(snapshotOption);
323− if (snapshot === null) {
324− return [];
325− }
250+ rows.authConnections.list().pipe(
251+ Effect.mapError((error) =>
252+ toSourceStoreErrorFromRowStore("credentials.connections.list", error),
253+ ),
254+ ),
255+ rows.workspaces.list().pipe(
256+ Effect.mapError((error) =>
257+ toSourceStoreErrorFromRowStore("credentials.workspaces.list", error),
258+ ),
259+ ),
260+ ]);
326261
327− const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId);
262+ const organizationId = resolveWorkspaceOrganizationId(workspaces, workspaceId);
328263
329− const scopedBindings = snapshot.sourceAuthBindings.filter(
264+ const scopedBindings = bindings.filter(
330265 (binding) =>
331266 binding.workspaceId === workspaceId
332267 || (binding.workspaceId === null && binding.organizationId === organizationId),
@@ -335,7 +270,7 @@ export const createPmCredentialsService = ( · 2 unmodified lines
335270 const compatBindings: Array<SourceCredentialBinding> = [];
336271
337272 for (const binding of scopedBindings) {
338− const connection = snapshot.authConnections.find(
273+ const connection = connections.find(
339274 (candidate) => candidate.id === binding.connectionId,
340275 );
341276
@@ -351,20 +286,33 @@ export const createPmCredentialsService = ( · 9 unmodified lines
351286
352287 upsertCredentialBinding: (input) =>
353288 Effect.gen(function* () {
354− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
355− Effect.mapError((error) =>
356− toSourceStoreErrorFromLocalState("credentials.upsert", error),
289+ const [bindings, connections, materials, oauthStates, workspaces] = yield* Effect.all([
290+ rows.sourceAuthBindings.list().pipe(
291+ Effect.mapError((error) =>
292+ toSourceStoreErrorFromRowStore("credentials.bindings.list", error),
293+ ),
357294 ),
358− );
359−
360− const snapshot = Option.getOrNull(snapshotOption);
361− if (snapshot === null) {
362− return yield* toSourceStoreError(
363− "credentials.upsert",
364− "Credential snapshot not found",
365− `workspace=${input.workspaceId}`,
366− );
367− }
295+ rows.authConnections.list().pipe(
296+ Effect.mapError((error) =>
297+ toSourceStoreErrorFromRowStore("credentials.connections.list", error),
298+ ),
299+ ),
300+ rows.authMaterials.list().pipe(
301+ Effect.mapError((error) =>
302+ toSourceStoreErrorFromRowStore("credentials.materials.list", error),
303+ ),
304+ ),
305+ rows.oauthStates.list().pipe(
306+ Effect.mapError((error) =>
307+ toSourceStoreErrorFromRowStore("credentials.oauth_states.list", error),
308+ ),
309+ ),
310+ rows.workspaces.list().pipe(
311+ Effect.mapError((error) =>
312+ toSourceStoreErrorFromRowStore("credentials.workspaces.list", error),
313+ ),
314+ ),
315+ ]);
368316
369317 if (input.payload.scopeType === "account" && input.payload.accountId === null) {
370318 return yield* toSourceStoreError(
@@ -388,10 +336,10 @@ export const createPmCredentialsService = ( · 17 unmodified lines
388336 const requestedBindingId = requestedId as SourceAuthBinding["id"] | undefined;
389337
390338 const existingBinding = requestedBindingId
391− ? snapshot.sourceAuthBindings.find((binding) => binding.id === requestedBindingId) ?? null
339+ ? bindings.find((binding) => binding.id === requestedBindingId) ?? null
392340 : null;
393341
394− const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId);
342+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
395343
396344 const scopeWorkspaceId =
397345 input.payload.scopeType === "workspace" ? input.workspaceId : null;
@@ -410,7 +358,7 @@ export const createPmCredentialsService = ( · 12 unmodified lines
410358 ?? (`conn_${crypto.randomUUID()}` as AuthConnection["id"])
411359 ) as AuthConnection["id"];
412360
413− const existingConnection = snapshot.authConnections.find(
361+ const existingConnection = connections.find(
414362 (connection) => connection.id === requestedConnectionId,
415363 ) ?? null;
416364
@@ -465,15 +413,17 @@ export const createPmCredentialsService = ( · 48 unmodified lines
465413 updatedAt: now,
466414 };
467415
468− let nextSnapshot: LocalStateSnapshot = {
469− ...snapshot,
470− generatedAt: now,
471− authConnections: upsertById(snapshot.authConnections, nextConnection),
472− sourceAuthBindings: upsertById(snapshot.sourceAuthBindings, nextBinding),
473− };
416+ yield* Effect.all([
417+ rows.authConnections.upsert(nextConnection),
418+ rows.sourceAuthBindings.upsert(nextBinding),
419+ ]).pipe(
420+ Effect.mapError((error) =>
421+ toSourceStoreErrorFromRowStore("credentials.upsert_rows", error),
422+ ),
423+ );
474424
475425 if (nextConnection.strategy === "oauth2") {
476− const existingOAuth = snapshot.oauthStates.find(
426+ const existingOAuth = oauthStates.find(
477427 (state) => state.connectionId === requestedConnectionId,
478428 ) ?? null;
479429 const refreshConfig = buildOAuthRefreshConfigFromPayload(
@@ -518,15 +468,16 @@ export const createPmCredentialsService = ( · 38 unmodified lines
518468 updatedAt: now,
519469 };
520470
521− nextSnapshot = {
522− ...nextSnapshot,
523− authMaterials: nextSnapshot.authMaterials.filter(
524− (material) => material.connectionId !== requestedConnectionId,
471+ yield* Effect.all([
472+ rows.oauthStates.upsert(oauthState),
473+ rows.authMaterials.removeByConnectionId(requestedConnectionId),
474+ ]).pipe(
475+ Effect.mapError((error) =>
476+ toSourceStoreErrorFromRowStore("credentials.upsert_oauth", error),
525477 ),
526− oauthStates: upsertById(nextSnapshot.oauthStates, oauthState),
527− };
478+ );
528479 } else {
529− const existingMaterial = snapshot.authMaterials.find(
480+ const existingMaterial = materials.find(
530481 (material) => material.connectionId === requestedConnectionId,
531482 ) ?? null;
532483
@@ -541,57 +492,81 @@ export const createPmCredentialsService = ( · 8 unmodified lines
541492 updatedAt: now,
542493 };
543494
544− nextSnapshot = {
545− ...nextSnapshot,
546− authMaterials: upsertById(nextSnapshot.authMaterials, material),
547− oauthStates: nextSnapshot.oauthStates.filter(
548− (state) => state.connectionId !== requestedConnectionId,
495+ yield* Effect.all([
496+ rows.authMaterials.upsert(material),
497+ rows.oauthStates.removeByConnectionId(requestedConnectionId),
498+ ]).pipe(
499+ Effect.mapError((error) =>
500+ toSourceStoreErrorFromRowStore("credentials.upsert_secret", error),
549501 ),
550− };
502+ );
551503 }
552504
553− yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
554− Effect.mapError((error) =>
555− toSourceStoreErrorFromLocalState("credentials.upsert_write", error),
556− ),
557− );
558−
559505 return toCompatSourceCredentialBinding(nextBinding, nextConnection);
560506 }),
561507
562508 removeCredentialBinding: (input) =>
563509 Effect.gen(function* () {
564− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
565− Effect.mapError((error) =>
566− toSourceStoreErrorFromLocalState("credentials.remove", error),
510+ const [bindings, workspaces] = yield* Effect.all([
511+ rows.sourceAuthBindings.list().pipe(
512+ Effect.mapError((error) =>
513+ toSourceStoreErrorFromRowStore("credentials.bindings.list", error),
514+ ),
515+ ),
516+ rows.workspaces.list().pipe(
517+ Effect.mapError((error) =>
518+ toSourceStoreErrorFromRowStore("credentials.workspaces.list", error),
519+ ),
567520 ),
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+ ),
568531 );
569532
570− const snapshot = Option.getOrNull(snapshotOption);
571− if (snapshot === null) {
533+ if (!binding) {
572534 return {
573535 removed: false,
574536 };
575537 }
576538
577− const next = removeCredentialBindingForWorkspace(
578− snapshot,
579− input.workspaceId,
580− input.credentialBindingId,
581− );
539+ const removed = yield* rows.sourceAuthBindings
540+ .removeById(binding.id)
541+ .pipe(
542+ Effect.mapError((error) =>
543+ toSourceStoreErrorFromRowStore("credentials.remove_binding", error),
544+ ),
545+ );
582546
583− if (!next.removed) {
547+ if (!removed) {
584548 return {
585549 removed: false,
586550 };
587551 }
588552
589− yield* localStateStore.writeSnapshot(next.snapshot).pipe(
590− Effect.mapError((error) =>
591− toSourceStoreErrorFromLocalState("credentials.remove_write", error),
592− ),
553+ const hasRemainingBindings = bindings.some(
554+ (candidate) =>
555+ candidate.id !== binding.id && candidate.connectionId === binding.connectionId,
593556 );
594557
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+
595570 return {
596571 removed: true,
597572 };
apps/pm/src/main.ts+93−27
@@ -1,4 +1,3 @@
1−import * as BunContext from "@effect/platform-bun/BunContext";
21 import {
32 ControlPlaneService,
43 makeControlPlaneService,
@@ -22,16 +21,16 @@ import { · 17 unmodified lines
2221 parseExecuteToolExposureMode,
2322 } from "@executor-v2/engine";
2423 import {
25− makeLocalSourceStore,
26− makeLocalStateStore,
27− makeLocalToolArtifactStore,
28−} from "@executor-v2/persistence-local";
24+ makeSqlControlPlanePersistence,
25+ type SqlControlPlanePersistence,
26+} from "@executor-v2/persistence-sql";
2927 import { type RuntimeToolCallResult } from "@executor-v2/sdk";
3028 import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader";
3129 import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess";
3230 import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc";
3331 import * as Effect from "effect/Effect";
3432 import * as Layer from "effect/Layer";
33+import * as path from "node:path";
3534
3635 import { PmActorLive } from "./actor";
3736 import {
@@ -91,6 +90,75 @@ const defaultToolExposureMode = readConfiguredToolExposureMode( · 53 unmodified lines
9190 process.env.PM_TOOL_EXPOSURE_MODE,
9291 );
9392
93+const ensurePmBootstrap = (
94+ persistence: SqlControlPlanePersistence,
95+) =>
96+ Effect.gen(function* () {
97+ const now = Date.now();
98+ const [organizations, memberships, workspaces, profileOption] = yield* Effect.all([
99+ persistence.rows.organizations.list(),
100+ persistence.rows.organizationMemberships.list(),
101+ persistence.rows.workspaces.list(),
102+ persistence.rows.profile.get(),
103+ ]);
104+
105+ const organizationId = "org_local";
106+ const accountId = "acct_local";
107+
108+ if (organizations.find((item) => item.id === organizationId) === undefined) {
109+ yield* persistence.rows.organizations.upsert({
110+ id: organizationId as any,
111+ slug: organizationId,
112+ name: "Local Organization",
113+ status: "active",
114+ createdByAccountId: accountId as any,
115+ createdAt: now,
116+ updatedAt: now,
117+ });
118+ }
119+
120+ if (
121+ memberships.find(
122+ (item) => item.organizationId === organizationId && item.accountId === accountId,
123+ ) === undefined
124+ ) {
125+ yield* persistence.rows.organizationMemberships.upsert({
126+ id: "org_member_local" as any,
127+ organizationId: organizationId as any,
128+ accountId: accountId as any,
129+ role: "owner",
130+ status: "active",
131+ billable: false,
132+ invitedByAccountId: null,
133+ joinedAt: now,
134+ createdAt: now,
135+ updatedAt: now,
136+ });
137+ }
138+
139+ if (workspaces.find((item) => item.id === workspaceId) === undefined) {
140+ yield* persistence.rows.workspaces.upsert({
141+ id: workspaceId as any,
142+ organizationId: organizationId as any,
143+ name: "Local Workspace",
144+ createdByAccountId: accountId as any,
145+ createdAt: now,
146+ updatedAt: now,
147+ });
148+ }
149+
150+ if (profileOption._tag === "None") {
151+ yield* persistence.rows.profile.upsert({
152+ id: "profile_local" as any,
153+ defaultWorkspaceId: workspaceId as any,
154+ displayName: "Local",
155+ runtimeMode: "local",
156+ createdAt: now,
157+ updatedAt: now,
158+ });
159+ }
160+ });
161+
94162 const pmRuntimeAdapters = [
95163 makeLocalInProcessRuntimeAdapter(),
96164 makeDenoSubprocessRuntimeAdapter(),
@@ -101,23 +169,20 @@ const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters); · 4 unmodified lines
101169 const defaultRuntimeKind =
102170 readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind;
103171
104−const sourceStore = await Effect.runPromise(
105− makeLocalSourceStore({
106− rootDir: pmStateRootDir,
107− }).pipe(Effect.provide(BunContext.layer)),
172+const persistence: SqlControlPlanePersistence = await Effect.runPromise(
173+ makeSqlControlPlanePersistence({
174+ databaseUrl: process.env.PM_CONTROL_PLANE_DATABASE_URL,
175+ sqlitePath:
176+ process.env.PM_CONTROL_PLANE_SQLITE_PATH
177+ ?? path.resolve(pmStateRootDir, "control-plane.sqlite"),
178+ postgresApplicationName: "executor-v2-pm",
179+ }),
108180 );
109181
110−const localStateStore = await Effect.runPromise(
111− makeLocalStateStore({
112− rootDir: pmStateRootDir,
113− }).pipe(Effect.provide(BunContext.layer)),
114−);
182+const sourceStore = persistence.sourceStore;
183+const toolArtifactStore = persistence.toolArtifactStore;
115184
116−const toolArtifactStore = await Effect.runPromise(
117− makeLocalToolArtifactStore({
118− rootDir: pmStateRootDir,
119− }).pipe(Effect.provide(BunContext.layer)),
120−);
185+await Effect.runPromise(ensurePmBootstrap(persistence));
121186
122187 const sourceCatalog = makeSourceCatalogService(sourceStore);
123188 const sourceManager = makeSourceManagerService(toolArtifactStore);
@@ -152,15 +217,15 @@ const sourcesService = { · 28 unmodified lines
152217 }),
153218 };
154219
155−const credentialsService = createPmCredentialsService(localStateStore);
156−const policiesService = createPmPoliciesService(localStateStore);
157−const organizationsService = createPmOrganizationsService(localStateStore);
158−const workspacesService = createPmWorkspacesService(localStateStore);
220+const credentialsService = createPmCredentialsService(persistence.rows);
221+const policiesService = createPmPoliciesService(persistence.rows);
222+const organizationsService = createPmOrganizationsService(persistence.rows);
223+const workspacesService = createPmWorkspacesService(persistence.rows);
159224 const toolsService = createPmToolsService(sourceStore, toolArtifactStore);
160−const storageService = createPmStorageService(localStateStore, {
225+const storageService = createPmStorageService(persistence.rows, {
161226 stateRootDir: pmStateRootDir,
162227 });
163−const approvalsService = createPmApprovalsService(localStateStore);
228+const approvalsService = createPmApprovalsService(persistence.rows);
164229 const controlPlaneService = makeControlPlaneService({
165230 sources: sourcesService,
166231 credentials: credentialsService,
@@ -174,7 +239,7 @@ const controlPlaneService = makeControlPlaneService({ · 7 unmodified lines
174239
175240 const controlPlaneWebHandler = makeControlPlaneWebHandler(
176241 Layer.succeed(ControlPlaneService, controlPlaneService),
177− PmActorLive(localStateStore),
242+ PmActorLive(persistence.rows),
178243 );
179244
180245 const toolProviderRegistry = makeToolProviderRegistry([
@@ -182,7 +247,7 @@ const toolProviderRegistry = makeToolProviderRegistry([ · 1 unmodified line
182247 makeMcpToolProvider(),
183248 makeGraphqlToolProvider(),
184249 ]);
185−const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(localStateStore, {
250+const persistentApprovalPolicy = createPmPersistentToolApprovalPolicy(persistence.rows, {
186251 requireApprovals: requireToolApprovals,
187252 });
188253 const toolRegistry = createSourceToolRegistry({
@@ -228,6 +293,7 @@ const server = startPmHttpServer({ · 39 unmodified lines
228293 const shutdown = async () => {
229294 server.stop();
230295 await controlPlaneWebHandler.dispose();
296+ await persistence.close();
231297 };
232298
233299 process.on("SIGINT", () => {
apps/pm/src/organizations-service.ts+44−77
@@ -1,19 +1,19 @@
11 import { SourceStoreError } from "@executor-v2/persistence-ports";
2+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
23 import {
34 makeControlPlaneOrganizationsService,
45 type ControlPlaneOrganizationsServiceShape,
56 } from "@executor-v2/management-api";
6−import {
7− type LocalStateSnapshot,
8− type LocalStateStore,
9− type LocalStateStoreError,
10−} from "@executor-v2/persistence-local";
117 import {
128 type Organization,
139 type OrganizationMembership,
1410 } from "@executor-v2/schema";
1511 import * as Effect from "effect/Effect";
16−import * as Option from "effect/Option";
12+
13+type OrganizationRows = Pick<
14+ SqlControlPlanePersistence["rows"],
15+ "organizations" | "organizationMemberships"
16+>;
1717
1818 const toSourceStoreError = (
1919 operation: string,
@@ -22,16 +22,16 @@ const toSourceStoreError = ( · 2 unmodified lines
2222 ): SourceStoreError =>
2323 new SourceStoreError({
2424 operation,
25− backend: "local-file",
26− location: "snapshot.json",
25+ backend: "sql",
26+ location: "organizations",
2727 message,
2828 reason: null,
2929 details,
3030 });
3131
32−const toSourceStoreErrorFromLocalState = (
32+const toSourceStoreErrorFromRowStore = (
3333 operation: string,
34− error: LocalStateStoreError,
34+ error: { message: string; details: string | null; reason: string | null },
3535 ): SourceStoreError =>
3636 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
3737
@@ -49,83 +49,45 @@ const sortOrganizations = ( · 11 unmodified lines
4949 return leftName.localeCompare(rightName);
5050 });
5151
52−const replaceOrganizationAt = (
53− snapshot: LocalStateSnapshot,
54− index: number,
55− organization: Organization,
56−): LocalStateSnapshot => {
57− const next = [...snapshot.organizations];
58− next[index] = organization;
59−
60− return {
61− ...snapshot,
62− generatedAt: Date.now(),
63− organizations: next,
64− };
65−};
66−
67−const appendOrganization = (
68− snapshot: LocalStateSnapshot,
69− organization: Organization,
70−): LocalStateSnapshot => ({
71− ...snapshot,
72− generatedAt: Date.now(),
73− organizations: [...snapshot.organizations, organization],
74−});
75−
76−const appendOrganizationMembership = (
77− snapshot: LocalStateSnapshot,
78− membership: OrganizationMembership,
79−): LocalStateSnapshot => ({
80− ...snapshot,
81− generatedAt: Date.now(),
82− organizationMemberships: [...snapshot.organizationMemberships, membership],
83−});
84−
8552 export const createPmOrganizationsService = (
86− localStateStore: LocalStateStore,
53+ rows: OrganizationRows,
8754 ): ControlPlaneOrganizationsServiceShape =>
8855 makeControlPlaneOrganizationsService({
8956 listOrganizations: () =>
9057 Effect.gen(function* () {
91− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
58+ const organizations = yield* rows.organizations.list().pipe(
9259 Effect.mapError((error) =>
93− toSourceStoreErrorFromLocalState("organizations.list", error),
60+ toSourceStoreErrorFromRowStore("organizations.list", error),
9461 ),
9562 );
9663
97− const snapshot = Option.getOrNull(snapshotOption);
98− if (snapshot === null) {
99− return [];
100− }
101−
102− return sortOrganizations(snapshot.organizations);
64+ return sortOrganizations(organizations);
10365 }),
10466
10567 upsertOrganization: (input) =>
10668 Effect.gen(function* () {
107− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
69+ const organizations = yield* rows.organizations.list().pipe(
10870 Effect.mapError((error) =>
109− toSourceStoreErrorFromLocalState("organizations.upsert", error),
71+ toSourceStoreErrorFromRowStore("organizations.upsert", error),
11072 ),
11173 );
11274
113− const snapshot = Option.getOrNull(snapshotOption);
114− if (snapshot === null) {
115− return yield* toSourceStoreError(
116− "organizations.upsert",
117− "Organization snapshot not found",
118− null,
119− );
120− }
75+ const memberships = yield* rows.organizationMemberships.list().pipe(
76+ Effect.mapError((error) =>
77+ toSourceStoreErrorFromRowStore(
78+ "organizations.memberships.list",
79+ error,
80+ ),
81+ ),
82+ );
12183
12284 const now = Date.now();
12385 const existingIndex = input.payload.id
124− ? snapshot.organizations.findIndex((organization) => organization.id === input.payload.id)
125− : snapshot.organizations.findIndex(
86+ ? organizations.findIndex((organization) => organization.id === input.payload.id)
87+ : organizations.findIndex(
12688 (organization) => organization.slug === input.payload.slug,
12789 );
128− const existing = existingIndex >= 0 ? snapshot.organizations[existingIndex] : null;
90+ const existing = existingIndex >= 0 ? organizations[existingIndex] : null;
12991
13092 const nextOrganization: Organization = {
13193 id:
@@ -142,19 +104,21 @@ export const createPmOrganizationsService = ( · 10 unmodified lines
142104 updatedAt: now,
143105 };
144106
145− let nextSnapshot = existingIndex >= 0
146− ? replaceOrganizationAt(snapshot, existingIndex, nextOrganization)
147− : appendOrganization(snapshot, nextOrganization);
107+ yield* rows.organizations.upsert(nextOrganization).pipe(
108+ Effect.mapError((error) =>
109+ toSourceStoreErrorFromRowStore("organizations.upsert_write", error),
110+ ),
111+ );
148112
149113 if (existing === null && nextOrganization.createdByAccountId !== null) {
150− const existingMembership = nextSnapshot.organizationMemberships.find(
114+ const existingMembership = memberships.find(
151115 (membership) =>
152116 membership.organizationId === nextOrganization.id
153117 && membership.accountId === nextOrganization.createdByAccountId,
154118 );
155119
156120 if (!existingMembership) {
157− nextSnapshot = appendOrganizationMembership(nextSnapshot, {
121+ const membership: OrganizationMembership = {
158122 id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"],
159123 organizationId: nextOrganization.id,
160124 accountId: nextOrganization.createdByAccountId,
@@ -165,16 +129,19 @@ export const createPmOrganizationsService = ( · 4 unmodified lines
165129 joinedAt: now,
166130 createdAt: now,
167131 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+ );
169142 }
170143 }
171144
172− yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
173− Effect.mapError((error) =>
174− toSourceStoreErrorFromLocalState("organizations.upsert_write", error),
175− ),
176− );
177−
178145 return nextOrganization;
179146 }),
180147 });
apps/pm/src/persistent-approval-policy.test.ts+23−49
@@ -1,60 +1,34 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import type {
3− LocalStateSnapshot,
4− LocalStateStore,
5−} from "@executor-v2/persistence-local";
62 import * as Effect from "effect/Effect";
7−import * as Option from "effect/Option";
3+import { type Approval } from "@executor-v2/schema";
84
95 import {
106 createPmApprovalsService,
117 createPmPersistentToolApprovalPolicy,
128 } from "./approvals-service";
139
14−const createTestSnapshot = (): LocalStateSnapshot =>
15− ({
16− schemaVersion: 1,
17− generatedAt: Date.now(),
18− profile: {
19− id: "profile_local",
20− defaultWorkspaceId: "ws_local",
21− displayName: "Local",
22− runtimeMode: "local",
23− createdAt: Date.now(),
24− updatedAt: Date.now(),
25− },
26− organizations: [],
27− organizationMemberships: [],
28− workspaces: [],
29− sources: [],
30− toolArtifacts: [],
31− authConnections: [],
32− sourceAuthBindings: [],
33− authMaterials: [],
34− oauthStates: [],
35− policies: [],
36− approvals: [],
37− taskRuns: [],
38− storageInstances: [],
39− syncStates: [],
40− }) as unknown as LocalStateSnapshot;
41−
4210 describe("PM persistent approval policy", () => {
4311 it.effect("creates pending approval records and reuses resolved decisions", () =>
4412 Effect.gen(function* () {
45− let snapshot: LocalStateSnapshot = createTestSnapshot();
13+ const approvals: Array<Approval> = [];
4614
47− const localStateStore: LocalStateStore = {
48− getSnapshot: () => Effect.succeed(Option.some(snapshot)),
49− writeSnapshot: (nextSnapshot) =>
50− Effect.sync(() => {
51− snapshot = nextSnapshot;
52− }),
53− readEvents: () => Effect.succeed([]),
54− appendEvents: () => Effect.void,
15+ const approvalRows = {
16+ approvals: {
17+ list: () => Effect.succeed(approvals),
18+ upsert: (approval: Approval) =>
19+ Effect.sync(() => {
20+ const index = approvals.findIndex((item) => item.id === approval.id);
21+ if (index >= 0) {
22+ approvals[index] = approval;
23+ return;
24+ }
25+
26+ approvals.push(approval);
27+ }),
28+ },
5529 };
5630
57− const policy = createPmPersistentToolApprovalPolicy(localStateStore, {
31+ const policy = createPmPersistentToolApprovalPolicy(approvalRows, {
5832 requireApprovals: true,
5933 retryAfterMs: 250,
6034 });
@@ -80,9 +54,9 @@ describe("PM persistent approval policy", () => { · 19 unmodified lines
8054 if (first.kind === "pending") {
8155 expect(first.retryAfterMs).toBe(250);
8256 }
83− expect(snapshot.approvals).toHaveLength(1);
84− expect(snapshot.approvals[0]?.status).toBe("pending");
85− expect(snapshot.approvals[0]?.callId).toBe("call_approval_1");
57+ expect(approvals).toHaveLength(1);
58+ expect(approvals[0]?.status).toBe("pending");
59+ expect(approvals[0]?.callId).toBe("call_approval_1");
8660
8761 const second = yield* Effect.promise(() =>
8862 Promise.resolve(
@@ -98,10 +72,10 @@ describe("PM persistent approval policy", () => { · 9 unmodified lines
9872 );
9973
10074 expect(second).toEqual(first);
101− expect(snapshot.approvals).toHaveLength(1);
75+ expect(approvals).toHaveLength(1);
10276
103− const approvalsService = createPmApprovalsService(localStateStore);
104− const createdApproval = snapshot.approvals[0];
77+ const approvalsService = createPmApprovalsService(approvalRows);
78+ const createdApproval = approvals[0];
10579 if (!createdApproval) {
10680 throw new Error("expected pending approval record");
10781 }
apps/pm/src/policies-service.ts+26−108
@@ -1,16 +1,13 @@
11 import { SourceStoreError } from "@executor-v2/persistence-ports";
2−import {
3− type LocalStateSnapshot,
4− type LocalStateStore,
5− type LocalStateStoreError,
6−} from "@executor-v2/persistence-local";
2+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
73 import {
84 makeControlPlanePoliciesService,
95 type ControlPlanePoliciesServiceShape,
106 } from "@executor-v2/management-api";
117 import { type Policy } from "@executor-v2/schema";
128 import * as Effect from "effect/Effect";
13−import * as Option from "effect/Option";
9+
10+type PolicyRows = Pick<SqlControlPlanePersistence["rows"], "policies">;
1411
1512 const toSourceStoreError = (
1613 operation: string,
@@ -19,16 +16,16 @@ const toSourceStoreError = ( · 2 unmodified lines
1916 ): SourceStoreError =>
2017 new SourceStoreError({
2118 operation,
22− backend: "local-file",
23− location: "snapshot.json",
19+ backend: "sql",
20+ location: "policies",
2421 message,
2522 reason: null,
2623 details,
2724 });
2825
29−const toSourceStoreErrorFromLocalState = (
26+const toSourceStoreErrorFromRowStore = (
3027 operation: string,
31− error: LocalStateStoreError,
28+ error: { message: string; details: string | null; reason: string | null },
3229 ): SourceStoreError =>
3330 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
3431
@@ -43,109 +40,42 @@ const sortPolicies = (policies: ReadonlyArray<Policy>): Array<Policy> => · 8 unmodified lines
4340 return leftPattern.localeCompare(rightPattern);
4441 });
4542
46−const replacePolicyAt = (
47− snapshot: LocalStateSnapshot,
48− index: number,
49− policy: Policy,
50−): LocalStateSnapshot => {
51− const next = [...snapshot.policies];
52− next[index] = policy;
53−
54− return {
55− ...snapshot,
56− generatedAt: Date.now(),
57− policies: next,
58− };
59−};
60−
61−const appendPolicy = (
62− snapshot: LocalStateSnapshot,
63− policy: Policy,
64−): LocalStateSnapshot => ({
65− ...snapshot,
66− generatedAt: Date.now(),
67− policies: [...snapshot.policies, policy],
68−});
69−
70−const removePolicyForWorkspace = (
71− snapshot: LocalStateSnapshot,
72− workspaceId: Policy["workspaceId"],
73− policyId: string,
74−): { snapshot: LocalStateSnapshot; removed: boolean } => {
75− const removeIndex = snapshot.policies.findIndex(
76− (policy) => policy.workspaceId === workspaceId && policy.id === policyId,
77− );
78−
79− if (removeIndex < 0) {
80− return {
81− snapshot,
82− removed: false,
83− };
84− }
85−
86− const next = [...snapshot.policies];
87− next.splice(removeIndex, 1);
88−
89− return {
90− removed: true,
91− snapshot: {
92− ...snapshot,
93− generatedAt: Date.now(),
94− policies: next,
95− },
96− };
97−};
98−
9943 export const createPmPoliciesService = (
100− localStateStore: LocalStateStore,
44+ rows: PolicyRows,
10145 ): ControlPlanePoliciesServiceShape =>
10246 makeControlPlanePoliciesService({
10347 listPolicies: (workspaceId) =>
10448 Effect.gen(function* () {
105− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
49+ const policies = yield* rows.policies.list().pipe(
10650 Effect.mapError((error) =>
107− toSourceStoreErrorFromLocalState("policies.list", error),
51+ toSourceStoreErrorFromRowStore("policies.list", error),
10852 ),
10953 );
11054
111− const snapshot = Option.getOrNull(snapshotOption);
112− if (snapshot === null) {
113− return [];
114− }
115−
11655 return sortPolicies(
117− snapshot.policies.filter((policy) => policy.workspaceId === workspaceId),
56+ policies.filter((policy) => policy.workspaceId === workspaceId),
11857 );
11958 }),
12059
12160 upsertPolicy: (input) =>
12261 Effect.gen(function* () {
123− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
62+ const policies = yield* rows.policies.list().pipe(
12463 Effect.mapError((error) =>
125− toSourceStoreErrorFromLocalState("policies.upsert", error),
64+ toSourceStoreErrorFromRowStore("policies.upsert", error),
12665 ),
12766 );
12867
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−
13868 const now = Date.now();
13969 const requestedId = input.payload.id;
14070
14171 const existingIndex = requestedId
142− ? snapshot.policies.findIndex(
72+ ? policies.findIndex(
14373 (policy) =>
14474 policy.workspaceId === input.workspaceId && policy.id === requestedId,
14575 )
14676 : -1;
14777
148− const existing = existingIndex >= 0 ? snapshot.policies[existingIndex] : null;
78+ const existing = existingIndex >= 0 ? policies[existingIndex] : null;
14979
15080 const nextPolicy: Policy = {
15181 id: existing?.id ?? (requestedId ?? (`pol_${crypto.randomUUID()}` as Policy["id"])),
@@ -156,13 +86,9 @@ export const createPmPoliciesService = ( · 4 unmodified lines
15686 updatedAt: now,
15787 };
15888
159− const nextSnapshot = existingIndex >= 0
160− ? replacePolicyAt(snapshot, existingIndex, nextPolicy)
161− : appendPolicy(snapshot, nextPolicy);
162−
163− yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
89+ yield* rows.policies.upsert(nextPolicy).pipe(
16490 Effect.mapError((error) =>
165− toSourceStoreErrorFromLocalState("policies.upsert_write", error),
91+ toSourceStoreErrorFromRowStore("policies.upsert_write", error),
16692 ),
16793 );
16894
@@ -171,38 +97,30 @@ export const createPmPoliciesService = ( · 2 unmodified lines
17197
17298 removePolicy: (input) =>
17399 Effect.gen(function* () {
174− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
100+ const policies = yield* rows.policies.list().pipe(
175101 Effect.mapError((error) =>
176− toSourceStoreErrorFromLocalState("policies.remove", error),
102+ toSourceStoreErrorFromRowStore("policies.remove", error),
177103 ),
178104 );
179105
180− const snapshot = Option.getOrNull(snapshotOption);
181− if (snapshot === null) {
182− return {
183− removed: false,
184− };
185− }
186−
187− const next = removePolicyForWorkspace(
188− snapshot,
189− input.workspaceId,
190− input.policyId,
106+ const existing = policies.find(
107+ (policy) => policy.workspaceId === input.workspaceId && policy.id === input.policyId,
191108 );
192− if (!next.removed) {
109+
110+ if (!existing) {
193111 return {
194112 removed: false,
195113 };
196114 }
197115
198− yield* localStateStore.writeSnapshot(next.snapshot).pipe(
116+ const removed = yield* rows.policies.removeById(input.policyId).pipe(
199117 Effect.mapError((error) =>
200− toSourceStoreErrorFromLocalState("policies.remove_write", error),
118+ toSourceStoreErrorFromRowStore("policies.remove_write", error),
201119 ),
202120 );
203121
204122 return {
205− removed: true,
123+ removed,
206124 };
207125 }),
208126 });
apps/pm/src/storage-service.test.ts+50−54
@@ -1,53 +1,21 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import type {
3− LocalStateSnapshot,
4− LocalStateStore,
5−} from "@executor-v2/persistence-local";
6−import type { StorageInstance, WorkspaceId } from "@executor-v2/schema";
2+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
3+import {
4+ type OrganizationId,
5+ type StorageInstance,
6+ type WorkspaceId,
7+} from "@executor-v2/schema";
78 import * as Effect from "effect/Effect";
8−import * as Option from "effect/Option";
99 import { mkdtemp, rm, writeFile } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import * as path from "node:path";
1212
1313 import { createPmStorageService } from "./storage-service";
1414
15−const createTestSnapshot = (): LocalStateSnapshot =>
16− ({
17− schemaVersion: 1,
18− generatedAt: Date.now(),
19− profile: {
20− id: "profile_local",
21− defaultWorkspaceId: "ws_local",
22− displayName: "Local",
23− runtimeMode: "local",
24− createdAt: Date.now(),
25− updatedAt: Date.now(),
26− },
27− organizations: [],
28− organizationMemberships: [],
29− workspaces: [
30− {
31− id: "ws_local",
32− organizationId: "org_local",
33− name: "Local Workspace",
34− createdByAccountId: null,
35− createdAt: Date.now(),
36− updatedAt: Date.now(),
37− },
38− ],
39− sources: [],
40− toolArtifacts: [],
41− authConnections: [],
42− sourceAuthBindings: [],
43− authMaterials: [],
44− oauthStates: [],
45− policies: [],
46− approvals: [],
47− taskRuns: [],
48− storageInstances: [],
49− syncStates: [],
50− }) as unknown as LocalStateSnapshot;
15+type StorageRows = Pick<
16+ SqlControlPlanePersistence["rows"],
17+ "workspaces" | "storageInstances"
18+>;
5119
5220 describe("PM storage service", () => {
5321 it.effect("opens, inspects, queries, and removes storage instances", () =>
@@ -56,24 +24,52 @@ describe("PM storage service", () => { · 2 unmodified lines
5624 mkdtemp(path.join(tmpdir(), "executor-v2-pm-storage-")),
5725 );
5826
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+ };
6036
61− const localStateStore: LocalStateStore = {
62− getSnapshot: () => Effect.succeed(Option.some(snapshot)),
63− writeSnapshot: (nextSnapshot) =>
64− Effect.sync(() => {
65− snapshot = nextSnapshot;
66− }),
67− readEvents: () => Effect.succeed([]),
68− appendEvents: () => Effect.void,
37+ const storageInstances: Array<StorageInstance> = [];
38+
39+ const rows: StorageRows = {
40+ workspaces: {
41+ list: () => Effect.succeed([workspace]),
42+ upsert: () => Effect.void,
43+ },
44+ storageInstances: {
45+ list: () => Effect.succeed(storageInstances),
46+ upsert: (storageInstance) =>
47+ Effect.sync(() => {
48+ const index = storageInstances.findIndex((item) => item.id === storageInstance.id);
49+ if (index >= 0) {
50+ storageInstances[index] = storageInstance;
51+ return;
52+ }
53+
54+ storageInstances.push(storageInstance);
55+ }),
56+ removeById: (storageInstanceId) =>
57+ Effect.sync(() => {
58+ const index = storageInstances.findIndex((item) => item.id === storageInstanceId);
59+ if (index < 0) {
60+ return false;
61+ }
62+
63+ storageInstances.splice(index, 1);
64+ return true;
65+ }),
66+ },
6967 };
7068
71− const service = createPmStorageService(localStateStore, {
69+ const service = createPmStorageService(rows, {
7270 stateRootDir,
7371 });
7472
75− const workspaceId = "ws_local" as WorkspaceId;
76−
7773 const storageInstance = yield* service.openStorageInstance({
7874 workspaceId,
7975 payload: {
apps/pm/src/storage-service.ts+148−270
@@ -3,18 +3,14 @@ import { · 2 unmodified lines
33 type ControlPlaneStorageServiceShape,
44 } from "@executor-v2/management-api";
55 import { SourceStoreError } from "@executor-v2/persistence-ports";
6−import {
7− type LocalStateSnapshot,
8− type LocalStateStore,
9− type LocalStateStoreError,
10−} from "@executor-v2/persistence-local";
6+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
117 import {
128 type OrganizationId,
139 type StorageInstance,
10+ type Workspace,
1411 type WorkspaceId,
1512 } from "@executor-v2/schema";
1613 import * as Effect from "effect/Effect";
17−import * as Option from "effect/Option";
1814 import {
1915 mkdir,
2016 readdir,
@@ -25,6 +21,11 @@ import { · 4 unmodified lines
2521 } from "node:fs/promises";
2622 import * as path from "node:path";
2723
24+type StorageRows = Pick<
25+ SqlControlPlanePersistence["rows"],
26+ "workspaces" | "storageInstances"
27+>;
28+
2829 const DEFAULT_EPHEMERAL_TTL_HOURS = 24;
2930 const DEFAULT_KV_LIMIT = 100;
3031 const MAX_KV_LIMIT = 1000;
@@ -68,16 +69,16 @@ const toSourceStoreError = ( · 37 unmodified lines
6869 ): SourceStoreError =>
6970 new SourceStoreError({
7071 operation,
71− backend: "local-file",
72− location: "snapshot.json",
72+ backend: "sql",
73+ location: "storage",
7374 message,
7475 reason: null,
7576 details,
7677 });
7778
78−const toSourceStoreErrorFromLocalState = (
79+const toSourceStoreErrorFromRowStore = (
7980 operation: string,
80− error: LocalStateStoreError,
81+ error: { message: string; details: string | null; reason: string | null },
8182 ): SourceStoreError =>
8283 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
8384
@@ -93,10 +94,10 @@ const toSourceStoreErrorFromCause = ( · 9 unmodified lines
9394 );
9495
9596 const resolveWorkspaceOrganizationId = (
96− snapshot: LocalStateSnapshot,
97+ workspaces: ReadonlyArray<Workspace>,
9798 workspaceId: WorkspaceId,
9899 ): OrganizationId => {
99− const workspace = snapshot.workspaces.find((item) => item.id === workspaceId);
100+ const workspace = workspaces.find((item) => item.id === workspaceId);
100101
101102 if (!workspace) {
102103 throw new Error(`Workspace not found: ${workspaceId}`);
@@ -124,70 +125,6 @@ const sortStorageInstances = ( · 21 unmodified lines
124125 return left.id.localeCompare(right.id);
125126 });
126127
127−const replaceStorageInstanceAt = (
128− snapshot: LocalStateSnapshot,
129− index: number,
130− storageInstance: StorageInstance,
131−): LocalStateSnapshot => {
132− const next = [...snapshot.storageInstances];
133− next[index] = storageInstance;
134−
135− return {
136− ...snapshot,
137− generatedAt: Date.now(),
138− storageInstances: next,
139− };
140−};
141−
142−const appendStorageInstance = (
143− snapshot: LocalStateSnapshot,
144− storageInstance: StorageInstance,
145−): LocalStateSnapshot => ({
146− ...snapshot,
147− generatedAt: Date.now(),
148− storageInstances: [...snapshot.storageInstances, storageInstance],
149−});
150−
151−const removeStorageInstanceById = (
152− snapshot: LocalStateSnapshot,
153− input: {
154− workspaceId: WorkspaceId;
155− organizationId: OrganizationId;
156− storageInstanceId: StorageInstance["id"];
157− },
158−): { snapshot: LocalStateSnapshot; removed: boolean } => {
159− let removed = false;
160−
161− const next = snapshot.storageInstances.filter((instance) => {
162− const matches =
163− instance.id === input.storageInstanceId
164− && canAccessStorageInstance(instance, input.workspaceId, input.organizationId);
165−
166− if (matches) {
167− removed = true;
168− return false;
169− }
170−
171− return true;
172− });
173−
174− if (!removed) {
175− return {
176− snapshot,
177− removed: false,
178− };
179− }
180−
181− return {
182− removed: true,
183− snapshot: {
184− ...snapshot,
185− generatedAt: Date.now(),
186− storageInstances: next,
187− },
188− };
189−};
190−
191128 const storageInstanceRootPath = (stateRootDir: string, storageInstanceId: string): string =>
192129 path.resolve(stateRootDir, STORAGE_ROOT_DIRECTORY, storageInstanceId);
193130
@@ -292,60 +229,49 @@ const readKvStore = async ( · 98 unmodified lines
292229 };
293230
294231 const findStorageInstance = (
295− snapshot: LocalStateSnapshot,
232+ storageInstances: ReadonlyArray<StorageInstance>,
233+ organizationId: OrganizationId,
296234 input: {
297235 workspaceId: WorkspaceId;
298236 storageInstanceId: StorageInstance["id"];
299237 },
300238 ): {
301239 storageInstance: StorageInstance;
302− storageInstanceIndex: number;
303240 } | null => {
304− const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId);
305− const storageInstanceIndex = snapshot.storageInstances.findIndex((instance) =>
241+ const storageInstance = storageInstances.find((instance) =>
306242 instance.id === input.storageInstanceId
307243 && canAccessStorageInstance(instance, input.workspaceId, organizationId)
308244 );
309245
310− if (storageInstanceIndex < 0) {
311− return null;
312− }
313−
314− const storageInstance = snapshot.storageInstances[storageInstanceIndex];
315246 if (!storageInstance) {
316247 return null;
317248 }
318249
319250 return {
320251 storageInstance,
321− storageInstanceIndex,
322252 };
323253 };
324254
325255 const touchStorageInstance = (
326− snapshot: LocalStateSnapshot,
327− storageInstanceIndex: number,
328−): {
329− snapshot: LocalStateSnapshot;
330− storageInstance: StorageInstance;
331−} => {
332− const existing = snapshot.storageInstances[storageInstanceIndex];
256+ rows: StorageRows,
257+ existing: StorageInstance,
258+ operation: string,
259+): Effect.Effect<StorageInstance, SourceStoreError> => {
333260 const now = Date.now();
334−
335− const touchedStorageInstance: StorageInstance = {
261+ const next: StorageInstance = {
336262 ...existing,
337263 updatedAt: now,
338264 lastSeenAt: now,
339265 };
340266
341− return {
342− storageInstance: touchedStorageInstance,
343− snapshot: replaceStorageInstanceAt(snapshot, storageInstanceIndex, touchedStorageInstance),
344− };
267+ return rows.storageInstances.upsert(next).pipe(
268+ Effect.mapError((error) => toSourceStoreErrorFromRowStore(operation, error)),
269+ Effect.as(next),
270+ );
345271 };
346272
347273 export const createPmStorageService = (
348− localStateStore: LocalStateStore,
274+ rows: StorageRows,
349275 options: {
350276 stateRootDir: string;
351277 },
@@ -353,21 +279,23 @@ export const createPmStorageService = ( · 1 unmodified line
353279 makeControlPlaneStorageService({
354280 listStorageInstances: (workspaceId) =>
355281 Effect.gen(function* () {
356− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
357− Effect.mapError((error) =>
358− toSourceStoreErrorFromLocalState("storage.list", error),
282+ const [storageInstances, workspaces] = yield* Effect.all([
283+ rows.storageInstances.list().pipe(
284+ Effect.mapError((error) =>
285+ toSourceStoreErrorFromRowStore("storage.list_instances", error),
286+ ),
359287 ),
360− );
361−
362− const snapshot = Option.getOrNull(snapshotOption);
363− if (snapshot === null) {
364− return [];
365− }
288+ rows.workspaces.list().pipe(
289+ Effect.mapError((error) =>
290+ toSourceStoreErrorFromRowStore("storage.list_instances.workspaces", error),
291+ ),
292+ ),
293+ ]);
366294
367− const organizationId = resolveWorkspaceOrganizationId(snapshot, workspaceId);
295+ const organizationId = resolveWorkspaceOrganizationId(workspaces, workspaceId);
368296
369297 return sortStorageInstances(
370− snapshot.storageInstances.filter((instance) =>
298+ storageInstances.filter((instance) =>
371299 canAccessStorageInstance(instance, workspaceId, organizationId)
372300 ),
373301 );
@@ -375,21 +303,6 @@ export const createPmStorageService = ( · 1 unmodified line
375303
376304 openStorageInstance: (input) =>
377305 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−
393306 if (input.payload.scopeType === "account" && input.payload.accountId === undefined) {
394307 return yield* toSourceStoreError(
395308 "storage.open",
@@ -398,8 +311,14 @@ export const createPmStorageService = ( · 2 unmodified lines
398311 );
399312 }
400313
314+ const workspaces = yield* rows.workspaces.list().pipe(
315+ Effect.mapError((error) =>
316+ toSourceStoreErrorFromRowStore("storage.open.workspaces", error),
317+ ),
318+ );
319+
401320 const now = Date.now();
402− const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId);
321+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
403322 const storageInstanceId =
404323 `storage_${crypto.randomUUID()}` as StorageInstance["id"];
405324 const ttlHours =
@@ -450,11 +369,9 @@ export const createPmStorageService = ( · 44 unmodified lines
450369 : null,
451370 };
452371
453− const nextSnapshot = appendStorageInstance(snapshot, nextStorageInstance);
454−
455− yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
372+ yield* rows.storageInstances.upsert(nextStorageInstance).pipe(
456373 Effect.mapError((error) =>
457− toSourceStoreErrorFromLocalState("storage.open_write", error),
374+ toSourceStoreErrorFromRowStore("storage.open_write", error),
458375 ),
459376 );
460377
@@ -463,22 +380,21 @@ export const createPmStorageService = ( · 2 unmodified lines
463380
464381 closeStorageInstance: (input) =>
465382 Effect.gen(function* () {
466− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
467− Effect.mapError((error) =>
468− toSourceStoreErrorFromLocalState("storage.close", error),
383+ const [storageInstances, workspaces] = yield* Effect.all([
384+ rows.storageInstances.list().pipe(
385+ Effect.mapError((error) =>
386+ toSourceStoreErrorFromRowStore("storage.close.instances", error),
387+ ),
469388 ),
470− );
471−
472− const snapshot = Option.getOrNull(snapshotOption);
473− if (snapshot === null) {
474− return yield* toSourceStoreError(
475− "storage.close",
476− "Storage snapshot not found",
477− `workspace=${input.workspaceId}`,
478− );
479− }
389+ rows.workspaces.list().pipe(
390+ Effect.mapError((error) =>
391+ toSourceStoreErrorFromRowStore("storage.close.workspaces", error),
392+ ),
393+ ),
394+ ]);
480395
481− const found = findStorageInstance(snapshot, {
396+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
397+ const found = findStorageInstance(storageInstances, organizationId, {
482398 workspaceId: input.workspaceId,
483399 storageInstanceId: input.storageInstanceId,
484400 });
@@ -501,15 +417,9 @@ export const createPmStorageService = ( · 16 unmodified lines
501417 closedAt: found.storageInstance.closedAt ?? now,
502418 };
503419
504− const nextSnapshot = replaceStorageInstanceAt(
505− snapshot,
506− found.storageInstanceIndex,
507− nextStorageInstance,
508− );
509−
510− yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
420+ yield* rows.storageInstances.upsert(nextStorageInstance).pipe(
511421 Effect.mapError((error) =>
512− toSourceStoreErrorFromLocalState("storage.close_write", error),
422+ toSourceStoreErrorFromRowStore("storage.close_write", error),
513423 ),
514424 );
515425
@@ -518,28 +428,26 @@ export const createPmStorageService = ( · 2 unmodified lines
518428
519429 removeStorageInstance: (input) =>
520430 Effect.gen(function* () {
521− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
522− Effect.mapError((error) =>
523− toSourceStoreErrorFromLocalState("storage.remove", error),
431+ const [storageInstances, workspaces] = yield* Effect.all([
432+ rows.storageInstances.list().pipe(
433+ Effect.mapError((error) =>
434+ toSourceStoreErrorFromRowStore("storage.remove.instances", error),
435+ ),
524436 ),
525− );
526−
527− const snapshot = Option.getOrNull(snapshotOption);
528− if (snapshot === null) {
529− return {
530− removed: false,
531− };
532− }
533−
534− const organizationId = resolveWorkspaceOrganizationId(snapshot, input.workspaceId);
437+ rows.workspaces.list().pipe(
438+ Effect.mapError((error) =>
439+ toSourceStoreErrorFromRowStore("storage.remove.workspaces", error),
440+ ),
441+ ),
442+ ]);
535443
536− const next = removeStorageInstanceById(snapshot, {
444+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
445+ const found = findStorageInstance(storageInstances, organizationId, {
537446 workspaceId: input.workspaceId,
538− organizationId,
539447 storageInstanceId: input.storageInstanceId,
540448 });
541449
542− if (!next.removed) {
450+ if (found === null) {
543451 return {
544452 removed: false,
545453 };
@@ -547,13 +455,10 @@ export const createPmStorageService = ( · 1 unmodified line
547455
548456 yield* Effect.tryPromise({
549457 try: () =>
550− rm(
551− storageInstanceRootPath(options.stateRootDir, input.storageInstanceId),
552− {
553− recursive: true,
554− force: true,
555− },
556− ),
458+ rm(storageInstanceRootPath(options.stateRootDir, input.storageInstanceId), {
459+ recursive: true,
460+ force: true,
461+ }),
557462 catch: (cause) =>
558463 toSourceStoreErrorFromCause(
559464 "storage.remove_files",
@@ -562,35 +467,34 @@ export const createPmStorageService = ( · 2 unmodified lines
562467 ),
563468 });
564469
565− yield* localStateStore.writeSnapshot(next.snapshot).pipe(
470+ const removed = yield* rows.storageInstances.removeById(input.storageInstanceId).pipe(
566471 Effect.mapError((error) =>
567− toSourceStoreErrorFromLocalState("storage.remove_write", error),
472+ toSourceStoreErrorFromRowStore("storage.remove_write", error),
568473 ),
569474 );
570475
571476 return {
572− removed: true,
477+ removed,
573478 };
574479 }),
575480
576481 listStorageDirectory: (input) =>
577482 Effect.gen(function* () {
578− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
579− Effect.mapError((error) =>
580− toSourceStoreErrorFromLocalState("storage.listDirectory", error),
483+ const [storageInstances, workspaces] = yield* Effect.all([
484+ rows.storageInstances.list().pipe(
485+ Effect.mapError((error) =>
486+ toSourceStoreErrorFromRowStore("storage.list_directory.instances", error),
487+ ),
581488 ),
582− );
583−
584− const snapshot = Option.getOrNull(snapshotOption);
585− if (snapshot === null) {
586− return yield* toSourceStoreError(
587− "storage.listDirectory",
588− "Storage snapshot not found",
589− `workspace=${input.workspaceId}`,
590− );
591− }
489+ rows.workspaces.list().pipe(
490+ Effect.mapError((error) =>
491+ toSourceStoreErrorFromRowStore("storage.list_directory.workspaces", error),
492+ ),
493+ ),
494+ ]);
592495
593− const found = findStorageInstance(snapshot, {
496+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
497+ const found = findStorageInstance(storageInstances, organizationId, {
594498 workspaceId: input.workspaceId,
595499 storageInstanceId: input.storageInstanceId,
596500 });
@@ -660,15 +564,10 @@ export const createPmStorageService = ( · 63 unmodified lines
660564 ),
661565 });
662566
663− const touched = touchStorageInstance(snapshot, found.storageInstanceIndex);
664−
665− yield* localStateStore.writeSnapshot(touched.snapshot).pipe(
666− Effect.mapError((error) =>
667− toSourceStoreErrorFromLocalState(
668− "storage.listDirectory_touch",
669− error,
670− ),
671− ),
567+ yield* touchStorageInstance(
568+ rows,
569+ found.storageInstance,
570+ "storage.listDirectory_touch",
672571 );
673572
674573 return result;
@@ -676,22 +575,21 @@ export const createPmStorageService = ( · 1 unmodified line
676575
677576 readStorageFile: (input) =>
678577 Effect.gen(function* () {
679− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
680− Effect.mapError((error) =>
681− toSourceStoreErrorFromLocalState("storage.readFile", error),
578+ const [storageInstances, workspaces] = yield* Effect.all([
579+ rows.storageInstances.list().pipe(
580+ Effect.mapError((error) =>
581+ toSourceStoreErrorFromRowStore("storage.read_file.instances", error),
582+ ),
682583 ),
683− );
684−
685− const snapshot = Option.getOrNull(snapshotOption);
686− if (snapshot === null) {
687− return yield* toSourceStoreError(
688− "storage.readFile",
689− "Storage snapshot not found",
690− `workspace=${input.workspaceId}`,
691− );
692− }
584+ rows.workspaces.list().pipe(
585+ Effect.mapError((error) =>
586+ toSourceStoreErrorFromRowStore("storage.read_file.workspaces", error),
587+ ),
588+ ),
589+ ]);
693590
694− const found = findStorageInstance(snapshot, {
591+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
592+ const found = findStorageInstance(storageInstances, organizationId, {
695593 workspaceId: input.workspaceId,
696594 storageInstanceId: input.storageInstanceId,
697595 });
@@ -741,35 +639,28 @@ export const createPmStorageService = ( · 43 unmodified lines
741639 ),
742640 });
743641
744− const touched = touchStorageInstance(snapshot, found.storageInstanceIndex);
745−
746− yield* localStateStore.writeSnapshot(touched.snapshot).pipe(
747− Effect.mapError((error) =>
748− toSourceStoreErrorFromLocalState("storage.readFile_touch", error),
749− ),
750− );
642+ yield* touchStorageInstance(rows, found.storageInstance, "storage.readFile_touch");
751643
752644 return result;
753645 }),
754646
755647 listStorageKv: (input) =>
756648 Effect.gen(function* () {
757− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
758− Effect.mapError((error) =>
759− toSourceStoreErrorFromLocalState("storage.listKv", error),
649+ const [storageInstances, workspaces] = yield* Effect.all([
650+ rows.storageInstances.list().pipe(
651+ Effect.mapError((error) =>
652+ toSourceStoreErrorFromRowStore("storage.list_kv.instances", error),
653+ ),
760654 ),
761− );
762−
763− const snapshot = Option.getOrNull(snapshotOption);
764− if (snapshot === null) {
765− return yield* toSourceStoreError(
766− "storage.listKv",
767− "Storage snapshot not found",
768− `workspace=${input.workspaceId}`,
769− );
770− }
655+ rows.workspaces.list().pipe(
656+ Effect.mapError((error) =>
657+ toSourceStoreErrorFromRowStore("storage.list_kv.workspaces", error),
658+ ),
659+ ),
660+ ]);
771661
772− const found = findStorageInstance(snapshot, {
662+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
663+ const found = findStorageInstance(storageInstances, organizationId, {
773664 workspaceId: input.workspaceId,
774665 storageInstanceId: input.storageInstanceId,
775666 });
@@ -815,35 +706,28 @@ export const createPmStorageService = ( · 39 unmodified lines
815706 ),
816707 });
817708
818− const touched = touchStorageInstance(snapshot, found.storageInstanceIndex);
819−
820− yield* localStateStore.writeSnapshot(touched.snapshot).pipe(
821− Effect.mapError((error) =>
822− toSourceStoreErrorFromLocalState("storage.listKv_touch", error),
823− ),
824− );
709+ yield* touchStorageInstance(rows, found.storageInstance, "storage.listKv_touch");
825710
826711 return result;
827712 }),
828713
829714 queryStorageSql: (input) =>
830715 Effect.gen(function* () {
831− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
832− Effect.mapError((error) =>
833− toSourceStoreErrorFromLocalState("storage.querySql", error),
716+ const [storageInstances, workspaces] = yield* Effect.all([
717+ rows.storageInstances.list().pipe(
718+ Effect.mapError((error) =>
719+ toSourceStoreErrorFromRowStore("storage.query_sql.instances", error),
720+ ),
834721 ),
835− );
836−
837− const snapshot = Option.getOrNull(snapshotOption);
838− if (snapshot === null) {
839− return yield* toSourceStoreError(
840− "storage.querySql",
841− "Storage snapshot not found",
842− `workspace=${input.workspaceId}`,
843− );
844− }
722+ rows.workspaces.list().pipe(
723+ Effect.mapError((error) =>
724+ toSourceStoreErrorFromRowStore("storage.query_sql.workspaces", error),
725+ ),
726+ ),
727+ ]);
845728
846− const found = findStorageInstance(snapshot, {
729+ const organizationId = resolveWorkspaceOrganizationId(workspaces, input.workspaceId);
730+ const found = findStorageInstance(storageInstances, organizationId, {
847731 workspaceId: input.workspaceId,
848732 storageInstanceId: input.storageInstanceId,
849733 });
@@ -894,16 +778,16 @@ export const createPmStorageService = ( · 44 unmodified lines
894778 try {
895779 const statement = db.query(sqlText);
896780 const rawRows = statement.all() as Array<Record<string, unknown>>;
897− const rows = rawRows.slice(0, maxRows);
781+ const rowsLimited = rawRows.slice(0, maxRows);
898782 const columns =
899− rows.length > 0
900− ? Array.from(new Set(rows.flatMap((row) => Object.keys(row))))
783+ rowsLimited.length > 0
784+ ? Array.from(new Set(rowsLimited.flatMap((row) => Object.keys(row))))
901785 : [];
902786
903787 return {
904− rows,
788+ rows: rowsLimited,
905789 columns,
906− rowCount: rows.length,
790+ rowCount: rowsLimited.length,
907791 };
908792 } catch {
909793 db.run(sqlText);
@@ -926,13 +810,7 @@ export const createPmStorageService = ( · 16 unmodified lines
926810 ),
927811 });
928812
929− const touched = touchStorageInstance(snapshot, found.storageInstanceIndex);
930−
931− yield* localStateStore.writeSnapshot(touched.snapshot).pipe(
932− Effect.mapError((error) =>
933− toSourceStoreErrorFromLocalState("storage.querySql_touch", error),
934− ),
935− );
813+ yield* touchStorageInstance(rows, found.storageInstance, "storage.querySql_touch");
936814
937815 return result;
938816 }),
apps/pm/src/tool-call-handler.test.ts+17−7
@@ -4,21 +4,31 @@ import { · 3 unmodified lines
44 } from "@executor-v2/engine";
55 import { describe, expect, it } from "@effect/vitest";
66 import * as Effect from "effect/Effect";
7−import * as Option from "effect/Option";
87
98 import { createPmResolveToolCredentials } from "./credential-resolver";
109
11−const emptyLocalStateStore = {
12− getSnapshot: () => Effect.succeed(Option.none()),
13− writeSnapshot: () => Effect.void,
14− readEvents: () => Effect.succeed([]),
15− appendEvents: () => Effect.void,
10+const emptyCredentialRows = {
11+ workspaces: {
12+ list: () => Effect.succeed([]),
13+ },
14+ sourceAuthBindings: {
15+ list: () => Effect.succeed([]),
16+ },
17+ authConnections: {
18+ list: () => Effect.succeed([]),
19+ },
20+ authMaterials: {
21+ list: () => Effect.succeed([]),
22+ },
23+ oauthStates: {
24+ list: () => Effect.succeed([]),
25+ },
1626 };
1727
1828 describe("PM runtime tool-call handling", () => {
1929 it.effect("returns failed callback result for unimplemented invoker", () =>
2030 Effect.gen(function* () {
21− const resolveCredentials = createPmResolveToolCredentials(emptyLocalStateStore);
31+ const resolveCredentials = createPmResolveToolCredentials(emptyCredentialRows);
2232 const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("pm");
2333 const handleToolCall = createRuntimeToolCallHandler({
2434 resolveCredentials,
apps/pm/src/tools-service.ts+2−2
@@ -31,8 +31,8 @@ const toSourceStoreError = ( · 30 unmodified lines
3131 ): SourceStoreError =>
3232 new SourceStoreError({
3333 operation,
34− backend: "local-file",
35− location: "tool-artifacts.json",
34+ backend: "sql",
35+ location: "tool_artifacts",
3636 message,
3737 reason: null,
3838 details,
apps/pm/src/workspaces-service.ts+17−62
@@ -1,16 +1,13 @@
11 import { SourceStoreError } from "@executor-v2/persistence-ports";
2+import { type SqlControlPlanePersistence } from "@executor-v2/persistence-sql";
23 import {
34 makeControlPlaneWorkspacesService,
45 type ControlPlaneWorkspacesServiceShape,
56 } from "@executor-v2/management-api";
6−import {
7− type LocalStateSnapshot,
8− type LocalStateStore,
9− type LocalStateStoreError,
10−} from "@executor-v2/persistence-local";
117 import { type Workspace } from "@executor-v2/schema";
128 import * as Effect from "effect/Effect";
13−import * as Option from "effect/Option";
9+
10+type WorkspaceRows = Pick<SqlControlPlanePersistence["rows"], "workspaces">;
1411
1512 const toSourceStoreError = (
1613 operation: string,
@@ -19,16 +16,16 @@ const toSourceStoreError = ( · 2 unmodified lines
1916 ): SourceStoreError =>
2017 new SourceStoreError({
2118 operation,
22− backend: "local-file",
23− location: "snapshot.json",
19+ backend: "sql",
20+ location: "workspaces",
2421 message,
2522 reason: null,
2623 details,
2724 });
2825
29−const toSourceStoreErrorFromLocalState = (
26+const toSourceStoreErrorFromRowStore = (
3027 operation: string,
31− error: LocalStateStoreError,
28+ error: { message: string; details: string | null; reason: string | null },
3229 ): SourceStoreError =>
3330 toSourceStoreError(operation, error.message, error.details ?? error.reason ?? null);
3431
@@ -44,72 +41,34 @@ const sortWorkspaces = (workspaces: ReadonlyArray<Workspace>): Array<Workspace> · 9 unmodified lines
4441 return leftName.localeCompare(rightName);
4542 });
4643
47−const replaceWorkspaceAt = (
48− snapshot: LocalStateSnapshot,
49− index: number,
50− workspace: Workspace,
51−): LocalStateSnapshot => {
52− const next = [...snapshot.workspaces];
53− next[index] = workspace;
54−
55− return {
56− ...snapshot,
57− generatedAt: Date.now(),
58− workspaces: next,
59− };
60−};
61−
62−const appendWorkspace = (
63− snapshot: LocalStateSnapshot,
64− workspace: Workspace,
65−): LocalStateSnapshot => ({
66− ...snapshot,
67− generatedAt: Date.now(),
68− workspaces: [...snapshot.workspaces, workspace],
69−});
70−
7144 export const createPmWorkspacesService = (
72− localStateStore: LocalStateStore,
45+ rows: WorkspaceRows,
7346 ): ControlPlaneWorkspacesServiceShape =>
7447 makeControlPlaneWorkspacesService({
7548 listWorkspaces: () =>
7649 Effect.gen(function* () {
77− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
50+ const workspaces = yield* rows.workspaces.list().pipe(
7851 Effect.mapError((error) =>
79− toSourceStoreErrorFromLocalState("workspaces.list", error),
52+ toSourceStoreErrorFromRowStore("workspaces.list", error),
8053 ),
8154 );
8255
83− const snapshot = Option.getOrNull(snapshotOption);
84− if (snapshot === null) {
85− return [];
86− }
87−
88− return sortWorkspaces(snapshot.workspaces);
56+ return sortWorkspaces(workspaces);
8957 }),
9058
9159 upsertWorkspace: (input) =>
9260 Effect.gen(function* () {
93− const snapshotOption = yield* localStateStore.getSnapshot().pipe(
61+ const workspaces = yield* rows.workspaces.list().pipe(
9462 Effect.mapError((error) =>
95− toSourceStoreErrorFromLocalState("workspaces.upsert", error),
63+ toSourceStoreErrorFromRowStore("workspaces.upsert", error),
9664 ),
9765 );
9866
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−
10867 const now = Date.now();
10968 const existingIndex = input.payload.id
110− ? snapshot.workspaces.findIndex((workspace) => workspace.id === input.payload.id)
69+ ? workspaces.findIndex((workspace) => workspace.id === input.payload.id)
11170 : -1;
112− const existing = existingIndex >= 0 ? snapshot.workspaces[existingIndex] : null;
71+ const existing = existingIndex >= 0 ? workspaces[existingIndex] : null;
11372
11473 const nextWorkspace: Workspace = {
11574 id: existing?.id ?? (input.payload.id ?? (`ws_${crypto.randomUUID()}` as Workspace["id"])),
@@ -120,13 +79,9 @@ export const createPmWorkspacesService = ( · 4 unmodified lines
12079 updatedAt: now,
12180 };
12281
123− const nextSnapshot = existingIndex >= 0
124− ? replaceWorkspaceAt(snapshot, existingIndex, nextWorkspace)
125− : appendWorkspace(snapshot, nextWorkspace);
126−
127− yield* localStateStore.writeSnapshot(nextSnapshot).pipe(
82+ yield* rows.workspaces.upsert(nextWorkspace).pipe(
12883 Effect.mapError((error) =>
129− toSourceStoreErrorFromLocalState("workspaces.upsert_write", error),
84+ toSourceStoreErrorFromRowStore("workspaces.upsert_write", error),
13085 ),
13186 );
13287
apps/web/README.md+13−4
@@ -13,9 +13,18 @@ Run: · 12 unmodified lines
1313 By default, browser control-plane API calls go through the same-origin proxy at
1414 `/api/control-plane`.
1515
16−The proxy forwards to the first available upstream value in:
17−`CONTROL_PLANE_SERVER_BASE_URL`, `CONTROL_PLANE_UPSTREAM_URL`, `NEXT_PUBLIC_CONVEX_URL`, `CONVEX_URL`.
18−If none are set, fallback is `http://127.0.0.1:8788`.
16+The API route now runs the control-plane server in-process.
17+
18+Database selection priority:
19+
20+- `CONTROL_PLANE_DATABASE_URL`
21+- `DATABASE_URL`
22+- `POSTGRES_URL`
23+
24+If none are set, fallback is local SQLite at:
25+
26+- `CONTROL_PLANE_SQLITE_PATH`
27+- default: `.executor-v2/web-state/control-plane.sqlite`
1928
2029 If needed, `NEXT_PUBLIC_CONTROL_PLANE_BASE_URL` can override the browser base URL.
2130
@@ -23,7 +32,7 @@ MCP install URL generation: · 1 unmodified line
2332
2433 - Derives from existing control-plane/frontend config.
2534 - 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.
2736
2837 WorkOS auth setup (optional but recommended):
2938
apps/web/app/(console)/tools/page.tsx+0−2
@@ -5,8 +5,6 @@ const resolveMcpBaseUrl = (): string | null => { · 4 unmodified lines
55 process.env.CONTROL_PLANE_UPSTREAM_URL,
66 process.env.CONTROL_PLANE_SERVER_BASE_URL,
77 process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL,
8− process.env.NEXT_PUBLIC_CONVEX_URL,
9− process.env.CONVEX_URL,
108 ];
119
1210 for (const candidate of candidates) {
apps/web/app/api/control-plane/[...path]/route.ts+47−98
@@ -1,9 +1,17 @@
11 import { withAuth } from "@workos-inc/authkit-nextjs";
22 import type { NextRequest } from "next/server";
33
4+import {
5+ applyPrincipalHeaders,
6+ createLocalPrincipal,
7+ createWorkosPrincipal,
8+ getControlPlaneRuntime,
9+ provisionPrincipal,
10+} from "../../../../lib/control-plane/server";
411 import { isWorkosEnabled } from "../../../../lib/workos";
512
613 export const dynamic = "force-dynamic";
14+export const runtime = "nodejs";
715
816 type RouteContext = {
917 params: Promise<{
@@ -11,81 +19,16 @@ type RouteContext = { · 1 unmodified line
1119 }>;
1220 };
1321
14−const trim = (value: string | undefined): string | undefined => {
15− const candidate = value?.trim();
16− return candidate && candidate.length > 0 ? candidate : undefined;
17−};
18−
19−const defaultControlPlaneUpstreamUrl = "http://127.0.0.1:8788";
20−
21−const controlPlaneUpstreamBaseUrl =
22− trim(process.env.CONTROL_PLANE_SERVER_BASE_URL)
23− ?? trim(process.env.CONTROL_PLANE_UPSTREAM_URL)
24− ?? trim(process.env.NEXT_PUBLIC_CONVEX_URL)
25− ?? trim(process.env.CONVEX_URL)
26− ?? defaultControlPlaneUpstreamUrl;
27−
28−const methodAllowsBody = (method: string): boolean =>
29− method !== "GET" && method !== "HEAD";
30−
31−const forwardableRequestHeaders = [
32− "accept",
33− "content-type",
34− "traceparent",
35− "b3",
36− "x-request-id",
37−] as const;
38−
3922 const isCsrfSafeMethod = (method: string): boolean =>
4023 method === "GET" || method === "HEAD" || method === "OPTIONS";
4124
42−const toAuthorizationHeader = (token: string): string =>
43− token.toLowerCase().startsWith("bearer ") ? token : `Bearer ${token}`;
44−
45−const buildUpstreamHeaders = (
46− request: NextRequest,
47− accessToken: string,
48−): Headers => {
49− const headers = new Headers();
50−
51− headers.set("authorization", toAuthorizationHeader(accessToken));
52−
53− for (const name of forwardableRequestHeaders) {
54− const value = request.headers.get(name);
55− if (value) {
56− headers.set(name, value);
57− }
58− }
59−
60− headers.set("accept-encoding", "identity");
61−
62− return headers;
63−};
64−
65−const responseHeadersToStrip = [
66− "content-encoding",
67− "content-length",
68− "transfer-encoding",
69− "connection",
70− "keep-alive",
71−] as const;
72−
73−const sanitizeUpstreamResponseHeaders = (headers: Headers): Headers => {
74− const sanitized = new Headers(headers);
75−
76− for (const name of responseHeadersToStrip) {
77− sanitized.delete(name);
78− }
79−
80− return sanitized;
81−};
82−
83−const resolveUpstreamUrl = (
25+const rewriteControlPlaneRequest = (
8426 request: NextRequest,
85− pathSegments: ReadonlyArray<string>,
86−): URL => {
87− const pathname = `/${pathSegments.join("/")}`;
88− return new URL(`${pathname}${request.nextUrl.search}`, controlPlaneUpstreamBaseUrl);
27+ path: ReadonlyArray<string>,
28+): Request => {
29+ const rewrittenUrl = new URL(request.url);
30+ rewrittenUrl.pathname = `/${path.join("/")}`;
31+ return new Request(rewrittenUrl, request);
8932 };
9033
9134 const handle = async (request: NextRequest, context: RouteContext): Promise<Response> => {
@@ -96,13 +39,6 @@ const handle = async (request: NextRequest, context: RouteContext): Promise<Resp · 4 unmodified lines
9639 return Response.json({ error: "Not found" }, { status: 404 });
9740 }
9841
99− if (!isWorkosEnabled()) {
100− return Response.json(
101− { error: "WorkOS auth is not enabled" },
102− { status: 503 },
103− );
104− }
105−
10642 if (!isCsrfSafeMethod(method)) {
10743 const origin = request.headers.get("origin");
10844 if (origin && origin !== request.nextUrl.origin) {
@@ -110,36 +46,49 @@ const handle = async (request: NextRequest, context: RouteContext): Promise<Resp · 1 unmodified line
11046 }
11147 }
11248
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+
11468 try {
115− ({ accessToken } = await withAuth());
69+ ({ user } = await withAuth());
11670 } catch {
117− return Response.json(
118− { error: "Authentication unavailable" },
119− { status: 503 },
120− );
71+ return Response.json({ error: "Authentication unavailable" }, { status: 503 });
12172 }
12273
123− if (!accessToken) {
74+ if (!user) {
12475 return Response.json({ error: "Unauthorized" }, { status: 401 });
12576 }
12677
127− const body = methodAllowsBody(method)
128− ? await request.arrayBuffer()
129− : undefined;
78+ const displayName = [user.firstName, user.lastName]
79+ .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
80+ .join(" ")
81+ || null;
13082
131− const upstreamResponse = await fetch(resolveUpstreamUrl(request, path), {
132− method,
133− headers: buildUpstreamHeaders(request, accessToken),
134− body,
135− redirect: "manual",
83+ const principal = createWorkosPrincipal({
84+ subject: user.id,
85+ email: user.email ?? null,
86+ displayName,
13687 });
13788
138− return new Response(upstreamResponse.body, {
139− status: upstreamResponse.status,
140− statusText: upstreamResponse.statusText,
141− headers: sanitizeUpstreamResponseHeaders(upstreamResponse.headers),
142− });
89+ await provisionPrincipal(runtime, principal);
90+
91+ return runtime.handleControlPlane(applyPrincipalHeaders(controlPlaneRequest, principal));
14392 };
14493
14594 export const GET = handle;
apps/web/lib/control-plane/server.ts+321−0
@@ -0,0 +1,321 @@
1+import {
2+ ControlPlaneAuthHeaders,
3+ ControlPlaneService,
4+ fetchOpenApiDocument,
5+ makeControlPlaneService,
6+ makeControlPlaneSourcesService,
7+ makeControlPlaneWebHandler,
8+ makeSourceCatalogService,
9+ makeSourceManagerService,
10+} from "@executor-v2/management-api";
11+import {
12+ makeSqlControlPlanePersistence,
13+ type SqlControlPlanePersistence,
14+} from "@executor-v2/persistence-sql";
15+import {
16+ type SourceStore,
17+ type ToolArtifactStore,
18+} from "@executor-v2/persistence-ports";
19+import {
20+ type Organization,
21+ type OrganizationMembership,
22+ type Workspace,
23+} from "@executor-v2/schema";
24+import * as Effect from "effect/Effect";
25+import * as Layer from "effect/Layer";
26+
27+import { PmActorLive } from "../../../pm/src/actor";
28+import { createPmApprovalsService } from "../../../pm/src/approvals-service";
29+import { createPmCredentialsService } from "../../../pm/src/credentials-service";
30+import { createPmOrganizationsService } from "../../../pm/src/organizations-service";
31+import { createPmPoliciesService } from "../../../pm/src/policies-service";
32+import { createPmStorageService } from "../../../pm/src/storage-service";
33+import { createPmToolsService } from "../../../pm/src/tools-service";
34+import { createPmWorkspacesService } from "../../../pm/src/workspaces-service";
35+
36+const trim = (value: string | undefined): string | undefined => {
37+ const candidate = value?.trim();
38+ return candidate && candidate.length > 0 ? candidate : undefined;
39+};
40+
41+const defaultControlPlaneStateRootDir = ".executor-v2/web-state";
42+const defaultControlPlaneSqliteFile = `${defaultControlPlaneStateRootDir}/control-plane.sqlite`;
43+
44+type ControlPlaneRuntime = {
45+ persistence: SqlControlPlanePersistence;
46+ sourceStore: SourceStore;
47+ toolArtifactStore: ToolArtifactStore;
48+ fetchOpenApiDocument: typeof fetchOpenApiDocument;
49+ handleControlPlane: (request: Request) => Promise<Response>;
50+ dispose: () => Promise<void>;
51+};
52+
53+type ControlPlanePrincipal = {
54+ accountId: string;
55+ provider: "local" | "workos" | "service";
56+ subject: string;
57+ email: string | null;
58+ displayName: string | null;
59+ organizationId: string;
60+ workspaceId: string;
61+};
62+
63+const normalizeIdPart = (value: string): string =>
64+ value.replace(/[^a-zA-Z0-9_-]/g, "_");
65+
66+const toAccountScopedIds = (subject: string) => {
67+ const normalized = normalizeIdPart(subject);
68+
69+ return {
70+ accountId: `acct_${normalized}`,
71+ organizationId: `org_${normalized}`,
72+ workspaceId: `ws_${normalized}`,
73+ };
74+};
75+
76+const resolveDatabaseUrl = (): string | undefined => {
77+ const candidates = [
78+ process.env.CONTROL_PLANE_DATABASE_URL,
79+ process.env.DATABASE_URL,
80+ process.env.POSTGRES_URL,
81+ ];
82+
83+ for (const candidate of candidates) {
84+ const value = trim(candidate);
85+ if (value) {
86+ return value;
87+ }
88+ }
89+
90+ return undefined;
91+};
92+
93+const resolveSqlitePath = (): string =>
94+ trim(process.env.CONTROL_PLANE_SQLITE_PATH) ?? defaultControlPlaneSqliteFile;
95+
96+const resolveStateRootDir = (): string =>
97+ trim(process.env.CONTROL_PLANE_STATE_ROOT_DIR) ?? defaultControlPlaneStateRootDir;
98+
99+const ensurePrincipalProvisioned = (
100+ persistence: SqlControlPlanePersistence,
101+ principal: ControlPlanePrincipal,
102+) =>
103+ Effect.gen(function* () {
104+ const now = Date.now();
105+ const [organizations, memberships, workspaces, profileOption] = yield* Effect.all([
106+ persistence.rows.organizations.list(),
107+ persistence.rows.organizationMemberships.list(),
108+ persistence.rows.workspaces.list(),
109+ persistence.rows.profile.get(),
110+ ]);
111+
112+ if (organizations.find((organization) => organization.id === principal.organizationId) === undefined) {
113+ yield* persistence.rows.organizations.upsert({
114+ id: principal.organizationId as Organization["id"],
115+ slug: principal.organizationId,
116+ name: principal.displayName
117+ ? `${principal.displayName}'s Organization`
118+ : principal.organizationId,
119+ status: "active",
120+ createdByAccountId: principal.accountId as Organization["createdByAccountId"],
121+ createdAt: now,
122+ updatedAt: now,
123+ });
124+ }
125+
126+ if (
127+ memberships.find(
128+ (membership) =>
129+ membership.organizationId === principal.organizationId
130+ && membership.accountId === principal.accountId,
131+ ) === undefined
132+ ) {
133+ yield* persistence.rows.organizationMemberships.upsert({
134+ id: `org_member_${crypto.randomUUID()}` as OrganizationMembership["id"],
135+ organizationId: principal.organizationId as OrganizationMembership["organizationId"],
136+ accountId: principal.accountId as OrganizationMembership["accountId"],
137+ role: "owner",
138+ status: "active",
139+ billable: false,
140+ invitedByAccountId: null,
141+ joinedAt: now,
142+ createdAt: now,
143+ updatedAt: now,
144+ });
145+ }
146+
147+ if (workspaces.find((workspace) => workspace.id === principal.workspaceId) === undefined) {
148+ yield* persistence.rows.workspaces.upsert({
149+ id: principal.workspaceId as Workspace["id"],
150+ organizationId: principal.organizationId as Workspace["organizationId"],
151+ name: principal.displayName
152+ ? `${principal.displayName}'s Workspace`
153+ : principal.workspaceId,
154+ createdByAccountId: principal.accountId as Workspace["createdByAccountId"],
155+ createdAt: now,
156+ updatedAt: now,
157+ });
158+ }
159+
160+ const existingProfile = profileOption._tag === "Some" ? profileOption.value : null;
161+
162+ if (
163+ existingProfile === null
164+ || existingProfile.defaultWorkspaceId !== principal.workspaceId
165+ || existingProfile.displayName !== (principal.displayName ?? existingProfile.displayName)
166+ ) {
167+ yield* persistence.rows.profile.upsert({
168+ id: existingProfile?.id ?? ("profile_local" as any),
169+ defaultWorkspaceId: principal.workspaceId as any,
170+ displayName: principal.displayName ?? existingProfile?.displayName ?? "Local",
171+ runtimeMode: existingProfile?.runtimeMode ?? "local",
172+ createdAt: existingProfile?.createdAt ?? now,
173+ updatedAt: now,
174+ });
175+ }
176+ });
177+
178+let runtimePromise: Promise<ControlPlaneRuntime> | undefined;
179+
180+const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => {
181+ const stateRootDir = resolveStateRootDir();
182+
183+ const persistence = await Effect.runPromise(
184+ makeSqlControlPlanePersistence({
185+ databaseUrl: resolveDatabaseUrl(),
186+ sqlitePath: resolveSqlitePath(),
187+ postgresApplicationName: "executor-v2-web",
188+ }),
189+ );
190+
191+ const sourceStore = persistence.sourceStore;
192+ const toolArtifactStore = persistence.toolArtifactStore;
193+
194+ const sourceCatalog = makeSourceCatalogService(sourceStore);
195+ const sourceManager = makeSourceManagerService(toolArtifactStore);
196+ const baseSourcesService = makeControlPlaneSourcesService(sourceCatalog);
197+
198+ const sourcesService = {
199+ ...baseSourcesService,
200+ upsertSource: (input: Parameters<typeof baseSourcesService.upsertSource>[0]) =>
201+ Effect.gen(function* () {
202+ const source = yield* baseSourcesService.upsertSource(input);
203+
204+ if (source.kind !== "openapi") {
205+ return source;
206+ }
207+
208+ const openApiSpecResult = yield* Effect.tryPromise({
209+ try: () => fetchOpenApiDocument(source.endpoint),
210+ catch: (cause) => String(cause),
211+ }).pipe(Effect.either);
212+
213+ if (openApiSpecResult._tag === "Left") {
214+ return source;
215+ }
216+
217+ yield* sourceManager
218+ .refreshOpenApiArtifact({
219+ source,
220+ openApiSpec: openApiSpecResult.right,
221+ })
222+ .pipe(Effect.ignore);
223+
224+ return source;
225+ }),
226+ };
227+
228+ const controlPlaneService = makeControlPlaneService({
229+ sources: sourcesService,
230+ credentials: createPmCredentialsService(persistence.rows),
231+ policies: createPmPoliciesService(persistence.rows),
232+ organizations: createPmOrganizationsService(persistence.rows),
233+ workspaces: createPmWorkspacesService(persistence.rows),
234+ tools: createPmToolsService(sourceStore, toolArtifactStore),
235+ storage: createPmStorageService(persistence.rows, {
236+ stateRootDir,
237+ }),
238+ approvals: createPmApprovalsService(persistence.rows),
239+ });
240+
241+ const controlPlaneWebHandler = makeControlPlaneWebHandler(
242+ Layer.succeed(ControlPlaneService, controlPlaneService),
243+ PmActorLive(persistence.rows),
244+ );
245+
246+ return {
247+ persistence,
248+ sourceStore,
249+ toolArtifactStore,
250+ fetchOpenApiDocument,
251+ handleControlPlane: controlPlaneWebHandler.handler,
252+ dispose: async () => {
253+ await controlPlaneWebHandler.dispose();
254+ await persistence.close();
255+ },
256+ };
257+};
258+
259+export const getControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => {
260+ if (!runtimePromise) {
261+ runtimePromise = createControlPlaneRuntime();
262+ }
263+
264+ return runtimePromise;
265+};
266+
267+export const createWorkosPrincipal = (input: {
268+ subject: string;
269+ email: string | null;
270+ displayName: string | null;
271+}): ControlPlanePrincipal => {
272+ const ids = toAccountScopedIds(input.subject);
273+
274+ return {
275+ accountId: ids.accountId,
276+ provider: "workos",
277+ subject: input.subject,
278+ email: input.email,
279+ displayName: input.displayName,
280+ organizationId: ids.organizationId,
281+ workspaceId: ids.workspaceId,
282+ };
283+};
284+
285+export const createLocalPrincipal = (): ControlPlanePrincipal => ({
286+ accountId: "acct_demo",
287+ provider: "local",
288+ subject: "local:demo",
289+ email: null,
290+ displayName: "Local Demo",
291+ organizationId: "org_demo",
292+ workspaceId: "ws_demo",
293+});
294+
295+export const applyPrincipalHeaders = (
296+ request: Request,
297+ principal: ControlPlanePrincipal,
298+): Request => {
299+ const headers = new Headers(request.headers);
300+
301+ headers.set(ControlPlaneAuthHeaders.accountId, principal.accountId);
302+ headers.set(ControlPlaneAuthHeaders.principalProvider, principal.provider);
303+ headers.set(ControlPlaneAuthHeaders.principalSubject, principal.subject);
304+
305+ if (principal.email) {
306+ headers.set(ControlPlaneAuthHeaders.principalEmail, principal.email);
307+ }
308+
309+ if (principal.displayName) {
310+ headers.set(ControlPlaneAuthHeaders.principalDisplayName, principal.displayName);
311+ }
312+
313+ return new Request(request, { headers });
314+};
315+
316+export const provisionPrincipal = async (
317+ runtime: ControlPlaneRuntime,
318+ principal: ControlPlanePrincipal,
319+): Promise<void> => {
320+ await Effect.runPromise(ensurePrincipalProvisioned(runtime.persistence, principal));
321+};
apps/web/package.json+2−0
@@ -12,6 +12,8 @@ · 11 unmodified lines
1212 "@effect-atom/atom": "^0.4.11",
1313 "@effect-atom/atom-react": "^0.4.6",
1414 "@executor-v2/management-api": "workspace:*",
15+ "@executor-v2/persistence-ports": "workspace:*",
16+ "@executor-v2/persistence-sql": "workspace:*",
1517 "@executor-v2/schema": "workspace:*",
1618 "@modelcontextprotocol/sdk": "^1.27.1",
1719 "@streamdown/code": "^1.0.3",
apps/web/tsconfig.json+2−1
@@ -16,7 +16,8 @@ · 15 unmodified lines
1616 }
1717 ],
1818 "types": [
19− "node"
19+ "node",
20+ "bun"
2021 ],
2122 "allowJs": true,
2223 "esModuleInterop": true,
apps/www/src/components/UsageCards.tsx+1−1
@@ -27,7 +27,7 @@ executor doctor # health check · 26 unmodified lines
2727 # Or wire up Claude Code directly:
2828 executor claude --no-bash`,
2929 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.",
3131 },
3232 {
3333 title: "Use with your team",
bun.lock+87−133
@@ -5,7 +5,6 @@ · 4 unmodified lines
55 "": {
66 "name": "executor-v2",
77 "dependencies": {
8− "convex": "^1.32.0",
98 "vercel": "^50.23.2",
109 },
1110 "devDependencies": {
@@ -16,48 +15,9 @@ · 4 unmodified lines
1615 "typescript": "catalog:",
1716 },
1817 },
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− },
3318 "apps/cli": {
3419 "name": "@executor-v2/app-cli",
3520 },
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− },
6121 "apps/pm": {
6222 "name": "@executor-v2/app-pm",
6323 "dependencies": {
@@ -67,7 +27,8 @@ · 3 unmodified lines
6727 "@executor-v2/engine": "workspace:*",
6828 "@executor-v2/management-api": "workspace:*",
6929 "@executor-v2/mcp-gateway": "workspace:*",
70− "@executor-v2/persistence-local": "workspace:*",
30+ "@executor-v2/persistence-ports": "workspace:*",
31+ "@executor-v2/persistence-sql": "workspace:*",
7132 "@executor-v2/runtime-cloudflare-worker-loader": "workspace:*",
7233 "@executor-v2/runtime-deno-subprocess": "workspace:*",
7334 "@executor-v2/runtime-local-inproc": "workspace:*",
@@ -86,6 +47,8 @@ · 12 unmodified lines
8647 "@effect-atom/atom": "^0.4.11",
8748 "@effect-atom/atom-react": "^0.4.6",
8849 "@executor-v2/management-api": "workspace:*",
50+ "@executor-v2/persistence-ports": "workspace:*",
51+ "@executor-v2/persistence-sql": "workspace:*",
8952 "@executor-v2/schema": "workspace:*",
9053 "@modelcontextprotocol/sdk": "^1.27.1",
9154 "@streamdown/code": "^1.0.3",
@@ -145,31 +108,6 @@ · 53 unmodified lines
145108 "vitest": "catalog:",
146109 },
147110 },
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− },
173111 "packages/domain": {
174112 "name": "@executor-v2/domain",
175113 "dependencies": {
@@ -222,6 +160,16 @@ · 46 unmodified lines
222160 "zod": "^4.3.6",
223161 },
224162 },
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+ },
225173 "packages/runtime-cloudflare-worker-loader": {
226174 "name": "@executor-v2/runtime-cloudflare-worker-loader",
227175 "dependencies": {
@@ -266,27 +214,11 @@ · 38 unmodified lines
266214 "effect": "catalog:",
267215 },
268216 },
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− },
284217 },
285218 "catalog": {
286219 "@effect/platform": "^0.93.6",
287220 "@effect/vitest": "^0.27.0",
288221 "@types/node": "^20.19.9",
289− "convex": "^1.32.0",
290222 "effect": "^3.19.8",
291223 "typescript": "^5.9.3",
292224 "vitest": "^3.2.4",
@@ -386,12 +318,6 @@ · 93 unmodified lines
386318
387319 "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260305.0", "", {}, "sha512-sCgPFnQ03SVpC2OVW8wysONLZW/A8hlp9Mq2ckG/h1oId4kr9NawA6vUiOmOjCWRn2hIohejBYVQ+Vu20rCdKA=="],
388320
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−
395321 "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
396322
397323 "@edge-runtime/format": ["@edge-runtime/format@2.2.1", "", {}, "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g=="],
@@ -502,20 +428,14 @@ · 104 unmodified lines
502428
503429 "@executor-v2/ai-sdk-adapter": ["@executor-v2/ai-sdk-adapter@workspace:packages/ai-sdk-adapter"],
504430
505− "@executor-v2/app-api": ["@executor-v2/app-api@workspace:apps/api"],
506−
507431 "@executor-v2/app-cli": ["@executor-v2/app-cli@workspace:apps/cli"],
508432
509− "@executor-v2/app-convex": ["@executor-v2/app-convex@workspace:apps/convex"],
510−
511433 "@executor-v2/app-pm": ["@executor-v2/app-pm@workspace:apps/pm"],
512434
513435 "@executor-v2/app-web": ["@executor-v2/app-web@workspace:apps/web"],
514436
515437 "@executor-v2/app-www": ["@executor-v2/app-www@workspace:apps/www"],
516438
517− "@executor-v2/confect": ["@executor-v2/confect@workspace:packages/confect"],
518−
519439 "@executor-v2/domain": ["@executor-v2/domain@workspace:packages/domain"],
520440
521441 "@executor-v2/engine": ["@executor-v2/engine@workspace:packages/engine"],
@@ -524,10 +444,10 @@ · 2 unmodified lines
524444
525445 "@executor-v2/mcp-gateway": ["@executor-v2/mcp-gateway@workspace:packages/mcp-gateway"],
526446
527− "@executor-v2/persistence-local": ["@executor-v2/persistence-local@workspace:packages/state-local-file"],
528−
529447 "@executor-v2/persistence-ports": ["@executor-v2/persistence-ports@workspace:packages/state-contracts"],
530448
449+ "@executor-v2/persistence-sql": ["@executor-v2/persistence-sql@workspace:packages/persistence-sql"],
450+
531451 "@executor-v2/runtime-cloudflare-worker-loader": ["@executor-v2/runtime-cloudflare-worker-loader@workspace:packages/runtime-cloudflare-worker-loader"],
532452
533453 "@executor-v2/runtime-deno-subprocess": ["@executor-v2/runtime-deno-subprocess@workspace:packages/runtime-deno-subprocess"],
@@ -890,6 +810,8 @@ · 356 unmodified lines
890810
891811 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
892812
813+ "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
814+
893815 "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
894816
895817 "@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
1062984
1063985 "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=="],
1064986
1065− "async-channel": ["async-channel@0.2.0", "", {}, "sha512-BJyjI/sfKlyijaBt2hbOSxT28xGNtLR0QLzAKO1Hlnv5BULY7sAoYoTPW3lfr1ZIC7y+FxabxO9T8GXpyoofGg=="],
1066−
1067987 "async-listen": ["async-listen@1.2.0", "", {}, "sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA=="],
1068988
1069989 "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
10821002
10831003 "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
10841004
1005+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
1006+
10851007 "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
10861008
10871009 "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="],
10881010
1011+ "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
1012+
10891013 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
10901014
10911015 "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
10921016
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+
10931019 "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
10941020
10951021 "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
11041030
11051031 "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=="],
11061032
1033+ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
1034+
11071035 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
11081036
11091037 "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
@@ -1184,12 +1112,6 @@ · 74 unmodified lines
11841112
11851113 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
11861114
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−
11931115 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
11941116
11951117 "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
@@ -1220,8 +1142,12 @@ · 24 unmodified lines
12201142
12211143 "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
12221144
1145+ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
1146+
12231147 "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
12241148
1149+ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
1150+
12251151 "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
12261152
12271153 "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
12581184
12591185 "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=="],
12601186
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+
12611189 "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
12621190
12631191 "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
13261254
13271255 "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=="],
13281256
1257+ "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
1258+
13291259 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
13301260
13311261 "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
13701300
13711301 "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
13721302
1303+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
1304+
13731305 "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=="],
13741306
13751307 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -1394,6 +1326,8 @@ · 18 unmodified lines
13941326
13951327 "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=="],
13961328
1329+ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
1330+
13971331 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
13981332
13991333 "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
14561390
14571391 "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
14581392
1393+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
1394+
14591395 "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
14601396
14611397 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
14621398
1399+ "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
1400+
14631401 "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
14641402
14651403 "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
@@ -1510,7 +1448,7 @@ · 44 unmodified lines
15101448
15111449 "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
15121450
1513− "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
1451+ "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
15141452
15151453 "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
15161454
@@ -1692,16 +1630,22 @@ · 175 unmodified lines
16921630
16931631 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
16941632
1633+ "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
1634+
16951635 "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=="],
16961636
16971637 "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
16981638
1639+ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
1640+
16991641 "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
17001642
17011643 "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
17021644
17031645 "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
17041646
1647+ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
1648+
17051649 "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
17061650
17071651 "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -1722,6 +1666,8 @@ · 14 unmodified lines
17221666
17231667 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
17241668
1669+ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
1670+
17251671 "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
17261672
17271673 "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],
@@ -1734,6 +1680,8 @@ · 6 unmodified lines
17341680
17351681 "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
17361682
1683+ "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
1684+
17371685 "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
17381686
17391687 "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
18541802
18551803 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
18561804
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+
18571809 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
18581810
18591811 "pretty-ms": ["pretty-ms@7.0.1", "", { "dependencies": { "parse-ms": "^2.1.0" } }, "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q=="],
@@ -1888,6 +1840,8 @@ · 28 unmodified lines
18881840
18891841 "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=="],
18901842
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+
18911845 "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
18921846
18931847 "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
18961850
18971851 "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
18981852
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+
18991855 "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
19001856
19011857 "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
@@ -1962,6 +1918,8 @@ · 60 unmodified lines
19621918
19631919 "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
19641920
1921+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
1922+
19651923 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
19661924
19671925 "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
@@ -1996,6 +1954,10 @@ · 28 unmodified lines
19961954
19971955 "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
19981956
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+
19991961 "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
20001962
20011963 "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
@@ -2032,12 +1994,16 @@ · 30 unmodified lines
20321994
20331995 "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=="],
20341996
1997+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
1998+
20351999 "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=="],
20362000
20372001 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
20382002
20392003 "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
20402004
2005+ "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
2006+
20412007 "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
20422008
20432009 "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
20622028
20632029 "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=="],
20642030
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+
20652035 "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
20662036
20672037 "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
21122082
21132083 "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=="],
21142084
2085+ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
2086+
21152087 "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=="],
21162088
21172089 "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
22662238
22672239 "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
22682240
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=="],
22702242
22712243 "xdg-app-paths": ["xdg-app-paths@5.1.0", "", { "dependencies": { "xdg-portable": "^7.0.0" } }, "sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA=="],
22722244
@@ -2326,16 +2298,14 @@ · 53 unmodified lines
23262298
23272299 "@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=="],
23282300
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−
23312301 "@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=="],
23322302
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−
23352303 "@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=="],
23362304
23372305 "@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=="],
23382306
2307+ "@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
2308+
23392309 "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
23402310
23412311 "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -2412,7 +2382,7 @@ · 70 unmodified lines
24122382
24132383 "@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=="],
24142384
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=="],
24162386
24172387 "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
24182388
@@ -2470,6 +2440,8 @@ · 51 unmodified lines
24702440
24712441 "miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
24722442
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+
24732445 "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
24742446
24752447 "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
24902462
24912463 "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
24922464
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+
24932469 "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
24942470
24952471 "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
24962472
24972473 "unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
24982474
2499− "vercel/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
2500−
25012475 "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
25022476
25032477 "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
25222496
25232497 "@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=="],
25242498
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−
25452499 "@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=="],
25462500
25472501 "@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
1919 "prepare": "effect-language-service patch",
2020 "dev": "turbo run dev --parallel",
2121 "typecheck": "turbo run typecheck",
22− "knip": "bunx knip --config knip.json",
23− "db:wipe": "python3 -c \"import zipfile; zipfile.ZipFile('/tmp/_convex_empty.zip','w').close()\" && cd apps/convex && npx convex import --replace-all --yes /tmp/_convex_empty.zip"
22+ "knip": "bunx knip --config knip.json"
2423 },
2524 "devDependencies": {
2625 "@effect/language-service": "^0.76.0",
@@ -30,7 +29,6 @@ · 3 unmodified lines
3029 "typescript": "catalog:"
3130 },
3231 "dependencies": {
33− "convex": "^1.32.0",
3432 "vercel": "^50.23.2"
3533 }
3634 }
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 @@
1−import type { FunctionReference, FunctionReturnType } from "convex/server";
2−import {
3− ConvexClient as ConvexClientImpl,
4− ConvexHttpClient,
5−} from "convex/browser";
6−import { Context, Effect, Layer, Stream } from "effect";
7−
8−export interface ConvexRequestMetadata {
9− readonly headers?: Readonly<Record<string, string>>;
10−}
11−
12−export interface ConvexClientService {
13− query<Query extends FunctionReference<"query">>(
14− query: Query,
15− args: Query["_args"],
16− requestMetadata?: ConvexRequestMetadata,
17− ): Effect.Effect<FunctionReturnType<Query>>;
18−
19− mutation<Mutation extends FunctionReference<"mutation">>(
20− mutation: Mutation,
21− args: Mutation["_args"],
22− requestMetadata?: ConvexRequestMetadata,
23− ): Effect.Effect<FunctionReturnType<Mutation>>;
24−
25− action<Action extends FunctionReference<"action">>(
26− action: Action,
27− args: Action["_args"],
28− requestMetadata?: ConvexRequestMetadata,
29− ): Effect.Effect<FunctionReturnType<Action>>;
30−
31− subscribe<Query extends FunctionReference<"query">>(
32− query: Query,
33− args: Query["_args"],
34− ): Stream.Stream<FunctionReturnType<Query>>;
35−}
36−
37−export class ConvexClient extends Context.Tag("@confect/ConvexClient")<
38− ConvexClient,
39− ConvexClientService
40−>() {}
41−
42−export const ConvexClientLayer = (
43− url: string,
44−): Layer.Layer<ConvexClient> => {
45− const wsClient = new ConvexClientImpl(url);
46−
47− // Shared HTTP client instance — no custom headers that would trigger CORS
48− // preflight failures in browsers. Telemetry context is already embedded
49− // in the payload by `withOptionalRpcTelemetryContext`, so transport-level
50− // headers are not needed for browser clients.
51− const httpClient = new ConvexHttpClient(url);
52−
53− const syncAuth = () => {
54− const auth = wsClient.getAuth();
55− if (auth !== undefined) {
56− httpClient.setAuth(auth.token);
57− }
58− };
59−
60− const service: ConvexClientService = {
61− query: <Query extends FunctionReference<"query">>(
62− query: Query,
63− args: Query["_args"],
64− _requestMetadata?: ConvexRequestMetadata,
65− ): Effect.Effect<FunctionReturnType<Query>> =>
66− Effect.promise(() => {
67− syncAuth();
68− return httpClient.query(query, args);
69− }),
70−
71− mutation: <Mutation extends FunctionReference<"mutation">>(
72− mutation: Mutation,
73− args: Mutation["_args"],
74− _requestMetadata?: ConvexRequestMetadata,
75− ): Effect.Effect<FunctionReturnType<Mutation>> =>
76− Effect.promise(() => {
77− syncAuth();
78− return httpClient.mutation(mutation, args);
79− }),
80−
81− action: <Action extends FunctionReference<"action">>(
82− action: Action,

78 more lines aren’t shown here.

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.