History

rework sources

Rhys Sullivan committed 8 months ago

Commit d61734b1 parent 8a8be7991 files · +8145 −4932Browse files

Unified · Split

91 files
  1. apps/executor/src/server/server.test.ts +15 −19
  2. apps/web/src/components/source-not-found-state.tsx +38 −0
  3. apps/web/src/main.tsx +2 −2
  4. apps/web/src/views/add-source.tsx +1 −1
  5. apps/web/src/views/source-detail.tsx +201 −57
  6. apps/web/src/views/source-editor.tsx +15 −0
  7. packages/codemode-core/src/discovery.ts +5 −1
  8. packages/codemode-core/src/index.test.ts +1 −0
  9. packages/codemode-core/src/system-tools.ts +37 −0
  10. packages/codemode-core/src/tool-map.ts +1 −0
  11. packages/codemode-core/src/types.ts +16 −0
  12. packages/codemode-openapi/src/index.ts +7 −0
  13. packages/codemode-openapi/src/openapi-definitions.ts +3 −1
  14. packages/codemode-openapi/src/openapi-schema-refs.ts +10 −4
  15. packages/codemode-openapi/src/openapi-tool-presentation.ts +7 −26
  16. packages/codemode-openapi/src/openapi-tools.ts +112 −73
  17. packages/codemode-openapi/src/openapi-types.ts +32 −0
  18. packages/control-plane/drizzle/20260310183000_source_recipe_schema_bundles/migration.sql +14 −0
  19. packages/control-plane/drizzle/20260310223000_source_adapter_registry_strings/migration.sql +9 −0
  20. packages/control-plane/drizzle/20260310233000_source_adapter_families_and_data_migrations/migration.sql +23 −0
  21. packages/control-plane/drizzle/20260311010000_source_binding_configs/migration.sql +62 −0
  22. packages/control-plane/drizzle/20260311133000_import_auth_slots_and_materialization_hash/migration.sql +82 −0
  23. packages/control-plane/drizzle/20260311170000_thin_source_recipe_operations/migration.sql +73 −0
  24. packages/control-plane/drizzle/20260311223000_drop_tool_artifacts/migration.sql +7 −0
  25. packages/control-plane/src/api/payload-schemas.test.ts +20 −0
  26. packages/control-plane/src/api/sources/api.ts +23 −54
  27. packages/control-plane/src/api/sources/http.ts +15 −24
  28. packages/control-plane/src/persistence/control-plane-rows.test.ts +436 −14
  29. packages/control-plane/src/persistence/data-migrations.ts +234 −0
  30. packages/control-plane/src/persistence/index.ts +19 −9
  31. packages/control-plane/src/persistence/post-migrations.test.ts +464 −73
  32. packages/control-plane/src/persistence/post-migrations.ts +0 −415
  33. packages/control-plane/src/persistence/repos/credentials-repo.ts +9 −0
  34. packages/control-plane/src/persistence/repos/data-migrations-repo.ts +41 −0
  35. packages/control-plane/src/persistence/repos/index.ts +2 −1
  36. packages/control-plane/src/persistence/repos/organizations-repo.ts +10 −79
  37. packages/control-plane/src/persistence/repos/shared.ts +96 −0
  38. packages/control-plane/src/persistence/repos/source-auth-sessions-repo.ts +4 −0
  39. packages/control-plane/src/persistence/repos/source-recipe-documents-repo.ts +9 −1
  40. packages/control-plane/src/persistence/repos/source-recipe-operations-repo.ts +10 −1
  41. packages/control-plane/src/persistence/repos/source-recipe-revisions-repo.ts +47 −1
  42. packages/control-plane/src/persistence/repos/source-recipe-schema-bundles-repo.ts +106 −0
  43. packages/control-plane/src/persistence/repos/sources-repo.ts +2 −69
  44. packages/control-plane/src/persistence/repos/tool-artifacts-repo.ts +0 −343
  45. packages/control-plane/src/persistence/repos/workspaces-repo.ts +14 −80
  46. packages/control-plane/src/persistence/schema.ts +58 −187
  47. packages/control-plane/src/runtime/control-plane-runtime.test.ts +11 −14
  48. packages/control-plane/src/runtime/executor-tools.ts +101 −97
  49. packages/control-plane/src/runtime/graphql-tools.ts +615 −326
  50. packages/control-plane/src/runtime/invocation-policy-engine.test.ts +4 −5
  51. packages/control-plane/src/runtime/invocation-policy-engine.ts +7 −33
  52. packages/control-plane/src/runtime/source-adapters/graphql.ts +526 −0
  53. packages/control-plane/src/runtime/source-adapters/index.ts +68 −0
  54. packages/control-plane/src/runtime/source-adapters/internal.ts +99 −0
  55. packages/control-plane/src/runtime/source-adapters/mcp.ts +357 −0
  56. packages/control-plane/src/runtime/source-adapters/openapi.ts +569 −0
  57. packages/control-plane/src/runtime/source-adapters/shared.ts +184 −0
  58. packages/control-plane/src/runtime/source-adapters/types.ts +138 −0
  59. packages/control-plane/src/runtime/source-auth-material.test.ts +4 −2
  60. packages/control-plane/src/runtime/source-auth-material.ts +72 −0
  61. packages/control-plane/src/runtime/source-auth-service.test.ts +2 −61
  62. packages/control-plane/src/runtime/source-auth-service.ts +152 −89
  63. packages/control-plane/src/runtime/source-definitions.test.ts +26 −15
  64. packages/control-plane/src/runtime/source-definitions.ts +173 −272
  65. packages/control-plane/src/runtime/source-discovery.ts +1 −1
  66. packages/control-plane/src/runtime/source-inspection.test.ts +309 −0
  67. packages/control-plane/src/runtime/source-inspection.ts +194 −366
  68. packages/control-plane/src/runtime/source-materialization.ts +57 −0
  69. packages/control-plane/src/runtime/source-names.ts +9 −0
  70. packages/control-plane/src/runtime/source-recipe-support.ts +374 −0
  71. packages/control-plane/src/runtime/source-recipes-runtime.test.ts +84 −99
  72. packages/control-plane/src/runtime/source-recipes-runtime.ts +318 −134
  73. packages/control-plane/src/runtime/source-store.test.ts +39 −22
  74. packages/control-plane/src/runtime/source-store.ts +64 −11
  75. packages/control-plane/src/runtime/sources-operations.ts +4 −8
  76. packages/control-plane/src/runtime/tool-artifacts.ts +0 −736
  77. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +704 −486
  78. packages/control-plane/src/runtime/workspace-execution-environment.ts +249 −234
  79. packages/control-plane/src/schema/ids.ts +4 −0
  80. packages/control-plane/src/schema/index.ts +1 −1
  81. packages/control-plane/src/schema/models/credential.ts +3 −0
  82. packages/control-plane/src/schema/models/data-migration.ts +14 −0
  83. packages/control-plane/src/schema/models/source-auth-session.ts +2 −0
  84. packages/control-plane/src/schema/models/source-inspection.ts +19 −6
  85. packages/control-plane/src/schema/models/source-recipe.ts +29 −144
  86. packages/control-plane/src/schema/models/source.ts +15 −26
  87. packages/control-plane/src/schema/models/tool-artifact.ts +0 −168
  88. packages/control-plane/src/schema/schema.test.ts +12 −28
  89. packages/kitchen-sink/src/source-runtime.test.ts +35 −13
  90. packages/react/src/index.test.tsx +35 −0
  91. packages/react/src/index.ts +70 −0
apps/executor/src/server/server.test.ts+15−19
@@ -1372,7 +1372,7 @@ describe("local-executor-server", () => { · 1371 unmodified lines
1372 const manifestJson = JSON.stringify(manifest);1372 const manifestJson = JSON.stringify(manifest);
1373 yield* server.runtime.persistence.rows.sourceRecipes.upsert({1373 yield* server.runtime.persistence.rows.sourceRecipes.upsert({
1374 id: recipeId,1374 id: recipeId,
1375 kind: "mcp_recipe",1375 kind: "mcp",
1376 importerKind: "mcp_manifest",1376 importerKind: "mcp_manifest",
1377 providerKey: "generic_mcp",1377 providerKey: "generic_mcp",
1378 name: "Demo",1378 name: "Demo",
@@ -1395,6 +1395,7 @@ describe("local-executor-server", () => { · 16 unmodified lines
1395 }),1395 }),
1396 manifestJson,1396 manifestJson,
1397 manifestHash: null,1397 manifestHash: null,
1398 materializationHash: null,
1398 createdAt: now,1399 createdAt: now,
1399 updatedAt: now,1400 updatedAt: now,
1400 });1401 });
@@ -1409,14 +1410,14 @@ describe("local-executor-server", () => { · 8 unmodified lines
1409 status: "connected",1410 status: "connected",
1410 enabled: true,1411 enabled: true,
1411 namespace: "demo",1412 namespace: "demo",
1412 transport: "streamable-http",1413 importAuthPolicy: "reuse_runtime",
1413 bindingConfigJson: null,1414 bindingConfigJson: JSON.stringify({
1414 queryParamsJson: null,1415 adapterKey: "mcp",
1415 headersJson: null,1416 transport: "streamable-http",
1416 specUrl: null,1417 queryParams: null,
1417 defaultHeadersJson: null,1418 headers: null,
1419 }),
1418 sourceHash: null,1420 sourceHash: null,
1419 sourceDocumentText: null,
1420 lastError: null,1421 lastError: null,
1421 createdAt: now,1422 createdAt: now,
1422 updatedAt: now,1423 updatedAt: now,
@@ -1450,17 +1451,12 @@ describe("local-executor-server", () => { · 27 unmodified lines
1450 inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null,1451 inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null,
1451 outputSchemaJson: null,1452 outputSchemaJson: null,
1452 providerKind: "mcp",1453 providerKind: "mcp",
1453 providerDataJson: null,1454 providerDataJson: JSON.stringify({
1454 mcpToolName: "gated_echo",1455 kind: "mcp",
1455 openApiMethod: null,1456 toolId: "gated_echo",
1456 openApiPathTemplate: null,1457 toolName: "gated_echo",
1457 openApiOperationHash: null,1458 description: "Asks for approval before echoing a value",
1458 openApiRawToolId: null,1459 }),
1459 openApiOperationId: null,
1460 openApiTagsJson: null,
1461 openApiRequestBodyRequired: null,
1462 graphqlOperationType: null,
1463 graphqlOperationName: null,
1464 createdAt: now,1460 createdAt: now,
1465 updatedAt: now,1461 updatedAt: now,
1466 }],1462 }],
apps/web/src/components/source-not-found-state.tsx+38−0
@@ -0,0 +1,38 @@
1import { Link } from "@tanstack/react-router";
2
3import { IconEmpty } from "./icons";
4import { cn } from "../lib/utils";
5
6export function SourceNotFoundState() {
7 return (
8 <div className="flex h-full min-h-48 items-center justify-center px-6">
9 <div className="flex max-w-sm flex-col items-center text-center">
10 <IconEmpty className="mb-4 text-muted-foreground/20" />
11 <h2 className="text-sm font-semibold text-foreground">Source not found</h2>
12 <p className="mt-2 text-sm text-muted-foreground">
13 This source no longer exists in the current workspace.
14 </p>
15 <div className="mt-5 flex items-center gap-2">
16 <Link
17 to="/"
18 className={cn(
19 "inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium transition-all",
20 "hover:bg-accent hover:text-accent-foreground",
21 )}
22 >
23 Back to dashboard
24 </Link>
25 <Link
26 to="/sources/add"
27 className={cn(
28 "inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-all",
29 "hover:bg-primary/90",
30 )}
31 >
32 Add source
33 </Link>
34 </div>
35 </div>
36 </div>
37 );
38}
apps/web/src/main.tsx+2−2
@@ -22,12 +22,12 @@ import { AddSourcePage } from "./views/add-source"; · 21 unmodified lines
22// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
23 23
24type SourceRouteSearch = {24type SourceRouteSearch = {
25 tab: "model" | "discover" | "manifest" | "definitions" | "raw";25 tab: "model" | "discover";
26 tool?: string;26 tool?: string;
27 query?: string;27 query?: string;
28};28};
29 29
30const sourceTabs = ["model", "discover", "manifest", "definitions", "raw"] as const;30const sourceTabs = ["model", "discover"] as const;
31 31
32// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------
33// Routes33// Routes
apps/web/src/views/add-source.tsx+1−1
@@ -217,7 +217,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 216 unmodified lines
217 217
218const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({218const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({
219 ...defaultConnectForm(),219 ...defaultConnectForm(),
220 kind: template.kind,220 kind: template.kind as ConnectFormState["kind"],
221 endpoint: template.endpoint,221 endpoint: template.endpoint,
222 specUrl: "specUrl" in template ? template.specUrl : "",222 specUrl: "specUrl" in template ? template.specUrl : "",
223 name: template.name,223 name: template.name,
apps/web/src/views/source-detail.tsx+201−57
@@ -1,11 +1,14 @@
1import { useState, useEffect, useCallback, useRef, useMemo } from "react";1import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import {3import {
4 useSource,
5 useSources,
4 useSourceInspection,6 useSourceInspection,
7 useSourceSchemaBundle,
5 useSourceToolDetail,8 useSourceToolDetail,
6 useSourceDiscovery,9 useSourceDiscovery,
7 usePrefetchToolDetail,
8 type Loadable,10 type Loadable,
11 type Source,
9 type SourceInspection,12 type SourceInspection,
10 type SourceInspectionToolDetail,13 type SourceInspectionToolDetail,
11 type SourceInspectionDiscoverResult,14 type SourceInspectionDiscoverResult,
@@ -15,6 +18,7 @@ import { Badge, MethodBadge } from "../components/ui/badge"; · 3 unmodified lines
15import { Button } from "../components/ui/button";18import { Button } from "../components/ui/button";
16import { LoadableBlock, EmptyState } from "../components/loadable";19import { LoadableBlock, EmptyState } from "../components/loadable";
17import { DocumentPanel } from "../components/document-panel";20import { DocumentPanel } from "../components/document-panel";
21import { SourceNotFoundState } from "../components/source-not-found-state";
18import {22import {
19 IconSearch,23 IconSearch,
20 IconChevron,24 IconChevron,
@@ -32,7 +36,7 @@ import { Markdown } from "../components/markdown"; · 11 unmodified lines
32// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
33 37
34type SourceRouteSearch = {38type SourceRouteSearch = {
35 tab: "model" | "discover" | "manifest" | "definitions" | "raw";39 tab: "model" | "discover";
36 tool?: string;40 tool?: string;
37 query?: string;41 query?: string;
38};42};
@@ -42,6 +46,122 @@ const visibleTabs: Array<{ id: SourceRouteSearch["tab"]; label: string }> = [ · 3 unmodified lines
42 { id: "discover", label: "Search" },46 { id: "discover", label: "Search" },
43];47];
44 48
49const isSourceNotFoundError = (loadable: Loadable<unknown>): boolean =>
50 loadable.status === "error"
51 && loadable.error.message.toLowerCase().includes("source not found");
52
53const listExcludesSource = (
54 sources: Loadable<ReadonlyArray<Source>>,
55 sourceId: string,
56): boolean => sources.status === "ready" && !sources.data.some((source) => source.id === sourceId);
57
58const isJsonObject = (value: unknown): value is Record<string, unknown> =>
59 typeof value === "object" && value !== null && !Array.isArray(value);
60
61const parseJson = (value: string): unknown | null => {
62 try {
63 return JSON.parse(value) as unknown;
64 } catch {
65 return null;
66 }
67};
68
69const resolveSchemaNode = (
70 value: unknown,
71 refHintTable: Readonly<Record<string, unknown>>,
72 parsedHintCache: Map<string, unknown>,
73 activeRefs: ReadonlySet<string>,
74): unknown => {
75 if (Array.isArray(value)) {
76 return value.map((item) =>
77 resolveSchemaNode(item, refHintTable, parsedHintCache, activeRefs)
78 );
79 }
80
81 if (!isJsonObject(value)) {
82 return value;
83 }
84
85 const ref = typeof value.$ref === "string" ? value.$ref : null;
86 if (ref && ref.startsWith("#/") && !activeRefs.has(ref)) {
87 let resolvedRefValue = parsedHintCache.get(ref);
88
89 if (resolvedRefValue === undefined) {
90 const rawHint = refHintTable[ref];
91 resolvedRefValue = typeof rawHint === "string"
92 ? parseJson(rawHint)
93 : isJsonObject(rawHint)
94 ? rawHint
95 : null;
96 parsedHintCache.set(ref, resolvedRefValue);
97 }
98
99 if (resolvedRefValue !== null && resolvedRefValue !== undefined) {
100 const nextActiveRefs = new Set(activeRefs);
101 nextActiveRefs.add(ref);
102
103 const { $ref: _ignoredRef, ...rest } = value;
104 const resolvedTarget = resolveSchemaNode(
105 resolvedRefValue,
106 refHintTable,
107 parsedHintCache,
108 nextActiveRefs,
109 );
110
111 if (Object.keys(rest).length === 0) {
112 return resolvedTarget;
113 }
114
115 const resolvedRest = resolveSchemaNode(
116 rest,
117 refHintTable,
118 parsedHintCache,
119 activeRefs,
120 );
121
122 if (isJsonObject(resolvedTarget) && isJsonObject(resolvedRest)) {
123 return { ...resolvedTarget, ...resolvedRest };
124 }
125
126 return resolvedTarget;
127 }
128 }
129
130 return Object.fromEntries(
131 Object.entries(value).map(([key, nestedValue]) => [
132 key,
133 resolveSchemaNode(nestedValue, refHintTable, parsedHintCache, activeRefs),
134 ]),
135 );
136};
137
138const resolveSchemaJsonWithRefHints = (
139 schemaJson: string | undefined,
140 refHintTable: Readonly<Record<string, unknown>>,
141): string | null => {
142 if (!schemaJson) {
143 return null;
144 }
145
146 const parsedSchema = parseJson(schemaJson);
147 if (parsedSchema === null) {
148 return schemaJson;
149 }
150
151 const resolved = resolveSchemaNode(
152 parsedSchema,
153 refHintTable,
154 new Map<string, unknown>(),
155 new Set<string>(),
156 );
157
158 try {
159 return JSON.stringify(resolved);
160 } catch {
161 return schemaJson;
162 }
163};
164
45// ---------------------------------------------------------------------------165// ---------------------------------------------------------------------------
46// SourceDetailPage (main export)166// SourceDetailPage (main export)
47// ---------------------------------------------------------------------------167// ---------------------------------------------------------------------------
@@ -52,7 +172,13 @@ export function SourceDetailPage(props: { · 4 unmodified lines
52 navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void;172 navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void;
53}) {173}) {
54 const { sourceId, search, navigate } = props;174 const { sourceId, search, navigate } = props;
175 const sources = useSources();
176 const source = useSource(sourceId);
55 const inspection = useSourceInspection(sourceId);177 const inspection = useSourceInspection(sourceId);
178 const missingSource =
179 listExcludesSource(sources, sourceId)
180 || isSourceNotFoundError(source)
181 || isSourceNotFoundError(inspection);
56 182
57 const selectedToolPath =183 const selectedToolPath =
58 search.tool184 search.tool
@@ -77,6 +203,10 @@ export function SourceDetailPage(props: { · 18 unmodified lines
77 void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true });203 void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true });
78 }, [inspection, navigate, search.tab, search.tool]);204 }, [inspection, navigate, search.tab, search.tool]);
79 205
206 if (missingSource) {
207 return <SourceNotFoundState />;
208 }
209
80 return (210 return (
81 <LoadableBlock loadable={inspection} loading="Loading source...">211 <LoadableBlock loadable={inspection} loading="Loading source...">
82 {(bundle) => {212 {(bundle) => {
@@ -151,21 +281,6 @@ export function SourceDetailPage(props: { · 68 unmodified lines
151 }281 }
152 />282 />
153 )}283 )}
154 {search.tab === "manifest" && (
155 <div className="flex-1 overflow-y-auto p-4">
156 <DocumentPanel title="Manifest" body={bundle.manifestJson} empty="No manifest available." />
157 </div>
158 )}
159 {search.tab === "definitions" && (
160 <div className="flex-1 overflow-y-auto p-4">
161 <DocumentPanel title="Definitions" body={bundle.definitionsJson} empty="No definitions available." />
162 </div>
163 )}
164 {search.tab === "raw" && (
165 <div className="flex-1 overflow-y-auto p-4">
166 <DocumentPanel title="Raw document" body={bundle.rawDocumentText} empty="No raw document." />
167 </div>
168 )}
169 </div>284 </div>
170 </div>285 </div>
171 );286 );
@@ -191,7 +306,7 @@ function ModelView(props: { · 19 unmodified lines
191 306
192 const filteredTools = props.bundle.tools.filter((tool) => {307 const filteredTools = props.bundle.tools.filter((tool) => {
193 if (terms.length === 0) return true;308 if (terms.length === 0) return true;
194 const corpus = [tool.path, tool.description ?? "", tool.title ?? "", tool.method ?? ""]309 const corpus = [tool.path, tool.method ?? ""]
195 .join(" ")310 .join(" ")
196 .toLowerCase();311 .toLowerCase();
197 return terms.every((t) => corpus.includes(t));312 return terms.every((t) => corpus.includes(t));
@@ -258,7 +373,6 @@ function ModelView(props: { · 60 unmodified lines
258 onSelectTool={props.onSelectTool}373 onSelectTool={props.onSelectTool}
259 search={search}374 search={search}
260 isFiltered={terms.length > 0}375 isFiltered={terms.length > 0}
261 sourceId={props.sourceId}
262 />376 />
263 </div>377 </div>
264 )}378 )}
@@ -270,7 +384,7 @@ function ModelView(props: { · 5 unmodified lines
270 <LoadableBlock loadable={props.detail} loading="Loading tool...">384 <LoadableBlock loadable={props.detail} loading="Loading tool...">
271 {(detail) =>385 {(detail) =>
272 detail ? (386 detail ? (
273 <ToolDetailPanel detail={detail} />387 <ToolDetailPanel sourceId={props.sourceId} detail={detail} />
274 ) : (388 ) : (
275 <EmptyState389 <EmptyState
276 title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}390 title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
@@ -316,10 +430,8 @@ function ToolTree(props: { · 39 unmodified lines
316 onSelectTool: (path: string) => void;430 onSelectTool: (path: string) => void;
317 search: string;431 search: string;
318 isFiltered: boolean;432 isFiltered: boolean;
319 sourceId: string;
320}) {433}) {
321 const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);434 const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);
322 const prefetch = usePrefetchToolDetail();
323 const entries = [...tree.children.values()].sort((a, b) =>435 const entries = [...tree.children.values()].sort((a, b) =>
324 a.segment.localeCompare(b.segment),436 a.segment.localeCompare(b.segment),
325 );437 );
@@ -335,8 +447,6 @@ function ToolTree(props: { · 9 unmodified lines
335 onSelectTool={props.onSelectTool}447 onSelectTool={props.onSelectTool}
336 search={props.search}448 search={props.search}
337 defaultOpen={props.isFiltered}449 defaultOpen={props.isFiltered}
338 sourceId={props.sourceId}
339 prefetch={prefetch}
340 />450 />
341 ))}451 ))}
342 </div>452 </div>
@@ -350,10 +460,8 @@ function ToolTreeNodeView(props: { · 7 unmodified lines
350 onSelectTool: (path: string) => void;460 onSelectTool: (path: string) => void;
351 search: string;461 search: string;
352 defaultOpen: boolean;462 defaultOpen: boolean;
353 sourceId: string;
354 prefetch: (sourceId: string, toolPath: string) => () => void;
355}) {463}) {
356 const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen, sourceId, prefetch } = props;464 const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen } = props;
357 const hasChildren = node.children.size > 0;465 const hasChildren = node.children.size > 0;
358 const isLeaf = !!node.tool && !hasChildren;466 const isLeaf = !!node.tool && !hasChildren;
359 467
@@ -383,8 +491,6 @@ function ToolTreeNodeView(props: { · 23 unmodified lines
383 onSelect={() => onSelectTool(node.tool!.path)}491 onSelect={() => onSelectTool(node.tool!.path)}
384 search={search}492 search={search}
385 depth={depth}493 depth={depth}
386 sourceId={sourceId}
387 prefetch={prefetch}
388 />494 />
389 );495 );
390 }496 }
@@ -420,8 +526,6 @@ function ToolTreeNodeView(props: { · 29 unmodified lines
420 search={search}526 search={search}
421 depth={-1}527 depth={-1}
422 className="flex-1 pl-1"528 className="flex-1 pl-1"
423 sourceId={sourceId}
424 prefetch={prefetch}
425 />529 />
426 </div>530 </div>
427 ) : (531 ) : (
@@ -469,8 +573,6 @@ function ToolTreeNodeView(props: { · 41 unmodified lines
469 onSelectTool={onSelectTool}573 onSelectTool={onSelectTool}
470 search={search}574 search={search}
471 defaultOpen={defaultOpen}575 defaultOpen={defaultOpen}
472 sourceId={sourceId}
473 prefetch={prefetch}
474 />576 />
475 ))}577 ))}
476 </div>578 </div>
@@ -498,12 +600,9 @@ function ToolListItem(props: { · 21 unmodified lines
498 search: string;600 search: string;
499 depth: number;601 depth: number;
500 className?: string;602 className?: string;
501 sourceId: string;
502 prefetch: (sourceId: string, toolPath: string) => () => void;
503}) {603}) {
504 const ref = useRef<HTMLButtonElement>(null);604 const ref = useRef<HTMLButtonElement>(null);
505 const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;605 const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
506 const prefetchedRef = useRef(false);
507 606
508 useEffect(() => {607 useEffect(() => {
509 if (props.active && ref.current) {608 if (props.active && ref.current) {
@@ -511,24 +610,6 @@ function ToolListItem(props: { · 1 unmodified line
511 }610 }
512 }, [props.active]);611 }, [props.active]);
513 612
514 useEffect(() => {
515 const el = ref.current;
516 if (!el || prefetchedRef.current) return;
517
518 const observer = new IntersectionObserver(
519 (entries) => {
520 if (entries[0]?.isIntersecting && !prefetchedRef.current) {
521 prefetchedRef.current = true;
522 props.prefetch(props.sourceId, props.tool.path);
523 observer.disconnect();
524 }
525 },
526 { threshold: 0 },
527 );
528 observer.observe(el);
529 return () => observer.disconnect();
530 }, [props.prefetch, props.sourceId, props.tool.path]);
531
532 const label = props.depth >= 0613 const label = props.depth >= 0
533 ? props.tool.path.split(".").pop() ?? props.tool.path614 ? props.tool.path.split(".").pop() ?? props.tool.path
534 : props.tool.path;615 : props.tool.path;
@@ -560,9 +641,14 @@ function ToolListItem(props: { · 25 unmodified lines
560// ToolDetailPanel641// ToolDetailPanel
561// ---------------------------------------------------------------------------642// ---------------------------------------------------------------------------
562 643
563function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) {644function ToolDetailPanel(props: { sourceId: string; detail: SourceInspectionToolDetail }) {
564 const { detail } = props;645 const { detail } = props;
565 const [copiedField, setCopiedField] = useState<string | null>(null);646 const [copiedField, setCopiedField] = useState<string | null>(null);
647 const [resolveRefs, setResolveRefs] = useState(false);
648 const schemaBundle = useSourceSchemaBundle(
649 props.sourceId,
650 resolveRefs ? detail.schemaBundleId : null,
651 );
566 652
567 const copy = useCallback((text: string, field: string) => {653 const copy = useCallback((text: string, field: string) => {
568 void navigator.clipboard.writeText(text).then(() => {654 void navigator.clipboard.writeText(text).then(() => {
@@ -571,6 +657,43 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 2 unmodified lines
571 });657 });
572 }, []);658 }, []);
573 659
660 useEffect(() => {
661 setResolveRefs(false);
662 }, [detail.summary.path]);
663
664 const resolvedSchemas = useMemo(() => {
665 if (!resolveRefs || schemaBundle.status !== "ready" || schemaBundle.data === null) {
666 return {
667 inputSchemaJson: detail.inputSchemaJson,
668 outputSchemaJson: detail.outputSchemaJson,
669 };
670 }
671
672 try {
673 const refHintTable = JSON.parse(schemaBundle.data.refsJson) as Record<string, unknown>;
674 return {
675 inputSchemaJson: resolveSchemaJsonWithRefHints(
676 detail.inputSchemaJson ?? undefined,
677 refHintTable,
678 ) ?? detail.inputSchemaJson,
679 outputSchemaJson: resolveSchemaJsonWithRefHints(
680 detail.outputSchemaJson ?? undefined,
681 refHintTable,
682 ) ?? detail.outputSchemaJson,
683 };
684 } catch {
685 return {
686 inputSchemaJson: detail.inputSchemaJson,
687 outputSchemaJson: detail.outputSchemaJson,
688 };
689 }
690 }, [
691 detail.inputSchemaJson,
692 detail.outputSchemaJson,
693 resolveRefs,
694 schemaBundle,
695 ]);
696
574 return (697 return (
575 <div className="flex h-full flex-col overflow-hidden">698 <div className="flex h-full flex-col overflow-hidden">
576 {/* Header */}699 {/* Header */}
@@ -613,9 +736,30 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 36 unmodified lines
613 </div>736 </div>
614 737
615 {/* Schemas */}738 {/* Schemas */}
739 {detail.schemaBundleId && (
740 <div className="flex items-center justify-between rounded-md border border-border bg-card/40 px-3 py-2">
741 <div className="text-[12px] text-muted-foreground">
742 {resolveRefs
743 ? schemaBundle.status === "loading"
744 ? "Loading shared refs..."
745 : schemaBundle.status === "error"
746 ? "Showing compact root schemas because shared refs failed to load."
747 : "Showing schemas with shared refs resolved client-side."
748 : "Showing compact root schemas. Shared refs are available on demand."}
749 </div>
750 <Button
751 type="button"
752 size="sm"
753 variant="outline"
754 onClick={() => setResolveRefs((value) => !value)}
755 >
756 {resolveRefs ? "Show compact roots" : "Resolve shared refs"}
757 </Button>
758 </div>
759 )}
616 <div className="grid grid-cols-1 gap-2 xl:grid-cols-2">760 <div className="grid grid-cols-1 gap-2 xl:grid-cols-2">
617 <DocumentPanel title="Input schema" body={detail.inputSchemaJson} empty="No input schema." compact />761 <DocumentPanel title="Input schema" body={resolvedSchemas.inputSchemaJson} empty="No input schema." compact />
618 <DocumentPanel title="Output schema" body={detail.outputSchemaJson} empty="No output schema." compact />762 <DocumentPanel title="Output schema" body={resolvedSchemas.outputSchemaJson} empty="No output schema." compact />
619 {detail.exampleInputJson && (763 {detail.exampleInputJson && (
620 <DocumentPanel title="Example request" body={detail.exampleInputJson} empty="" compact />764 <DocumentPanel title="Example request" body={detail.exampleInputJson} empty="" compact />
621 )}765 )}
apps/web/src/views/source-editor.tsx+15−0
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
15 useRefreshSecrets,15 useRefreshSecrets,
16 useRemoveSource,16 useRemoveSource,
17 useSecrets,17 useSecrets,
18 useSources,
18 useStartSourceOAuth,19 useStartSourceOAuth,
19 useSource,20 useSource,
20 useUpdateSource,21 useUpdateSource,
@@ -22,6 +23,7 @@ import { · 1 unmodified line
22import { Badge } from "../components/ui/badge";23import { Badge } from "../components/ui/badge";
23import { Button } from "../components/ui/button";24import { Button } from "../components/ui/button";
24import { LoadableBlock } from "../components/loadable";25import { LoadableBlock } from "../components/loadable";
26import { SourceNotFoundState } from "../components/source-not-found-state";
25import { SourceFavicon } from "../components/source-favicon";27import { SourceFavicon } from "../components/source-favicon";
26import {28import {
27 IconArrowLeft,29 IconArrowLeft,
@@ -56,6 +58,10 @@ type SourceOAuthPopupMessage = · 28 unmodified lines
56const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;58const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
57const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";59const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
58 60
61const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean =>
62 loadable.status === "error"
63 && loadable.error.message.toLowerCase().includes("source not found");
64
59type TransportValue = "" | NonNullable<Source["transport"]>;65type TransportValue = "" | NonNullable<Source["transport"]>;
60 66
61type SourceFormState = {67type SourceFormState = {
@@ -495,7 +501,16 @@ export function NewSourcePage() { · 433 unmodified lines
495}501}
496 502
497export function EditSourcePage(props: { sourceId: string }) {503export function EditSourcePage(props: { sourceId: string }) {
504 const sources = useSources();
498 const source = useSource(props.sourceId);505 const source = useSource(props.sourceId);
506 const missingSource =
507 (sources.status === "ready"
508 && !sources.data.some((candidate) => candidate.id === props.sourceId))
509 || isSourceNotFoundLoadable(source);
510
511 if (missingSource) {
512 return <SourceNotFoundState />;
513 }
499 514
500 return (515 return (
501 <LoadableBlock loadable={source} loading="Loading source...">516 <LoadableBlock loadable={source} loading="Loading source...">
packages/codemode-core/src/discovery.ts+5−1
@@ -111,6 +111,7 @@ export function createToolCatalogFromTools(input: { · 110 unmodified lines
111 })111 })
112 : null,112 : null,
113 ),113 ),
114 getSchemaBundle: () => Effect.succeed(null),
114 searchTools: ({ query, namespace, limit }) => {115 searchTools: ({ query, namespace, limit }) => {
115 const queryTokens = tokenize(query);116 const queryTokens = tokenize(query);
116 117
@@ -184,6 +185,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 67 unmodified lines
184 const describe: DescribePrimitive = {185 const describe: DescribePrimitive = {
185 tool: ({ path, includeSchemas = false }) =>186 tool: ({ path, includeSchemas = false }) =>
186 catalog.getToolByPath({ path, includeSchemas }),187 catalog.getToolByPath({ path, includeSchemas }),
188 schemaBundle: ({ id }) => catalog.getSchemaBundle({ id }),
187 };189 };
188 190
189 const discover: DiscoverPrimitive = ({191 const discover: DiscoverPrimitive = ({
@@ -234,6 +236,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 44 unmodified lines
234 ? {236 ? {
235 inputSchemaJson: descriptor.inputSchemaJson,237 inputSchemaJson: descriptor.inputSchemaJson,
236 outputSchemaJson: descriptor.outputSchemaJson,238 outputSchemaJson: descriptor.outputSchemaJson,
239 schemaBundleId: descriptor.schemaBundleId,
237 }240 }
238 : {}),241 : {}),
239 };242 };
@@ -296,7 +299,8 @@ export function buildDynamicExecuteDescription(input: { · 56 unmodified lines
296 "Workflow:",299 "Workflow:",
297 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',300 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
298 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",301 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
299 "3) Call selected tools.<path>(input).",302 "3) If details.schemaBundleId is present, fetch it once with tools.describe.schemaBundle({ id }).",
303 "4) Call selected tools.<path>(input).",
300 "Do not use fetch; use tools.* only.",304 "Do not use fetch; use tools.* only.",
301 ].join("\n");305 ].join("\n");
302 });306 });
packages/codemode-core/src/index.test.ts+1−0
@@ -331,6 +331,7 @@ describe("codemode-core", () => { · 330 unmodified lines
331 ),331 ),
332 getToolByPath: ({ path }) =>332 getToolByPath: ({ path }) =>
333 Effect.succeed(descriptors[path] ?? null),333 Effect.succeed(descriptors[path] ?? null),
334 getSchemaBundle: () => Effect.succeed(null),
334 searchTools: () =>335 searchTools: () =>
335 Effect.succeed([336 Effect.succeed([
336 { path: asToolPath("source.issues.create"), score: 0.93 },337 { path: asToolPath("source.issues.create"), score: 0.93 },
packages/codemode-core/src/system-tools.ts+37−0
@@ -51,6 +51,7 @@ const catalogToolsOutputSchema = Schema.standardSchemaV1( · 50 unmodified lines
51 outputType: Schema.optional(Schema.String),51 outputType: Schema.optional(Schema.String),
52 inputSchemaJson: Schema.optional(Schema.String),52 inputSchemaJson: Schema.optional(Schema.String),
53 outputSchemaJson: Schema.optional(Schema.String),53 outputSchemaJson: Schema.optional(Schema.String),
54 schemaBundleId: Schema.optional(Schema.String),
54 exampleInputJson: Schema.optional(Schema.String),55 exampleInputJson: Schema.optional(Schema.String),
55 exampleOutputJson: Schema.optional(Schema.String),56 exampleOutputJson: Schema.optional(Schema.String),
56 providerKind: Schema.optional(Schema.String),57 providerKind: Schema.optional(Schema.String),
@@ -71,6 +72,23 @@ const describeToolOutputSchema = Schema.standardSchemaV1( · 14 unmodified lines
71 Schema.NullOr(Schema.Unknown),72 Schema.NullOr(Schema.Unknown),
72);73);
73 74
75const describeSchemaBundleInputSchema = Schema.standardSchemaV1(
76 Schema.Struct({
77 id: Schema.String,
78 }),
79);
80
81const describeSchemaBundleOutputSchema = Schema.standardSchemaV1(
82 Schema.NullOr(
83 Schema.Struct({
84 id: Schema.String,
85 kind: Schema.String,
86 hash: Schema.String,
87 refsJson: Schema.String,
88 }),
89 ),
90);
91
74const discoverInputSchema = Schema.standardSchemaV1(92const discoverInputSchema = Schema.standardSchemaV1(
75 Schema.Struct({93 Schema.Struct({
76 query: Schema.String,94 query: Schema.String,
@@ -88,6 +106,7 @@ const discoverResultItemSchema = Schema.Struct({ · 11 unmodified lines
88 outputType: Schema.optional(Schema.String),106 outputType: Schema.optional(Schema.String),
89 inputSchemaJson: Schema.optional(Schema.String),107 inputSchemaJson: Schema.optional(Schema.String),
90 outputSchemaJson: Schema.optional(Schema.String),108 outputSchemaJson: Schema.optional(Schema.String),
109 schemaBundleId: Schema.optional(Schema.String),
91});110});
92 111
93const discoverOutputSchema = Schema.standardSchemaV1(112const discoverOutputSchema = Schema.standardSchemaV1(
@@ -182,6 +201,24 @@ export const createSystemToolMap = ( · 88 unmodified lines
182 interaction: "auto",201 interaction: "auto",
183 },202 },
184 });203 });
204
205 tools["describe.schemaBundle"] = toTool({
206 tool: {
207 description: "Get a shared schema bundle by id",
208 inputSchema: describeSchemaBundleInputSchema,
209 outputSchema: describeSchemaBundleOutputSchema,
210 execute: ({ id }: { id: string }) =>
211 Effect.runPromise(
212 primitives.describe!.schemaBundle({
213 id,
214 }),
215 ),
216 },
217 metadata: {
218 sourceKey,
219 interaction: "auto",
220 },
221 });
185 }222 }
186 223
187 if (primitives.discover) {224 if (primitives.discover) {
packages/codemode-core/src/tool-map.ts+1−0
@@ -438,6 +438,7 @@ export function toolDescriptorsFromTools(input: { · 437 unmodified lines
438 metadata?.outputType ?? inferTypeFromSchemaJson(outputSchemaJson, "unknown"),438 metadata?.outputType ?? inferTypeFromSchemaJson(outputSchemaJson, "unknown"),
439 inputSchemaJson,439 inputSchemaJson,
440 outputSchemaJson,440 outputSchemaJson,
441 ...(metadata?.schemaBundleId ? { schemaBundleId: metadata.schemaBundleId } : {}),
441 ...(metadata?.exampleInputJson442 ...(metadata?.exampleInputJson
442 ? { exampleInputJson: metadata.exampleInputJson }443 ? { exampleInputJson: metadata.exampleInputJson }
443 : {}),444 : {}),
packages/codemode-core/src/types.ts+16−0
@@ -81,6 +81,7 @@ export type ToolMetadata = { · 80 unmodified lines
81 outputType?: string;81 outputType?: string;
82 inputSchemaJson?: string;82 inputSchemaJson?: string;
83 outputSchemaJson?: string;83 outputSchemaJson?: string;
84 schemaBundleId?: string;
84 exampleInputJson?: string;85 exampleInputJson?: string;
85 exampleOutputJson?: string;86 exampleOutputJson?: string;
86 sourceKey?: string;87 sourceKey?: string;
@@ -134,12 +135,20 @@ export type ToolDescriptor = { · 47 unmodified lines
134 outputType?: string;135 outputType?: string;
135 inputSchemaJson?: string;136 inputSchemaJson?: string;
136 outputSchemaJson?: string;137 outputSchemaJson?: string;
138 schemaBundleId?: string;
137 exampleInputJson?: string;139 exampleInputJson?: string;
138 exampleOutputJson?: string;140 exampleOutputJson?: string;
139 providerKind?: string;141 providerKind?: string;
140 providerDataJson?: string;142 providerDataJson?: string;
141};143};
142 144
145export type ToolSchemaBundle = {
146 id: string;
147 kind: string;
148 hash: string;
149 refsJson: string;
150};
151
143export type ToolNamespace = {152export type ToolNamespace = {
144 namespace: string;153 namespace: string;
145 displayName?: string;154 displayName?: string;
@@ -168,6 +177,10 @@ export interface ToolCatalog { · 22 unmodified lines
168 includeSchemas: boolean;177 includeSchemas: boolean;
169 }): Effect.Effect<ToolDescriptor | null, unknown>;178 }): Effect.Effect<ToolDescriptor | null, unknown>;
170 179
180 getSchemaBundle(input: {
181 id: string;
182 }): Effect.Effect<ToolSchemaBundle | null, unknown>;
183
171 searchTools(input: {184 searchTools(input: {
172 query: string;185 query: string;
173 namespace?: string;186 namespace?: string;
@@ -196,6 +209,9 @@ export type DescribePrimitive = { · 22 unmodified lines
196 path: ToolPath;209 path: ToolPath;
197 includeSchemas?: boolean;210 includeSchemas?: boolean;
198 }): Effect.Effect<ToolDescriptor | null, unknown>;211 }): Effect.Effect<ToolDescriptor | null, unknown>;
212 schemaBundle(input: {
213 id: string;
214 }): Effect.Effect<ToolSchemaBundle | null, unknown>;
199};215};
200 216
201export type DiscoverPrimitive = (input: {217export type DiscoverPrimitive = (input: {
packages/codemode-openapi/src/index.ts+7−0
@@ -19,6 +19,7 @@ export { · 18 unmodified lines
19} from "./openapi-tool-presentation";19} from "./openapi-tool-presentation";
20export {20export {
21 OpenApiToolInvocationError,21 OpenApiToolInvocationError,
22 createOpenApiToolFromDefinition,
22 createOpenApiToolsFromManifest,23 createOpenApiToolsFromManifest,
23 createOpenApiToolsFromSpec,24 createOpenApiToolsFromSpec,
24} from "./openapi-tools";25} from "./openapi-tools";
@@ -39,6 +40,9 @@ export { · 14 unmodified lines
39 OpenApiRequestBodyDocumentationSchema,40 OpenApiRequestBodyDocumentationSchema,
40 OpenApiResponseDocumentationSchema,41 OpenApiResponseDocumentationSchema,
41 OpenApiToolDocumentationSchema,42 OpenApiToolDocumentationSchema,
43 OpenApiToolProviderDataSchema,
44 OpenApiRefHintTableSchema,
45 OpenApiRefHintValueSchema,
42 type DiscoveryTypingPayload,46 type DiscoveryTypingPayload,
43 type OpenApiExtractedTool,47 type OpenApiExtractedTool,
44 type OpenApiHttpMethod,48 type OpenApiHttpMethod,
@@ -49,6 +53,9 @@ export { · 4 unmodified lines
49 type OpenApiParameterLocation,53 type OpenApiParameterLocation,
50 type OpenApiSpecInput,54 type OpenApiSpecInput,
51 type OpenApiToolManifest,55 type OpenApiToolManifest,
56 type OpenApiToolProviderData,
57 type OpenApiRefHintTable,
58 type OpenApiRefHintValue,
52 type OpenApiToolParameter,59 type OpenApiToolParameter,
53 type OpenApiToolRequestBody,60 type OpenApiToolRequestBody,
54} from "./openapi-types";61} from "./openapi-types";
packages/codemode-openapi/src/openapi-definitions.ts+3−1
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5 OpenApiHttpMethodSchema,5 OpenApiHttpMethodSchema,
6 OpenApiInvocationPayloadSchema,6 OpenApiInvocationPayloadSchema,
7 OpenApiToolDocumentationSchema,7 OpenApiToolDocumentationSchema,
8 OpenApiToolProviderDataSchema,
8 type OpenApiExtractedTool,9 type OpenApiExtractedTool,
9 type OpenApiToolManifest,10 type OpenApiToolManifest,
10} from "./openapi-types";11} from "./openapi-types";
@@ -250,5 +251,6 @@ export const openApiProviderDataJsonFromDefinition = ( · 239 unmodified lines
250 method: definition.method,251 method: definition.method,
251 path: definition.path,252 path: definition.path,
252 operationHash: definition.operationHash,253 operationHash: definition.operationHash,
254 invocation: definition.invocation,
253 documentation: definition.documentation,255 documentation: definition.documentation,
254 });256 } satisfies typeof OpenApiToolProviderDataSchema.Type);
packages/codemode-openapi/src/openapi-schema-refs.ts+10−4
@@ -1,4 +1,5 @@
1type JsonObject = Record<string, unknown>;1type JsonObject = Record<string, unknown>;
2type RefHintValue = string | JsonObject;
2 3
3type DiscoveryTypingLike = {4type DiscoveryTypingLike = {
4 inputSchemaJson?: string;5 inputSchemaJson?: string;
@@ -18,7 +19,7 @@ const parseJson = (value: string): unknown | null => { · 13 unmodified lines
18 19
19const resolveSchemaNode = (20const resolveSchemaNode = (
20 value: unknown,21 value: unknown,
21 refHintTable: Readonly<Record<string, string>>,22 refHintTable: Readonly<Record<string, RefHintValue>>,
22 parsedHintCache: Map<string, unknown>,23 parsedHintCache: Map<string, unknown>,
23 activeRefs: ReadonlySet<string>,24 activeRefs: ReadonlySet<string>,
24): unknown => {25): unknown => {
@@ -38,7 +39,12 @@ const resolveSchemaNode = ( · 13 unmodified lines
38 39
39 if (resolvedRefValue === undefined) {40 if (resolvedRefValue === undefined) {
40 const rawHint = refHintTable[ref];41 const rawHint = refHintTable[ref];
41 resolvedRefValue = typeof rawHint === "string" ? parseJson(rawHint) : null;42 resolvedRefValue =
43 typeof rawHint === "string"
44 ? parseJson(rawHint)
45 : isJsonObject(rawHint)
46 ? rawHint
47 : null;
42 parsedHintCache.set(ref, resolvedRefValue);48 parsedHintCache.set(ref, resolvedRefValue);
43 }49 }
44 50
@@ -88,7 +94,7 @@ const resolveSchemaNode = ( · 43 unmodified lines
88 94
89export const resolveSchemaJsonWithRefHints = (95export const resolveSchemaJsonWithRefHints = (
90 schemaJson: string | undefined,96 schemaJson: string | undefined,
91 refHintTable: Readonly<Record<string, string>> | undefined,97 refHintTable: Readonly<Record<string, RefHintValue>> | undefined,
92): string | null => {98): string | null => {
93 if (!schemaJson) {99 if (!schemaJson) {
94 return null;100 return null;
@@ -119,7 +125,7 @@ export const resolveSchemaJsonWithRefHints = ( · 24 unmodified lines
119 125
120export const resolveTypingSchemasWithRefHints = (126export const resolveTypingSchemasWithRefHints = (
121 typing: DiscoveryTypingLike | undefined,127 typing: DiscoveryTypingLike | undefined,
122 refHintTable: Readonly<Record<string, string>> | undefined,128 refHintTable: Readonly<Record<string, RefHintValue>> | undefined,
123): {129): {
124 inputSchemaJson: string | null;130 inputSchemaJson: string | null;
125 outputSchemaJson: string | null;131 outputSchemaJson: string | null;
packages/codemode-openapi/src/openapi-tool-presentation.ts+7−26
@@ -1,14 +1,13 @@
1import { typeSignatureFromSchemaJson } from "@executor/codemode-core";1import { typeSignatureFromSchemaJson } from "@executor/codemode-core";
2 2
3import {
4 resolveTypingSchemasWithRefHints,
5} from "./openapi-schema-refs";
6import type {3import type {
7 OpenApiExample,4 OpenApiExample,
8 OpenApiToolDocumentation,5 OpenApiToolDocumentation,
9 OpenApiToolManifest,
10} from "./openapi-types";6} from "./openapi-types";
11import type { OpenApiToolDefinition } from "./openapi-definitions";7import {
8 openApiProviderDataJsonFromDefinition,
9 type OpenApiToolDefinition,
10} from "./openapi-definitions";
12 11
13const parseJson = (value: string): unknown | undefined => {12const parseJson = (value: string): unknown | undefined => {
14 try {13 try {
@@ -104,15 +103,10 @@ export type OpenApiToolPresentation = { · 89 unmodified lines
104};103};
105 104
106export const buildOpenApiToolPresentation = (input: {105export const buildOpenApiToolPresentation = (input: {
107 manifest: OpenApiToolManifest;
108 definition: OpenApiToolDefinition;106 definition: OpenApiToolDefinition;
109}): OpenApiToolPresentation => {107}): OpenApiToolPresentation => {
110 const resolvedSchemas = resolveTypingSchemasWithRefHints(108 const inputSchemaJson = input.definition.typing?.inputSchemaJson;
111 input.definition.typing,109 const outputSchemaJson = input.definition.typing?.outputSchemaJson;
112 input.manifest.refHintTable,
113 );
114 const inputSchemaJson = resolvedSchemas.inputSchemaJson ?? undefined;
115 const outputSchemaJson = resolvedSchemas.outputSchemaJson ?? undefined;
116 const exampleInputJson = buildExampleInputJson(input.definition.documentation);110 const exampleInputJson = buildExampleInputJson(input.definition.documentation);
117 const exampleOutputJson = buildExampleOutputJson(input.definition.documentation);111 const exampleOutputJson = buildExampleOutputJson(input.definition.documentation);
118 112
@@ -123,19 +117,6 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines
123 ...(outputSchemaJson ? { outputSchemaJson } : {}),117 ...(outputSchemaJson ? { outputSchemaJson } : {}),
124 ...(exampleInputJson ? { exampleInputJson } : {}),118 ...(exampleInputJson ? { exampleInputJson } : {}),
125 ...(exampleOutputJson ? { exampleOutputJson } : {}),119 ...(exampleOutputJson ? { exampleOutputJson } : {}),
126 providerDataJson: JSON.stringify({120 providerDataJson: openApiProviderDataJsonFromDefinition(input.definition),
127 kind: "openapi",
128 toolId: input.definition.toolId,
129 rawToolId: input.definition.rawToolId,
130 operationId: input.definition.operationId,
131 group: input.definition.group,
132 leaf: input.definition.leaf,
133 tags: input.definition.tags,
134 versionSegment: input.definition.versionSegment,
135 method: input.definition.method,
136 path: input.definition.path,
137 operationHash: input.definition.operationHash,
138 documentation: input.definition.documentation,
139 }),
140 };121 };
141};122};
packages/codemode-openapi/src/openapi-tools.ts+112−73
@@ -19,11 +19,13 @@ import { · 18 unmodified lines
19 type OpenApiToolDefinition,19 type OpenApiToolDefinition,
20} from "./openapi-definitions";20} from "./openapi-definitions";
21import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";21import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
22import { resolveSchemaJsonWithRefHints } from "./openapi-schema-refs";
22import {23import {
23 extractOpenApiManifest,24 extractOpenApiManifest,
24 type OpenApiExtractionError,25 type OpenApiExtractionError,
25} from "./openapi-extraction";26} from "./openapi-extraction";
26import {27import {
28 type OpenApiRefHintTable,
27 type OpenApiInvocationPayload,29 type OpenApiInvocationPayload,
28 type OpenApiSpecInput,30 type OpenApiSpecInput,
29 type OpenApiToolManifest,31 type OpenApiToolManifest,
@@ -256,13 +258,21 @@ const summarizeHttpResponseBody = (body: unknown): string | null => { · 226 unmodified lines
256 }258 }
257};259};
258 260
259const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {261const inputSchemaFromTypingJson = (input: {
260 if (!inputSchemaJson) {262 inputSchemaJson: string | undefined;
263 refHintTable?: Readonly<OpenApiRefHintTable>;
264}) => {
265 const resolvedSchemaJson = resolveSchemaJsonWithRefHints(
266 input.inputSchemaJson,
267 input.refHintTable,
268 ) ?? input.inputSchemaJson;
269
270 if (!resolvedSchemaJson) {
261 return unknownInputSchema;271 return unknownInputSchema;
262 }272 }
263 273
264 try {274 try {
265 return standardSchemaFromJsonSchema(JSON.parse(inputSchemaJson), {275 return standardSchemaFromJsonSchema(JSON.parse(resolvedSchemaJson), {
266 vendor: "openapi",276 vendor: "openapi",
267 fallback: unknownInputSchema,277 fallback: unknownInputSchema,
268 });278 });
@@ -396,6 +406,97 @@ const buildFetchRequest = (input: { · 127 unmodified lines
396const createToolPath = (namespace: string | undefined, definition: OpenApiToolDefinition): string =>406const createToolPath = (namespace: string | undefined, definition: OpenApiToolDefinition): string =>
397 namespace ? `${namespace}.${definition.toolId}` : definition.toolId;407 namespace ? `${namespace}.${definition.toolId}` : definition.toolId;
398 408
409export type CreateOpenApiToolFromDefinitionInput = {
410 definition: OpenApiToolDefinition;
411 path: string;
412 sourceKey: string;
413 baseUrl: string;
414 defaultHeaders?: Readonly<Record<string, string>>;
415 credentialHeaders?: Readonly<Record<string, string>>;
416 refHintTable?: Readonly<OpenApiRefHintTable>;
417 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
418};
419
420export const createOpenApiToolFromDefinition = (
421 input: CreateOpenApiToolFromDefinitionInput,
422) => {
423 const defaultHeaders = input.defaultHeaders ?? {};
424 const credentialHeaders = input.credentialHeaders ?? {};
425 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
426 const presentation = buildOpenApiToolPresentation({
427 definition: input.definition,
428 });
429
430 return toTool({
431 tool: {
432 description: input.definition.description,
433 inputSchema: inputSchemaFromTypingJson({
434 inputSchemaJson: presentation.inputSchemaJson ?? input.definition.typing?.inputSchemaJson,
435 refHintTable: input.refHintTable,
436 }),
437 execute: async (args: unknown) => {
438 const decodedArgs = asToolArgs(args);
439 const request = buildFetchRequest({
440 payload: input.definition.invocation,
441 args: decodedArgs,
442 baseUrl: input.baseUrl,
443 defaultHeaders,
444 credentialHeaders,
445 });
446
447 return Effect.runPromise(
448 Effect.gen(function* () {
449 const client = yield* HttpClient.HttpClient;
450 let clientRequest = HttpClientRequest.make(
451 request.method as Parameters<typeof HttpClientRequest.make>[0],
452 )(request.url, {
453 headers: request.headers,
454 });
455
456 if (request.body !== undefined) {
457 clientRequest = HttpClientRequest.bodyText(
458 clientRequest,
459 request.body,
460 request.headers["content-type"],
461 );
462 }
463
464 const response = yield* client.execute(clientRequest).pipe(
465 Effect.mapError((cause) =>
466 cause instanceof Error ? cause : new Error(String(cause)),
467 ),
468 );
469 const body = yield* decodeHttpClientResponseBody(response);
470
471 if (response.status < 200 || response.status >= 300) {
472 throw new OpenApiToolInvocationError({
473 operation: "http_response",
474 message: `OpenAPI request failed with HTTP ${response.status}`,
475 details: summarizeHttpResponseBody(body),
476 });
477 }
478
479 return body;
480 }).pipe(
481 Effect.provide(httpClientLayer),
482 ),
483 );
484 },
485 },
486 metadata: {
487 sourceKey: input.sourceKey,
488 inputType: presentation.inputType,
489 outputType: presentation.outputType,
490 inputSchemaJson: presentation.inputSchemaJson,
491 outputSchemaJson: presentation.outputSchemaJson,
492 exampleInputJson: presentation.exampleInputJson,
493 exampleOutputJson: presentation.exampleOutputJson,
494 providerKind: "openapi",
495 providerDataJson: presentation.providerDataJson,
496 },
497 });
498};
499
399export const createOpenApiToolsFromManifest = (500export const createOpenApiToolsFromManifest = (
400 input: CreateOpenApiToolsFromManifestInput,501 input: CreateOpenApiToolsFromManifestInput,
401): ToolMap => {502): ToolMap => {
@@ -410,77 +511,15 @@ export const createOpenApiToolsFromManifest = ( · 8 unmodified lines
410 511
411 for (const definition of definitions) {512 for (const definition of definitions) {
412 const toolPath = createToolPath(input.namespace, definition);513 const toolPath = createToolPath(input.namespace, definition);
413 const presentation = buildOpenApiToolPresentation({514 result[toolPath] = createOpenApiToolFromDefinition({
414 manifest: input.manifest,
415 definition,515 definition,
416 });516 path: toolPath,
417 517 sourceKey,
418 result[toolPath] = toTool({518 baseUrl,
419 tool: {519 defaultHeaders,
420 description: definition.description,520 credentialHeaders,
421 inputSchema: inputSchemaFromTypingJson(521 refHintTable: input.manifest.refHintTable,
422 presentation.inputSchemaJson ?? definition.typing?.inputSchemaJson,522 httpClientLayer,
423 ),
424 execute: async (args: unknown) => {
425 const decodedArgs = asToolArgs(args);
426 const request = buildFetchRequest({
427 payload: definition.invocation,
428 args: decodedArgs,
429 baseUrl,
430 defaultHeaders,
431 credentialHeaders,
432 });
433
434 return Effect.runPromise(
435 Effect.gen(function* () {
436 const client = yield* HttpClient.HttpClient;
437 let clientRequest = HttpClientRequest.make(
438 request.method as Parameters<typeof HttpClientRequest.make>[0],
439 )(request.url, {
440 headers: request.headers,
441 });
442
443 if (request.body !== undefined) {
444 clientRequest = HttpClientRequest.bodyText(
445 clientRequest,
446 request.body,
447 request.headers["content-type"],
448 );
449 }
450
451 const response = yield* client.execute(clientRequest).pipe(
452 Effect.mapError((cause) =>
453 cause instanceof Error ? cause : new Error(String(cause)),
454 ),
455 );
456 const body = yield* decodeHttpClientResponseBody(response);
457
458 if (response.status < 200 || response.status >= 300) {
459 throw new OpenApiToolInvocationError({
460 operation: "http_response",
461 message: `OpenAPI request failed with HTTP ${response.status}`,
462 details: summarizeHttpResponseBody(body),
463 });
464 }
465
466 return body;
467 }).pipe(
468 Effect.provide(httpClientLayer),
469 ),
470 );
471 },
472 },
473 metadata: {
474 sourceKey,
475 inputType: presentation.inputType,
476 outputType: presentation.outputType,
477 inputSchemaJson: presentation.inputSchemaJson,
478 outputSchemaJson: presentation.outputSchemaJson,
479 exampleInputJson: presentation.exampleInputJson,
480 exampleOutputJson: presentation.exampleOutputJson,
481 providerKind: "openapi",
482 providerDataJson: presentation.providerDataJson,
483 },
484 });523 });
485 }524 }
486 525
packages/codemode-openapi/src/openapi-types.ts+32−0
@@ -63,6 +63,19 @@ export const DiscoveryTypingPayloadSchema = Schema.Struct({ · 62 unmodified lines
63 refHintKeys: Schema.optional(Schema.Array(Schema.String)),63 refHintKeys: Schema.optional(Schema.Array(Schema.String)),
64});64});
65 65
66export const OpenApiRefHintValueSchema = Schema.Union(
67 Schema.String,
68 Schema.Record({
69 key: Schema.String,
70 value: Schema.Unknown,
71 }),
72);
73
74export const OpenApiRefHintTableSchema = Schema.Record({
75 key: Schema.String,
76 value: OpenApiRefHintValueSchema,
77});
78
66export const OpenApiExampleSchema = Schema.Struct({79export const OpenApiExampleSchema = Schema.Struct({
67 valueJson: Schema.String,80 valueJson: Schema.String,
68 mediaType: Schema.optional(Schema.String),81 mediaType: Schema.optional(Schema.String),
@@ -97,6 +110,22 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 28 unmodified lines
97 response: Schema.optional(OpenApiResponseDocumentationSchema),110 response: Schema.optional(OpenApiResponseDocumentationSchema),
98});111});
99 112
113export const OpenApiToolProviderDataSchema = Schema.Struct({
114 kind: Schema.Literal("openapi"),
115 toolId: Schema.String,
116 rawToolId: Schema.String,
117 operationId: Schema.optional(Schema.String),
118 group: Schema.String,
119 leaf: Schema.String,
120 tags: Schema.Array(Schema.String),
121 versionSegment: Schema.optional(Schema.String),
122 method: OpenApiHttpMethodSchema,
123 path: Schema.String,
124 operationHash: Schema.String,
125 invocation: OpenApiInvocationPayloadSchema,
126 documentation: Schema.optional(OpenApiToolDocumentationSchema),
127});
128
100export const OpenApiExtractedToolSchema = Schema.Struct({129export const OpenApiExtractedToolSchema = Schema.Struct({
101 toolId: Schema.String,130 toolId: Schema.String,
102 operationId: Schema.optional(Schema.String),131 operationId: Schema.optional(Schema.String),
@@ -129,10 +158,13 @@ export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; · 26 unmodified lines
129export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;158export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
130export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;159export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
131export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;160export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;
161export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;
162export type OpenApiRefHintTable = typeof OpenApiRefHintTableSchema.Type;
132export type OpenApiExample = typeof OpenApiExampleSchema.Type;163export type OpenApiExample = typeof OpenApiExampleSchema.Type;
133export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;164export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;
134export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;165export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;
135export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;166export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;
136export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;167export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;
168export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;
137export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;169export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;
138export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;170export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;
packages/control-plane/drizzle/20260310183000_source_recipe_schema_bundles/migration.sql+14−0
@@ -0,0 +1,14 @@
1CREATE TABLE "source_recipe_schema_bundles" (
2 "id" text PRIMARY KEY NOT NULL,
3 "recipe_revision_id" text NOT NULL,
4 "bundle_kind" text NOT NULL,
5 "refs_json" text NOT NULL,
6 "content_hash" text NOT NULL,
7 "created_at" bigint NOT NULL,
8 "updated_at" bigint NOT NULL,
9 CONSTRAINT "source_recipe_schema_bundles_kind_check" CHECK ("source_recipe_schema_bundles"."bundle_kind" in ('json_schema_ref_map'))
10);
11--> statement-breakpoint
12CREATE UNIQUE INDEX "source_recipe_schema_bundles_revision_kind_idx" ON "source_recipe_schema_bundles" USING btree ("recipe_revision_id","bundle_kind");
13--> statement-breakpoint
14CREATE INDEX "source_recipe_schema_bundles_revision_created_idx" ON "source_recipe_schema_bundles" USING btree ("recipe_revision_id","created_at","id");
packages/control-plane/drizzle/20260310223000_source_adapter_registry_strings/migration.sql+9−0
@@ -0,0 +1,9 @@
1ALTER TABLE "sources" DROP CONSTRAINT IF EXISTS "sources_kind_check";
2--> statement-breakpoint
3ALTER TABLE "source_recipes" DROP CONSTRAINT IF EXISTS "source_recipes_importer_kind_check";
4--> statement-breakpoint
5ALTER TABLE "source_recipe_documents" DROP CONSTRAINT IF EXISTS "source_recipe_documents_kind_check";
6--> statement-breakpoint
7ALTER TABLE "source_recipe_schema_bundles" DROP CONSTRAINT IF EXISTS "source_recipe_schema_bundles_kind_check";
8--> statement-breakpoint
9ALTER TABLE "source_recipe_operations" DROP CONSTRAINT IF EXISTS "source_recipe_operations_provider_kind_check";
packages/control-plane/drizzle/20260310233000_source_adapter_families_and_data_migrations/migration.sql+23−0
@@ -0,0 +1,23 @@
1ALTER TABLE "source_recipes"
2DROP CONSTRAINT IF EXISTS "source_recipes_kind_check";--> statement-breakpoint
3
4UPDATE "source_recipes"
5SET "kind" = 'http_api'
6WHERE "kind" IN ('http_recipe', 'graphql_recipe');--> statement-breakpoint
7
8UPDATE "source_recipes"
9SET "kind" = 'mcp'
10WHERE "kind" = 'mcp_recipe';--> statement-breakpoint
11
12UPDATE "source_recipes"
13SET "kind" = 'internal'
14WHERE "kind" = 'internal_recipe';--> statement-breakpoint
15
16ALTER TABLE "source_recipes"
17ADD CONSTRAINT "source_recipes_kind_check"
18CHECK ("kind" in ('http_api', 'mcp', 'internal'));--> statement-breakpoint
19
20CREATE TABLE IF NOT EXISTS "control_plane_data_migrations" (
21 "id" text PRIMARY KEY NOT NULL,
22 "applied_at" bigint NOT NULL
23);
packages/control-plane/drizzle/20260311010000_source_binding_configs/migration.sql+62−0
@@ -0,0 +1,62 @@
1UPDATE "sources"
2SET "binding_config_json" = CASE
3 WHEN "binding_config_json" IS NOT NULL AND btrim("binding_config_json") <> '' THEN "binding_config_json"
4 WHEN "kind" = 'openapi' THEN jsonb_build_object(
5 'adapterKey', 'openapi',
6 'specUrl', COALESCE("spec_url", "endpoint"),
7 'defaultHeaders',
8 CASE
9 WHEN "default_headers_json" IS NULL OR btrim("default_headers_json") = '' THEN NULL
10 ELSE "default_headers_json"::jsonb
11 END
12 )::text
13 WHEN "kind" = 'graphql' THEN jsonb_build_object(
14 'adapterKey', 'graphql',
15 'defaultHeaders',
16 CASE
17 WHEN "default_headers_json" IS NULL OR btrim("default_headers_json") = '' THEN NULL
18 ELSE "default_headers_json"::jsonb
19 END
20 )::text
21 WHEN "kind" = 'mcp' THEN jsonb_build_object(
22 'adapterKey', 'mcp',
23 'transport', "transport",
24 'queryParams',
25 CASE
26 WHEN "query_params_json" IS NULL OR btrim("query_params_json") = '' THEN NULL
27 ELSE "query_params_json"::jsonb
28 END,
29 'headers',
30 CASE
31 WHEN "headers_json" IS NULL OR btrim("headers_json") = '' THEN NULL
32 ELSE "headers_json"::jsonb
33 END
34 )::text
35 ELSE jsonb_build_object(
36 'adapterKey', "kind"
37 )::text
38END;--> statement-breakpoint
39
40ALTER TABLE "sources"
41ALTER COLUMN "binding_config_json" SET NOT NULL;--> statement-breakpoint
42
43ALTER TABLE "sources"
44DROP CONSTRAINT IF EXISTS "sources_transport_check";--> statement-breakpoint
45
46ALTER TABLE "sources"
47DROP COLUMN IF EXISTS "transport";--> statement-breakpoint
48
49ALTER TABLE "sources"
50DROP COLUMN IF EXISTS "query_params_json";--> statement-breakpoint
51
52ALTER TABLE "sources"
53DROP COLUMN IF EXISTS "headers_json";--> statement-breakpoint
54
55ALTER TABLE "sources"
56DROP COLUMN IF EXISTS "spec_url";--> statement-breakpoint
57
58ALTER TABLE "sources"
59DROP COLUMN IF EXISTS "default_headers_json";--> statement-breakpoint
60
61ALTER TABLE "sources"
62DROP COLUMN IF EXISTS "source_document_text";
packages/control-plane/drizzle/20260311133000_import_auth_slots_and_materialization_hash/migration.sql+82−0
@@ -0,0 +1,82 @@
1ALTER TABLE "sources" ADD COLUMN "import_auth_policy" text;--> statement-breakpoint
2
3UPDATE "sources"
4SET "import_auth_policy" = CASE
5 WHEN "kind" = 'internal' THEN 'none'
6 ELSE 'reuse_runtime'
7END
8WHERE "import_auth_policy" IS NULL;--> statement-breakpoint
9
10ALTER TABLE "sources"
11ALTER COLUMN "import_auth_policy" SET NOT NULL;--> statement-breakpoint
12
13ALTER TABLE "sources"
14DROP CONSTRAINT IF EXISTS "sources_import_auth_policy_check";--> statement-breakpoint
15
16ALTER TABLE "sources"
17ADD CONSTRAINT "sources_import_auth_policy_check"
18CHECK ("import_auth_policy" in ('none', 'reuse_runtime', 'separate'));--> statement-breakpoint
19
20ALTER TABLE "source_recipe_revisions" ADD COLUMN "materialization_hash" text;--> statement-breakpoint
21
22UPDATE "source_recipe_revisions"
23SET "materialization_hash" = "manifest_hash"
24WHERE "materialization_hash" IS NULL;--> statement-breakpoint
25
26DROP INDEX IF EXISTS "source_recipe_revisions_recipe_manifest_idx";--> statement-breakpoint
27
28CREATE UNIQUE INDEX "source_recipe_revisions_recipe_materialization_idx"
29ON "source_recipe_revisions" ("recipe_id","materialization_hash");--> statement-breakpoint
30
31CREATE INDEX "source_recipe_revisions_recipe_manifest_idx"
32ON "source_recipe_revisions" ("recipe_id","manifest_hash");--> statement-breakpoint
33
34ALTER TABLE "workspace_source_credentials" ADD COLUMN "slot" text;--> statement-breakpoint
35
36UPDATE "workspace_source_credentials"
37SET "slot" = 'runtime'
38WHERE "slot" IS NULL;--> statement-breakpoint
39
40ALTER TABLE "workspace_source_credentials"
41ALTER COLUMN "slot" SET NOT NULL;--> statement-breakpoint
42
43DROP INDEX IF EXISTS "credentials_workspace_source_actor_idx";--> statement-breakpoint
44
45ALTER TABLE "workspace_source_credentials"
46DROP CONSTRAINT IF EXISTS "credentials_slot_check";--> statement-breakpoint
47
48ALTER TABLE "workspace_source_credentials"
49ADD CONSTRAINT "credentials_slot_check"
50CHECK ("slot" in ('runtime', 'import'));--> statement-breakpoint
51
52CREATE UNIQUE INDEX "credentials_workspace_source_actor_idx"
53ON "workspace_source_credentials" ("workspace_id","source_id","actor_account_id","slot");--> statement-breakpoint
54
55ALTER TABLE "source_auth_sessions" ADD COLUMN "credential_slot" text;--> statement-breakpoint
56
57UPDATE "source_auth_sessions"
58SET "credential_slot" = 'runtime'
59WHERE "credential_slot" IS NULL;--> statement-breakpoint
60
61ALTER TABLE "source_auth_sessions"
62ALTER COLUMN "credential_slot" SET NOT NULL;--> statement-breakpoint
63
64DROP INDEX IF EXISTS "source_auth_sessions_pending_idx";--> statement-breakpoint
65
66ALTER TABLE "source_auth_sessions"
67DROP CONSTRAINT IF EXISTS "source_auth_sessions_credential_slot_check";--> statement-breakpoint
68
69ALTER TABLE "source_auth_sessions"
70ADD CONSTRAINT "source_auth_sessions_credential_slot_check"
71CHECK ("credential_slot" in ('runtime', 'import'));--> statement-breakpoint
72
73CREATE INDEX "source_auth_sessions_pending_idx"
74ON "source_auth_sessions" (
75 "workspace_id",
76 "source_id",
77 "actor_account_id",
78 "credential_slot",
79 "status",
80 "updated_at",
81 "id"
82);--> statement-breakpoint
packages/control-plane/drizzle/20260311170000_thin_source_recipe_operations/migration.sql+73−0
@@ -0,0 +1,73 @@
1UPDATE "source_recipe_operations"
2SET "provider_data_json" = CASE
3 WHEN "provider_kind" = 'openapi' THEN jsonb_strip_nulls(jsonb_build_object(
4 'kind', 'openapi',
5 'toolId', "tool_id",
6 'rawToolId', COALESCE("openapi_raw_tool_id", "tool_id"),
7 'operationId', "openapi_operation_id",
8 'group', CASE
9 WHEN position('.' in "tool_id") > 0 THEN split_part("tool_id", '.', 1)
10 ELSE "tool_id"
11 END,
12 'leaf', CASE
13 WHEN position('.' in "tool_id") > 0 THEN substring("tool_id" from position('.' in "tool_id") + 1)
14 ELSE "tool_id"
15 END,
16 'tags', CASE
17 WHEN "openapi_tags_json" IS NOT NULL THEN "openapi_tags_json"::jsonb
18 ELSE '[]'::jsonb
19 END,
20 'method', COALESCE("openapi_method", 'get'),
21 'path', COALESCE("openapi_path_template", '/'),
22 'operationHash', COALESCE(
23 "openapi_operation_hash",
24 md5(COALESCE("tool_id", '') || ':' || COALESCE("openapi_path_template", '/'))
25 ),
26 'invocation', jsonb_build_object(
27 'method', COALESCE("openapi_method", 'get'),
28 'pathTemplate', COALESCE("openapi_path_template", '/'),
29 'parameters', '[]'::jsonb,
30 'requestBody', CASE
31 WHEN "openapi_request_body_required" IS NULL THEN null
32 ELSE jsonb_build_object(
33 'required', "openapi_request_body_required",
34 'contentTypes', '[]'::jsonb
35 )
36 END
37 )
38 ))::text
39 WHEN "provider_kind" = 'graphql' THEN jsonb_build_object(
40 'kind', 'graphql',
41 'toolKind', 'field',
42 'toolId', "tool_id",
43 'rawToolId', null,
44 'group', null,
45 'leaf', null,
46 'fieldName', null,
47 'operationType', "graphql_operation_type",
48 'operationName', "graphql_operation_name",
49 'operationDocument', null,
50 'queryTypeName', null,
51 'mutationTypeName', null,
52 'subscriptionTypeName', null
53 )::text
54 WHEN "provider_kind" = 'mcp' THEN jsonb_build_object(
55 'kind', 'mcp',
56 'toolId', "tool_id",
57 'toolName', COALESCE("mcp_tool_name", "title", "tool_id"),
58 'description', "description"
59 )::text
60 ELSE "provider_data_json"
61END
62WHERE "provider_data_json" IS NULL;--> statement-breakpoint
63
64ALTER TABLE "source_recipe_operations" DROP COLUMN "mcp_tool_name";--> statement-breakpoint
65ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_method";--> statement-breakpoint
66ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_path_template";--> statement-breakpoint
67ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_operation_hash";--> statement-breakpoint
68ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_raw_tool_id";--> statement-breakpoint
69ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_operation_id";--> statement-breakpoint
70ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_tags_json";--> statement-breakpoint
71ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_request_body_required";--> statement-breakpoint
72ALTER TABLE "source_recipe_operations" DROP COLUMN "graphql_operation_type";--> statement-breakpoint
73ALTER TABLE "source_recipe_operations" DROP COLUMN "graphql_operation_name";--> statement-breakpoint
packages/control-plane/drizzle/20260311223000_drop_tool_artifacts/migration.sql+7−0
@@ -0,0 +1,7 @@
1DROP TABLE IF EXISTS "tool_artifact_parameters";
2--> statement-breakpoint
3DROP TABLE IF EXISTS "tool_artifact_request_body_content_types";
4--> statement-breakpoint
5DROP TABLE IF EXISTS "tool_artifact_ref_hint_keys";
6--> statement-breakpoint
7DROP TABLE IF EXISTS "tool_artifacts";
packages/control-plane/src/api/payload-schemas.test.ts+20−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11} from "./organizations/api";11} from "./organizations/api";
12import { CreatePolicyPayloadSchema } from "./policies/api";12import { CreatePolicyPayloadSchema } from "./policies/api";
13import {13import {
14 ConnectSourcePayloadSchema,
14 CreateSourcePayloadSchema,15 CreateSourcePayloadSchema,
15 UpdateSourcePayloadSchema,16 UpdateSourcePayloadSchema,
16} from "./sources/api";17} from "./sources/api";
@@ -56,6 +57,18 @@ describe("control-plane payload schemas", () => { · 39 unmodified lines
56 code: "console.log('ok')",57 code: "console.log('ok')",
57 });58 });
58 59
60 expect(
61 Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({
62 kind: "openapi",
63 endpoint: " https://api.github.com ",
64 specUrl: " https://example.com/openapi.json ",
65 }),
66 ).toEqual({
67 kind: "openapi",
68 endpoint: "https://api.github.com",
69 specUrl: "https://example.com/openapi.json",
70 });
71
59 expect(72 expect(
60 Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({73 Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({
61 resourcePattern: " source.github.* ",74 resourcePattern: " source.github.* ",
@@ -83,5 +96,12 @@ describe("control-plane payload schemas", () => { · 21 unmodified lines
83 endpoint: " ",96 endpoint: " ",
84 })97 })
85 );98 );
99
100 throws(() =>
101 Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({
102 kind: "graphql",
103 endpoint: " ",
104 })
105 );
86 });106 });
87});107});
packages/control-plane/src/api/sources/api.ts+23−54
@@ -1,15 +1,16 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
3 ExecutionInteractionIdSchema,3 ExecutionInteractionIdSchema,
4 SecretRefSchema,
5 SourceAuthSchema,4 SourceAuthSchema,
6 SourceAuthSessionIdSchema,5 SourceAuthSessionIdSchema,
7 SourceDiscoveryResultSchema,6 SourceDiscoveryResultSchema,
8 SourceIdSchema,7 SourceIdSchema,
9 SourceInspectionDiscoverPayloadSchema,8 SourceInspectionDiscoverPayloadSchema,
10 SourceInspectionDiscoverResultSchema,9 SourceInspectionDiscoverResultSchema,
10 SourceInspectionSchemaBundleSchema,
11 SourceInspectionSchema,11 SourceInspectionSchema,
12 SourceInspectionToolDetailSchema,12 SourceInspectionToolDetailSchema,
13 SourceImportAuthPolicySchema,
13 SourceKindSchema,14 SourceKindSchema,
14 SourceProbeAuthSchema,15 SourceProbeAuthSchema,
15 SourceSchema,16 SourceSchema,
@@ -31,6 +32,10 @@ import { · 15 unmodified lines
31 OptionalTrimmedNonEmptyStringSchema,32 OptionalTrimmedNonEmptyStringSchema,
32 TrimmedNonEmptyStringSchema,33 TrimmedNonEmptyStringSchema,
33} from "../string-schemas";34} from "../string-schemas";
35import {
36 ConnectSourcePayloadSchema,
37 type ConnectSourcePayload,
38} from "../../runtime/source-adapters";
34 39
35const createSourcePayloadRequiredSchema = Schema.Struct({40const createSourcePayloadRequiredSchema = Schema.Struct({
36 name: TrimmedNonEmptyStringSchema,41 name: TrimmedNonEmptyStringSchema,
@@ -47,6 +52,8 @@ const createSourcePayloadOptionalSchema = Schema.Struct({ · 10 unmodified lines
47 headers: Schema.optional(Schema.NullOr(StringMapSchema)),52 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
48 specUrl: Schema.optional(Schema.NullOr(Schema.String)),53 specUrl: Schema.optional(Schema.NullOr(Schema.String)),
49 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),54 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
55 importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema),
56 importAuth: Schema.optional(SourceAuthSchema),
50 auth: Schema.optional(SourceAuthSchema),57 auth: Schema.optional(SourceAuthSchema),
51 sourceHash: Schema.optional(Schema.NullOr(Schema.String)),58 sourceHash: Schema.optional(Schema.NullOr(Schema.String)),
52 lastError: Schema.optional(Schema.NullOr(Schema.String)),59 lastError: Schema.optional(Schema.NullOr(Schema.String)),
@@ -71,6 +78,8 @@ export const UpdateSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines
71 headers: Schema.optional(Schema.NullOr(StringMapSchema)),78 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
72 specUrl: Schema.optional(Schema.NullOr(Schema.String)),79 specUrl: Schema.optional(Schema.NullOr(Schema.String)),
73 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),80 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
81 importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema),
82 importAuth: Schema.optional(SourceAuthSchema),
74 auth: Schema.optional(SourceAuthSchema),83 auth: Schema.optional(SourceAuthSchema),
75 sourceHash: Schema.optional(Schema.NullOr(Schema.String)),84 sourceHash: Schema.optional(Schema.NullOr(Schema.String)),
76 lastError: Schema.optional(Schema.NullOr(Schema.String)),85 lastError: Schema.optional(Schema.NullOr(Schema.String)),
@@ -81,6 +90,7 @@ export type UpdateSourcePayload = typeof UpdateSourcePayloadSchema.Type; · 4 unmodified lines
81const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);90const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
82const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);91const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
83const toolPathParam = HttpApiSchema.param("toolPath", Schema.String);92const toolPathParam = HttpApiSchema.param("toolPath", Schema.String);
93const schemaBundleIdParam = HttpApiSchema.param("schemaBundleId", Schema.String);
84 94
85const CredentialPageUrlParamsSchema = Schema.Struct({95const CredentialPageUrlParamsSchema = Schema.Struct({
86 interactionId: ExecutionInteractionIdSchema,96 interactionId: ExecutionInteractionIdSchema,
@@ -105,59 +115,6 @@ const DiscoverSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines
105 115
106export type DiscoverSourcePayload = typeof DiscoverSourcePayloadSchema.Type;116export type DiscoverSourcePayload = typeof DiscoverSourcePayloadSchema.Type;
107 117
108const ConnectBearerAuthSchema = Schema.Struct({
109 kind: Schema.Literal("bearer"),
110 headerName: Schema.optional(Schema.NullOr(Schema.String)),
111 prefix: Schema.optional(Schema.NullOr(Schema.String)),
112 token: Schema.optional(Schema.NullOr(Schema.String)),
113 tokenRef: Schema.optional(Schema.NullOr(SecretRefSchema)),
114});
115
116const ConnectOAuth2AuthSchema = Schema.Struct({
117 kind: Schema.Literal("oauth2"),
118 headerName: Schema.optional(Schema.NullOr(Schema.String)),
119 prefix: Schema.optional(Schema.NullOr(Schema.String)),
120 accessToken: Schema.optional(Schema.NullOr(Schema.String)),
121 accessTokenRef: Schema.optional(Schema.NullOr(SecretRefSchema)),
122 refreshToken: Schema.optional(Schema.NullOr(Schema.String)),
123 refreshTokenRef: Schema.optional(Schema.NullOr(SecretRefSchema)),
124});
125
126const ConnectHttpAuthSchema = Schema.Union(
127 Schema.Struct({ kind: Schema.Literal("none") }),
128 ConnectBearerAuthSchema,
129 ConnectOAuth2AuthSchema,
130);
131
132const ConnectSourcePayloadSchema = Schema.Union(
133 Schema.Struct({
134 kind: Schema.Literal("openapi"),
135 endpoint: TrimmedNonEmptyStringSchema,
136 specUrl: TrimmedNonEmptyStringSchema,
137 name: Schema.optional(Schema.NullOr(Schema.String)),
138 namespace: Schema.optional(Schema.NullOr(Schema.String)),
139 auth: Schema.optional(ConnectHttpAuthSchema),
140 }),
141 Schema.Struct({
142 kind: Schema.Literal("graphql"),
143 endpoint: TrimmedNonEmptyStringSchema,
144 name: Schema.optional(Schema.NullOr(Schema.String)),
145 namespace: Schema.optional(Schema.NullOr(Schema.String)),
146 auth: Schema.optional(ConnectHttpAuthSchema),
147 }),
148 Schema.Struct({
149 kind: Schema.Literal("mcp"),
150 endpoint: TrimmedNonEmptyStringSchema,
151 name: Schema.optional(Schema.NullOr(Schema.String)),
152 namespace: Schema.optional(Schema.NullOr(Schema.String)),
153 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
154 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
155 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
156 }),
157);
158
159export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type;
160
161const ConnectSourceResultSchema = Schema.Union(118const ConnectSourceResultSchema = Schema.Union(
162 Schema.Struct({119 Schema.Struct({
163 kind: Schema.Literal("connected"),120 kind: Schema.Literal("connected"),
@@ -166,6 +123,7 @@ const ConnectSourceResultSchema = Schema.Union( · 2 unmodified lines
166 Schema.Struct({123 Schema.Struct({
167 kind: Schema.Literal("credential_required"),124 kind: Schema.Literal("credential_required"),
168 source: SourceSchema,125 source: SourceSchema,
126 credentialSlot: Schema.Literal("runtime", "import"),
169 }),127 }),
170 Schema.Struct({128 Schema.Struct({
171 kind: Schema.Literal("oauth_required"),129 kind: Schema.Literal("oauth_required"),
@@ -183,6 +141,8 @@ export { · 11 unmodified lines
183 DiscoverSourcePayloadSchema,141 DiscoverSourcePayloadSchema,
184};142};
185 143
144export type { ConnectSourcePayload };
145
186const HtmlSchema = HttpApiSchema.Text({146const HtmlSchema = HttpApiSchema.Text({
187 contentType: "text/html",147 contentType: "text/html",
188});148});
@@ -292,6 +252,15 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 103 unmodified lines
292 .addError(ControlPlaneNotFoundError)252 .addError(ControlPlaneNotFoundError)
293 .addError(ControlPlaneStorageError),253 .addError(ControlPlaneStorageError),
294 )254 )
255 .add(
256 HttpApiEndpoint.get("inspectionSchemaBundle")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/schema-bundles/${schemaBundleIdParam}`
257 .addSuccess(SourceInspectionSchemaBundleSchema)
258 .addError(ControlPlaneBadRequestError)
259 .addError(ControlPlaneUnauthorizedError)
260 .addError(ControlPlaneForbiddenError)
261 .addError(ControlPlaneNotFoundError)
262 .addError(ControlPlaneStorageError),
263 )
295 .add(264 .add(
296 HttpApiEndpoint.post("inspectionDiscover")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/discover`265 HttpApiEndpoint.post("inspectionDiscover")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/discover`
297 .setPayload(SourceInspectionDiscoverPayloadSchema)266 .setPayload(SourceInspectionDiscoverPayloadSchema)
packages/control-plane/src/api/sources/http.ts+15−24
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
21import {21import {
22 discoverSourceInspectionTools,22 discoverSourceInspectionTools,
23 getSourceInspection,23 getSourceInspection,
24 getSourceInspectionSchemaBundle,
24 getSourceInspectionToolDetail,25 getSourceInspectionToolDetail,
25} from "../../runtime/source-inspection";26} from "../../runtime/source-inspection";
26import {27import {
@@ -29,6 +30,7 @@ import { · 2 unmodified lines
29 submitSourceCredentialInteraction,30 submitSourceCredentialInteraction,
30} from "../../runtime/local-operations";31} from "../../runtime/local-operations";
31import { discoverSource } from "../../runtime/source-discovery";32import { discoverSource } from "../../runtime/source-discovery";
33import { hasSourceAdapterFamily } from "../../runtime/source-adapters";
32import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service";34import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service";
33 35
34import {36import {
@@ -682,7 +684,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 647 unmodified lines
682 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;684 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
683 const baseUrl = resolveRequestOrigin(request);685 const baseUrl = resolveRequestOrigin(request);
684 686
685 if (payload.kind === "mcp") {687 if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) {
686 return yield* sourceAuthService.connectMcpSource({688 return yield* sourceAuthService.connectMcpSource({
687 workspaceId: path.workspaceId,689 workspaceId: path.workspaceId,
688 actorAccountId: actor.principal.accountId,690 actorAccountId: actor.principal.accountId,
@@ -696,35 +698,13 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 7 unmodified lines
696 });698 });
697 }699 }
698 700
699 if (payload.kind === "openapi") {
700 return yield* sourceAuthService.addExecutorSource(
701 {
702 workspaceId: path.workspaceId,
703 actorAccountId: actor.principal.accountId,
704 executionId: null,
705 interactionId: null,
706 kind: "openapi",
707 endpoint: payload.endpoint,
708 specUrl: payload.specUrl,
709 name: payload.name,
710 namespace: payload.namespace,
711 auth: payload.auth,
712 },
713 { baseUrl },
714 );
715 }
716
717 return yield* sourceAuthService.addExecutorSource(701 return yield* sourceAuthService.addExecutorSource(
718 {702 {
719 workspaceId: path.workspaceId,703 workspaceId: path.workspaceId,
720 actorAccountId: actor.principal.accountId,704 actorAccountId: actor.principal.accountId,
721 executionId: null,705 executionId: null,
722 interactionId: null,706 interactionId: null,
723 kind: "graphql",707 ...payload,
724 endpoint: payload.endpoint,
725 name: payload.name,
726 namespace: payload.namespace,
727 auth: payload.auth,
728 },708 },
729 { baseUrl },709 { baseUrl },
730 );710 );
@@ -781,6 +761,17 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 50 unmodified lines
781 ),761 ),
782 ),762 ),
783 )763 )
764 .handle("inspectionSchemaBundle", ({ path }) =>
765 withWorkspaceRequestActor("sources.inspection_schema_bundle", path.workspaceId, () =>
766 withPolicy(requireReadSources(path.workspaceId))(
767 getSourceInspectionSchemaBundle({
768 workspaceId: path.workspaceId,
769 sourceId: path.sourceId,
770 schemaBundleId: path.schemaBundleId,
771 }),
772 ),
773 ),
774 )
784 .handle("inspectionDiscover", ({ path, payload }) =>775 .handle("inspectionDiscover", ({ path, payload }) =>
785 withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () =>776 withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () =>
786 withPolicy(requireReadSources(path.workspaceId))(777 withPolicy(requireReadSources(path.workspaceId))(
packages/control-plane/src/persistence/control-plane-rows.test.ts+436−14
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 SourceAuthSessionIdSchema,11 SourceAuthSessionIdSchema,
12 SourceIdSchema,12 SourceIdSchema,
13 SourceRecipeIdSchema,13 SourceRecipeIdSchema,
14 SourceRecipeSchemaBundleIdSchema,
14 SourceRecipeRevisionIdSchema,15 SourceRecipeRevisionIdSchema,
15 WorkspaceIdSchema,16 WorkspaceIdSchema,
16} from "#schema";17} from "#schema";
@@ -39,6 +40,35 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 22 unmodified lines
39 40
40const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);41const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
41 42
43const openApiBindingConfigJson = (specUrl: string): string =>
44 JSON.stringify({
45 adapterKey: "openapi",
46 specUrl,
47 defaultHeaders: null,
48 });
49
50const baseRevisionRecord = (input: {
51 id: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;
52 recipeId: ReturnType<typeof SourceRecipeIdSchema.make>;
53 revisionNumber: number;
54 sourceConfigJson: string;
55 manifestJson?: string | null;
56 manifestHash?: string | null;
57 materializationHash?: string | null;
58 createdAt: number;
59 updatedAt: number;
60}) => ({
61 id: input.id,
62 recipeId: input.recipeId,
63 revisionNumber: input.revisionNumber,
64 sourceConfigJson: input.sourceConfigJson,
65 manifestJson: input.manifestJson ?? null,
66 manifestHash: input.manifestHash ?? null,
67 materializationHash: input.materializationHash ?? null,
68 createdAt: input.createdAt,
69 updatedAt: input.updatedAt,
70});
71
42const seedWorkspaceSourceState = (input: {72const seedWorkspaceSourceState = (input: {
43 persistence: SqlControlPlanePersistence;73 persistence: SqlControlPlanePersistence;
44 accountId: ReturnType<typeof AccountIdSchema.make>;74 accountId: ReturnType<typeof AccountIdSchema.make>;
@@ -68,6 +98,33 @@ const seedWorkspaceSourceState = (input: { · 23 unmodified lines
68 createdAt: now,98 createdAt: now,
69 updatedAt: now,99 updatedAt: now,
70 });100 });
101 yield* input.persistence.rows.sourceRecipes.upsert({
102 id: recipeId,
103 kind: "http_api",
104 importerKind: "openapi",
105 providerKey: "openapi:https://api.github.com",
106 name: "Github",
107 summary: null,
108 visibility: "workspace",
109 latestRevisionId: recipeRevisionId,
110 createdAt: now,
111 updatedAt: now,
112 });
113 yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
114 id: recipeRevisionId,
115 recipeId,
116 revisionNumber: 1,
117 sourceConfigJson: JSON.stringify({
118 kind: "openapi",
119 endpoint: "https://api.github.com",
120 specUrl: "https://api.github.com/openapi.json",
121 }),
122 manifestJson: null,
123 manifestHash: null,
124 materializationHash: null,
125 createdAt: now,
126 updatedAt: now,
127 }));
71 yield* input.persistence.rows.sources.insert({128 yield* input.persistence.rows.sources.insert({
72 id: input.sourceId,129 id: input.sourceId,
73 workspaceId: input.workspaceId,130 workspaceId: input.workspaceId,
@@ -79,14 +136,9 @@ const seedWorkspaceSourceState = (input: { · 5 unmodified lines
79 status: "connected",136 status: "connected",
80 enabled: true,137 enabled: true,
81 namespace: "github",138 namespace: "github",
82 transport: null,139 importAuthPolicy: "reuse_runtime",
83 bindingConfigJson: null,140 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
84 queryParamsJson: null,
85 headersJson: null,
86 specUrl: "https://api.github.com/openapi.json",
87 defaultHeadersJson: null,
88 sourceHash: null,141 sourceHash: null,
89 sourceDocumentText: null,
90 lastError: null,142 lastError: null,
91 createdAt: now,143 createdAt: now,
92 updatedAt: now,144 updatedAt: now,
@@ -131,6 +183,7 @@ const seedWorkspaceCredentialState = (input: { · 38 unmodified lines
131 workspaceId: input.workspaceId,183 workspaceId: input.workspaceId,
132 sourceId: input.sourceId,184 sourceId: input.sourceId,
133 actorAccountId: input.accountId,185 actorAccountId: input.accountId,
186 slot: "runtime",
134 authKind: "oauth2",187 authKind: "oauth2",
135 authHeaderName: "Authorization",188 authHeaderName: "Authorization",
136 authPrefix: "Bearer ",189 authPrefix: "Bearer ",
@@ -149,6 +202,7 @@ const seedWorkspaceCredentialState = (input: { · 12 unmodified lines
149 executionId: null,202 executionId: null,
150 interactionId: null,203 interactionId: null,
151 providerKind: "mcp_oauth",204 providerKind: "mcp_oauth",
205 credentialSlot: "runtime",
152 status: "pending",206 status: "pending",
153 state: `state_${input.workspaceId}`,207 state: `state_${input.workspaceId}`,
154 sessionDataJson: encodeSessionDataJson({208 sessionDataJson: encodeSessionDataJson({
@@ -212,14 +266,9 @@ describe("control-plane-persistence-drizzle", () => { · 57 unmodified lines
212 status: "draft",266 status: "draft",
213 enabled: true,267 enabled: true,
214 namespace: "github",268 namespace: "github",
215 transport: null,269 importAuthPolicy: "reuse_runtime",
216 bindingConfigJson: null,270 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
217 queryParamsJson: null,
218 headersJson: null,
219 specUrl: "https://api.github.com/openapi.json",
220 defaultHeadersJson: null,
221 sourceHash: null,271 sourceHash: null,
222 sourceDocumentText: null,
223 lastError: null,272 lastError: null,
224 createdAt: now,273 createdAt: now,
225 updatedAt: now,274 updatedAt: now,
@@ -302,6 +351,94 @@ describe("control-plane-persistence-drizzle", () => { · 76 unmodified lines
302 }),351 }),
303 );352 );
304 353
354 it.scoped("batches large recipe operation replacement inserts", () =>
355 Effect.gen(function* () {
356 const persistence = yield* makePersistence;
357 const now = Date.now();
358 const recipeId = SourceRecipeIdSchema.make("src_recipe_bulk_ops");
359 const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bulk_ops");
360 const operationCount = 3_000;
361
362 yield* persistence.rows.sourceRecipes.upsert({
363 id: recipeId,
364 kind: "http_api",
365 importerKind: "openapi",
366 providerKey: "openapi:https://api.cloudflare.com/client/v4",
367 name: "Cloudflare API",
368 summary: null,
369 visibility: "private",
370 latestRevisionId: recipeRevisionId,
371 createdAt: now,
372 updatedAt: now,
373 });
374
375 yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
376 id: recipeRevisionId,
377 recipeId,
378 revisionNumber: 1,
379 sourceConfigJson: JSON.stringify({
380 endpoint: "https://api.cloudflare.com/client/v4",
381 specUrl:
382 "https://raw.githubusercontent.com/cloudflare/api-schemas/refs/heads/main/openapi.json",
383 }),
384 manifestHash: null,
385 manifestJson: null,
386 materializationHash: null,
387 createdAt: now,
388 updatedAt: now,
389 }));
390
391 yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
392 recipeRevisionId,
393 operations: Array.from({ length: operationCount }, (_, index) => ({
394 id: `src_recipe_op_${index}`,
395 recipeRevisionId,
396 operationKey: `zones.get${index}`,
397 transportKind: "http" as const,
398 toolId: `zones.get${index}`,
399 title: `Get zone ${index}`,
400 description: `Operation ${index}`,
401 operationKind: "read" as const,
402 searchText: `zones get ${index}`,
403 inputSchemaJson: null,
404 outputSchemaJson: null,
405 providerKind: "openapi" as const,
406 providerDataJson: JSON.stringify({
407 kind: "openapi",
408 toolId: `zones.get${index}`,
409 rawToolId: `getZone${index}`,
410 operationId: `getZone${index}`,
411 group: "zones",
412 leaf: `get${index}`,
413 tags: ["zones"],
414 method: "get",
415 path: `/zones/${index}`,
416 operationHash: `hash_${index}`,
417 invocation: {
418 method: "get",
419 pathTemplate: `/zones/${index}`,
420 parameters: [],
421 requestBody: null,
422 },
423 }),
424 createdAt: now,
425 updatedAt: now,
426 })),
427 });
428
429 const operations = yield* persistence.rows.sourceRecipeOperations.listByRevisionId(
430 recipeRevisionId,
431 );
432 expect(operations).toHaveLength(operationCount);
433 expect(operations.some((operation) => operation.toolId === "zones.get0")).toBe(true);
434 expect(
435 operations.some(
436 (operation) => operation.toolId === `zones.get${operationCount - 1}`,
437 ),
438 ).toBe(true);
439 }),
440 );
441
305 it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () =>442 it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () =>
306 Effect.gen(function* () {443 Effect.gen(function* () {
307 const persistence = yield* makePersistence;444 const persistence = yield* makePersistence;
@@ -329,6 +466,7 @@ describe("control-plane-persistence-drizzle", () => { · 21 unmodified lines
329 workspaceId,466 workspaceId,
330 sourceId,467 sourceId,
331 actorAccountId: null,468 actorAccountId: null,
469 slot: "runtime",
332 authKind: "bearer",470 authKind: "bearer",
333 authHeaderName: "Authorization",471 authHeaderName: "Authorization",
334 authPrefix: "Bearer ",472 authPrefix: "Bearer ",
@@ -344,6 +482,7 @@ describe("control-plane-persistence-drizzle", () => { · 9 unmodified lines
344 workspaceId,482 workspaceId,
345 sourceId,483 sourceId,
346 actorAccountId: null,484 actorAccountId: null,
485 slot: "runtime",
347 authKind: "bearer",486 authKind: "bearer",
348 authHeaderName: "Authorization",487 authHeaderName: "Authorization",
349 authPrefix: "Bearer ",488 authPrefix: "Bearer ",
@@ -362,6 +501,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
362 workspaceId,501 workspaceId,
363 sourceId,502 sourceId,
364 actorAccountId: null,503 actorAccountId: null,
504 slot: "runtime",
365 authKind: "oauth2",505 authKind: "oauth2",
366 authHeaderName: "X-Auth",506 authHeaderName: "X-Auth",
367 authPrefix: "Token ",507 authPrefix: "Token ",
@@ -378,6 +518,7 @@ describe("control-plane-persistence-drizzle", () => { · 10 unmodified lines
378 workspaceId,518 workspaceId,
379 sourceId,519 sourceId,
380 actorAccountId: accountId,520 actorAccountId: accountId,
521 slot: "runtime",
381 authKind: "bearer",522 authKind: "bearer",
382 authHeaderName: "Authorization",523 authHeaderName: "Authorization",
383 authPrefix: "Bearer ",524 authPrefix: "Bearer ",
@@ -418,6 +559,7 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines
418 workspaceId,559 workspaceId,
419 sourceId,560 sourceId,
420 actorAccountId: null,561 actorAccountId: null,
562 slot: "runtime",
421 });563 });
422 assertTrue(Option.isSome(nullActorOnly));564 assertTrue(Option.isSome(nullActorOnly));
423 if (Option.isSome(nullActorOnly)) {565 if (Option.isSome(nullActorOnly)) {
@@ -428,6 +570,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines
428 workspaceId,570 workspaceId,
429 sourceId,571 sourceId,
430 actorAccountId: AccountIdSchema.make("acc_missing_credentials"),572 actorAccountId: AccountIdSchema.make("acc_missing_credentials"),
573 slot: "runtime",
431 });574 });
432 expect(Option.isNone(missingActor)).toBe(true);575 expect(Option.isNone(missingActor)).toBe(true);
433 }),576 }),
@@ -440,6 +583,7 @@ describe("control-plane-persistence-drizzle", () => { · 6 unmodified lines
440 const organizationId = OrganizationIdSchema.make("org_ws_cleanup");583 const organizationId = OrganizationIdSchema.make("org_ws_cleanup");
441 const workspaceId = WorkspaceIdSchema.make("ws_cleanup");584 const workspaceId = WorkspaceIdSchema.make("ws_cleanup");
442 const sourceId = SourceIdSchema.make("src_ws_cleanup");585 const sourceId = SourceIdSchema.make("src_ws_cleanup");
586 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
443 587
444 const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({588 const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({
445 persistence,589 persistence,
@@ -448,17 +592,295 @@ describe("control-plane-persistence-drizzle", () => { · 2 unmodified lines
448 workspaceId,592 workspaceId,
449 sourceId,593 sourceId,
450 });594 });
595 yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
596 recipeRevisionId,
597 documents: [
598 {
599 id: "src_recipe_doc_ws_cleanup",
600 recipeRevisionId,
601 documentKind: "openapi",
602 documentKey: "https://api.github.com/openapi.json",
603 contentText: "{\"openapi\":\"3.1.0\"}",
604 contentHash: "doc_hash_ws_cleanup",
605 fetchedAt: null,
606 createdAt: Date.now(),
607 updatedAt: Date.now(),
608 },
609 ],
610 });
611 yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
612 recipeRevisionId,
613 bundles: [
614 {
615 id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_ws_cleanup"),
616 recipeRevisionId,
617 bundleKind: "json_schema_ref_map",
618 refsJson: "{}",
619 contentHash: "bundle_hash_ws_cleanup",
620 createdAt: Date.now(),
621 updatedAt: Date.now(),
622 },
623 ],
624 });
625 yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
626 recipeRevisionId,
627 operations: [
628 {
629 id: "src_recipe_op_ws_cleanup",
630 recipeRevisionId,
631 operationKey: "users.getAuthenticated",
632 transportKind: "http",
633 toolId: "users.getAuthenticated",
634 title: "Get authenticated user",
635 description: null,
636 operationKind: "read",
637 searchText: "users get authenticated",
638 inputSchemaJson: null,
639 outputSchemaJson: null,
640 providerKind: "openapi",
641 providerDataJson: JSON.stringify({
642 kind: "openapi",
643 toolId: "users.getAuthenticated",
644 rawToolId: "users.getAuthenticated",
645 operationId: "users.getAuthenticated",
646 group: "users",
647 leaf: "getAuthenticated",
648 tags: ["users"],
649 method: "get",
650 path: "/user",
651 operationHash: "op_hash_ws_cleanup",
652 invocation: {
653 method: "get",
654 pathTemplate: "/user",
655 pathParameterOrder: [],
656 serverUrl: "https://api.github.com",
657 parameters: [],
658 requestBody: null,
659 },
660 }),
661 createdAt: Date.now(),
662 updatedAt: Date.now(),
663 },
664 ],
665 });
451 666
452 const removed = yield* persistence.rows.workspaces.removeById(workspaceId);667 const removed = yield* persistence.rows.workspaces.removeById(workspaceId);
453 expect(removed).toBe(true);668 expect(removed).toBe(true);
454 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);669 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
455 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);670 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
456 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);671 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
672 expect(
673 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId),
674 ).toHaveLength(0);
675 expect(
676 yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(recipeRevisionId),
677 ).toHaveLength(0);
678 expect(
679 yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId),
680 ).toHaveLength(0);
457 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);681 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
458 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);682 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
459 }),683 }),
460 );684 );
461 685
686 it.scoped("deleting a workspace retains shared recipe data still referenced elsewhere", () =>
687 Effect.gen(function* () {
688 const persistence = yield* makePersistence;
689 const now = Date.now();
690 const accountId = AccountIdSchema.make("acc_shared_recipe");
691 const organizationId = OrganizationIdSchema.make("org_shared_recipe");
692 const removedWorkspaceId = WorkspaceIdSchema.make("ws_shared_removed");
693 const remainingWorkspaceId = WorkspaceIdSchema.make("ws_shared_remaining");
694 const sharedRecipeId = SourceRecipeIdSchema.make("src_recipe_shared_workspace");
695 const sharedRecipeRevisionId = SourceRecipeRevisionIdSchema.make(
696 "src_recipe_rev_shared_workspace",
697 );
698
699 yield* persistence.rows.organizations.insert({
700 id: organizationId,
701 slug: "org-shared-recipe",
702 name: "Org Shared Recipe",
703 status: "active",
704 createdByAccountId: accountId,
705 createdAt: now,
706 updatedAt: now,
707 });
708 yield* persistence.rows.workspaces.insert({
709 id: removedWorkspaceId,
710 organizationId,
711 name: "Workspace Removed",
712 createdByAccountId: accountId,
713 createdAt: now,
714 updatedAt: now,
715 });
716 yield* persistence.rows.workspaces.insert({
717 id: remainingWorkspaceId,
718 organizationId,
719 name: "Workspace Remaining",
720 createdByAccountId: accountId,
721 createdAt: now,
722 updatedAt: now,
723 });
724 yield* persistence.rows.sourceRecipes.upsert({
725 id: sharedRecipeId,
726 kind: "http_api",
727 importerKind: "openapi",
728 providerKey: "openapi:https://api.github.com",
729 name: "Github",
730 summary: null,
731 visibility: "workspace",
732 latestRevisionId: sharedRecipeRevisionId,
733 createdAt: now,
734 updatedAt: now,
735 });
736 yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
737 id: sharedRecipeRevisionId,
738 recipeId: sharedRecipeId,
739 revisionNumber: 1,
740 sourceConfigJson: JSON.stringify({
741 kind: "openapi",
742 endpoint: "https://api.github.com",
743 specUrl: "https://api.github.com/openapi.json",
744 }),
745 manifestJson: null,
746 manifestHash: null,
747 materializationHash: null,
748 createdAt: now,
749 updatedAt: now,
750 }));
751 yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({
752 recipeRevisionId: sharedRecipeRevisionId,
753 documents: [
754 {
755 id: "src_recipe_doc_shared_workspace",
756 recipeRevisionId: sharedRecipeRevisionId,
757 documentKind: "openapi",
758 documentKey: "https://api.github.com/openapi.json",
759 contentText: "{\"openapi\":\"3.1.0\"}",
760 contentHash: "doc_hash_shared_workspace",
761 fetchedAt: null,
762 createdAt: now,
763 updatedAt: now,
764 },
765 ],
766 });
767 yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
768 recipeRevisionId: sharedRecipeRevisionId,
769 bundles: [
770 {
771 id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_shared_workspace"),
772 recipeRevisionId: sharedRecipeRevisionId,
773 bundleKind: "json_schema_ref_map",
774 refsJson: "{}",
775 contentHash: "bundle_hash_shared_workspace",
776 createdAt: now,
777 updatedAt: now,
778 },
779 ],
780 });
781 yield* persistence.rows.sourceRecipeOperations.replaceForRevision({
782 recipeRevisionId: sharedRecipeRevisionId,
783 operations: [
784 {
785 id: "src_recipe_op_shared_workspace",
786 recipeRevisionId: sharedRecipeRevisionId,
787 operationKey: "users.getAuthenticated",
788 transportKind: "http",
789 toolId: "users.getAuthenticated",
790 title: "Get authenticated user",
791 description: null,
792 operationKind: "read",
793 searchText: "users get authenticated",
794 inputSchemaJson: null,
795 outputSchemaJson: null,
796 providerKind: "openapi",
797 providerDataJson: JSON.stringify({
798 kind: "openapi",
799 toolId: "users.getAuthenticated",
800 rawToolId: "users.getAuthenticated",
801 operationId: "users.getAuthenticated",
802 group: "users",
803 leaf: "getAuthenticated",
804 tags: ["users"],
805 method: "get",
806 path: "/user",
807 operationHash: "op_hash_shared_workspace",
808 invocation: {
809 method: "get",
810 pathTemplate: "/user",
811 pathParameterOrder: [],
812 serverUrl: "https://api.github.com",
813 parameters: [],
814 requestBody: null,
815 },
816 }),
817 createdAt: now,
818 updatedAt: now,
819 },
820 ],
821 });
822 yield* persistence.rows.sources.insert({
823 id: SourceIdSchema.make("src_shared_removed"),
824 workspaceId: removedWorkspaceId,
825 recipeId: sharedRecipeId,
826 recipeRevisionId: sharedRecipeRevisionId,
827 name: "Github Removed",
828 kind: "openapi",
829 endpoint: "https://api.github.com",
830 status: "connected",
831 enabled: true,
832 namespace: "github",
833 importAuthPolicy: "reuse_runtime",
834 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
835 sourceHash: null,
836 lastError: null,
837 createdAt: now,
838 updatedAt: now,
839 });
840 yield* persistence.rows.sources.insert({
841 id: SourceIdSchema.make("src_shared_remaining"),
842 workspaceId: remainingWorkspaceId,
843 recipeId: sharedRecipeId,
844 recipeRevisionId: sharedRecipeRevisionId,
845 name: "Github Remaining",
846 kind: "openapi",
847 endpoint: "https://api.github.com",
848 status: "connected",
849 enabled: true,
850 namespace: "github",
851 importAuthPolicy: "reuse_runtime",
852 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
853 sourceHash: null,
854 lastError: null,
855 createdAt: now,
856 updatedAt: now,
857 });
858
859 const removed = yield* persistence.rows.workspaces.removeById(removedWorkspaceId);
860 expect(removed).toBe(true);
861 expect(Option.isNone(yield* persistence.rows.workspaces.getById(removedWorkspaceId))).toBe(true);
862 expect(Option.isSome(yield* persistence.rows.workspaces.getById(remainingWorkspaceId))).toBe(true);
863 expect(
864 yield* persistence.rows.sources.listByWorkspaceId(remainingWorkspaceId),
865 ).toHaveLength(1);
866 expect(
867 Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId)),
868 ).toBe(true);
869 expect(
870 Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)),
871 ).toBe(true);
872 expect(
873 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId),
874 ).toHaveLength(1);
875 expect(
876 yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(sharedRecipeRevisionId),
877 ).toHaveLength(1);
878 expect(
879 yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId),
880 ).toHaveLength(1);
881 }),
882 );
883
462 it.scoped("deleting an organization removes workspace credential state and postgres secrets", () =>884 it.scoped("deleting an organization removes workspace credential state and postgres secrets", () =>
463 Effect.gen(function* () {885 Effect.gen(function* () {
464 const persistence = yield* makePersistence;886 const persistence = yield* makePersistence;
packages/control-plane/src/persistence/data-migrations.ts+234−0
@@ -0,0 +1,234 @@
1import {
2 type McpSourceAuthSessionData,
3 type SourceAuthSession,
4 type StoredSourceRecipeDocumentRecord,
5 type StoredSourceRecord,
6} from "#schema";
7import {
8 JsonObjectSchema,
9 McpSourceAuthSessionDataJsonSchema,
10} from "#schema";
11import * as Either from "effect/Either";
12import * as Effect from "effect/Effect";
13import * as ParseResult from "effect/ParseResult";
14import * as Schema from "effect/Schema";
15
16import { rebuildGraphqlRecipeRevisionContent } from "../runtime/source-adapters/graphql";
17import { rebuildOpenApiRecipeRevisionContent } from "../runtime/source-adapters/openapi";
18import type { SqlControlPlaneRows } from "./index";
19
20type DataMigration = {
21 id: string;
22 run: (rows: SqlControlPlaneRows) => Effect.Effect<void, Error, never>;
23};
24
25const LegacyMcpSourceAuthSessionDataSchema = Schema.Struct({
26 kind: Schema.Literal("mcp_oauth"),
27 endpoint: Schema.String,
28 redirectUri: Schema.String,
29 scope: Schema.NullOr(Schema.String),
30 resourceMetadataUrl: Schema.NullOr(Schema.String),
31 authorizationServerUrl: Schema.NullOr(Schema.String),
32 resourceMetadataJson: Schema.NullOr(Schema.String),
33 authorizationServerMetadataJson: Schema.NullOr(Schema.String),
34 clientInformationJson: Schema.NullOr(Schema.String),
35 codeVerifier: Schema.NullOr(Schema.String),
36 authorizationUrl: Schema.NullOr(Schema.String),
37});
38
39const decodeLegacyMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
40 Schema.parseJson(LegacyMcpSourceAuthSessionDataSchema),
41);
42
43const decodeMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
44 McpSourceAuthSessionDataJsonSchema,
45);
46
47const encodeMcpSourceAuthSessionDataJson = Schema.encodeSync(
48 McpSourceAuthSessionDataJsonSchema,
49);
50
51const decodeJsonObjectFromJson = Schema.decodeUnknownEither(
52 Schema.parseJson(JsonObjectSchema),
53);
54
55const decodeLegacyJsonObject = (value: string | null) => {
56 if (value === null) {
57 return null;
58 }
59
60 const decoded = decodeJsonObjectFromJson(value);
61 return Either.isRight(decoded) ? decoded.right : null;
62};
63
64const repairLegacyMcpSourceAuthSession = (input: {
65 rows: SqlControlPlaneRows;
66 session: SourceAuthSession;
67}): Effect.Effect<void, never, never> => {
68 if (input.session.providerKind !== "mcp_oauth") {
69 return Effect.void;
70 }
71
72 if (
73 Either.isRight(
74 decodeMcpSourceAuthSessionDataJson(input.session.sessionDataJson),
75 )
76 ) {
77 return Effect.void;
78 }
79
80 const legacy = decodeLegacyMcpSourceAuthSessionDataJson(
81 input.session.sessionDataJson,
82 );
83 if (Either.isLeft(legacy)) {
84 console.warn(
85 `Skipping source auth session data migration for ${input.session.id}: ${ParseResult.TreeFormatter.formatErrorSync(legacy.left)}`,
86 );
87 return Effect.void;
88 }
89
90 const repaired: McpSourceAuthSessionData = {
91 kind: "mcp_oauth",
92 endpoint: legacy.right.endpoint,
93 redirectUri: legacy.right.redirectUri,
94 scope: legacy.right.scope,
95 resourceMetadataUrl: legacy.right.resourceMetadataUrl,
96 authorizationServerUrl: legacy.right.authorizationServerUrl,
97 resourceMetadata: decodeLegacyJsonObject(legacy.right.resourceMetadataJson),
98 authorizationServerMetadata: decodeLegacyJsonObject(
99 legacy.right.authorizationServerMetadataJson,
100 ),
101 clientInformation: decodeLegacyJsonObject(
102 legacy.right.clientInformationJson,
103 ),
104 codeVerifier: legacy.right.codeVerifier,
105 authorizationUrl: legacy.right.authorizationUrl,
106 };
107
108 return input.rows.sourceAuthSessions
109 .update(input.session.id, {
110 sessionDataJson: encodeMcpSourceAuthSessionDataJson(repaired),
111 })
112 .pipe(Effect.asVoid, Effect.orDie);
113};
114
115const migrateLegacySourceAuthSessions: DataMigration = {
116 id: "20260310_repair_legacy_mcp_oauth_sessions",
117 run: (rows) =>
118 Effect.gen(function* () {
119 const sourceAuthSessions = yield* rows.sourceAuthSessions.listAll();
120 yield* Effect.forEach(
121 sourceAuthSessions,
122 (session) =>
123 repairLegacyMcpSourceAuthSession({
124 rows,
125 session,
126 }),
127 { discard: true },
128 );
129 }),
130};
131
132const rebuildPersistedSourceRecipes: DataMigration = {
133 id: "20260310_rebuild_persisted_source_recipes",
134 run: (rows) =>
135 Effect.gen(function* () {
136 const sourceRecords = yield* rows.sources.listAll();
137 if (sourceRecords.length === 0) {
138 return;
139 }
140
141 const revisionIds = [
142 ...new Set(sourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId)),
143 ];
144 const [revisions, documents] = yield* Effect.all([
145 rows.sourceRecipeRevisions.listByIds(revisionIds),
146 rows.sourceRecipeDocuments.listByRevisionIds(revisionIds),
147 ]);
148
149 const revisionById = new Map(
150 revisions.map((revision) => [revision.id, revision]),
151 );
152 const sourceRecordByRevisionId = new Map<string, StoredSourceRecord>();
153 for (const sourceRecord of sourceRecords) {
154 if (!sourceRecordByRevisionId.has(sourceRecord.recipeRevisionId)) {
155 sourceRecordByRevisionId.set(sourceRecord.recipeRevisionId, sourceRecord);
156 }
157 }
158
159 const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();
160 for (const document of documents) {
161 const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
162 existing.push(document);
163 documentsByRevisionId.set(document.recipeRevisionId, existing);
164 }
165
166 yield* Effect.forEach(
167 revisionIds,
168 (revisionId) =>
169 Effect.gen(function* () {
170 const sourceRecord = sourceRecordByRevisionId.get(revisionId);
171 const revision = revisionById.get(revisionId);
172 const documentsForRevision = documentsByRevisionId.get(revisionId) ?? [];
173 if (!sourceRecord || !revision) {
174 return;
175 }
176
177 if (sourceRecord.kind === "openapi") {
178 if (documentsForRevision.length === 0) {
179 return;
180 }
181 yield* rebuildOpenApiRecipeRevisionContent({
182 rows,
183 sourceRecord,
184 revision,
185 documents: documentsForRevision,
186 });
187 return;
188 }
189
190 if (sourceRecord.kind !== "graphql") {
191 return;
192 }
193
194 if (documentsForRevision.length === 0) {
195 return;
196 }
197
198 yield* rebuildGraphqlRecipeRevisionContent({
199 rows,
200 sourceRecord,
201 revision,
202 documents: documentsForRevision,
203 });
204 }),
205 { discard: true },
206 );
207 }),
208};
209
210const dataMigrations = [
211 rebuildPersistedSourceRecipes,
212 migrateLegacySourceAuthSessions,
213] as const satisfies readonly DataMigration[];
214
215export const runDataMigrations = (
216 rows: SqlControlPlaneRows,
217): Effect.Effect<void, Error, never> =>
218 Effect.gen(function* () {
219 const applied = new Set(
220 (yield* rows.dataMigrations.listAll()).map((migration) => migration.id),
221 );
222
223 for (const migration of dataMigrations) {
224 if (applied.has(migration.id)) {
225 continue;
226 }
227
228 yield* migration.run(rows);
229 yield* rows.dataMigrations.upsert({
230 id: migration.id,
231 appliedAt: Date.now(),
232 });
233 }
234 });
packages/control-plane/src/persistence/index.ts+19−9
@@ -7,6 +7,7 @@ import { createDrizzleClient, type DrizzleClient } from "./client"; · 6 unmodified lines
7import {7import {
8 createAccountsRepo,8 createAccountsRepo,
9 createCredentialsRepo,9 createCredentialsRepo,
10 createDataMigrationsRepo,
10 createExecutionInteractionsRepo,11 createExecutionInteractionsRepo,
11 createExecutionsRepo,12 createExecutionsRepo,
12 createLocalInstallationsRepo,13 createLocalInstallationsRepo,
@@ -17,11 +18,11 @@ import { · 4 unmodified lines
17 createSourceAuthSessionsRepo,18 createSourceAuthSessionsRepo,
18 createSourceOauthClientsRepo,19 createSourceOauthClientsRepo,
19 createSourceRecipeDocumentsRepo,20 createSourceRecipeDocumentsRepo,
21 createSourceRecipeSchemaBundlesRepo,
20 createSourceRecipeOperationsRepo,22 createSourceRecipeOperationsRepo,
21 createSourceRecipeRevisionsRepo,23 createSourceRecipeRevisionsRepo,
22 createSourceRecipesRepo,24 createSourceRecipesRepo,
23 createSourcesRepo,25 createSourcesRepo,
24 createToolArtifactsRepo,
25 createWorkspacesRepo,26 createWorkspacesRepo,
26} from "./repos";27} from "./repos";
27import { drizzleSchema, tableNames, type DrizzleTables } from "./schema";28import { drizzleSchema, tableNames, type DrizzleTables } from "./schema";
@@ -33,7 +34,7 @@ import { · 5 unmodified lines
33 type SqlBackend,34 type SqlBackend,
34 type SqlRuntime,35 type SqlRuntime,
35} from "./sql-runtime";36} from "./sql-runtime";
36import { runPostMigrationRepairs } from "./post-migrations";37import { runDataMigrations } from "./data-migrations";
37 38
38export { tableNames, type DrizzleTables } from "./schema";39export { tableNames, type DrizzleTables } from "./schema";
39export {40export {
@@ -60,10 +61,11 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 20 unmodified lines
60 sourceRecipes: createSourceRecipesRepo(client, tables),61 sourceRecipes: createSourceRecipesRepo(client, tables),
61 sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables),62 sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables),
62 sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables),63 sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables),
64 sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables),
63 sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),65 sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),
66 dataMigrations: createDataMigrationsRepo(client, tables),
64 credentials: createCredentialsRepo(client, tables),67 credentials: createCredentialsRepo(client, tables),
65 sourceOauthClients: createSourceOauthClientsRepo(client, tables),68 sourceOauthClients: createSourceOauthClientsRepo(client, tables),
66 toolArtifacts: createToolArtifactsRepo(client, tables),
67 secretMaterials: createSecretMaterialsRepo(client, tables),69 secretMaterials: createSecretMaterialsRepo(client, tables),
68 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),70 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),
69 policies: createPoliciesRepo(client, tables),71 policies: createPoliciesRepo(client, tables),
@@ -126,8 +128,14 @@ const closeRuntimeEffect = (runtime: SqlRuntime) => · 56 unmodified lines
126 cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")),128 cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")),
127 }).pipe(Effect.orDie);129 }).pipe(Effect.orDie);
128 130
131export type CreateSqlControlPlanePersistenceOptions =
132 & CreateSqlRuntimeOptions
133 & {
134 runDataMigrations?: boolean;
135 };
136
129export const createSqlControlPlanePersistence = (137export const createSqlControlPlanePersistence = (
130 options: CreateSqlRuntimeOptions,138 options: CreateSqlControlPlanePersistenceOptions,
131): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> =>139): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> =>
132 Effect.flatMap(createRuntimeEffect(options), (runtime) =>140 Effect.flatMap(createRuntimeEffect(options), (runtime) =>
133 runMigrationsEffect(runtime, options.migrationsFolder).pipe(141 runMigrationsEffect(runtime, options.migrationsFolder).pipe(
@@ -146,10 +154,12 @@ export const createSqlControlPlanePersistence = ( · 12 unmodified lines
146 } satisfies SqlControlPlanePersistence;154 } satisfies SqlControlPlanePersistence;
147 }),155 }),
148 Effect.flatMap((persistence) =>156 Effect.flatMap((persistence) =>
149 runPostMigrationRepairs(persistence.rows).pipe(157 options.runDataMigrations === false
150 Effect.mapError(toBootstrapError),158 ? Effect.succeed(persistence)
151 Effect.map(() => persistence),159 : runDataMigrations(persistence.rows).pipe(
152 )),160 Effect.mapError(toBootstrapError),
161 Effect.map(() => persistence),
162 )),
153 Effect.catchAll((error) =>163 Effect.catchAll((error) =>
154 closeRuntimeEffect(runtime).pipe(164 closeRuntimeEffect(runtime).pipe(
155 Effect.zipRight(Effect.fail(error)),165 Effect.zipRight(Effect.fail(error)),
@@ -157,7 +167,7 @@ export const createSqlControlPlanePersistence = ( · 1 unmodified line
157 ));167 ));
158 168
159export const SqlControlPlanePersistenceLive = (169export const SqlControlPlanePersistenceLive = (
160 options: CreateSqlRuntimeOptions,170 options: CreateSqlControlPlanePersistenceOptions,
161) =>171) =>
162 Layer.scoped(172 Layer.scoped(
163 SqlControlPlanePersistenceService,173 SqlControlPlanePersistenceService,
packages/control-plane/src/persistence/post-migrations.test.ts+464−73
@@ -1,3 +1,8 @@
1import { cpSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2import { tmpdir } from "node:os";
3import path from "node:path";
4import { fileURLToPath } from "node:url";
5
1import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
2import {7import {
3 AccountIdSchema,8 AccountIdSchema,
@@ -13,29 +18,88 @@ import * as Effect from "effect/Effect"; · 9 unmodified lines
13import * as Option from "effect/Option";18import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";19import * as Schema from "effect/Schema";
15import * as Scope from "effect/Scope";20import * as Scope from "effect/Scope";
16import {21import { buildSchema, getIntrospectionQuery, graphqlSync } from "graphql";
17 buildSchema,22import { sql } from "drizzle-orm";
18 getIntrospectionQuery,
19 graphqlSync,
20} from "graphql";
21 23
22import {24import {
23 createSqlControlPlanePersistence,25 createSqlControlPlanePersistence,
24 type SqlControlPlanePersistence,26 type SqlControlPlanePersistence,
25} from "./index";27} from "./index";
26import { runPostMigrationRepairs } from "./post-migrations";28import { runDataMigrations } from "./data-migrations";
29import { loadSourceById } from "../runtime/source-store";
27 30
28const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =31const openApiBindingConfigJson = (specUrl: string): string =>
29 Effect.acquireRelease(32 JSON.stringify({
30 createSqlControlPlanePersistence({33 adapterKey: "openapi",
31 localDataDir: ":memory:",34 specUrl,
32 }),35 defaultHeaders: null,
33 (persistence) =>36 });
34 Effect.tryPromise({37
35 try: () => persistence.close(),38const graphqlBindingConfigJson = (): string =>
36 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),39 JSON.stringify({
37 }).pipe(Effect.orDie),40 adapterKey: "graphql",
38 );41 defaultHeaders: null,
42 });
43
44const baseRevisionRecord = (input: {
45 id: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;
46 recipeId: ReturnType<typeof SourceRecipeIdSchema.make>;
47 revisionNumber: number;
48 sourceConfigJson: string;
49 manifestJson?: string | null;
50 manifestHash?: string | null;
51 materializationHash?: string | null;
52 createdAt: number;
53 updatedAt: number;
54}) => ({
55 id: input.id,
56 recipeId: input.recipeId,
57 revisionNumber: input.revisionNumber,
58 sourceConfigJson: input.sourceConfigJson,
59 manifestJson: input.manifestJson ?? null,
60 manifestHash: input.manifestHash ?? null,
61 materializationHash: input.materializationHash ?? null,
62 createdAt: input.createdAt,
63 updatedAt: input.updatedAt,
64});
65
66const drizzleDir = path.resolve(
67 path.dirname(fileURLToPath(import.meta.url)),
68 "../../drizzle",
69);
70
71const copyMigrationsBefore = (cutoffDirectoryName: string): string => {
72 const targetDir = mkdtempSync(path.join(tmpdir(), "executor-cp-migrations-"));
73 for (const directoryName of readdirSync(drizzleDir).sort()) {
74 if (directoryName >= cutoffDirectoryName) {
75 continue;
76 }
77
78 cpSync(
79 path.join(drizzleDir, directoryName),
80 path.join(targetDir, directoryName),
81 { recursive: true },
82 );
83 }
84
85 return targetDir;
86};
87
88const makePersistence: Effect.Effect<
89 SqlControlPlanePersistence,
90 unknown,
91 Scope.Scope
92> = Effect.acquireRelease(
93 createSqlControlPlanePersistence({
94 localDataDir: ":memory:",
95 }),
96 (persistence) =>
97 Effect.tryPromise({
98 try: () => persistence.close(),
99 catch: (cause) =>
100 cause instanceof Error ? cause : new Error(String(cause)),
101 }).pipe(Effect.orDie),
102);
39 103
40const seedMigratedSourceRecipe = (input: {104const seedMigratedSourceRecipe = (input: {
41 persistence: SqlControlPlanePersistence;105 persistence: SqlControlPlanePersistence;
@@ -43,15 +107,21 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line
43 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;107 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
44 sourceId: ReturnType<typeof SourceIdSchema.make>;108 sourceId: ReturnType<typeof SourceIdSchema.make>;
45 documentText: string;109 documentText: string;
46}): Effect.Effect<{110}): Effect.Effect<
47 recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;111 {
48}, unknown, never> =>112 recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;
113 },
114 unknown,
115 never
116> =>
49 Effect.gen(function* () {117 Effect.gen(function* () {
50 const now = Date.now();118 const now = Date.now();
51 const accountId = AccountIdSchema.make(`acc_${input.sourceId}`);119 const accountId = AccountIdSchema.make(`acc_${input.sourceId}`);
52 const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`);120 const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`);
53 const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);121 const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);
54 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`);122 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(
123 `src_recipe_rev_${input.sourceId}`,
124 );
55 125
56 yield* input.persistence.rows.organizations.insert({126 yield* input.persistence.rows.organizations.insert({
57 id: organizationId,127 id: organizationId,
@@ -72,9 +142,11 @@ const seedMigratedSourceRecipe = (input: { · 14 unmodified lines
72 });142 });
73 yield* input.persistence.rows.sourceRecipes.upsert({143 yield* input.persistence.rows.sourceRecipes.upsert({
74 id: recipeId,144 id: recipeId,
75 kind: input.kind === "openapi" ? "http_recipe" : "graphql_recipe",145 kind: "http_api",
76 importerKind: input.kind === "openapi" ? "openapi" : "graphql_introspection",146 importerKind:
77 providerKey: input.kind === "openapi" ? "generic_http" : "generic_graphql",147 input.kind === "openapi" ? "openapi" : "graphql_introspection",
148 providerKey:
149 input.kind === "openapi" ? "generic_http" : "generic_graphql",
78 name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",150 name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",
79 summary: null,151 summary: null,
80 visibility: "workspace",152 visibility: "workspace",
@@ -82,7 +154,7 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line
82 createdAt: now,154 createdAt: now,
83 updatedAt: now,155 updatedAt: now,
84 });156 });
85 yield* input.persistence.rows.sourceRecipeRevisions.upsert({157 yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
86 id: recipeRevisionId,158 id: recipeRevisionId,
87 recipeId,159 recipeId,
88 revisionNumber: 1,160 revisionNumber: 1,
@@ -102,9 +174,10 @@ const seedMigratedSourceRecipe = (input: { · 13 unmodified lines
102 ),174 ),
103 manifestJson: null,175 manifestJson: null,
104 manifestHash: null,176 manifestHash: null,
177 materializationHash: null,
105 createdAt: now,178 createdAt: now,
106 updatedAt: now,179 updatedAt: now,
107 });180 }));
108 yield* input.persistence.rows.sources.insert({181 yield* input.persistence.rows.sources.insert({
109 id: input.sourceId,182 id: input.sourceId,
110 workspaceId: input.workspaceId,183 workspaceId: input.workspaceId,
@@ -119,45 +192,47 @@ const seedMigratedSourceRecipe = (input: { · 8 unmodified lines
119 status: "connected",192 status: "connected",
120 enabled: true,193 enabled: true,
121 namespace: input.kind === "openapi" ? "github" : "graphql",194 namespace: input.kind === "openapi" ? "github" : "graphql",
122 transport: null,195 importAuthPolicy: "reuse_runtime",
123 bindingConfigJson: null,196 bindingConfigJson:
124 queryParamsJson: null,197 input.kind === "openapi"
125 headersJson: null,198 ? openApiBindingConfigJson("https://api.example.com/openapi.json")
126 specUrl: input.kind === "openapi" ? "https://api.example.com/openapi.json" : null,199 : graphqlBindingConfigJson(),
127 defaultHeadersJson: null,
128 sourceHash: null,200 sourceHash: null,
129 sourceDocumentText: null,
130 lastError: null,201 lastError: null,
131 createdAt: now,202 createdAt: now,
132 updatedAt: now,203 updatedAt: now,
133 });204 });
134 yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({205 yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({
135 recipeRevisionId,206 recipeRevisionId,
136 documents: [{207 documents: [
137 id: `src_recipe_doc_${input.sourceId}`,208 {
138 recipeRevisionId,209 id: `src_recipe_doc_${input.sourceId}`,
139 documentKind: input.kind === "openapi" ? "openapi" : "graphql_introspection",210 recipeRevisionId,
140 documentKey:211 documentKind:
141 input.kind === "openapi"212 input.kind === "openapi" ? "openapi" : "graphql_introspection",
142 ? "https://api.example.com/openapi.json"213 documentKey:
143 : "https://api.example.com/graphql",214 input.kind === "openapi"
144 contentText: input.documentText,215 ? "https://api.example.com/openapi.json"
145 contentHash: `hash_${input.sourceId}`,216 : "https://api.example.com/graphql",
146 fetchedAt: now,217 contentText: input.documentText,
147 createdAt: now,218 contentHash: `hash_${input.sourceId}`,
148 updatedAt: now,219 fetchedAt: now,
149 }],220 createdAt: now,
221 updatedAt: now,
222 },
223 ],
150 });224 });
151 225
152 return { recipeRevisionId };226 return { recipeRevisionId };
153 });227 });
154 228
155describe("post-migrations", () => {229describe("data-migrations", () => {
156 it.scoped("repairs migrated OpenAPI recipes from stored documents", () =>230 it.scoped("repairs migrated OpenAPI recipes from stored documents", () =>
157 Effect.gen(function* () {231 Effect.gen(function* () {
158 const persistence = yield* makePersistence;232 const persistence = yield* makePersistence;
159 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi");233 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi");
160 const sourceId = SourceIdSchema.make("src_post_migration_openapi");234 const sourceId = SourceIdSchema.make("src_post_migration_openapi");
235 yield* persistence.rows.dataMigrations.clearAll();
161 236
162 const openApiDocument = JSON.stringify({237 const openApiDocument = JSON.stringify({
163 openapi: "3.0.3",238 openapi: "3.0.3",
@@ -189,11 +264,7 @@ describe("post-migrations", () => { · 25 unmodified lines
189 content: {264 content: {
190 "application/json": {265 "application/json": {
191 schema: {266 schema: {
192 type: "object",267 $ref: "#/components/schemas/Repository",
193 properties: {
194 full_name: { type: "string" },
195 },
196 required: ["full_name"],
197 },268 },
198 },269 },
199 },270 },
@@ -202,6 +273,17 @@ describe("post-migrations", () => { · 2 unmodified lines
202 },273 },
203 },274 },
204 },275 },
276 components: {
277 schemas: {
278 Repository: {
279 type: "object",
280 properties: {
281 full_name: { type: "string" },
282 },
283 required: ["full_name"],
284 },
285 },
286 },
205 });287 });
206 288
207 const { recipeRevisionId } = yield* seedMigratedSourceRecipe({289 const { recipeRevisionId } = yield* seedMigratedSourceRecipe({
@@ -212,13 +294,22 @@ describe("post-migrations", () => { · 4 unmodified lines
212 documentText: openApiDocument,294 documentText: openApiDocument,
213 });295 });
214 296
215 yield* runPostMigrationRepairs(persistence.rows);297 yield* runDataMigrations(persistence.rows);
216 298
217 const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);299 const revision =
300 yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
218 expect(Option.isSome(revision)).toBe(true);301 expect(Option.isSome(revision)).toBe(true);
219 expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();302 expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();
220 expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length)303 expect(
221 .toBeGreaterThan(0);304 (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(
305 recipeRevisionId,
306 )).length,
307 ).toBe(1);
308 expect(
309 (yield* persistence.rows.sourceRecipeOperations.listByRevisionId(
310 recipeRevisionId,
311 )).length,
312 ).toBeGreaterThan(0);
222 }),313 }),
223 );314 );
224 315
@@ -227,6 +318,7 @@ describe("post-migrations", () => { · 2 unmodified lines
227 const persistence = yield* makePersistence;318 const persistence = yield* makePersistence;
228 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql");319 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql");
229 const sourceId = SourceIdSchema.make("src_post_migration_graphql");320 const sourceId = SourceIdSchema.make("src_post_migration_graphql");
321 yield* persistence.rows.dataMigrations.clearAll();
230 const schema = buildSchema(`322 const schema = buildSchema(`
231 type Query {323 type Query {
232 viewer: User!324 viewer: User!
@@ -260,13 +352,22 @@ describe("post-migrations", () => { · 27 unmodified lines
260 documentText: graphqlDocument,352 documentText: graphqlDocument,
261 });353 });
262 354
263 yield* runPostMigrationRepairs(persistence.rows);355 yield* runDataMigrations(persistence.rows);
264 356
265 const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);357 const revision =
358 yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
266 expect(Option.isSome(revision)).toBe(true);359 expect(Option.isSome(revision)).toBe(true);
267 expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();360 expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();
268 expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length)361 expect(
269 .toBeGreaterThan(0);362 (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(
363 recipeRevisionId,
364 )).length,
365 ).toBe(1);
366 expect(
367 (yield* persistence.rows.sourceRecipeOperations.listByRevisionId(
368 recipeRevisionId,
369 )).length,
370 ).toBeGreaterThan(0);
270 }),371 }),
271 );372 );
272 373
@@ -275,7 +376,10 @@ describe("post-migrations", () => { · 2 unmodified lines
275 const persistence = yield* makePersistence;376 const persistence = yield* makePersistence;
276 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_session");377 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_session");
277 const sourceId = SourceIdSchema.make("src_post_migration_session");378 const sourceId = SourceIdSchema.make("src_post_migration_session");
278 const sessionId = SourceAuthSessionIdSchema.make("src_auth_post_migration_session");379 yield* persistence.rows.dataMigrations.clearAll();
380 const sessionId = SourceAuthSessionIdSchema.make(
381 "src_auth_post_migration_session",
382 );
279 const now = Date.now();383 const now = Date.now();
280 384
281 yield* persistence.rows.sourceAuthSessions.insert({385 yield* persistence.rows.sourceAuthSessions.insert({
@@ -283,20 +387,23 @@ describe("post-migrations", () => { · 1 unmodified line
283 workspaceId,387 workspaceId,
284 sourceId,388 sourceId,
285 actorAccountId: null,389 actorAccountId: null,
286 executionId: null,390 executionId: null,
287 interactionId: null,391 interactionId: null,
288 providerKind: "mcp_oauth",392 providerKind: "mcp_oauth",
289 status: "pending",393 credentialSlot: "runtime",
394 status: "pending",
290 state: "state_post_migration_session",395 state: "state_post_migration_session",
291 sessionDataJson: JSON.stringify({396 sessionDataJson: JSON.stringify({
292 kind: "mcp_oauth",397 kind: "mcp_oauth",
293 endpoint: "https://api.github.com",398 endpoint: "https://api.github.com",
294 redirectUri: "http://127.0.0.1/callback",399 redirectUri: "http://127.0.0.1/callback",
295 scope: null,400 scope: null,
296 resourceMetadataUrl: "https://api.github.com/.well-known/oauth-protected-resource",401 resourceMetadataUrl:
402 "https://api.github.com/.well-known/oauth-protected-resource",
297 authorizationServerUrl: "https://github.com/login/oauth",403 authorizationServerUrl: "https://github.com/login/oauth",
298 resourceMetadataJson: '{"issuer":"https://api.github.com"}',404 resourceMetadataJson: '{"issuer":"https://api.github.com"}',
299 authorizationServerMetadataJson: '{"token_endpoint":"https://github.com/login/oauth/access_token"}',405 authorizationServerMetadataJson:
406 '{"token_endpoint":"https://github.com/login/oauth/access_token"}',
300 clientInformationJson: '{"client_id":"abc123"}',407 clientInformationJson: '{"client_id":"abc123"}',
301 codeVerifier: "verifier",408 codeVerifier: "verifier",
302 authorizationUrl: "https://github.com/login/oauth/authorize",409 authorizationUrl: "https://github.com/login/oauth/authorize",
@@ -307,15 +414,16 @@ describe("post-migrations", () => { · 4 unmodified lines
307 updatedAt: now,414 updatedAt: now,
308 });415 });
309 416
310 yield* runPostMigrationRepairs(persistence.rows);417 yield* runDataMigrations(persistence.rows);
311 418
312 const session = yield* persistence.rows.sourceAuthSessions.getById(sessionId);419 const session =
420 yield* persistence.rows.sourceAuthSessions.getById(sessionId);
313 expect(Option.isSome(session)).toBe(true);421 expect(Option.isSome(session)).toBe(true);
314 const repairedSession = session.pipe(Option.getOrNull);422 const repairedSession = session.pipe(Option.getOrNull);
315 expect(repairedSession).not.toBeNull();423 expect(repairedSession).not.toBeNull();
316 const repaired = Schema.decodeUnknownSync(McpSourceAuthSessionDataJsonSchema)(424 const repaired = Schema.decodeUnknownSync(
317 repairedSession!.sessionDataJson,425 McpSourceAuthSessionDataJsonSchema,
318 );426 )(repairedSession!.sessionDataJson);
319 expect(repaired.resourceMetadata).toEqual({427 expect(repaired.resourceMetadata).toEqual({
320 issuer: "https://api.github.com",428 issuer: "https://api.github.com",
321 });429 });
@@ -327,4 +435,287 @@ describe("post-migrations", () => { · 5 unmodified lines
327 });435 });
328 }),436 }),
329 );437 );
438
439 it("upgrades legacy source binding columns into adapter-owned binding config", async () => {
440 const localDataDir = mkdtempSync(path.join(tmpdir(), "executor-cp-db-"));
441 const legacyMigrationsDir = copyMigrationsBefore(
442 "20260311010000_source_binding_configs",
443 );
444 const workspaceId = WorkspaceIdSchema.make("ws_legacy_source_bindings");
445 const accountId = AccountIdSchema.make("acc_legacy_source_bindings");
446 const organizationId = OrganizationIdSchema.make("org_legacy_source_bindings");
447 const now = Date.now();
448 let legacyPersistence: SqlControlPlanePersistence | null = null;
449 let upgradedPersistence: SqlControlPlanePersistence | null = null;
450
451 try {
452 legacyPersistence = await Effect.runPromise(
453 createSqlControlPlanePersistence({
454 localDataDir,
455 migrationsFolder: legacyMigrationsDir,
456 runDataMigrations: false,
457 }),
458 );
459
460 await Effect.runPromise(legacyPersistence.rows.organizations.insert({
461 id: organizationId,
462 slug: "org-legacy-source-bindings",
463 name: "Legacy Org",
464 status: "active",
465 createdByAccountId: accountId,
466 createdAt: now,
467 updatedAt: now,
468 }));
469 await Effect.runPromise(legacyPersistence.rows.workspaces.insert({
470 id: workspaceId,
471 organizationId,
472 name: "Legacy Workspace",
473 createdByAccountId: accountId,
474 createdAt: now,
475 updatedAt: now,
476 }));
477
478 const seedRecipe = async (input: {
479 sourceId: string;
480 kind: "openapi" | "graphql" | "mcp";
481 importerKind: string;
482 providerKey: string;
483 name: string;
484 endpoint: string;
485 sourceConfigJson: string;
486 }) => {
487 const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`);
488 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(
489 `src_recipe_rev_${input.sourceId}`,
490 );
491
492 await Effect.runPromise(legacyPersistence!.rows.sourceRecipes.upsert({
493 id: recipeId,
494 kind: input.kind === "mcp" ? "mcp" : "http_api",
495 importerKind: input.importerKind,
496 providerKey: input.providerKey,
497 name: input.name,
498 summary: null,
499 visibility: "workspace",
500 latestRevisionId: recipeRevisionId,
501 createdAt: now,
502 updatedAt: now,
503 }));
504 await legacyPersistence!.db.execute(sql`
505 INSERT INTO "source_recipe_revisions" (
506 "id",
507 "recipe_id",
508 "revision_number",
509 "source_config_json",
510 "manifest_json",
511 "manifest_hash",
512 "created_at",
513 "updated_at"
514 ) VALUES (
515 ${recipeRevisionId},
516 ${recipeId},
517 1,
518 ${input.sourceConfigJson},
519 NULL,
520 NULL,
521 ${now},
522 ${now}
523 )
524 `);
525
526 return { recipeId, recipeRevisionId };
527 };
528
529 const openapi = await seedRecipe({
530 sourceId: "src_legacy_openapi",
531 kind: "openapi",
532 importerKind: "openapi",
533 providerKey: "generic_http",
534 name: "Legacy OpenAPI",
535 endpoint: "https://api.example.com",
536 sourceConfigJson: JSON.stringify({
537 kind: "openapi",
538 endpoint: "https://api.example.com",
539 specUrl: "https://api.example.com/openapi.json",
540 defaultHeaders: { accept: "application/json" },
541 }),
542 });
543 const graphql = await seedRecipe({
544 sourceId: "src_legacy_graphql",
545 kind: "graphql",
546 importerKind: "graphql_introspection",
547 providerKey: "generic_graphql",
548 name: "Legacy GraphQL",
549 endpoint: "https://api.example.com/graphql",
550 sourceConfigJson: JSON.stringify({
551 kind: "graphql",
552 endpoint: "https://api.example.com/graphql",
553 defaultHeaders: { accept: "application/json" },
554 }),
555 });
556 const mcp = await seedRecipe({
557 sourceId: "src_legacy_mcp",
558 kind: "mcp",
559 importerKind: "mcp_manifest",
560 providerKey: "generic_mcp",
561 name: "Legacy MCP",
562 endpoint: "https://api.example.com/mcp",
563 sourceConfigJson: JSON.stringify({
564 kind: "mcp",
565 endpoint: "https://api.example.com/mcp",
566 transport: "streamable-http",
567 queryParams: { tenant: "acme" },
568 headers: { "x-tenant": "acme" },
569 }),
570 });
571
572 await legacyPersistence.db.execute(sql`
573 INSERT INTO "sources" (
574 "workspace_id",
575 "source_id",
576 "recipe_id",
577 "recipe_revision_id",
578 "name",
579 "kind",
580 "endpoint",
581 "status",
582 "enabled",
583 "namespace",
584 "binding_config_json",
585 "transport",
586 "query_params_json",
587 "headers_json",
588 "spec_url",
589 "default_headers_json",
590 "source_hash",
591 "source_document_text",
592 "last_error",
593 "created_at",
594 "updated_at"
595 ) VALUES
596 (
597 ${workspaceId},
598 ${SourceIdSchema.make("src_legacy_openapi")},
599 ${openapi.recipeId},
600 ${openapi.recipeRevisionId},
601 'Legacy OpenAPI',
602 'openapi',
603 'https://api.example.com',
604 'connected',
605 true,
606 'legacy.openapi',
607 NULL,
608 NULL,
609 NULL,
610 NULL,
611 'https://api.example.com/openapi.json',
612 '{"accept":"application/json"}',
613 NULL,
614 NULL,
615 NULL,
616 ${now},
617 ${now}
618 ),
619 (
620 ${workspaceId},
621 ${SourceIdSchema.make("src_legacy_graphql")},
622 ${graphql.recipeId},
623 ${graphql.recipeRevisionId},
624 'Legacy GraphQL',
625 'graphql',
626 'https://api.example.com/graphql',
627 'connected',
628 true,
629 'legacy.graphql',
630 NULL,
631 NULL,
632 NULL,
633 NULL,
634 NULL,
635 '{"accept":"application/json"}',
636 NULL,
637 NULL,
638 NULL,
639 ${now},
640 ${now}
641 ),
642 (
643 ${workspaceId},
644 ${SourceIdSchema.make("src_legacy_mcp")},
645 ${mcp.recipeId},
646 ${mcp.recipeRevisionId},
647 'Legacy MCP',
648 'mcp',
649 'https://api.example.com/mcp',
650 'connected',
651 true,
652 'legacy.mcp',
653 NULL,
654 'streamable-http',
655 '{"tenant":"acme"}',
656 '{"x-tenant":"acme"}',
657 NULL,
658 NULL,
659 NULL,
660 NULL,
661 NULL,
662 ${now},
663 ${now}
664 );
665 `);
666
667 await legacyPersistence.close();
668 legacyPersistence = null;
669
670 upgradedPersistence = await Effect.runPromise(
671 createSqlControlPlanePersistence({
672 localDataDir,
673 }),
674 );
675
676 const openApiSource = await Effect.runPromise(loadSourceById(
677 upgradedPersistence.rows,
678 {
679 workspaceId,
680 sourceId: SourceIdSchema.make("src_legacy_openapi"),
681 },
682 ));
683 expect(openApiSource.specUrl).toBe("https://api.example.com/openapi.json");
684 expect(openApiSource.defaultHeaders).toEqual({
685 accept: "application/json",
686 });
687
688 const graphqlSource = await Effect.runPromise(loadSourceById(
689 upgradedPersistence.rows,
690 {
691 workspaceId,
692 sourceId: SourceIdSchema.make("src_legacy_graphql"),
693 },
694 ));
695 expect(graphqlSource.defaultHeaders).toEqual({
696 accept: "application/json",
697 });
698 expect(graphqlSource.specUrl).toBeNull();
699
700 const mcpSource = await Effect.runPromise(loadSourceById(
701 upgradedPersistence.rows,
702 {
703 workspaceId,
704 sourceId: SourceIdSchema.make("src_legacy_mcp"),
705 },
706 ));
707 expect(mcpSource.transport).toBe("streamable-http");
708 expect(mcpSource.queryParams).toEqual({
709 tenant: "acme",
710 });
711 expect(mcpSource.headers).toEqual({
712 "x-tenant": "acme",
713 });
714 } finally {
715 await legacyPersistence?.close().catch(() => undefined);
716 await upgradedPersistence?.close().catch(() => undefined);
717 rmSync(localDataDir, { recursive: true, force: true });
718 rmSync(legacyMigrationsDir, { recursive: true, force: true });
719 }
720 });
330});721});
packages/control-plane/src/persistence/post-migrations.ts+0−415
@@ -1,415 +0,0 @@
1import { randomUUID } from "node:crypto";
2
3import {
4 buildOpenApiToolPresentation,
5 compileOpenApiToolDefinitions,
6 extractOpenApiManifest,
7} from "@executor/codemode-openapi";
8import type {
9 McpSourceAuthSessionData,
10 StoredSourceRecipeDocumentRecord,
11 StoredSourceRecipeOperationRecord,
12 StoredSourceRecipeRevisionRecord,
13 StoredSourceRecord,
14 SourceAuthSession,
15} from "#schema";
16import {
17 JsonObjectSchema,
18 McpSourceAuthSessionDataJsonSchema,
19} from "#schema";
20import * as Either from "effect/Either";
21import * as Effect from "effect/Effect";
22import * as ParseResult from "effect/ParseResult";
23import * as Schema from "effect/Schema";
24
25import {
26 buildGraphqlToolPresentation,
27 compileGraphqlToolDefinitions,
28 extractGraphqlManifest,
29} from "../runtime/graphql-tools";
30import type { SqlControlPlaneRows } from "./index";
31
32const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
33 parts
34 .flatMap((part) => (part ? [part.trim()] : []))
35 .filter(Boolean)
36 .join(" ")
37 .toLowerCase();
38
39const LegacyMcpSourceAuthSessionDataSchema = Schema.Struct({
40 kind: Schema.Literal("mcp_oauth"),
41 endpoint: Schema.String,
42 redirectUri: Schema.String,
43 scope: Schema.NullOr(Schema.String),
44 resourceMetadataUrl: Schema.NullOr(Schema.String),
45 authorizationServerUrl: Schema.NullOr(Schema.String),
46 resourceMetadataJson: Schema.NullOr(Schema.String),
47 authorizationServerMetadataJson: Schema.NullOr(Schema.String),
48 clientInformationJson: Schema.NullOr(Schema.String),
49 codeVerifier: Schema.NullOr(Schema.String),
50 authorizationUrl: Schema.NullOr(Schema.String),
51});
52
53const decodeLegacyMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
54 Schema.parseJson(LegacyMcpSourceAuthSessionDataSchema),
55);
56
57const decodeMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
58 McpSourceAuthSessionDataJsonSchema,
59);
60
61const encodeMcpSourceAuthSessionDataJson = Schema.encodeSync(
62 McpSourceAuthSessionDataJsonSchema,
63);
64
65const decodeJsonObjectFromJson = Schema.decodeUnknownEither(
66 Schema.parseJson(JsonObjectSchema),
67);
68
69const decodeLegacyJsonObject = (value: string | null) => {
70 if (value === null) {
71 return null;
72 }
73
74 const decoded = decodeJsonObjectFromJson(value);
75 return Either.isRight(decoded) ? decoded.right : null;
76};
77
78const repairLegacyMcpSourceAuthSession = (input: {
79 rows: SqlControlPlaneRows;
80 session: SourceAuthSession;
81}): Effect.Effect<void, never, never> => {
82 if (input.session.providerKind !== "mcp_oauth") {
83 return Effect.void;
84 }
85
86 if (Either.isRight(decodeMcpSourceAuthSessionDataJson(input.session.sessionDataJson))) {
87 return Effect.void;
88 }
89
90 const legacy = decodeLegacyMcpSourceAuthSessionDataJson(input.session.sessionDataJson);
91 if (Either.isLeft(legacy)) {
92 console.warn(
93 `Skipping source auth session repair for ${input.session.id}: ${ParseResult.TreeFormatter.formatErrorSync(legacy.left)}`,
94 );
95 return Effect.void;
96 }
97
98 const repaired: McpSourceAuthSessionData = {
99 kind: "mcp_oauth",
100 endpoint: legacy.right.endpoint,
101 redirectUri: legacy.right.redirectUri,
102 scope: legacy.right.scope,
103 resourceMetadataUrl: legacy.right.resourceMetadataUrl,
104 authorizationServerUrl: legacy.right.authorizationServerUrl,
105 resourceMetadata: decodeLegacyJsonObject(legacy.right.resourceMetadataJson),
106 authorizationServerMetadata: decodeLegacyJsonObject(
107 legacy.right.authorizationServerMetadataJson,
108 ),
109 clientInformation: decodeLegacyJsonObject(legacy.right.clientInformationJson),
110 codeVerifier: legacy.right.codeVerifier,
111 authorizationUrl: legacy.right.authorizationUrl,
112 };
113
114 return input.rows.sourceAuthSessions.update(input.session.id, {
115 sessionDataJson: encodeMcpSourceAuthSessionDataJson(repaired),
116 }).pipe(Effect.asVoid, Effect.orDie);
117};
118
119const toOpenApiRecipeOperationRecord = (input: {
120 recipeRevisionId: StoredSourceRecipeRevisionRecord["id"];
121 definition: ReturnType<typeof compileOpenApiToolDefinitions>[number];
122 manifest: Parameters<typeof buildOpenApiToolPresentation>[0]["manifest"];
123 now: number;
124}): StoredSourceRecipeOperationRecord => {
125 const presentation = buildOpenApiToolPresentation({
126 manifest: input.manifest,
127 definition: input.definition,
128 });
129 const method = input.definition.method.toUpperCase();
130
131 return {
132 id: `src_recipe_op_${randomUUID()}`,
133 recipeRevisionId: input.recipeRevisionId,
134 operationKey: input.definition.toolId,
135 transportKind: "http",
136 toolId: input.definition.toolId,
137 title: input.definition.name,
138 description: input.definition.description,
139 operationKind:
140 method === "GET" || method === "HEAD"
141 ? "read"
142 : method === "DELETE"
143 ? "delete"
144 : "write",
145 searchText: normalizeSearchText(
146 input.definition.toolId,
147 input.definition.name,
148 input.definition.description,
149 input.definition.rawToolId,
150 input.definition.operationId ?? undefined,
151 input.definition.method,
152 input.definition.path,
153 input.definition.group,
154 input.definition.leaf,
155 input.definition.tags.join(" "),
156 ),
157 inputSchemaJson: presentation.inputSchemaJson ?? null,
158 outputSchemaJson: presentation.outputSchemaJson ?? null,
159 providerKind: "openapi",
160 providerDataJson: presentation.providerDataJson,
161 mcpToolName: null,
162 openApiMethod: input.definition.method,
163 openApiPathTemplate: input.definition.path,
164 openApiOperationHash: input.definition.operationHash,
165 openApiRawToolId: input.definition.rawToolId,
166 openApiOperationId: input.definition.operationId ?? null,
167 openApiTagsJson: JSON.stringify(input.definition.tags),
168 openApiRequestBodyRequired: input.definition.invocation.requestBody?.required ?? null,
169 graphqlOperationType: null,
170 graphqlOperationName: null,
171 createdAt: input.now,
172 updatedAt: input.now,
173 };
174};
175
176const toGraphqlRecipeOperationRecord = (input: {
177 recipeRevisionId: StoredSourceRecipeRevisionRecord["id"];
178 definition: ReturnType<typeof compileGraphqlToolDefinitions>[number];
179 manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"];
180 now: number;
181}): StoredSourceRecipeOperationRecord => {
182 const presentation = buildGraphqlToolPresentation({
183 manifest: input.manifest,
184 definition: input.definition,
185 });
186
187 return {
188 id: `src_recipe_op_${randomUUID()}`,
189 recipeRevisionId: input.recipeRevisionId,
190 operationKey: input.definition.toolId,
191 transportKind: "graphql",
192 toolId: input.definition.toolId,
193 title: input.definition.name,
194 description: input.definition.description,
195 operationKind:
196 input.definition.operationType === "query"
197 ? "read"
198 : input.definition.operationType === "mutation"
199 ? "write"
200 : "unknown",
201 searchText: normalizeSearchText(
202 input.definition.toolId,
203 input.definition.name,
204 input.definition.description,
205 input.definition.rawToolId,
206 input.definition.group,
207 input.definition.leaf,
208 input.definition.fieldName,
209 input.definition.operationType,
210 input.definition.operationName,
211 input.definition.searchTerms.join(" "),
212 ),
213 inputSchemaJson: presentation.inputSchemaJson ?? null,
214 outputSchemaJson: presentation.outputSchemaJson ?? null,
215 providerKind: "graphql",
216 providerDataJson: presentation.providerDataJson,
217 mcpToolName: null,
218 openApiMethod: null,
219 openApiPathTemplate: null,
220 openApiOperationHash: null,
221 openApiRawToolId: null,
222 openApiOperationId: null,
223 openApiTagsJson: null,
224 openApiRequestBodyRequired: null,
225 graphqlOperationType: input.definition.operationType,
226 graphqlOperationName: input.definition.operationName,
227 createdAt: input.now,
228 updatedAt: input.now,
229 };
230};
231
232const primaryRecipeDocument = (input: {
233 sourceRecord: StoredSourceRecord;
234 documents: readonly StoredSourceRecipeDocumentRecord[];
235}): StoredSourceRecipeDocumentRecord | null => {
236 const documentKind =
237 input.sourceRecord.kind === "openapi"
238 ? "openapi"
239 : input.sourceRecord.kind === "graphql"
240 ? "graphql_introspection"
241 : null;
242
243 if (documentKind === null) {
244 return null;
245 }
246
247 return input.documents.find((document) => document.documentKind === documentKind) ?? null;
248};
249
250const repairOpenApiRecipeRevision = (input: {
251 rows: SqlControlPlaneRows;
252 sourceRecord: StoredSourceRecord;
253 revision: StoredSourceRecipeRevisionRecord;
254 document: StoredSourceRecipeDocumentRecord;
255}): Effect.Effect<void, Error, never> =>
256 Effect.gen(function* () {
257 const manifest = yield* extractOpenApiManifest(
258 input.sourceRecord.name,
259 input.document.contentText,
260 );
261 const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt);
262 const definitions = compileOpenApiToolDefinitions(manifest);
263
264 yield* input.rows.sourceRecipeRevisions.update(input.revision.id, {
265 manifestJson: JSON.stringify(manifest),
266 manifestHash: manifest.sourceHash,
267 updatedAt: now,
268 });
269 yield* input.rows.sourceRecipeOperations.replaceForRevision({
270 recipeRevisionId: input.revision.id,
271 operations: definitions.map((definition) =>
272 toOpenApiRecipeOperationRecord({
273 recipeRevisionId: input.revision.id,
274 definition,
275 manifest,
276 now,
277 })
278 ),
279 });
280
281 if (input.sourceRecord.sourceHash !== manifest.sourceHash) {
282 yield* input.rows.sources.update(
283 input.sourceRecord.workspaceId,
284 input.sourceRecord.id,
285 {
286 sourceHash: manifest.sourceHash,
287 updatedAt: now,
288 },
289 );
290 }
291 });
292
293const repairGraphqlRecipeRevision = (input: {
294 rows: SqlControlPlaneRows;
295 sourceRecord: StoredSourceRecord;
296 revision: StoredSourceRecipeRevisionRecord;
297 document: StoredSourceRecipeDocumentRecord;
298}): Effect.Effect<void, Error, never> =>
299 Effect.gen(function* () {
300 const manifest = yield* extractGraphqlManifest(
301 input.sourceRecord.name,
302 input.document.contentText,
303 );
304 const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt);
305 const definitions = compileGraphqlToolDefinitions(manifest);
306
307 yield* input.rows.sourceRecipeRevisions.update(input.revision.id, {
308 manifestJson: JSON.stringify(manifest),
309 manifestHash: manifest.sourceHash,
310 updatedAt: now,
311 });
312 yield* input.rows.sourceRecipeOperations.replaceForRevision({
313 recipeRevisionId: input.revision.id,
314 operations: definitions.map((definition) =>
315 toGraphqlRecipeOperationRecord({
316 recipeRevisionId: input.revision.id,
317 definition,
318 manifest,
319 now,
320 })
321 ),
322 });
323
324 if (input.sourceRecord.sourceHash !== manifest.sourceHash) {
325 yield* input.rows.sources.update(
326 input.sourceRecord.workspaceId,
327 input.sourceRecord.id,
328 {
329 sourceHash: manifest.sourceHash,
330 updatedAt: now,
331 },
332 );
333 }
334 });
335
336// Repair pre-recipe OpenAPI/GraphQL sources that were migrated with raw documents
337// but without the canonical manifest/operation rows required by the new runtime.
338export const runPostMigrationRepairs = (
339 rows: SqlControlPlaneRows,
340): Effect.Effect<void, Error, never> =>
341 Effect.gen(function* () {
342 const sourceRecords = yield* rows.sources.listAll();
343 if (sourceRecords.length > 0) {
344 const revisionIds = [...new Set(sourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
345 const revisions = yield* rows.sourceRecipeRevisions.listByIds(revisionIds);
346 const documents = yield* rows.sourceRecipeDocuments.listByRevisionIds(revisionIds);
347 const operations = yield* rows.sourceRecipeOperations.listByRevisionIds(revisionIds);
348
349 const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));
350 const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();
351 for (const document of documents) {
352 const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
353 existing.push(document);
354 documentsByRevisionId.set(document.recipeRevisionId, existing);
355 }
356
357 const operationCountsByRevisionId = new Map<string, number>();
358 for (const operation of operations) {
359 operationCountsByRevisionId.set(
360 operation.recipeRevisionId,
361 (operationCountsByRevisionId.get(operation.recipeRevisionId) ?? 0) + 1,
362 );
363 }
364
365 yield* Effect.forEach(sourceRecords, (sourceRecord) =>
366 Effect.gen(function* () {
367 if (sourceRecord.kind !== "openapi" && sourceRecord.kind !== "graphql") {
368 return;
369 }
370
371 const revision = revisionById.get(sourceRecord.recipeRevisionId);
372 if (!revision) {
373 return;
374 }
375
376 const existingOperationCount = operationCountsByRevisionId.get(revision.id) ?? 0;
377 if (revision.manifestJson !== null && existingOperationCount > 0) {
378 return;
379 }
380
381 const sourceDocuments = documentsByRevisionId.get(revision.id) ?? [];
382 const document = primaryRecipeDocument({
383 sourceRecord,
384 documents: sourceDocuments,
385 });
386 if (!document) {
387 return;
388 }
389
390 if (sourceRecord.kind === "openapi") {
391 yield* repairOpenApiRecipeRevision({
392 rows,
393 sourceRecord,
394 revision,
395 document,
396 });
397 return;
398 }
399
400 yield* repairGraphqlRecipeRevision({
401 rows,
402 sourceRecord,
403 revision,
404 document,
405 });
406 }), { discard: true });
407 }
408
409 const sourceAuthSessions = yield* rows.sourceAuthSessions.listAll();
410 yield* Effect.forEach(sourceAuthSessions, (session) =>
411 repairLegacyMcpSourceAuthSession({
412 rows,
413 session,
414 }), { discard: true });
415 });
packages/control-plane/src/persistence/repos/credentials-repo.ts+9−0
@@ -88,6 +88,7 @@ export const createCredentialsRepo = ( · 87 unmodified lines
88 ),88 ),
89 )89 )
90 .orderBy(90 .orderBy(
91 asc(tables.credentialsTable.slot),
91 asc(tables.credentialsTable.actorAccountId),92 asc(tables.credentialsTable.actorAccountId),
92 asc(tables.credentialsTable.updatedAt),93 asc(tables.credentialsTable.updatedAt),
93 asc(tables.credentialsTable.id),94 asc(tables.credentialsTable.id),
@@ -100,6 +101,7 @@ export const createCredentialsRepo = ( · 6 unmodified lines
100 workspaceId: Credential["workspaceId"];101 workspaceId: Credential["workspaceId"];
101 sourceId: Credential["sourceId"];102 sourceId: Credential["sourceId"];
102 actorAccountId: Credential["actorAccountId"];103 actorAccountId: Credential["actorAccountId"];
104 slot: Credential["slot"];
103 }) =>105 }) =>
104 client.use("rows.credentials.get_by_workspace_source_actor", async (db) => {106 client.use("rows.credentials.get_by_workspace_source_actor", async (db) => {
105 const rows = await db107 const rows = await db
@@ -109,6 +111,7 @@ export const createCredentialsRepo = ( · 3 unmodified lines
109 and(111 and(
110 eq(tables.credentialsTable.workspaceId, input.workspaceId),112 eq(tables.credentialsTable.workspaceId, input.workspaceId),
111 eq(tables.credentialsTable.sourceId, input.sourceId),113 eq(tables.credentialsTable.sourceId, input.sourceId),
114 eq(tables.credentialsTable.slot, input.slot),
112 input.actorAccountId === null115 input.actorAccountId === null
113 ? isNull(tables.credentialsTable.actorAccountId)116 ? isNull(tables.credentialsTable.actorAccountId)
114 : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),117 : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),
@@ -134,6 +137,7 @@ export const createCredentialsRepo = ( · 19 unmodified lines
134 and(137 and(
135 eq(tables.credentialsTable.workspaceId, credential.workspaceId),138 eq(tables.credentialsTable.workspaceId, credential.workspaceId),
136 eq(tables.credentialsTable.sourceId, credential.sourceId),139 eq(tables.credentialsTable.sourceId, credential.sourceId),
140 eq(tables.credentialsTable.slot, credential.slot),
137 isNull(tables.credentialsTable.actorAccountId),141 isNull(tables.credentialsTable.actorAccountId),
138 ),142 ),
139 )143 )
@@ -170,6 +174,7 @@ export const createCredentialsRepo = ( · 30 unmodified lines
170 tables.credentialsTable.workspaceId,174 tables.credentialsTable.workspaceId,
171 tables.credentialsTable.sourceId,175 tables.credentialsTable.sourceId,
172 tables.credentialsTable.actorAccountId,176 tables.credentialsTable.actorAccountId,
177 tables.credentialsTable.slot,
173 ],178 ],
174 set: {179 set: {
175 ...credentialUpdateSet(credential),180 ...credentialUpdateSet(credential),
@@ -181,6 +186,7 @@ export const createCredentialsRepo = ( · 5 unmodified lines
181 workspaceId: Credential["workspaceId"];186 workspaceId: Credential["workspaceId"];
182 sourceId: Credential["sourceId"];187 sourceId: Credential["sourceId"];
183 actorAccountId: Credential["actorAccountId"];188 actorAccountId: Credential["actorAccountId"];
189 slot?: Credential["slot"];
184 }) =>190 }) =>
185 client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => {191 client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => {
186 const deleted = await db192 const deleted = await db
@@ -189,6 +195,9 @@ export const createCredentialsRepo = ( · 2 unmodified lines
189 and(195 and(
190 eq(tables.credentialsTable.workspaceId, input.workspaceId),196 eq(tables.credentialsTable.workspaceId, input.workspaceId),
191 eq(tables.credentialsTable.sourceId, input.sourceId),197 eq(tables.credentialsTable.sourceId, input.sourceId),
198 input.slot === undefined
199 ? undefined
200 : eq(tables.credentialsTable.slot, input.slot),
192 input.actorAccountId === null201 input.actorAccountId === null
193 ? isNull(tables.credentialsTable.actorAccountId)202 ? isNull(tables.credentialsTable.actorAccountId)
194 : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),203 : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),
packages/control-plane/src/persistence/repos/data-migrations-repo.ts+41−0
@@ -0,0 +1,41 @@
1import type {
2 StoredDataMigrationRecord,
3} from "#schema";
4import { StoredDataMigrationRecordSchema } from "#schema";
5import { Schema } from "effect";
6import { asc } from "drizzle-orm";
7
8import type { DrizzleClient } from "../client";
9import type { DrizzleTables } from "../schema";
10
11const decodeStoredDataMigrationRecord = Schema.decodeUnknownSync(
12 StoredDataMigrationRecordSchema,
13);
14
15export const createDataMigrationsRepo = (
16 client: DrizzleClient,
17 tables: DrizzleTables,
18) => ({
19 listAll: () =>
20 client.use("rows.data_migrations.list_all", async (db) => {
21 const rows = await db
22 .select()
23 .from(tables.dataMigrationsTable)
24 .orderBy(asc(tables.dataMigrationsTable.appliedAt));
25
26 return rows.map((row) => decodeStoredDataMigrationRecord(row));
27 }),
28
29 upsert: (record: StoredDataMigrationRecord) =>
30 client.use("rows.data_migrations.upsert", async (db) => {
31 await db
32 .insert(tables.dataMigrationsTable)
33 .values(record)
34 .onConflictDoNothing();
35 }),
36
37 clearAll: () =>
38 client.use("rows.data_migrations.clear_all", async (db) => {
39 await db.delete(tables.dataMigrationsTable);
40 }),
41});
packages/control-plane/src/persistence/repos/index.ts+2−1
@@ -6,10 +6,11 @@ export { createSourcesRepo } from "./sources-repo"; · 5 unmodified lines
6export { createSourceRecipesRepo } from "./source-recipes-repo";6export { createSourceRecipesRepo } from "./source-recipes-repo";
7export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";7export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";
8export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo";8export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo";
9export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo";
9export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";10export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";
11export { createDataMigrationsRepo } from "./data-migrations-repo";
10export { createCredentialsRepo } from "./credentials-repo";12export { createCredentialsRepo } from "./credentials-repo";
11export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";13export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
12export { createToolArtifactsRepo } from "./tool-artifacts-repo";
13export { createSecretMaterialsRepo } from "./secret-materials-repo";14export { createSecretMaterialsRepo } from "./secret-materials-repo";
14export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";15export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";
15export { createPoliciesRepo } from "./policies-repo";16export { createPoliciesRepo } from "./policies-repo";
packages/control-plane/src/persistence/repos/organizations-repo.ts+10−79
@@ -6,11 +6,12 @@ import { · 5 unmodified lines
6} from "#schema";6} from "#schema";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import { Schema } from "effect";8import { Schema } from "effect";
9import { and, asc, eq, inArray, or } from "drizzle-orm";9import { asc, eq, inArray } from "drizzle-orm";
10 10
11import type { DrizzleClient } from "../client";11import type { DrizzleClient } from "../client";
12import type { DrizzleTables } from "../schema";12import type { DrizzleTables } from "../schema";
13import {13import {
14 cleanupOrphanedSourceRecipes,
14 firstOption,15 firstOption,
15 postgresSecretHandlesFromCredentials,16 postgresSecretHandlesFromCredentials,
16 withoutCreatedAt,17 withoutCreatedAt,
@@ -138,8 +139,8 @@ export const createOrganizationsRepo = ( · 121 unmodified lines
138 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));139 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
139 const sourceRows = await tx140 const sourceRows = await tx
140 .select({141 .select({
141 sourceId: tables.sourcesTable.sourceId,
142 recipeId: tables.sourcesTable.recipeId,142 recipeId: tables.sourcesTable.recipeId,
143 recipeRevisionId: tables.sourcesTable.recipeRevisionId,
143 })144 })
144 .from(tables.sourcesTable)145 .from(tables.sourcesTable)
145 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));146 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
@@ -154,28 +155,8 @@ export const createOrganizationsRepo = ( · 8 unmodified lines
154 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));155 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
155 156
156 const executionIds = executionRows.map((execution) => execution.id);157 const executionIds = executionRows.map((execution) => execution.id);
157 const sourceIds = sourceRows.map((source) => source.sourceId);
158 const recipeIds = sourceRows.map((source) => source.recipeId);158 const recipeIds = sourceRows.map((source) => source.recipeId);
159 const recipeRevisionRows = recipeIds.length > 0159 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
160 ? await tx
161 .select({ id: tables.sourceRecipeRevisionsTable.id })
162 .from(tables.sourceRecipeRevisionsTable)
163 .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds))
164 : [];
165 const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id);
166 const toolArtifactPaths = sourceIds.length > 0
167 ? (
168 await tx
169 .select({ path: tables.toolArtifactsTable.path })
170 .from(tables.toolArtifactsTable)
171 .where(
172 and(
173 inArray(tables.toolArtifactsTable.workspaceId, workspaceIds),
174 inArray(tables.toolArtifactsTable.sourceId, sourceIds),
175 ),
176 )
177 ).map((row) => row.path)
178 : [];
179 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);160 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
180 161
181 if (executionIds.length > 0) {162 if (executionIds.length > 0) {
@@ -186,39 +167,6 @@ export const createOrganizationsRepo = ( · 4 unmodified lines
186 );167 );
187 }168 }
188 169
189 if (toolArtifactPaths.length > 0) {
190 await tx
191 .delete(tables.toolArtifactParametersTable)
192 .where(
193 and(
194 inArray(tables.toolArtifactParametersTable.workspaceId, workspaceIds),
195 or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))),
196 ),
197 );
198
199 await tx
200 .delete(tables.toolArtifactRequestBodyContentTypesTable)
201 .where(
202 and(
203 inArray(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceIds),
204 or(
205 ...toolArtifactPaths.map((path) =>
206 eq(tables.toolArtifactRequestBodyContentTypesTable.path, path)
207 ),
208 ),
209 ),
210 );
211
212 await tx
213 .delete(tables.toolArtifactRefHintKeysTable)
214 .where(
215 and(
216 inArray(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceIds),
217 or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))),
218 ),
219 );
220 }
221
222 await tx170 await tx
223 .delete(tables.executionsTable)171 .delete(tables.executionsTable)
224 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));172 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
@@ -235,33 +183,16 @@ export const createOrganizationsRepo = ( · 10 unmodified lines
235 .delete(tables.credentialsTable)183 .delete(tables.credentialsTable)
236 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));184 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
237 185
238 await tx
239 .delete(tables.toolArtifactsTable)
240 .where(inArray(tables.toolArtifactsTable.workspaceId, workspaceIds));
241
242 await tx186 await tx
243 .delete(tables.sourcesTable)187 .delete(tables.sourcesTable)
244 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));188 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
245 189
246 if (recipeRevisionIds.length > 0) {190 await cleanupOrphanedSourceRecipes({
247 await tx191 tx,
248 .delete(tables.sourceRecipeDocumentsTable)192 tables,
249 .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds));193 candidateRecipeIds: recipeIds,
250 194 candidateRecipeRevisionIds: recipeRevisionIds,
251 await tx195 });
252 .delete(tables.sourceRecipeOperationsTable)
253 .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds));
254 }
255
256 if (recipeIds.length > 0) {
257 await tx
258 .delete(tables.sourceRecipeRevisionsTable)
259 .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds));
260
261 await tx
262 .delete(tables.sourceRecipesTable)
263 .where(inArray(tables.sourceRecipesTable.id, recipeIds));
264 }
265 196
266 await tx197 await tx
267 .delete(tables.policiesTable)198 .delete(tables.policiesTable)
packages/control-plane/src/persistence/repos/shared.ts+96−0
@@ -1,5 +1,9 @@
1import { inArray } from "drizzle-orm";
1import * as Option from "effect/Option";2import * as Option from "effect/Option";
2 3
4import type { DrizzleClient } from "../client";
5import type { DrizzleTables } from "../schema";
6
3export const firstOption = <A>(rows: ReadonlyArray<A>): Option.Option<A> =>7export const firstOption = <A>(rows: ReadonlyArray<A>): Option.Option<A> =>
4 rows.length > 0 ? Option.some(rows[0] as A) : Option.none<A>();8 rows.length > 0 ? Option.some(rows[0] as A) : Option.none<A>();
5 9
@@ -37,3 +41,95 @@ export const postgresSecretHandlesFromCredentials = ( · 31 unmodified lines
37 41
38 return [...handles];42 return [...handles];
39};43};
44
45export const chunkArray = <A>(
46 values: ReadonlyArray<A>,
47 chunkSize: number,
48): ReadonlyArray<ReadonlyArray<A>> => {
49 if (chunkSize <= 0) {
50 throw new Error(`chunkSize must be positive, received ${chunkSize}`);
51 }
52
53 const chunks: Array<ReadonlyArray<A>> = [];
54 for (let index = 0; index < values.length; index += chunkSize) {
55 chunks.push(values.slice(index, index + chunkSize));
56 }
57
58 return chunks;
59};
60
61export const cleanupOrphanedSourceRecipes = async (input: {
62 tx: Parameters<Parameters<DrizzleClient["useTx"]>[1]>[0];
63 tables: DrizzleTables;
64 candidateRecipeIds: ReadonlyArray<string>;
65 candidateRecipeRevisionIds: ReadonlyArray<string>;
66}) => {
67 const recipeIds = [...new Set(input.candidateRecipeIds)];
68 const recipeRevisionIds = [...new Set(input.candidateRecipeRevisionIds)];
69
70 const remainingRevisionIds = recipeRevisionIds.length > 0
71 ? (
72 await input.tx
73 .select({ recipeRevisionId: input.tables.sourcesTable.recipeRevisionId })
74 .from(input.tables.sourcesTable)
75 .where(inArray(input.tables.sourcesTable.recipeRevisionId, recipeRevisionIds))
76 ).map((row) => row.recipeRevisionId)
77 : [];
78 const orphanRevisionIds = recipeRevisionIds.filter(
79 (recipeRevisionId) => !remainingRevisionIds.includes(recipeRevisionId),
80 );
81
82 if (orphanRevisionIds.length > 0) {
83 await input.tx
84 .delete(input.tables.sourceRecipeDocumentsTable)
85 .where(inArray(input.tables.sourceRecipeDocumentsTable.recipeRevisionId, orphanRevisionIds));
86 await input.tx
87 .delete(input.tables.sourceRecipeSchemaBundlesTable)
88 .where(inArray(input.tables.sourceRecipeSchemaBundlesTable.recipeRevisionId, orphanRevisionIds));
89 await input.tx
90 .delete(input.tables.sourceRecipeOperationsTable)
91 .where(inArray(input.tables.sourceRecipeOperationsTable.recipeRevisionId, orphanRevisionIds));
92 }
93
94 const remainingRecipeIds = recipeIds.length > 0
95 ? (
96 await input.tx
97 .select({ recipeId: input.tables.sourcesTable.recipeId })
98 .from(input.tables.sourcesTable)
99 .where(inArray(input.tables.sourcesTable.recipeId, recipeIds))
100 ).map((row) => row.recipeId)
101 : [];
102 const orphanRecipeIds = recipeIds.filter(
103 (recipeId) => !remainingRecipeIds.includes(recipeId),
104 );
105
106 if (orphanRecipeIds.length === 0) {
107 return;
108 }
109
110 const orphanRecipeRevisionIds = (
111 await input.tx
112 .select({ id: input.tables.sourceRecipeRevisionsTable.id })
113 .from(input.tables.sourceRecipeRevisionsTable)
114 .where(inArray(input.tables.sourceRecipeRevisionsTable.recipeId, orphanRecipeIds))
115 ).map((revision) => revision.id);
116
117 if (orphanRecipeRevisionIds.length > 0) {
118 await input.tx
119 .delete(input.tables.sourceRecipeDocumentsTable)
120 .where(inArray(input.tables.sourceRecipeDocumentsTable.recipeRevisionId, orphanRecipeRevisionIds));
121 await input.tx
122 .delete(input.tables.sourceRecipeSchemaBundlesTable)
123 .where(inArray(input.tables.sourceRecipeSchemaBundlesTable.recipeRevisionId, orphanRecipeRevisionIds));
124 await input.tx
125 .delete(input.tables.sourceRecipeOperationsTable)
126 .where(inArray(input.tables.sourceRecipeOperationsTable.recipeRevisionId, orphanRecipeRevisionIds));
127 }
128
129 await input.tx
130 .delete(input.tables.sourceRecipeRevisionsTable)
131 .where(inArray(input.tables.sourceRecipeRevisionsTable.recipeId, orphanRecipeIds));
132 await input.tx
133 .delete(input.tables.sourceRecipesTable)
134 .where(inArray(input.tables.sourceRecipesTable.id, orphanRecipeIds));
135};
packages/control-plane/src/persistence/repos/source-auth-sessions-repo.ts+4−0
@@ -74,6 +74,7 @@ export const createSourceAuthSessionsRepo = ( · 73 unmodified lines
74 workspaceId: SourceAuthSession["workspaceId"];74 workspaceId: SourceAuthSession["workspaceId"];
75 sourceId: SourceAuthSession["sourceId"];75 sourceId: SourceAuthSession["sourceId"];
76 actorAccountId: SourceAuthSession["actorAccountId"];76 actorAccountId: SourceAuthSession["actorAccountId"];
77 credentialSlot?: SourceAuthSession["credentialSlot"];
77 }) =>78 }) =>
78 client.use(79 client.use(
79 "rows.source_auth_sessions.get_pending_by_workspace_source_actor",80 "rows.source_auth_sessions.get_pending_by_workspace_source_actor",
@@ -85,6 +86,9 @@ export const createSourceAuthSessionsRepo = ( · 5 unmodified lines
85 and(86 and(
86 eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId),87 eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId),
87 eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId),88 eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId),
89 input.credentialSlot === undefined
90 ? undefined
91 : eq(tables.sourceAuthSessionsTable.credentialSlot, input.credentialSlot),
88 input.actorAccountId === null92 input.actorAccountId === null
89 ? isNull(tables.sourceAuthSessionsTable.actorAccountId)93 ? isNull(tables.sourceAuthSessionsTable.actorAccountId)
90 : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId),94 : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId),
packages/control-plane/src/persistence/repos/source-recipe-documents-repo.ts+9−1
@@ -7,11 +7,14 @@ import { asc, eq, inArray } from "drizzle-orm"; · 6 unmodified lines
7 7
8import type { DrizzleClient } from "../client";8import type { DrizzleClient } from "../client";
9import type { DrizzleTables } from "../schema";9import type { DrizzleTables } from "../schema";
10import { chunkArray } from "./shared";
10 11
11const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync(12const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync(
12 StoredSourceRecipeDocumentRecordSchema,13 StoredSourceRecipeDocumentRecordSchema,
13);14);
14 15
16const RECIPE_DOCUMENT_INSERT_BATCH_SIZE = 500;
17
15export const createSourceRecipeDocumentsRepo = (18export const createSourceRecipeDocumentsRepo = (
16 client: DrizzleClient,19 client: DrizzleClient,
17 tables: DrizzleTables,20 tables: DrizzleTables,
@@ -61,7 +64,12 @@ export const createSourceRecipeDocumentsRepo = ( · 43 unmodified lines
61 .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId));64 .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId));
62 65
63 if (input.documents.length > 0) {66 if (input.documents.length > 0) {
64 await tx.insert(tables.sourceRecipeDocumentsTable).values([...input.documents]);67 for (const batch of chunkArray(
68 input.documents,
69 RECIPE_DOCUMENT_INSERT_BATCH_SIZE,
70 )) {
71 await tx.insert(tables.sourceRecipeDocumentsTable).values([...batch]);
72 }
65 }73 }
66 }),74 }),
67 75
packages/control-plane/src/persistence/repos/source-recipe-operations-repo.ts+10−1
@@ -7,11 +7,15 @@ import { asc, eq, inArray } from "drizzle-orm"; · 6 unmodified lines
7 7
8import type { DrizzleClient } from "../client";8import type { DrizzleClient } from "../client";
9import type { DrizzleTables } from "../schema";9import type { DrizzleTables } from "../schema";
10import { chunkArray } from "./shared";
10 11
11const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync(12const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync(
12 StoredSourceRecipeOperationRecordSchema,13 StoredSourceRecipeOperationRecordSchema,
13);14);
14 15
16// Keep wide recipe-operation inserts well below Postgres/PGlite parameter limits.
17const RECIPE_OPERATION_INSERT_BATCH_SIZE = 200;
18
15export const createSourceRecipeOperationsRepo = (19export const createSourceRecipeOperationsRepo = (
16 client: DrizzleClient,20 client: DrizzleClient,
17 tables: DrizzleTables,21 tables: DrizzleTables,
@@ -61,7 +65,12 @@ export const createSourceRecipeOperationsRepo = ( · 43 unmodified lines
61 .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId));65 .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId));
62 66
63 if (input.operations.length > 0) {67 if (input.operations.length > 0) {
64 await tx.insert(tables.sourceRecipeOperationsTable).values([...input.operations]);68 for (const batch of chunkArray(
69 input.operations,
70 RECIPE_OPERATION_INSERT_BATCH_SIZE,
71 )) {
72 await tx.insert(tables.sourceRecipeOperationsTable).values([...batch]);
73 }
65 }74 }
66 }),75 }),
67 76
packages/control-plane/src/persistence/repos/source-recipe-revisions-repo.ts+47−1
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
4} from "#schema";4} from "#schema";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { Schema } from "effect";6import { Schema } from "effect";
7import { asc, eq, inArray } from "drizzle-orm";7import { asc, desc, eq, inArray, and } from "drizzle-orm";
8 8
9import type { DrizzleClient } from "../client";9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";10import type { DrizzleTables } from "../schema";
@@ -69,6 +69,42 @@ export const createSourceRecipeRevisionsRepo = ( · 58 unmodified lines
69 : Option.none<StoredSourceRecipeRevisionRecord>();69 : Option.none<StoredSourceRecipeRevisionRecord>();
70 }),70 }),
71 71
72 getByRecipeAndMaterializationHash: (input: {
73 recipeId: StoredSourceRecipeRevisionRecord["recipeId"];
74 materializationHash: string;
75 }) =>
76 client.use("rows.source_recipe_revisions.get_by_recipe_materialization", async (db) => {
77 const rows = await db
78 .select()
79 .from(tables.sourceRecipeRevisionsTable)
80 .where(
81 and(
82 eq(tables.sourceRecipeRevisionsTable.recipeId, input.recipeId),
83 eq(tables.sourceRecipeRevisionsTable.materializationHash, input.materializationHash),
84 ),
85 )
86 .limit(1);
87
88 const row = firstOption(rows);
89 return Option.isSome(row)
90 ? Option.some(decodeStoredSourceRecipeRevisionRecord(row.value))
91 : Option.none<StoredSourceRecipeRevisionRecord>();
92 }),
93
94 nextRevisionNumber: (recipeId: StoredSourceRecipeRevisionRecord["recipeId"]) =>
95 client.use("rows.source_recipe_revisions.next_revision_number", async (db) => {
96 const rows = await db
97 .select({
98 revisionNumber: tables.sourceRecipeRevisionsTable.revisionNumber,
99 })
100 .from(tables.sourceRecipeRevisionsTable)
101 .where(eq(tables.sourceRecipeRevisionsTable.recipeId, recipeId))
102 .orderBy(desc(tables.sourceRecipeRevisionsTable.revisionNumber))
103 .limit(1);
104
105 return Number(rows[0]?.revisionNumber ?? 0) + 1;
106 }),
107
72 upsert: (revision: StoredSourceRecipeRevisionRecord) =>108 upsert: (revision: StoredSourceRecipeRevisionRecord) =>
73 client.use("rows.source_recipe_revisions.upsert", async (db) => {109 client.use("rows.source_recipe_revisions.upsert", async (db) => {
74 await db110 await db
@@ -108,4 +144,14 @@ export const createSourceRecipeRevisionsRepo = ( · 33 unmodified lines
108 144
109 return deleted.length;145 return deleted.length;
110 }),146 }),
147
148 removeById: (id: StoredSourceRecipeRevisionRecord["id"]) =>
149 client.use("rows.source_recipe_revisions.remove_by_id", async (db) => {
150 const deleted = await db
151 .delete(tables.sourceRecipeRevisionsTable)
152 .where(eq(tables.sourceRecipeRevisionsTable.id, id))
153 .returning();
154
155 return deleted.length > 0;
156 }),
111});157});
packages/control-plane/src/persistence/repos/source-recipe-schema-bundles-repo.ts+106−0
@@ -0,0 +1,106 @@
1import {
2 type StoredSourceRecipeSchemaBundleRecord,
3 StoredSourceRecipeSchemaBundleRecordSchema,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { asc, eq, inArray } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";
12
13const decodeStoredSourceRecipeSchemaBundleRecord = Schema.decodeUnknownSync(
14 StoredSourceRecipeSchemaBundleRecordSchema,
15);
16
17export const createSourceRecipeSchemaBundlesRepo = (
18 client: DrizzleClient,
19 tables: DrizzleTables,
20) => ({
21 getById: (id: StoredSourceRecipeSchemaBundleRecord["id"]) =>
22 client.use("rows.source_recipe_schema_bundles.get_by_id", async (db) => {
23 const rows = await db
24 .select()
25 .from(tables.sourceRecipeSchemaBundlesTable)
26 .where(eq(tables.sourceRecipeSchemaBundlesTable.id, id))
27 .limit(1);
28
29 const row = firstOption(rows);
30 return Option.isSome(row)
31 ? Option.some(decodeStoredSourceRecipeSchemaBundleRecord(row.value))
32 : Option.none<StoredSourceRecipeSchemaBundleRecord>();
33 }),
34
35 listByRevisionId: (recipeRevisionId: StoredSourceRecipeSchemaBundleRecord["recipeRevisionId"]) =>
36 client.use("rows.source_recipe_schema_bundles.list_by_revision", async (db) => {
37 const rows = await db
38 .select()
39 .from(tables.sourceRecipeSchemaBundlesTable)
40 .where(eq(tables.sourceRecipeSchemaBundlesTable.recipeRevisionId, recipeRevisionId))
41 .orderBy(
42 asc(tables.sourceRecipeSchemaBundlesTable.bundleKind),
43 asc(tables.sourceRecipeSchemaBundlesTable.createdAt),
44 );
45
46 return rows.map((row) => decodeStoredSourceRecipeSchemaBundleRecord(row));
47 }),
48
49 listByRevisionIds: (
50 recipeRevisionIds: readonly StoredSourceRecipeSchemaBundleRecord["recipeRevisionId"][],
51 ) =>
52 client.use("rows.source_recipe_schema_bundles.list_by_revisions", async (db) => {
53 if (recipeRevisionIds.length === 0) {
54 return [] as StoredSourceRecipeSchemaBundleRecord[];
55 }
56
57 const rows = await db
58 .select()
59 .from(tables.sourceRecipeSchemaBundlesTable)
60 .where(inArray(tables.sourceRecipeSchemaBundlesTable.recipeRevisionId, [...recipeRevisionIds]))
61 .orderBy(
62 asc(tables.sourceRecipeSchemaBundlesTable.recipeRevisionId),
63 asc(tables.sourceRecipeSchemaBundlesTable.bundleKind),
64 asc(tables.sourceRecipeSchemaBundlesTable.createdAt),
65 );
66
67 return rows.map((row) => decodeStoredSourceRecipeSchemaBundleRecord(row));
68 }),
69
70 replaceForRevision: (input: {
71 recipeRevisionId: StoredSourceRecipeSchemaBundleRecord["recipeRevisionId"];
72 bundles: readonly StoredSourceRecipeSchemaBundleRecord[];
73 }) =>
74 client.useTx("rows.source_recipe_schema_bundles.replace_for_revision", async (tx) => {
75 await tx
76 .delete(tables.sourceRecipeSchemaBundlesTable)
77 .where(eq(tables.sourceRecipeSchemaBundlesTable.recipeRevisionId, input.recipeRevisionId));
78
79 if (input.bundles.length > 0) {
80 await tx.insert(tables.sourceRecipeSchemaBundlesTable).values([...input.bundles]);
81 }
82 }),
83
84 upsert: (bundle: StoredSourceRecipeSchemaBundleRecord) =>
85 client.use("rows.source_recipe_schema_bundles.upsert", async (db) => {
86 await db
87 .insert(tables.sourceRecipeSchemaBundlesTable)
88 .values(bundle)
89 .onConflictDoUpdate({
90 target: [tables.sourceRecipeSchemaBundlesTable.id],
91 set: {
92 ...withoutCreatedAt(bundle),
93 },
94 });
95 }),
96
97 removeByRevisionId: (recipeRevisionId: StoredSourceRecipeSchemaBundleRecord["recipeRevisionId"]) =>
98 client.use("rows.source_recipe_schema_bundles.remove_by_revision", async (db) => {
99 const deleted = await db
100 .delete(tables.sourceRecipeSchemaBundlesTable)
101 .where(eq(tables.sourceRecipeSchemaBundlesTable.recipeRevisionId, recipeRevisionId))
102 .returning();
103
104 return deleted.length;
105 }),
106});
packages/control-plane/src/persistence/repos/sources-repo.ts+2−69
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
4} from "#schema";4} from "#schema";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { Schema } from "effect";6import { Schema } from "effect";
7import { and, asc, count, eq, or } from "drizzle-orm";7import { and, asc, count, eq } from "drizzle-orm";
8 8
9import type { DrizzleClient } from "../client";9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";10import type { DrizzleTables } from "../schema";
@@ -26,16 +26,9 @@ const toSourceUpdateSet = ( · 15 unmodified lines
26 if (patch.status !== undefined) set.status = patch.status;26 if (patch.status !== undefined) set.status = patch.status;
27 if (patch.enabled !== undefined) set.enabled = patch.enabled;27 if (patch.enabled !== undefined) set.enabled = patch.enabled;
28 if (patch.namespace !== undefined) set.namespace = patch.namespace;28 if (patch.namespace !== undefined) set.namespace = patch.namespace;
29 if (patch.importAuthPolicy !== undefined) set.importAuthPolicy = patch.importAuthPolicy;
29 if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson;30 if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson;
30 if (patch.transport !== undefined) set.transport = patch.transport;
31 if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson;
32 if (patch.headersJson !== undefined) set.headersJson = patch.headersJson;
33 if (patch.specUrl !== undefined) set.specUrl = patch.specUrl;
34 if (patch.defaultHeadersJson !== undefined) {
35 set.defaultHeadersJson = patch.defaultHeadersJson;
36 }
37 if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash;31 if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash;
38 if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText;
39 if (patch.lastError !== undefined) set.lastError = patch.lastError;32 if (patch.lastError !== undefined) set.lastError = patch.lastError;
40 if (patch.updatedAt !== undefined) set.updatedAt = patch.updatedAt;33 if (patch.updatedAt !== undefined) set.updatedAt = patch.updatedAt;
41 34
@@ -146,66 +139,6 @@ export const createSourcesRepo = ( · 104 unmodified lines
146 sourceId: StoredSourceRecord["id"],139 sourceId: StoredSourceRecord["id"],
147 ) =>140 ) =>
148 client.useTx("rows.sources.remove", async (tx) => {141 client.useTx("rows.sources.remove", async (tx) => {
149 const existingToolPaths = (
150 await tx
151 .select({
152 path: tables.toolArtifactsTable.path,
153 })
154 .from(tables.toolArtifactsTable)
155 .where(
156 and(
157 eq(tables.toolArtifactsTable.workspaceId, workspaceId),
158 eq(tables.toolArtifactsTable.sourceId, sourceId),
159 ),
160 )
161 ).map((row) => row.path);
162
163 if (existingToolPaths.length > 0) {
164 await tx
165 .delete(tables.toolArtifactParametersTable)
166 .where(
167 and(
168 eq(tables.toolArtifactParametersTable.workspaceId, workspaceId),
169 or(
170 ...existingToolPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path)),
171 ),
172 ),
173 );
174
175 await tx
176 .delete(tables.toolArtifactRequestBodyContentTypesTable)
177 .where(
178 and(
179 eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId),
180 or(
181 ...existingToolPaths.map((path) =>
182 eq(tables.toolArtifactRequestBodyContentTypesTable.path, path)
183 ),
184 ),
185 ),
186 );
187
188 await tx
189 .delete(tables.toolArtifactRefHintKeysTable)
190 .where(
191 and(
192 eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId),
193 or(
194 ...existingToolPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path)),
195 ),
196 ),
197 );
198 }
199
200 await tx
201 .delete(tables.toolArtifactsTable)
202 .where(
203 and(
204 eq(tables.toolArtifactsTable.workspaceId, workspaceId),
205 eq(tables.toolArtifactsTable.sourceId, sourceId),
206 ),
207 );
208
209 const deleted = await tx142 const deleted = await tx
210 .delete(tables.sourcesTable)143 .delete(tables.sourcesTable)
211 .where(144 .where(
packages/control-plane/src/persistence/repos/tool-artifacts-repo.ts+0−343
@@ -1,343 +0,0 @@
1import {
2 type StoredToolArtifactParameterRecord,
3 StoredToolArtifactParameterRecordSchema,
4 type StoredToolArtifactRecord,
5 StoredToolArtifactRecordSchema,
6 type StoredToolArtifactRefHintKeyRecord,
7 StoredToolArtifactRefHintKeyRecordSchema,
8 type StoredToolArtifactRequestBodyContentTypeRecord,
9 StoredToolArtifactRequestBodyContentTypeRecordSchema,
10} from "#schema";
11import * as Option from "effect/Option";
12import { Schema } from "effect";
13import {
14 and,
15 asc,
16 count,
17 eq,
18 or,
19 sql,
20} from "drizzle-orm";
21
22import type { DrizzleClient } from "../client";
23import type { DrizzleTables } from "../schema";
24import { firstOption } from "./shared";
25
26const decodeStoredToolArtifactRecord = Schema.decodeUnknownSync(
27 StoredToolArtifactRecordSchema,
28);
29const decodeStoredToolArtifactParameterRecord = Schema.decodeUnknownSync(
30 StoredToolArtifactParameterRecordSchema,
31);
32const decodeStoredToolArtifactRequestBodyContentTypeRecord = Schema.decodeUnknownSync(
33 StoredToolArtifactRequestBodyContentTypeRecordSchema,
34);
35const decodeStoredToolArtifactRefHintKeyRecord = Schema.decodeUnknownSync(
36 StoredToolArtifactRefHintKeyRecordSchema,
37);
38
39const tokenizeQuery = (value: string | undefined): string[] =>
40 value
41 ?.trim()
42 .toLowerCase()
43 .replaceAll(/[^\p{L}\p{N}_]+/gu, " ")
44 .split(/\s+/)
45 .filter(Boolean)
46 ?? [];
47
48const toTsQuery = (
49 value: string | undefined,
50 operator: "&" | "|",
51): string | undefined => {
52 const tokens = tokenizeQuery(value);
53 if (tokens.length === 0) {
54 return undefined;
55 }
56
57 return tokens.join(` ${operator} `);
58};
59
60const buildSearchTextClause = (
61 table: DrizzleTables["toolArtifactsTable"],
62 value: string | undefined,
63 operator: "&" | "|",
64) => {
65 const tsQuery = toTsQuery(value, operator);
66 return tsQuery
67 ? sql`to_tsvector('simple', ${table.searchText}) @@ to_tsquery('simple', ${tsQuery})`
68 : undefined;
69};
70
71const buildListWhereClause = (
72 table: DrizzleTables["toolArtifactsTable"],
73 input: {
74 workspaceId: StoredToolArtifactRecord["workspaceId"];
75 sourceId?: StoredToolArtifactRecord["sourceId"];
76 namespace?: string;
77 query?: string;
78 },
79) => {
80 return and(
81 eq(table.workspaceId, input.workspaceId),
82 input.sourceId ? eq(table.sourceId, input.sourceId) : undefined,
83 input.namespace ? eq(table.searchNamespace, input.namespace) : undefined,
84 buildSearchTextClause(table, input.query, "&"),
85 );
86};
87
88const buildSearchWhereClause = (
89 table: DrizzleTables["toolArtifactsTable"],
90 input: {
91 workspaceId: StoredToolArtifactRecord["workspaceId"];
92 namespace?: string;
93 query: string;
94 },
95) => {
96 if (tokenizeQuery(input.query).length === 0) {
97 return and(
98 eq(table.workspaceId, input.workspaceId),
99 input.namespace ? eq(table.searchNamespace, input.namespace) : undefined,
100 eq(table.workspaceId, "__never__"),
101 );
102 }
103
104 return and(
105 eq(table.workspaceId, input.workspaceId),
106 input.namespace ? eq(table.searchNamespace, input.namespace) : undefined,
107 buildSearchTextClause(table, input.query, "|"),
108 );
109};
110
111export const createToolArtifactsRepo = (
112 client: DrizzleClient,
113 tables: DrizzleTables,
114) => ({
115 listByWorkspaceId: (
116 workspaceId: StoredToolArtifactRecord["workspaceId"],
117 input?: {
118 sourceId?: StoredToolArtifactRecord["sourceId"];
119 namespace?: string;
120 query?: string;
121 limit?: number;
122 },
123 ) =>
124 client.use("rows.tool_artifacts.list_by_workspace", async (db) => {
125 const rows = await db
126 .select()
127 .from(tables.toolArtifactsTable)
128 .where(
129 buildListWhereClause(tables.toolArtifactsTable, {
130 workspaceId,
131 sourceId: input?.sourceId,
132 namespace: input?.namespace,
133 query: input?.query,
134 }),
135 )
136 .orderBy(
137 asc(tables.toolArtifactsTable.searchNamespace),
138 asc(tables.toolArtifactsTable.path),
139 )
140 .limit(input?.limit ?? 200);
141
142 return rows.map((row) => decodeStoredToolArtifactRecord(row));
143 }),
144
145 listNamespacesByWorkspaceId: (
146 workspaceId: StoredToolArtifactRecord["workspaceId"],
147 input?: {
148 limit?: number;
149 },
150 ) =>
151 client.use("rows.tool_artifacts.list_namespaces_by_workspace", async (db) => {
152 const rows = await db
153 .select({
154 namespace: tables.toolArtifactsTable.searchNamespace,
155 toolCount: count(),
156 })
157 .from(tables.toolArtifactsTable)
158 .where(eq(tables.toolArtifactsTable.workspaceId, workspaceId))
159 .groupBy(tables.toolArtifactsTable.searchNamespace)
160 .orderBy(asc(tables.toolArtifactsTable.searchNamespace))
161 .limit(input?.limit ?? 200);
162
163 return rows.map((row) => ({
164 namespace: row.namespace,
165 toolCount: Number(row.toolCount),
166 }));
167 }),
168
169 searchByWorkspaceId: (
170 workspaceId: StoredToolArtifactRecord["workspaceId"],
171 input: {
172 namespace?: string;
173 query: string;
174 limit?: number;
175 },
176 ) =>
177 client.use("rows.tool_artifacts.search_by_workspace", async (db) => {
178 const baseQuery = db
179 .select()
180 .from(tables.toolArtifactsTable)
181 .where(
182 buildSearchWhereClause(tables.toolArtifactsTable, {
183 workspaceId,
184 namespace: input.namespace,
185 query: input.query,
186 }),
187 )
188 .orderBy(
189 asc(tables.toolArtifactsTable.searchNamespace),
190 asc(tables.toolArtifactsTable.path),
191 );
192 const rows = input.limit !== undefined
193 ? await baseQuery.limit(input.limit)
194 : await baseQuery;
195
196 return rows.map((row) => decodeStoredToolArtifactRecord(row));
197 }),
198
199 getByWorkspaceAndPath: (
200 workspaceId: StoredToolArtifactRecord["workspaceId"],
201 path: StoredToolArtifactRecord["path"],
202 ) =>
203 client.use("rows.tool_artifacts.get_by_workspace_and_path", async (db) => {
204 const rows = await db
205 .select()
206 .from(tables.toolArtifactsTable)
207 .where(
208 and(
209 eq(tables.toolArtifactsTable.workspaceId, workspaceId),
210 eq(tables.toolArtifactsTable.path, path),
211 ),
212 )
213 .limit(1);
214
215 const row = firstOption(rows);
216 return Option.isSome(row)
217 ? Option.some(decodeStoredToolArtifactRecord(row.value))
218 : Option.none<StoredToolArtifactRecord>();
219 }),
220
221 listParametersByWorkspaceAndPath: (
222 workspaceId: StoredToolArtifactRecord["workspaceId"],
223 path: StoredToolArtifactRecord["path"],
224 ) =>
225 client.use("rows.tool_artifacts.list_parameters_by_workspace_and_path", async (db) => {
226 const rows = await db
227 .select()
228 .from(tables.toolArtifactParametersTable)
229 .where(
230 and(
231 eq(tables.toolArtifactParametersTable.workspaceId, workspaceId),
232 eq(tables.toolArtifactParametersTable.path, path),
233 ),
234 )
235 .orderBy(asc(tables.toolArtifactParametersTable.position));
236
237 return rows.map((row) => decodeStoredToolArtifactParameterRecord(row));
238 }),
239
240 listRequestBodyContentTypesByWorkspaceAndPath: (
241 workspaceId: StoredToolArtifactRecord["workspaceId"],
242 path: StoredToolArtifactRecord["path"],
243 ) =>
244 client.use(
245 "rows.tool_artifacts.list_request_body_content_types_by_workspace_and_path",
246 async (db) => {
247 const rows = await db
248 .select()
249 .from(tables.toolArtifactRequestBodyContentTypesTable)
250 .where(
251 and(
252 eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId),
253 eq(tables.toolArtifactRequestBodyContentTypesTable.path, path),
254 ),
255 )
256 .orderBy(asc(tables.toolArtifactRequestBodyContentTypesTable.position));
257
258 return rows.map((row) => decodeStoredToolArtifactRequestBodyContentTypeRecord(row));
259 },
260 ),
261
262 listRefHintKeysByWorkspaceAndPath: (
263 workspaceId: StoredToolArtifactRecord["workspaceId"],
264 path: StoredToolArtifactRecord["path"],
265 ) =>
266 client.use("rows.tool_artifacts.list_ref_hint_keys_by_workspace_and_path", async (db) => {
267 const rows = await db
268 .select()
269 .from(tables.toolArtifactRefHintKeysTable)
270 .where(
271 and(
272 eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId),
273 eq(tables.toolArtifactRefHintKeysTable.path, path),
274 ),
275 )
276 .orderBy(asc(tables.toolArtifactRefHintKeysTable.position));
277
278 return rows.map((row) => decodeStoredToolArtifactRefHintKeyRecord(row));
279 }),
280
281 removeByWorkspaceAndSourceId: (
282 workspaceId: StoredToolArtifactRecord["workspaceId"],
283 sourceId: StoredToolArtifactRecord["sourceId"],
284 ) =>
285 client.useTx("rows.tool_artifacts.remove_by_workspace_and_source_id", async (tx) => {
286 const existingPaths = (
287 await tx
288 .select({
289 path: tables.toolArtifactsTable.path,
290 })
291 .from(tables.toolArtifactsTable)
292 .where(
293 and(
294 eq(tables.toolArtifactsTable.workspaceId, workspaceId),
295 eq(tables.toolArtifactsTable.sourceId, sourceId),
296 ),
297 )
298 ).map((row) => row.path);
299
300 if (existingPaths.length > 0) {
301 await tx
302 .delete(tables.toolArtifactParametersTable)
303 .where(
304 and(
305 eq(tables.toolArtifactParametersTable.workspaceId, workspaceId),
306 or(...existingPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))),
307 ),
308 );
309 await tx
310 .delete(tables.toolArtifactRequestBodyContentTypesTable)
311 .where(
312 and(
313 eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId),
314 or(
315 ...existingPaths.map((path) =>
316 eq(tables.toolArtifactRequestBodyContentTypesTable.path, path)
317 ),
318 ),
319 ),
320 );
321 await tx
322 .delete(tables.toolArtifactRefHintKeysTable)
323 .where(
324 and(
325 eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId),
326 or(...existingPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))),
327 ),
328 );
329 }
330
331 const deleted = await tx
332 .delete(tables.toolArtifactsTable)
333 .where(
334 and(
335 eq(tables.toolArtifactsTable.workspaceId, workspaceId),
336 eq(tables.toolArtifactsTable.sourceId, sourceId),
337 ),
338 )
339 .returning();
340
341 return deleted.length;
342 }),
343});
packages/control-plane/src/persistence/repos/workspaces-repo.ts+14−80
@@ -1,11 +1,15 @@
1import { type Workspace, WorkspaceSchema } from "#schema";1import { type Workspace, WorkspaceSchema } from "#schema";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3import { Schema } from "effect";3import { Schema } from "effect";
4import { and, asc, eq, inArray, or } from "drizzle-orm";4import { and, asc, eq, inArray } from "drizzle-orm";
5 5
6import type { DrizzleClient } from "../client";6import type { DrizzleClient } from "../client";
7import type { DrizzleTables } from "../schema";7import type { DrizzleTables } from "../schema";
8import { firstOption, postgresSecretHandlesFromCredentials } from "./shared";8import {
9 cleanupOrphanedSourceRecipes,
10 firstOption,
11 postgresSecretHandlesFromCredentials,
12} from "./shared";
9 13
10const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);14const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
11 15
@@ -71,14 +75,14 @@ export const createWorkspacesRepo = ( · 59 unmodified lines
71 .where(eq(tables.executionsTable.workspaceId, workspaceId));75 .where(eq(tables.executionsTable.workspaceId, workspaceId));
72 const sourceRows = await tx76 const sourceRows = await tx
73 .select({77 .select({
74 sourceId: tables.sourcesTable.sourceId,
75 recipeId: tables.sourcesTable.recipeId,78 recipeId: tables.sourcesTable.recipeId,
79 recipeRevisionId: tables.sourcesTable.recipeRevisionId,
76 })80 })
77 .from(tables.sourcesTable)81 .from(tables.sourcesTable)
78 .where(eq(tables.sourcesTable.workspaceId, workspaceId));82 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
79 const executionIds = executionRows.map((execution) => execution.id);83 const executionIds = executionRows.map((execution) => execution.id);
80 const sourceIds = sourceRows.map((source) => source.sourceId);
81 const recipeIds = sourceRows.map((source) => source.recipeId);84 const recipeIds = sourceRows.map((source) => source.recipeId);
85 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
82 const credentials = await tx86 const credentials = await tx
83 .select({87 .select({
84 tokenProviderId: tables.credentialsTable.tokenProviderId,88 tokenProviderId: tables.credentialsTable.tokenProviderId,
@@ -88,26 +92,6 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
88 })92 })
89 .from(tables.credentialsTable)93 .from(tables.credentialsTable)
90 .where(eq(tables.credentialsTable.workspaceId, workspaceId));94 .where(eq(tables.credentialsTable.workspaceId, workspaceId));
91 const recipeRevisionRows = recipeIds.length > 0
92 ? await tx
93 .select({ id: tables.sourceRecipeRevisionsTable.id })
94 .from(tables.sourceRecipeRevisionsTable)
95 .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds))
96 : [];
97 const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id);
98 const toolArtifactPaths = sourceIds.length > 0
99 ? (
100 await tx
101 .select({ path: tables.toolArtifactsTable.path })
102 .from(tables.toolArtifactsTable)
103 .where(
104 and(
105 eq(tables.toolArtifactsTable.workspaceId, workspaceId),
106 inArray(tables.toolArtifactsTable.sourceId, sourceIds),
107 ),
108 )
109 ).map((row) => row.path)
110 : [];
111 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);95 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
112 96
113 if (executionIds.length > 0) {97 if (executionIds.length > 0) {
@@ -116,39 +100,6 @@ export const createWorkspacesRepo = ( · 2 unmodified lines
116 .where(inArray(tables.executionInteractionsTable.executionId, executionIds));100 .where(inArray(tables.executionInteractionsTable.executionId, executionIds));
117 }101 }
118 102
119 if (toolArtifactPaths.length > 0) {
120 await tx
121 .delete(tables.toolArtifactParametersTable)
122 .where(
123 and(
124 eq(tables.toolArtifactParametersTable.workspaceId, workspaceId),
125 or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))),
126 ),
127 );
128
129 await tx
130 .delete(tables.toolArtifactRequestBodyContentTypesTable)
131 .where(
132 and(
133 eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId),
134 or(
135 ...toolArtifactPaths.map((path) =>
136 eq(tables.toolArtifactRequestBodyContentTypesTable.path, path)
137 ),
138 ),
139 ),
140 );
141
142 await tx
143 .delete(tables.toolArtifactRefHintKeysTable)
144 .where(
145 and(
146 eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId),
147 or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))),
148 ),
149 );
150 }
151
152 await tx103 await tx
153 .delete(tables.executionsTable)104 .delete(tables.executionsTable)
154 .where(eq(tables.executionsTable.workspaceId, workspaceId));105 .where(eq(tables.executionsTable.workspaceId, workspaceId));
@@ -165,33 +116,16 @@ export const createWorkspacesRepo = ( · 10 unmodified lines
165 .delete(tables.credentialsTable)116 .delete(tables.credentialsTable)
166 .where(eq(tables.credentialsTable.workspaceId, workspaceId));117 .where(eq(tables.credentialsTable.workspaceId, workspaceId));
167 118
168 await tx
169 .delete(tables.toolArtifactsTable)
170 .where(eq(tables.toolArtifactsTable.workspaceId, workspaceId));
171
172 await tx119 await tx
173 .delete(tables.sourcesTable)120 .delete(tables.sourcesTable)
174 .where(eq(tables.sourcesTable.workspaceId, workspaceId));121 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
175 122
176 if (recipeRevisionIds.length > 0) {123 await cleanupOrphanedSourceRecipes({
177 await tx124 tx,
178 .delete(tables.sourceRecipeDocumentsTable)125 tables,
179 .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds));126 candidateRecipeIds: recipeIds,
180 127 candidateRecipeRevisionIds: recipeRevisionIds,
181 await tx128 });
182 .delete(tables.sourceRecipeOperationsTable)
183 .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds));
184 }
185
186 if (recipeIds.length > 0) {
187 await tx
188 .delete(tables.sourceRecipeRevisionsTable)
189 .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds));
190
191 await tx
192 .delete(tables.sourceRecipesTable)
193 .where(inArray(tables.sourceRecipesTable.id, recipeIds));
194 }
195 129
196 await tx130 await tx
197 .delete(tables.policiesTable)131 .delete(tables.policiesTable)
packages/control-plane/src/persistence/schema.ts+58−187
@@ -19,11 +19,9 @@ export const tableNames = { · 18 unmodified lines
19 sourceRecipes: "source_recipes",19 sourceRecipes: "source_recipes",
20 sourceRecipeRevisions: "source_recipe_revisions",20 sourceRecipeRevisions: "source_recipe_revisions",
21 sourceRecipeDocuments: "source_recipe_documents",21 sourceRecipeDocuments: "source_recipe_documents",
22 sourceRecipeSchemaBundles: "source_recipe_schema_bundles",
22 sourceRecipeOperations: "source_recipe_operations",23 sourceRecipeOperations: "source_recipe_operations",
23 toolArtifacts: "tool_artifacts",24 dataMigrations: "control_plane_data_migrations",
24 toolArtifactParameters: "tool_artifact_parameters",
25 toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",
26 toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",
27 credentials: "workspace_source_credentials",25 credentials: "workspace_source_credentials",
28 workspaceSourceOauthClients: "workspace_source_oauth_clients",26 workspaceSourceOauthClients: "workspace_source_oauth_clients",
29 secretMaterials: "secret_materials",27 secretMaterials: "secret_materials",
@@ -145,14 +143,9 @@ export const sourcesTable = pgTable( · 115 unmodified lines
145 status: text("status").notNull(),143 status: text("status").notNull(),
146 enabled: boolean("enabled").notNull(),144 enabled: boolean("enabled").notNull(),
147 namespace: text("namespace"),145 namespace: text("namespace"),
148 bindingConfigJson: text("binding_config_json"),146 importAuthPolicy: text("import_auth_policy").notNull(),
149 transport: text("transport"),147 bindingConfigJson: text("binding_config_json").notNull(),
150 queryParamsJson: text("query_params_json"),
151 headersJson: text("headers_json"),
152 specUrl: text("spec_url"),
153 defaultHeadersJson: text("default_headers_json"),
154 sourceHash: text("source_hash"),148 sourceHash: text("source_hash"),
155 sourceDocumentText: text("source_document_text"),
156 lastError: text("last_error"),149 lastError: text("last_error"),
157 createdAt: bigint("created_at", { mode: "number" }).notNull(),150 createdAt: bigint("created_at", { mode: "number" }).notNull(),
158 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),151 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
@@ -173,17 +166,13 @@ export const sourcesTable = pgTable( · 14 unmodified lines
173 table.sourceId,166 table.sourceId,
174 ),167 ),
175 uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name),168 uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name),
176 check(
177 "sources_kind_check",
178 sql`${table.kind} in ('mcp', 'openapi', 'graphql', 'internal')`,
179 ),
180 check(169 check(
181 "sources_status_check",170 "sources_status_check",
182 sql`${table.status} in ('draft', 'probing', 'auth_required', 'connected', 'error')`,171 sql`${table.status} in ('draft', 'probing', 'auth_required', 'connected', 'error')`,
183 ),172 ),
184 check(173 check(
185 "sources_transport_check",174 "sources_import_auth_policy_check",
186 sql`${table.transport} is null or ${table.transport} in ('auto', 'streamable-http', 'sse')`,175 sql`${table.importAuthPolicy} in ('none', 'reuse_runtime', 'separate')`,
187 ),176 ),
188 ],177 ],
189);178);
@@ -215,19 +204,7 @@ export const sourceRecipesTable = pgTable( · 25 unmodified lines
215 ),204 ),
216 check(205 check(
217 "source_recipes_kind_check",206 "source_recipes_kind_check",
218 sql`${table.kind} in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')`,207 sql`${table.kind} in ('http_api', 'mcp', 'internal')`,
219 ),
220 check(
221 "source_recipes_importer_kind_check",
222 sql`${table.importerKind} in (
223 'openapi',
224 'google_discovery',
225 'postman_collection',
226 'snippet_bundle',
227 'graphql_introspection',
228 'mcp_manifest',
229 'internal_manifest'
230 )`,
231 ),208 ),
232 check(209 check(
233 "source_recipes_visibility_check",210 "source_recipes_visibility_check",
@@ -245,6 +222,7 @@ export const sourceRecipeRevisionsTable = pgTable( · 11 unmodified lines
245 sourceConfigJson: text("source_config_json").notNull(),222 sourceConfigJson: text("source_config_json").notNull(),
246 manifestJson: text("manifest_json"),223 manifestJson: text("manifest_json"),
247 manifestHash: text("manifest_hash"),224 manifestHash: text("manifest_hash"),
225 materializationHash: text("materialization_hash"),
248 createdAt: bigint("created_at", { mode: "number" }).notNull(),226 createdAt: bigint("created_at", { mode: "number" }).notNull(),
249 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),227 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
250 },228 },
@@ -253,7 +231,11 @@ export const sourceRecipeRevisionsTable = pgTable( · 2 unmodified lines
253 table.recipeId,231 table.recipeId,
254 table.revisionNumber,232 table.revisionNumber,
255 ),233 ),
256 uniqueIndex("source_recipe_revisions_recipe_manifest_idx").on(234 uniqueIndex("source_recipe_revisions_recipe_materialization_idx").on(
235 table.recipeId,
236 table.materializationHash,
237 ),
238 index("source_recipe_revisions_recipe_manifest_idx").on(
257 table.recipeId,239 table.recipeId,
258 table.manifestHash,240 table.manifestHash,
259 ),241 ),
@@ -289,16 +271,29 @@ export const sourceRecipeDocumentsTable = pgTable( · 29 unmodified lines
289 table.createdAt,271 table.createdAt,
290 table.id,272 table.id,
291 ),273 ),
292 check(274 ],
293 "source_recipe_documents_kind_check",275);
294 sql`${table.documentKind} in (276
295 'google_discovery',277export const sourceRecipeSchemaBundlesTable = pgTable(
296 'openapi',278 tableNames.sourceRecipeSchemaBundles,
297 'postman_collection',279 {
298 'postman_environment',280 id: text("id").notNull().primaryKey(),
299 'graphql_introspection',281 recipeRevisionId: text("recipe_revision_id").notNull(),
300 'mcp_manifest'282 bundleKind: text("bundle_kind").notNull(),
301 )`,283 refsJson: text("refs_json").notNull(),
284 contentHash: text("content_hash").notNull(),
285 createdAt: bigint("created_at", { mode: "number" }).notNull(),
286 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
287 },
288 (table) => [
289 uniqueIndex("source_recipe_schema_bundles_revision_kind_idx").on(
290 table.recipeRevisionId,
291 table.bundleKind,
292 ),
293 index("source_recipe_schema_bundles_revision_created_idx").on(
294 table.recipeRevisionId,
295 table.createdAt,
296 table.id,
302 ),297 ),
303 ],298 ],
304);299);
@@ -319,16 +314,6 @@ export const sourceRecipeOperationsTable = pgTable( · 14 unmodified lines
319 outputSchemaJson: text("output_schema_json"),314 outputSchemaJson: text("output_schema_json"),
320 providerKind: text("provider_kind").notNull(),315 providerKind: text("provider_kind").notNull(),
321 providerDataJson: text("provider_data_json"),316 providerDataJson: text("provider_data_json"),
322 mcpToolName: text("mcp_tool_name"),
323 openApiMethod: text("openapi_method"),
324 openApiPathTemplate: text("openapi_path_template"),
325 openApiOperationHash: text("openapi_operation_hash"),
326 openApiRawToolId: text("openapi_raw_tool_id"),
327 openApiOperationId: text("openapi_operation_id"),
328 openApiTagsJson: text("openapi_tags_json"),
329 openApiRequestBodyRequired: boolean("openapi_request_body_required"),
330 graphqlOperationType: text("graphql_operation_type"),
331 graphqlOperationName: text("graphql_operation_name"),
332 createdAt: bigint("created_at", { mode: "number" }).notNull(),317 createdAt: bigint("created_at", { mode: "number" }).notNull(),
333 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),318 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
334 },319 },
@@ -355,13 +340,17 @@ export const sourceRecipeOperationsTable = pgTable( · 20 unmodified lines
355 "source_recipe_operations_kind_check",340 "source_recipe_operations_kind_check",
356 sql`${table.operationKind} in ('read', 'write', 'delete', 'unknown')`,341 sql`${table.operationKind} in ('read', 'write', 'delete', 'unknown')`,
357 ),342 ),
358 check(
359 "source_recipe_operations_provider_kind_check",
360 sql`${table.providerKind} in ('mcp', 'openapi', 'graphql', 'internal')`,
361 ),
362 ],343 ],
363);344);
364 345
346export const dataMigrationsTable = pgTable(
347 tableNames.dataMigrations,
348 {
349 id: text("id").notNull().primaryKey(),
350 appliedAt: bigint("applied_at", { mode: "number" }).notNull(),
351 },
352);
353
365export const credentialsTable = pgTable(354export const credentialsTable = pgTable(
366 tableNames.credentials,355 tableNames.credentials,
367 {356 {
@@ -369,6 +358,7 @@ export const credentialsTable = pgTable( · 1 unmodified line
369 workspaceId: text("workspace_id").notNull(),358 workspaceId: text("workspace_id").notNull(),
370 sourceId: text("source_id").notNull(),359 sourceId: text("source_id").notNull(),
371 actorAccountId: text("actor_account_id"),360 actorAccountId: text("actor_account_id"),
361 slot: text("slot").notNull(),
372 authKind: text("auth_kind").notNull(),362 authKind: text("auth_kind").notNull(),
373 authHeaderName: text("auth_header_name").notNull(),363 authHeaderName: text("auth_header_name").notNull(),
374 authPrefix: text("auth_prefix").notNull(),364 authPrefix: text("auth_prefix").notNull(),
@@ -389,6 +379,7 @@ export const credentialsTable = pgTable( · 14 unmodified lines
389 table.workspaceId,379 table.workspaceId,
390 table.sourceId,380 table.sourceId,
391 table.actorAccountId,381 table.actorAccountId,
382 table.slot,
392 ),383 ),
393 index("credentials_workspace_source_idx").on(384 index("credentials_workspace_source_idx").on(
394 table.workspaceId,385 table.workspaceId,
@@ -400,6 +391,10 @@ export const credentialsTable = pgTable( · 5 unmodified lines
400 "credentials_auth_kind_check",391 "credentials_auth_kind_check",
401 sql`${table.authKind} in ('bearer', 'oauth2')`,392 sql`${table.authKind} in ('bearer', 'oauth2')`,
402 ),393 ),
394 check(
395 "credentials_slot_check",
396 sql`${table.slot} in ('runtime', 'import')`,
397 ),
403 ],398 ],
404);399);
405 400
@@ -431,134 +426,6 @@ export const workspaceSourceOauthClientsTable = pgTable( · 25 unmodified lines
431 ],426 ],
432);427);
433 428
434export const toolArtifactsTable = pgTable(
435 tableNames.toolArtifacts,
436 {
437 workspaceId: text("workspace_id").notNull(),
438 path: text("path").notNull(),
439 toolId: text("tool_id").notNull(),
440 sourceId: text("source_id").notNull(),
441 title: text("title"),
442 description: text("description"),
443 searchNamespace: text("search_namespace").notNull(),
444 searchText: text("search_text").notNull(),
445 inputSchemaJson: text("input_schema_json"),
446 outputSchemaJson: text("output_schema_json"),
447 providerKind: text("provider_kind").notNull(),
448 mcpToolName: text("mcp_tool_name"),
449 openApiMethod: text("openapi_method"),
450 openApiPathTemplate: text("openapi_path_template"),
451 openApiOperationHash: text("openapi_operation_hash"),
452 openApiRawToolId: text("openapi_raw_tool_id"),
453 openApiOperationId: text("openapi_operation_id"),
454 openApiTagsJson: text("openapi_tags_json"),
455 openApiRequestBodyRequired: boolean("openapi_request_body_required"),
456 createdAt: bigint("created_at", { mode: "number" }).notNull(),
457 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
458 },
459 (table) => [
460 primaryKey({
461 columns: [table.workspaceId, table.path],
462 }),
463 index("tool_artifacts_workspace_source_idx").on(
464 table.workspaceId,
465 table.sourceId,
466 table.updatedAt,
467 table.path,
468 ),
469 index("tool_artifacts_workspace_namespace_idx").on(
470 table.workspaceId,
471 table.searchNamespace,
472 table.path,
473 ),
474 index("tool_artifacts_search_text_idx").using(
475 "gin",
476 sql`to_tsvector('simple', ${table.searchText})`,
477 ),
478 check(
479 "tool_artifacts_provider_kind_check",
480 sql`${table.providerKind} in ('mcp', 'openapi')`,
481 ),
482 check(
483 "tool_artifacts_mcp_shape_check",
484 sql`${table.providerKind} <> 'mcp'
485 or (
486 ${table.mcpToolName} is not null
487 and ${table.openApiMethod} is null
488 and ${table.openApiPathTemplate} is null
489 and ${table.openApiOperationHash} is null
490 and ${table.openApiRawToolId} is null
491 and ${table.openApiOperationId} is null
492 and ${table.openApiTagsJson} is null
493 and ${table.openApiRequestBodyRequired} is null
494 )`,
495 ),
496 check(
497 "tool_artifacts_openapi_shape_check",
498 sql`${table.providerKind} <> 'openapi'
499 or (
500 ${table.mcpToolName} is null
501 and ${table.openApiMethod} in ('get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace')
502 and ${table.openApiPathTemplate} is not null
503 and ${table.openApiOperationHash} is not null
504 and ${table.openApiRawToolId} is not null
505 and ${table.openApiTagsJson} is not null
506 )`,
507 ),
508 ],
509);
510
511export const toolArtifactParametersTable = pgTable(
512 tableNames.toolArtifactParameters,
513 {
514 workspaceId: text("workspace_id").notNull(),
515 path: text("path").notNull(),
516 position: bigint("position", { mode: "number" }).notNull(),
517 name: text("name").notNull(),
518 location: text("location").notNull(),
519 required: boolean("required").notNull(),
520 },
521 (table) => [
522 primaryKey({
523 columns: [table.workspaceId, table.path, table.position],
524 }),
525 check(
526 "tool_artifact_parameters_location_check",
527 sql`${table.location} in ('path', 'query', 'header', 'cookie')`,
528 ),
529 ],
530);
531
532export const toolArtifactRequestBodyContentTypesTable = pgTable(
533 tableNames.toolArtifactRequestBodyContentTypes,
534 {
535 workspaceId: text("workspace_id").notNull(),
536 path: text("path").notNull(),
537 position: bigint("position", { mode: "number" }).notNull(),
538 contentType: text("content_type").notNull(),
539 },
540 (table) => [
541 primaryKey({
542 columns: [table.workspaceId, table.path, table.position],
543 }),
544 ],
545);
546
547export const toolArtifactRefHintKeysTable = pgTable(
548 tableNames.toolArtifactRefHintKeys,
549 {
550 workspaceId: text("workspace_id").notNull(),
551 path: text("path").notNull(),
552 position: bigint("position", { mode: "number" }).notNull(),
553 refHintKey: text("ref_hint_key").notNull(),
554 },
555 (table) => [
556 primaryKey({
557 columns: [table.workspaceId, table.path, table.position],
558 }),
559 ],
560);
561
562export const secretMaterialsTable = pgTable(429export const secretMaterialsTable = pgTable(
563 tableNames.secretMaterials,430 tableNames.secretMaterials,
564 {431 {
@@ -585,6 +452,7 @@ export const sourceAuthSessionsTable = pgTable( · 20 unmodified lines
585 workspaceId: text("workspace_id").notNull(),452 workspaceId: text("workspace_id").notNull(),
586 sourceId: text("source_id").notNull(),453 sourceId: text("source_id").notNull(),
587 actorAccountId: text("actor_account_id"),454 actorAccountId: text("actor_account_id"),
455 credentialSlot: text("credential_slot").notNull(),
588 executionId: text("execution_id"),456 executionId: text("execution_id"),
589 interactionId: text("interaction_id"),457 interactionId: text("interaction_id"),
590 providerKind: text("provider_kind").notNull(),458 providerKind: text("provider_kind").notNull(),
@@ -606,6 +474,7 @@ export const sourceAuthSessionsTable = pgTable( · 15 unmodified lines
606 table.workspaceId,474 table.workspaceId,
607 table.sourceId,475 table.sourceId,
608 table.actorAccountId,476 table.actorAccountId,
477 table.credentialSlot,
609 table.status,478 table.status,
610 table.updatedAt,479 table.updatedAt,
611 table.id,480 table.id,
@@ -619,6 +488,10 @@ export const sourceAuthSessionsTable = pgTable( · 7 unmodified lines
619 "source_auth_sessions_status_check",488 "source_auth_sessions_status_check",
620 sql`${table.status} in ('pending', 'completed', 'failed', 'cancelled')`,489 sql`${table.status} in ('pending', 'completed', 'failed', 'cancelled')`,
621 ),490 ),
491 check(
492 "source_auth_sessions_credential_slot_check",
493 sql`${table.credentialSlot} in ('runtime', 'import')`,
494 ),
622 ],495 ],
623);496);
624 497
@@ -762,13 +635,11 @@ export const drizzleSchema = { · 137 unmodified lines
762 sourceRecipesTable,635 sourceRecipesTable,
763 sourceRecipeRevisionsTable,636 sourceRecipeRevisionsTable,
764 sourceRecipeDocumentsTable,637 sourceRecipeDocumentsTable,
638 sourceRecipeSchemaBundlesTable,
765 sourceRecipeOperationsTable,639 sourceRecipeOperationsTable,
640 dataMigrationsTable,
766 credentialsTable,641 credentialsTable,
767 workspaceSourceOauthClientsTable,642 workspaceSourceOauthClientsTable,
768 toolArtifactsTable,
769 toolArtifactParametersTable,
770 toolArtifactRequestBodyContentTypesTable,
771 toolArtifactRefHintKeysTable,
772 secretMaterialsTable,643 secretMaterialsTable,
773 sourceAuthSessionsTable,644 sourceAuthSessionsTable,
774 policiesTable,645 policiesTable,
packages/control-plane/src/runtime/control-plane-runtime.test.ts+11−14
@@ -29,6 +29,13 @@ const makeRuntime = Effect.acquireRelease( · 28 unmodified lines
29 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),29 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
30);30);
31 31
32const openApiBindingConfigJson = (specUrl: string): string =>
33 JSON.stringify({
34 adapterKey: "openapi",
35 specUrl,
36 defaultHeaders: null,
37 });
38
32type OpenApiSpecServer = {39type OpenApiSpecServer = {
33 baseUrl: string;40 baseUrl: string;
34 specUrl: string;41 specUrl: string;
@@ -288,14 +295,9 @@ describe("control-plane-runtime", () => { · 253 unmodified lines
288 status: "auth_required",295 status: "auth_required",
289 enabled: true,296 enabled: true,
290 namespace: "github",297 namespace: "github",
291 bindingConfigJson: null,298 importAuthPolicy: "reuse_runtime",
292 transport: null,299 bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
293 queryParamsJson: null,
294 headersJson: null,
295 specUrl: "https://example.com/github-openapi.yaml",
296 defaultHeadersJson: null,
297 sourceHash: null,300 sourceHash: null,
298 sourceDocumentText: null,
299 lastError: null,301 lastError: null,
300 createdAt: now,302 createdAt: now,
301 updatedAt: now,303 updatedAt: now,
@@ -448,14 +450,9 @@ describe("control-plane-runtime", () => { · 146 unmodified lines
448 status: "auth_required",450 status: "auth_required",
449 enabled: true,451 enabled: true,
450 namespace: "github",452 namespace: "github",
451 bindingConfigJson: null,453 importAuthPolicy: "reuse_runtime",
452 transport: null,454 bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
453 queryParamsJson: null,
454 headersJson: null,
455 specUrl: "https://example.com/github-openapi.yaml",
456 defaultHeadersJson: null,
457 sourceHash: null,455 sourceHash: null,
458 sourceDocumentText: null,
459 lastError: null,456 lastError: null,
460 createdAt: now,457 createdAt: now,
461 updatedAt: now,458 updatedAt: now,
packages/control-plane/src/runtime/executor-tools.ts+101−97
@@ -36,59 +36,20 @@ import { · 35 unmodified lines
36 deriveSchemaJson,36 deriveSchemaJson,
37 deriveSchemaTypeSignature,37 deriveSchemaTypeSignature,
38} from "./schema-type-signature";38} from "./schema-type-signature";
39import {
40 ExecutorAddSourceInputSchema,
41 executorAddableSourceAdapters,
42} from "./source-adapters";
39import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";43import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
40 44
41const ExecutorMcpSourceAddInputSchema = Schema.Struct({
42 kind: Schema.optional(Schema.Literal("mcp")),
43 endpoint: Schema.String,
44 name: Schema.optional(Schema.NullOr(Schema.String)),
45 namespace: Schema.optional(Schema.NullOr(Schema.String)),
46});
47
48const ExecutorOpenApiSourceAddInputSchema = Schema.Struct({
49 kind: Schema.Literal("openapi"),
50 endpoint: Schema.String,
51 specUrl: Schema.String,
52 name: Schema.optional(Schema.NullOr(Schema.String)),
53 namespace: Schema.optional(Schema.NullOr(Schema.String)),
54});
55
56const ExecutorGraphqlSourceAddInputSchema = Schema.Struct({
57 kind: Schema.Literal("graphql"),
58 endpoint: Schema.String,
59 name: Schema.optional(Schema.NullOr(Schema.String)),
60 namespace: Schema.optional(Schema.NullOr(Schema.String)),
61});
62
63const ExecutorSourcesAddSchema = Schema.Union(
64 ExecutorMcpSourceAddInputSchema,
65 ExecutorOpenApiSourceAddInputSchema,
66 ExecutorGraphqlSourceAddInputSchema,
67);
68
69const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1(45const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1(
70 ExecutorSourcesAddSchema,46 ExecutorAddSourceInputSchema,
71);47);
72 48
73const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(SourceSchema);49const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(SourceSchema);
74 50
75export const EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE = deriveSchemaTypeSignature(
76 ExecutorMcpSourceAddInputSchema,
77 240,
78);
79
80export const EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE = deriveSchemaTypeSignature(
81 ExecutorOpenApiSourceAddInputSchema,
82 420,
83);
84
85export const EXECUTOR_SOURCES_ADD_GRAPHQL_INPUT_SIGNATURE = deriveSchemaTypeSignature(
86 ExecutorGraphqlSourceAddInputSchema,
87 320,
88);
89
90export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature(51export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature(
91 ExecutorSourcesAddInputSchema,52 ExecutorAddSourceInputSchema,
92 320,53 320,
93);54);
94 55
@@ -98,7 +59,7 @@ export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature( · 3 unmodified lines
98);59);
99 60
100export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify(61export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify(
101 deriveSchemaJson(ExecutorSourcesAddSchema) ?? {},62 deriveSchemaJson(ExecutorAddSourceInputSchema) ?? {},
102);63);
103 64
104export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON = JSON.stringify(65export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON = JSON.stringify(
@@ -107,12 +68,16 @@ export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON = JSON.stringify( · 2 unmodified lines
107 68
108export const EXECUTOR_SOURCES_ADD_HELP_LINES = [69export const EXECUTOR_SOURCES_ADD_HELP_LINES = [
109 "Source add input shapes:",70 "Source add input shapes:",
110 `- MCP: ${EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE}`,71 ...executorAddableSourceAdapters.flatMap((adapter) =>
111 ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.',72 adapter.executorAddInputSchema
112 `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`,73 && adapter.executorAddInputSignatureWidth !== null
113 " endpoint is the base API URL. specUrl is the OpenAPI document URL.",74 && adapter.executorAddHelpText
114 `- GraphQL: ${EXECUTOR_SOURCES_ADD_GRAPHQL_INPUT_SIGNATURE}`,75 ? [
115 " endpoint is the GraphQL HTTP endpoint.",76 `- ${adapter.displayName}: ${deriveSchemaTypeSignature(adapter.executorAddInputSchema, adapter.executorAddInputSignatureWidth)}`,
77 ...adapter.executorAddHelpText.map((line) => ` ${line}`),
78 ]
79 : [],
80 ),
116 " executor handles the credential setup for you.",81 " executor handles the credential setup for you.",
117] as const;82] as const;
118 83
@@ -168,6 +133,7 @@ const promptForSourceCredentialSelection = (input: { · 49 unmodified lines
168 name?: string | null;133 name?: string | null;
169 namespace?: string | null;134 namespace?: string | null;
170 };135 };
136 credentialSlot: "runtime" | "import";
171 source: Source;137 source: Source;
172 executionId: string;138 executionId: string;
173 interactionId: string;139 interactionId: string;
@@ -200,7 +166,10 @@ const promptForSourceCredentialSelection = (input: { · 26 unmodified lines
200 context: input.invocation,166 context: input.invocation,
201 elicitation: {167 elicitation: {
202 mode: "url",168 mode: "url",
203 message: `Open the secure credential page to connect ${input.source.name}`,169 message:
170 input.credentialSlot === "import"
171 ? `Open the secure credential page to configure import access for ${input.source.name}`
172 : `Open the secure credential page to connect ${input.source.name}`,
204 url: resolveLocalCredentialUrl({173 url: resolveLocalCredentialUrl({
205 baseUrl: input.localServerBaseUrl,174 baseUrl: input.localServerBaseUrl,
206 workspaceId: input.args.workspaceId,175 workspaceId: input.args.workspaceId,
@@ -272,7 +241,7 @@ export const createExecutorToolMap = (input: { · 65 unmodified lines
272 `executor.sources.add:${crypto.randomUUID()}`,241 `executor.sources.add:${crypto.randomUUID()}`,
273 );242 );
274 const preparedArgs: ExecutorAddSourceInput =243 const preparedArgs: ExecutorAddSourceInput =
275 args.kind === "openapi" || args.kind === "graphql"244 args.kind === "openapi"
276 ? {245 ? {
277 ...args,246 ...args,
278 workspaceId: input.workspaceId,247 workspaceId: input.workspaceId,
@@ -280,6 +249,14 @@ export const createExecutorToolMap = (input: { · 1 unmodified line
280 executionId,249 executionId,
281 interactionId,250 interactionId,
282 }251 }
252 : args.kind === "graphql"
253 ? {
254 ...args,
255 workspaceId: input.workspaceId,
256 actorAccountId: input.accountId,
257 executionId,
258 interactionId,
259 }
283 : {260 : {
284 kind: args.kind,261 kind: args.kind,
285 endpoint: args.endpoint,262 endpoint: args.endpoint,
@@ -290,7 +267,7 @@ export const createExecutorToolMap = (input: { · 4 unmodified lines
290 executionId,267 executionId,
291 interactionId,268 interactionId,
292 };269 };
293 const result = await runEffect(270 let result = await runEffect(
294 input.sourceAuthService.addExecutorSource(271 input.sourceAuthService.addExecutorSource(
295 preparedArgs,272 preparedArgs,
296 context?.onElicitation273 context?.onElicitation
@@ -313,61 +290,88 @@ export const createExecutorToolMap = (input: { · 16 unmodified lines
313 }290 }
314 291
315 if (result.kind === "credential_required") {292 if (result.kind === "credential_required") {
316 const preparedHttpArgs = preparedArgs as Extract<293 let pendingResult = result;
294 let pendingArgs = preparedArgs as Extract<
317 ExecutorAddSourceInput,295 ExecutorAddSourceInput,
318 { kind: "openapi" | "graphql" }296 { kind: "openapi" | "graphql" }
319 >;297 >;
320 const selectedAuth = await runEffect(298
321 promptForSourceCredentialSelection({299 while (pendingResult.kind === "credential_required") {
322 args: {300 const selectedAuth = await runEffect(
323 ...preparedHttpArgs,301 promptForSourceCredentialSelection({
324 workspaceId: input.workspaceId,302 args: {
325 sourceId: result.source.id,303 ...pendingArgs,
326 },304 workspaceId: input.workspaceId,
327 source: result.source,305 sourceId: pendingResult.source.id,
328 executionId,306 },
329 interactionId,307 credentialSlot: pendingResult.credentialSlot,
330 path: context?.path ?? asToolPath("executor.sources.add"),308 source: pendingResult.source,
331 sourceKey: context?.sourceKey ?? "executor",309 executionId,
332 localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(),310 interactionId,
333 metadata: context?.metadata,311 path: context?.path ?? asToolPath("executor.sources.add"),
334 invocation: context?.invocation,312 sourceKey: context?.sourceKey ?? "executor",
335 onElicitation: context?.onElicitation,313 localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(),
336 }),314 metadata: context?.metadata,
337 );315 invocation: context?.invocation,
338 316 onElicitation: context?.onElicitation,
339 const completed = await runEffect(317 }),
340 input.sourceAuthService.addExecutorSource(318 );
341 {319
342 ...preparedHttpArgs,320 pendingArgs = pendingResult.credentialSlot === "import"
343 auth: selectedAuth,321 && pendingArgs.importAuthPolicy === "separate"
344 },322 ? {
345 context?.onElicitation323 ...pendingArgs,
346 ? {324 importAuth: selectedAuth,
347 mcpDiscoveryElicitation: {
348 onElicitation: context.onElicitation,
349 path: context.path ?? asToolPath("executor.sources.add"),
350 sourceKey: context.sourceKey,
351 args,
352 metadata: context.metadata,
353 invocation: context.invocation,
354 },
355 }325 }
356 : undefined,326 : {
357 ),327 ...pendingArgs,
358 );328 auth: selectedAuth,
329 };
330
331 const completed = await runEffect(
332 input.sourceAuthService.addExecutorSource(
333 pendingArgs,
334 context?.onElicitation
335 ? {
336 mcpDiscoveryElicitation: {
337 onElicitation: context.onElicitation,
338 path: context.path ?? asToolPath("executor.sources.add"),
339 sourceKey: context.sourceKey,
340 args,
341 metadata: context.metadata,
342 invocation: context.invocation,
343 },
344 }
345 : undefined,
346 ),
347 );
348
349 if (completed.kind === "connected") {
350 return completed.source;
351 }
352
353 if (completed.kind === "credential_required") {
354 pendingResult = completed;
355 continue;
356 }
359 357
360 if (completed.kind === "connected") {358 result = completed;
361 return completed.source;359 break;
362 }360 }
363 361
364 throw new Error(`Source add was not completed for ${result.source.id}`);362 if (pendingResult.kind === "credential_required") {
363 result = pendingResult;
364 }
365 }365 }
366 366
367 if (!context?.onElicitation) {367 if (!context?.onElicitation) {
368 throw new Error("executor.sources.add requires an elicitation-capable host");368 throw new Error("executor.sources.add requires an elicitation-capable host");
369 }369 }
370 370
371 if (result.kind !== "oauth_required") {
372 throw new Error(`Source add did not reach OAuth continuation for ${result.source.id}`);
373 }
374
371 const response: ElicitationResponse = await runEffect(375 const response: ElicitationResponse = await runEffect(
372 context.onElicitation({376 context.onElicitation({
373 interactionId,377 interactionId,
packages/control-plane/src/runtime/graphql-tools.ts+615−326
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10 typeSignatureFromSchemaJson,10 typeSignatureFromSchemaJson,
11} from "@executor/codemode-core";11} from "@executor/codemode-core";
12import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
13import {14import {
14 buildClientSchema,15 buildClientSchema,
15 getIntrospectionQuery,16 getIntrospectionQuery,
@@ -25,13 +26,18 @@ import { · 9 unmodified lines
25 isUnionType,26 isUnionType,
26 type GraphQLArgument,27 type GraphQLArgument,
27 type GraphQLField,28 type GraphQLField,
29 type GraphQLInputObjectType,
28 type GraphQLInputType,30 type GraphQLInputType,
31 type GraphQLInterfaceType,
32 type GraphQLNamedType,
33 type GraphQLObjectType,
29 type GraphQLOutputType,34 type GraphQLOutputType,
30 type GraphQLSchema,35 type GraphQLSchema,
31 type IntrospectionQuery,36 type IntrospectionQuery,
32} from "graphql";37} from "graphql";
33 38
34type JsonSchema = Record<string, unknown>;39type JsonSchema = Record<string, unknown>;
40type GraphqlSchemaRefTable = Record<string, string>;
35 41
36type GraphqlToolKind = "request" | "field";42type GraphqlToolKind = "request" | "field";
37 43
@@ -72,6 +78,7 @@ export type GraphqlToolManifest = { · 34 unmodified lines
72 queryTypeName: string | null;78 queryTypeName: string | null;
73 mutationTypeName: string | null;79 mutationTypeName: string | null;
74 subscriptionTypeName: string | null;80 subscriptionTypeName: string | null;
81 schemaRefTable?: GraphqlSchemaRefTable;
75 tools: readonly GraphqlToolManifestEntry[];82 tools: readonly GraphqlToolManifestEntry[];
76};83};
77 84
@@ -98,6 +105,28 @@ export type GraphqlToolPresentation = { · 20 unmodified lines
98 providerDataJson: string;105 providerDataJson: string;
99};106};
100 107
108const GraphqlToolKindSchema = Schema.Literal("request", "field");
109
110const GraphqlOperationTypeSchema = Schema.Literal("query", "mutation");
111
112export const GraphqlToolProviderDataSchema = Schema.Struct({
113 kind: Schema.Literal("graphql"),
114 toolKind: GraphqlToolKindSchema,
115 toolId: Schema.String,
116 rawToolId: Schema.NullOr(Schema.String),
117 group: Schema.NullOr(Schema.String),
118 leaf: Schema.NullOr(Schema.String),
119 fieldName: Schema.NullOr(Schema.String),
120 operationType: Schema.NullOr(GraphqlOperationTypeSchema),
121 operationName: Schema.NullOr(Schema.String),
122 operationDocument: Schema.NullOr(Schema.String),
123 queryTypeName: Schema.NullOr(Schema.String),
124 mutationTypeName: Schema.NullOr(Schema.String),
125 subscriptionTypeName: Schema.NullOr(Schema.String),
126});
127
128export type GraphqlToolProviderData = typeof GraphqlToolProviderDataSchema.Type;
129
101type SelectedGraphqlOutput = {130type SelectedGraphqlOutput = {
102 selectionSet: string;131 selectionSet: string;
103 schema: JsonSchema;132 schema: JsonSchema;
@@ -218,11 +247,11 @@ const normalizeHttpUrl = (value: string): string => { · 114 unmodified lines
218};247};
219 248
220const graphqlToolError = (message: string, cause?: unknown): Error =>249const graphqlToolError = (message: string, cause?: unknown): Error =>
221 new Error(250 new Error(cause instanceof Error ? `${message}: ${cause.message}` : message);
222 cause instanceof Error ? `${message}: ${cause.message}` : message,
223 );
224 251
225const parseGraphqlResponseBody = async (response: Response): Promise<unknown> => {252const parseGraphqlResponseBody = async (
253 response: Response,
254): Promise<unknown> => {
226 const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";255 const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
227 if (contentType.includes("application/json")) {256 if (contentType.includes("application/json")) {
228 return await response.json();257 return await response.json();
@@ -271,10 +300,8 @@ const withSchemaDescription = ( · 42 unmodified lines
271 return trimmed ? { ...schema, description: trimmed } : schema;300 return trimmed ? { ...schema, description: trimmed } : schema;
272};301};
273 302
274const withSchemaDefault = (303const withSchemaDefault = (schema: JsonSchema, value: unknown): JsonSchema =>
275 schema: JsonSchema,304 value !== undefined ? { ...schema, default: value } : schema;
276 value: unknown,
277): JsonSchema => (value !== undefined ? { ...schema, default: value } : schema);
278 305
279const withSchemaDeprecation = (306const withSchemaDeprecation = (
280 schema: JsonSchema,307 schema: JsonSchema,
@@ -286,7 +313,9 @@ const withSchemaDeprecation = ( · 5 unmodified lines
286 : schema;313 : schema;
287};314};
288 315
289const introspectionQueryFromDocument = (documentText: string): IntrospectionQuery => {316const introspectionQueryFromDocument = (
317 documentText: string,
318): IntrospectionQuery => {
290 let parsed: unknown;319 let parsed: unknown;
291 try {320 try {
292 parsed = JSON.parse(documentText) as unknown;321 parsed = JSON.parse(documentText) as unknown;
@@ -416,68 +445,286 @@ const scalarExampleValue = (name: string): unknown => { · 123 unmodified lines
416 }445 }
417};446};
418 447
419const schemaForGraphqlInputType = (448const GRAPHQL_SCHEMA_REF_PREFIX = "#/$defs/graphql";
420 type: GraphQLInputType,
421 depth = 0,
422): JsonSchema => {
423 if (isNonNullType(type)) {
424 return schemaForGraphqlInputType(type.ofType, depth);
425 }
426 449
427 if (isListType(type)) {450const graphqlScalarRef = (name: string): string =>
428 return {451 `${GRAPHQL_SCHEMA_REF_PREFIX}/scalars/${name}`;
429 type: "array",
430 items: schemaForGraphqlInputType(type.ofType, depth + 1),
431 };
432 }
433 452
434 const namedType = getNamedType(type);453const graphqlEnumRef = (name: string): string =>
435 if (isScalarType(namedType)) {454 `${GRAPHQL_SCHEMA_REF_PREFIX}/enums/${name}`;
436 return scalarInputSchema(namedType.name);
437 }
438 455
439 if (isEnumType(namedType)) {456const graphqlInputObjectRef = (name: string): string =>
440 return {457 `${GRAPHQL_SCHEMA_REF_PREFIX}/input/${name}`;
441 type: "string",
442 enum: namedType.getValues().map((value) => value.name),
443 };
444 }
445 458
446 if (isInputObjectType(namedType)) {459const graphqlOutputTypeRef = (name: string, depth: number): string =>
447 if (depth >= 2) {460 `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`;
448 return {461
462const schemaRef = (ref: string): JsonSchema => ({
463 $ref: ref,
464});
465
466const typenameOnlyObjectSchema = (): JsonSchema => ({
467 type: "object",
468 properties: {
469 __typename: { type: "string" },
470 },
471 required: ["__typename"],
472 additionalProperties: false,
473});
474
475const createGraphqlSchemaRefTableBuilder = () => {
476 const refTable: GraphqlSchemaRefTable = {};
477 const outputSelectionsByRef = new Map<string, SelectedGraphqlOutput>();
478
479 const defineRefSchema = (ref: string, schema: JsonSchema): void => {
480 refTable[ref] = JSON.stringify(schema);
481 };
482
483 const ensureScalarRef = (name: string): JsonSchema => {
484 const ref = graphqlScalarRef(name);
485 if (!(ref in refTable)) {
486 defineRefSchema(ref, scalarInputSchema(name));
487 }
488
489 return schemaRef(ref);
490 };
491
492 const ensureEnumRef = (
493 name: string,
494 values: readonly string[],
495 ): JsonSchema => {
496 const ref = graphqlEnumRef(name);
497 if (!(ref in refTable)) {
498 defineRefSchema(ref, {
499 type: "string",
500 enum: [...values],
501 });
502 }
503
504 return schemaRef(ref);
505 };
506
507 const ensureInputObjectRef = (
508 namedType: GraphQLInputObjectType,
509 ): JsonSchema => {
510 const ref = graphqlInputObjectRef(namedType.name);
511 if (!(ref in refTable)) {
512 defineRefSchema(ref, {});
513
514 const fields = Object.values(namedType.getFields());
515 const properties = Object.fromEntries(
516 fields.map((field) => {
517 const schema = inputSchemaForType(field.type);
518 const enriched = withSchemaDeprecation(
519 withSchemaDefault(
520 withSchemaDescription(schema, field.description),
521 field.defaultValue,
522 ),
523 field.deprecationReason,
524 );
525 return [field.name, enriched];
526 }),
527 );
528 const required = fields
529 .filter(
530 (field) =>
531 isNonNullType(field.type) && field.defaultValue === undefined,
532 )
533 .map((field) => field.name);
534
535 defineRefSchema(ref, {
449 type: "object",536 type: "object",
450 additionalProperties: true,537 properties,
538 ...(required.length > 0 ? { required } : {}),
539 additionalProperties: false,
540 });
541 }
542
543 return schemaRef(ref);
544 };
545
546 const inputSchemaForType = (type: GraphQLInputType): JsonSchema => {
547 if (isNonNullType(type)) {
548 return inputSchemaForType(type.ofType);
549 }
550
551 if (isListType(type)) {
552 return {
553 type: "array",
554 items: inputSchemaForType(type.ofType),
451 };555 };
452 }556 }
453 557
454 const fields = Object.values(namedType.getFields());558 const namedType = getNamedType(type);
455 const properties = Object.fromEntries(559 if (isScalarType(namedType)) {
456 fields.map((field) => {560 return ensureScalarRef(namedType.name);
457 const schema = schemaForGraphqlInputType(field.type, depth + 1);561 }
458 const enriched = withSchemaDeprecation(562
459 withSchemaDefault(563 if (isEnumType(namedType)) {
460 withSchemaDescription(schema, field.description),564 return ensureEnumRef(
461 field.defaultValue,565 namedType.name,
462 ),566 namedType.getValues().map((value) => value.name),
463 field.deprecationReason,567 );
464 );568 }
465 return [field.name, enriched];569
466 }),570 if (isInputObjectType(namedType)) {
571 return ensureInputObjectRef(namedType);
572 }
573
574 return {};
575 };
576
577 const selectedOutputForNamedType = (
578 namedType: GraphQLNamedType,
579 depth: number,
580 ): SelectedGraphqlOutput => {
581 if (isScalarType(namedType)) {
582 return {
583 selectionSet: "",
584 schema: ensureScalarRef(namedType.name),
585 };
586 }
587
588 if (isEnumType(namedType)) {
589 return {
590 selectionSet: "",
591 schema: ensureEnumRef(
592 namedType.name,
593 namedType.getValues().map((value) => value.name),
594 ),
595 };
596 }
597
598 const ref = graphqlOutputTypeRef(namedType.name, depth);
599 const cached = outputSelectionsByRef.get(ref);
600 if (cached) {
601 return cached;
602 }
603
604 if (isUnionType(namedType)) {
605 const result = {
606 selectionSet: "{ __typename }",
607 schema: schemaRef(ref),
608 } satisfies SelectedGraphqlOutput;
609 outputSelectionsByRef.set(ref, result);
610 defineRefSchema(ref, typenameOnlyObjectSchema());
611 return result;
612 }
613
614 if (!isObjectType(namedType) && !isInterfaceType(namedType)) {
615 return {
616 selectionSet: "{ __typename }",
617 schema: typenameOnlyObjectSchema(),
618 };
619 }
620
621 const fallback = {
622 selectionSet: "{ __typename }",
623 schema: schemaRef(ref),
624 } satisfies SelectedGraphqlOutput;
625 outputSelectionsByRef.set(ref, fallback);
626
627 if (depth >= 2) {
628 defineRefSchema(ref, typenameOnlyObjectSchema());
629 return fallback;
630 }
631
632 const fields = Object.values(namedType.getFields()).filter(
633 (field) => !field.name.startsWith("__"),
634 );
635 const leafFields = fields.filter((field) =>
636 isLeafType(getNamedType(field.type)),
637 );
638 const nestedFields = fields.filter(
639 (field) => !isLeafType(getNamedType(field.type)),
467 );640 );
468 const required = fields
469 .filter((field) => isNonNullType(field.type) && field.defaultValue === undefined)
470 .map((field) => field.name);
471 641
472 return {642 const selectedLeafFields = pickPreferredFields({
643 fields: leafFields,
644 preferredNames: PREFERRED_LEAF_FIELD_NAMES,
645 limit: 3,
646 });
647 const selectedNestedFields = pickPreferredFields({
648 fields: nestedFields,
649 preferredNames: PREFERRED_NESTED_FIELD_NAMES,
650 limit: 2,
651 });
652 const selectedFields = dedupeFields([
653 ...selectedLeafFields,
654 ...selectedNestedFields,
655 ]);
656
657 if (selectedFields.length === 0) {
658 defineRefSchema(ref, typenameOnlyObjectSchema());
659 return fallback;
660 }
661
662 const selectionParts: string[] = [];
663 const properties: Record<string, JsonSchema> = {};
664 const required: string[] = [];
665
666 for (const field of selectedFields) {
667 const selected = selectedOutputForType(field.type, depth + 1);
668 const propertySchema = withSchemaDeprecation(
669 withSchemaDescription(selected.schema, field.description),
670 field.deprecationReason,
671 );
672
673 properties[field.name] = propertySchema;
674 required.push(field.name);
675 selectionParts.push(
676 selected.selectionSet.length > 0
677 ? `${field.name} ${selected.selectionSet}`
678 : field.name,
679 );
680 }
681
682 properties.__typename = { type: "string" };
683 required.push("__typename");
684 selectionParts.push("__typename");
685
686 defineRefSchema(ref, {
473 type: "object",687 type: "object",
474 properties,688 properties,
475 ...(required.length > 0 ? { required } : {}),689 required,
476 additionalProperties: false,690 additionalProperties: false,
477 };691 });
478 }
479 692
480 return {};693 const result = {
694 selectionSet: `{ ${selectionParts.join(" ")} }`,
695 schema: schemaRef(ref),
696 } satisfies SelectedGraphqlOutput;
697 outputSelectionsByRef.set(ref, result);
698 return result;
699 };
700
701 const selectedOutputForType = (
702 type: GraphQLOutputType,
703 depth = 0,
704 ): SelectedGraphqlOutput => {
705 if (isNonNullType(type)) {
706 return selectedOutputForType(type.ofType, depth);
707 }
708
709 if (isListType(type)) {
710 const selectedItem = selectedOutputForType(type.ofType, depth);
711 return {
712 selectionSet: selectedItem.selectionSet,
713 schema: {
714 type: "array",
715 items: selectedItem.schema,
716 },
717 };
718 }
719
720 return selectedOutputForNamedType(getNamedType(type), depth);
721 };
722
723 return {
724 refTable,
725 inputSchemaForType,
726 selectedOutputForType,
727 };
481};728};
482 729
483const exampleValueForGraphqlInputType = (730const exampleValueForGraphqlInputType = (
@@ -510,14 +757,14 @@ const exampleValueForGraphqlInputType = ( · 26 unmodified lines
510 const requiredFields = fields.filter(757 const requiredFields = fields.filter(
511 (field) => isNonNullType(field.type) && field.defaultValue === undefined,758 (field) => isNonNullType(field.type) && field.defaultValue === undefined,
512 );759 );
513 const fieldsForExample = requiredFields.length > 0760 const fieldsForExample =
514 ? requiredFields761 requiredFields.length > 0 ? requiredFields : fields.slice(0, 1);
515 : fields.slice(0, 1);
516 762
517 return Object.fromEntries(763 return Object.fromEntries(
518 fieldsForExample.map((field) => [764 fieldsForExample.map((field) => [
519 field.name,765 field.name,
520 field.defaultValue ?? exampleValueForGraphqlInputType(field.type, depth + 1),766 field.defaultValue ??
767 exampleValueForGraphqlInputType(field.type, depth + 1),
521 ]),768 ]),
522 );769 );
523 }770 }
@@ -525,35 +772,9 @@ const exampleValueForGraphqlInputType = ( · 1 unmodified line
525 return {};772 return {};
526};773};
527 774
528const scalarOutputSchema = (name: string): JsonSchema => scalarInputSchema(name);775const dedupeFields = <T extends { name: string }>(
529 776 fields: readonly T[],
530const selectedLeafOutputForType = (type: GraphQLOutputType): SelectedGraphqlOutput => {777): T[] => {
531 const namedType = getNamedType(type);
532
533 if (isScalarType(namedType)) {
534 return {
535 selectionSet: "",
536 schema: scalarOutputSchema(namedType.name),
537 };
538 }
539
540 if (isEnumType(namedType)) {
541 return {
542 selectionSet: "",
543 schema: {
544 type: "string",
545 enum: namedType.getValues().map((value) => value.name),
546 },
547 };
548 }
549
550 return {
551 selectionSet: "",
552 schema: {},
553 };
554};
555
556const dedupeFields = <T extends { name: string }>(fields: readonly T[]): T[] => {
557 const seen = new Set<string>();778 const seen = new Set<string>();
558 const deduped: T[] = [];779 const deduped: T[] = [];
559 780
@@ -585,155 +806,6 @@ const pickPreferredFields = <T extends { name: string }>(input: { · 25 unmodified lines
585 return dedupeFields([...matches, ...input.fields]).slice(0, input.limit);806 return dedupeFields([...matches, ...input.fields]).slice(0, input.limit);
586};807};
587 808
588const selectedObjectOutputForType = (
589 type: GraphQLOutputType,
590 depth: number,
591 seenTypeNames: ReadonlySet<string>,
592): SelectedGraphqlOutput => {
593 const namedType = getNamedType(type);
594 if (!isObjectType(namedType) && !isInterfaceType(namedType)) {
595 return {
596 selectionSet: "{ __typename }",
597 schema: {
598 type: "object",
599 properties: {
600 __typename: { type: "string" },
601 },
602 required: ["__typename"],
603 additionalProperties: false,
604 },
605 };
606 }
607
608 if (depth >= 2 || seenTypeNames.has(namedType.name)) {
609 return {
610 selectionSet: "{ __typename }",
611 schema: {
612 type: "object",
613 properties: {
614 __typename: { type: "string" },
615 },
616 required: ["__typename"],
617 additionalProperties: false,
618 },
619 };
620 }
621
622 const nextSeen = new Set(seenTypeNames);
623 nextSeen.add(namedType.name);
624
625 const fields = Object.values(namedType.getFields()).filter(
626 (field) => !field.name.startsWith("__"),
627 );
628 const leafFields = fields.filter((field) => isLeafType(getNamedType(field.type)));
629 const nestedFields = fields.filter((field) => !isLeafType(getNamedType(field.type)));
630
631 const selectedLeafFields = pickPreferredFields({
632 fields: leafFields,
633 preferredNames: PREFERRED_LEAF_FIELD_NAMES,
634 limit: 3,
635 });
636 const selectedNestedFields = pickPreferredFields({
637 fields: nestedFields,
638 preferredNames: PREFERRED_NESTED_FIELD_NAMES,
639 limit: 2,
640 });
641 const selectedFields = dedupeFields([
642 ...selectedLeafFields,
643 ...selectedNestedFields,
644 ]);
645
646 if (selectedFields.length === 0) {
647 return {
648 selectionSet: "{ __typename }",
649 schema: {
650 type: "object",
651 properties: {
652 __typename: { type: "string" },
653 },
654 required: ["__typename"],
655 additionalProperties: false,
656 },
657 };
658 }
659
660 const selectionParts: string[] = [];
661 const properties: Record<string, JsonSchema> = {};
662 const required: string[] = [];
663
664 for (const field of selectedFields) {
665 const selected = selectedOutputForType(field.type, depth + 1, nextSeen);
666 const propertySchema = withSchemaDeprecation(
667 withSchemaDescription(selected.schema, field.description),
668 field.deprecationReason,
669 );
670
671 properties[field.name] = propertySchema;
672 required.push(field.name);
673 selectionParts.push(
674 selected.selectionSet.length > 0
675 ? `${field.name} ${selected.selectionSet}`
676 : field.name,
677 );
678 }
679
680 properties.__typename = { type: "string" };
681 required.push("__typename");
682 selectionParts.push("__typename");
683
684 return {
685 selectionSet: `{ ${selectionParts.join(" ")} }`,
686 schema: {
687 type: "object",
688 properties,
689 required,
690 additionalProperties: false,
691 },
692 };
693};
694
695const selectedOutputForType = (
696 type: GraphQLOutputType,
697 depth = 0,
698 seenTypeNames: ReadonlySet<string> = new Set<string>(),
699): SelectedGraphqlOutput => {
700 if (isNonNullType(type)) {
701 return selectedOutputForType(type.ofType, depth, seenTypeNames);
702 }
703
704 if (isListType(type)) {
705 const selectedItem = selectedOutputForType(type.ofType, depth, seenTypeNames);
706 return {
707 selectionSet: selectedItem.selectionSet,
708 schema: {
709 type: "array",
710 items: selectedItem.schema,
711 },
712 };
713 }
714
715 const namedType = getNamedType(type);
716 if (isLeafType(namedType)) {
717 return selectedLeafOutputForType(type);
718 }
719
720 if (isUnionType(namedType)) {
721 return {
722 selectionSet: "{ __typename }",
723 schema: {
724 type: "object",
725 properties: {
726 __typename: { type: "string" },
727 },
728 required: ["__typename"],
729 additionalProperties: false,
730 },
731 };
732 }
733
734 return selectedObjectOutputForType(type, depth, seenTypeNames);
735};
736
737const printGraphqlType = (type: GraphQLInputType): string => {809const printGraphqlType = (type: GraphQLInputType): string => {
738 if (isNonNullType(type)) {810 if (isNonNullType(type)) {
739 return `${printGraphqlType(type.ofType)}!`;811 return `${printGraphqlType(type.ofType)}!`;
@@ -746,10 +818,13 @@ const printGraphqlType = (type: GraphQLInputType): string => { · 6 unmodified lines
746 return type.name;818 return type.name;
747};819};
748 820
749const inputSchemaForFieldArguments = (args: readonly GraphQLArgument[]): JsonSchema => {821const inputSchemaForFieldArguments = (
822 args: readonly GraphQLArgument[],
823 bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>,
824): JsonSchema => {
750 const properties = Object.fromEntries(825 const properties = Object.fromEntries(
751 args.map((arg) => {826 args.map((arg) => {
752 const schema = schemaForGraphqlInputType(arg.type);827 const schema = bundleBuilder.inputSchemaForType(arg.type);
753 const enriched = withSchemaDeprecation(828 const enriched = withSchemaDeprecation(
754 withSchemaDefault(829 withSchemaDefault(
755 withSchemaDescription(schema, arg.description),830 withSchemaDescription(schema, arg.description),
@@ -781,8 +856,11 @@ const inputSchemaForFieldArguments = (args: readonly GraphQLArgument[]): JsonSch · 25 unmodified lines
781 };856 };
782};857};
783 858
784const outputEnvelopeSchemaForField = (fieldType: GraphQLOutputType): JsonSchema => {859const outputEnvelopeSchemaForField = (
785 const selectedOutput = selectedOutputForType(fieldType);860 fieldType: GraphQLOutputType,
861 bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>,
862): JsonSchema => {
863 const selectedOutput = bundleBuilder.selectedOutputForType(fieldType);
786 864
787 return {865 return {
788 type: "object",866 type: "object",
@@ -801,7 +879,9 @@ const outputEnvelopeSchemaForField = (fieldType: GraphQLOutputType): JsonSchema · 12 unmodified lines
801 };879 };
802};880};
803 881
804const exampleInputJsonForField = (args: readonly GraphQLArgument[]): string | undefined => {882const exampleInputJsonForField = (
883 args: readonly GraphQLArgument[],
884): string | undefined => {
805 if (args.length === 0) {885 if (args.length === 0) {
806 return JSON.stringify({});886 return JSON.stringify({});
807 }887 }
@@ -809,7 +889,8 @@ const exampleInputJsonForField = (args: readonly GraphQLArgument[]): string | un · 1 unmodified line
809 const requiredArgs = args.filter(889 const requiredArgs = args.filter(
810 (arg) => isNonNullType(arg.type) && arg.defaultValue === undefined,890 (arg) => isNonNullType(arg.type) && arg.defaultValue === undefined,
811 );891 );
812 const argsForExample = requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1);892 const argsForExample =
893 requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1);
813 const example = Object.fromEntries(894 const example = Object.fromEntries(
814 argsForExample.map((arg) => [895 argsForExample.map((arg) => [
815 arg.name,896 arg.name,
@@ -825,8 +906,11 @@ const buildGraphqlFieldOperationDocument = (input: { · 9 unmodified lines
825 fieldName: string;906 fieldName: string;
826 args: readonly GraphQLArgument[];907 args: readonly GraphQLArgument[];
827 fieldType: GraphQLOutputType;908 fieldType: GraphQLOutputType;
909 bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>;
828}): { operationName: string; operationDocument: string } => {910}): { operationName: string; operationDocument: string } => {
829 const selectedOutput = selectedOutputForType(input.fieldType);911 const selectedOutput = input.bundleBuilder.selectedOutputForType(
912 input.fieldType,
913 );
830 const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`;914 const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`;
831 const variableDefinitions = input.args915 const variableDefinitions = input.args
832 .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`)916 .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`)
@@ -834,12 +918,14 @@ const buildGraphqlFieldOperationDocument = (input: { · 1 unmodified line
834 const fieldArguments = input.args918 const fieldArguments = input.args
835 .map((arg) => `${arg.name}: $${arg.name}`)919 .map((arg) => `${arg.name}: $${arg.name}`)
836 .join(", ");920 .join(", ");
837 const fieldCall = fieldArguments.length > 0921 const fieldCall =
838 ? `${input.fieldName}(${fieldArguments})`922 fieldArguments.length > 0
839 : input.fieldName;923 ? `${input.fieldName}(${fieldArguments})`
840 const selectionSuffix = selectedOutput.selectionSet.length > 0924 : input.fieldName;
841 ? ` ${selectedOutput.selectionSet}`925 const selectionSuffix =
842 : "";926 selectedOutput.selectionSet.length > 0
927 ? ` ${selectedOutput.selectionSet}`
928 : "";
843 929
844 return {930 return {
845 operationName,931 operationName,
@@ -907,23 +993,30 @@ const resolveGraphqlFieldToolIds = ( · 61 unmodified lines
907 }993 }
908 };994 };
909 995
910 applyDuplicates((draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`);996 applyDuplicates(
911 applyDuplicates((draft) =>997 (draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`,
912 `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`,998 );
999 applyDuplicates(
1000 (draft) =>
1001 `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`,
913 );1002 );
914 1003
915 return staged1004 return staged
916 .sort((left, right) =>1005 .sort(
917 left.toolId.localeCompare(right.toolId)1006 (left, right) =>
918 || left.fieldName.localeCompare(right.fieldName)1007 left.toolId.localeCompare(right.toolId) ||
919 || left.operationType.localeCompare(right.operationType),1008 left.fieldName.localeCompare(right.fieldName) ||
1009 left.operationType.localeCompare(right.operationType),
920 )1010 )
921 .map((draft) => ({ ...draft }));1011 .map((draft) => ({ ...draft }));
922};1012};
923 1013
924const fieldToolDraftsFromRootType = (input: {1014const fieldToolDraftsFromRootType = (input: {
925 rootType: ReturnType<GraphQLSchema["getQueryType"]> | ReturnType<GraphQLSchema["getMutationType"]>;1015 rootType:
1016 | ReturnType<GraphQLSchema["getQueryType"]>
1017 | ReturnType<GraphQLSchema["getMutationType"]>;
926 operationType: GraphqlOperationType;1018 operationType: GraphqlOperationType;
1019 bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>;
927}): GraphqlFieldToolDraft[] => {1020}): GraphqlFieldToolDraft[] => {
928 if (!input.rootType) {1021 if (!input.rootType) {
929 return [];1022 return [];
@@ -933,14 +1026,20 @@ const fieldToolDraftsFromRootType = (input: { · 3 unmodified lines
933 .filter((field) => !field.name.startsWith("__"))1026 .filter((field) => !field.name.startsWith("__"))
934 .map((field) => {1027 .map((field) => {
935 const leaf = toCamelCase(field.name);1028 const leaf = toCamelCase(field.name);
936 const outputSchemaJson = JSON.stringify(outputEnvelopeSchemaForField(field.type));1029 const outputSchemaJson = JSON.stringify(
937 const inputSchemaJson = JSON.stringify(inputSchemaForFieldArguments(field.args));1030 outputEnvelopeSchemaForField(field.type, input.bundleBuilder),
938 const { operationName, operationDocument } = buildGraphqlFieldOperationDocument({1031 );
939 operationType: input.operationType,1032 const inputSchemaJson = JSON.stringify(
940 fieldName: field.name,1033 inputSchemaForFieldArguments(field.args, input.bundleBuilder),
941 args: field.args,1034 );
942 fieldType: field.type,1035 const { operationName, operationDocument } =
943 });1036 buildGraphqlFieldOperationDocument({
1037 operationType: input.operationType,
1038 fieldName: field.name,
1039 args: field.args,
1040 fieldType: field.type,
1041 bundleBuilder: input.bundleBuilder,
1042 });
944 1043
945 return {1044 return {
946 kind: "field",1045 kind: "field",
@@ -983,14 +1082,17 @@ export const extractGraphqlManifest = ( · 36 unmodified lines
983 const introspection = introspectionQueryFromDocument(documentText);1082 const introspection = introspectionQueryFromDocument(documentText);
984 const schema = buildClientSchema(introspection);1083 const schema = buildClientSchema(introspection);
985 const sourceHash = createGraphqlSourceHash(documentText);1084 const sourceHash = createGraphqlSourceHash(documentText);
1085 const bundleBuilder = createGraphqlSchemaRefTableBuilder();
986 const fieldTools = resolveGraphqlFieldToolIds([1086 const fieldTools = resolveGraphqlFieldToolIds([
987 ...fieldToolDraftsFromRootType({1087 ...fieldToolDraftsFromRootType({
988 rootType: schema.getQueryType(),1088 rootType: schema.getQueryType(),
989 operationType: "query",1089 operationType: "query",
1090 bundleBuilder,
990 }),1091 }),
991 ...fieldToolDraftsFromRootType({1092 ...fieldToolDraftsFromRootType({
992 rootType: schema.getMutationType(),1093 rootType: schema.getMutationType(),
993 operationType: "mutation",1094 operationType: "mutation",
1095 bundleBuilder,
994 }),1096 }),
995 ]);1097 ]);
996 1098
@@ -1000,10 +1102,10 @@ export const extractGraphqlManifest = ( · 3 unmodified lines
1000 queryTypeName: schema.getQueryType()?.name ?? null,1102 queryTypeName: schema.getQueryType()?.name ?? null,
1001 mutationTypeName: schema.getMutationType()?.name ?? null,1103 mutationTypeName: schema.getMutationType()?.name ?? null,
1002 subscriptionTypeName: schema.getSubscriptionType()?.name ?? null,1104 subscriptionTypeName: schema.getSubscriptionType()?.name ?? null,
1003 tools: [1105 ...(Object.keys(bundleBuilder.refTable).length > 0
1004 ...fieldTools,1106 ? { schemaRefTable: bundleBuilder.refTable }
1005 requestToolManifestEntry(sourceName),1107 : {}),
1006 ],1108 tools: [...fieldTools, requestToolManifestEntry(sourceName)],
1007 } satisfies GraphqlToolManifest;1109 } satisfies GraphqlToolManifest;
1008 },1110 },
1009 catch: (cause) =>1111 catch: (cause) =>
@@ -1024,23 +1126,38 @@ export const compileGraphqlToolDefinitions = ( · 14 unmodified lines
1024 operationType: tool.kind === "field" ? tool.operationType : null,1126 operationType: tool.kind === "field" ? tool.operationType : null,
1025 operationName: tool.kind === "field" ? tool.operationName : null,1127 operationName: tool.kind === "field" ? tool.operationName : null,
1026 operationDocument: tool.kind === "field" ? tool.operationDocument : null,1128 operationDocument: tool.kind === "field" ? tool.operationDocument : null,
1027 searchTerms: tool.kind === "field" ? tool.searchTerms : ["request", "graphql", "query", "mutation"],1129 searchTerms:
1130 tool.kind === "field"
1131 ? tool.searchTerms
1132 : ["request", "graphql", "query", "mutation"],
1028 }));1133 }));
1029 1134
1030export const buildGraphqlToolPresentation = (input: {1135export const buildGraphqlToolPresentation = (input: {
1031 manifest: GraphqlToolManifest;1136 manifest: GraphqlToolManifest;
1032 definition: GraphqlToolDefinition;1137 definition: GraphqlToolDefinition;
1033}): GraphqlToolPresentation => {1138}): GraphqlToolPresentation => {
1034 const entry = input.manifest.tools.find((tool) => tool.toolId === input.definition.toolId);1139 const entry = input.manifest.tools.find(
1140 (tool) => tool.toolId === input.definition.toolId,
1141 );
1035 const inputSchemaJson = entry?.inputSchemaJson;1142 const inputSchemaJson = entry?.inputSchemaJson;
1036 const outputSchemaJson = entry?.outputSchemaJson;1143 const outputSchemaJson = entry?.outputSchemaJson;
1037 1144
1038 return {1145 return {
1039 inputType: typeSignatureFromSchemaJson(inputSchemaJson, "unknown", Infinity),1146 inputType: typeSignatureFromSchemaJson(
1040 outputType: typeSignatureFromSchemaJson(outputSchemaJson, "unknown", Infinity),1147 inputSchemaJson,
1148 "unknown",
1149 Infinity,
1150 ),
1151 outputType: typeSignatureFromSchemaJson(
1152 outputSchemaJson,
1153 "unknown",
1154 Infinity,
1155 ),
1041 ...(inputSchemaJson ? { inputSchemaJson } : {}),1156 ...(inputSchemaJson ? { inputSchemaJson } : {}),
1042 ...(outputSchemaJson ? { outputSchemaJson } : {}),1157 ...(outputSchemaJson ? { outputSchemaJson } : {}),
1043 ...(entry?.exampleInputJson ? { exampleInputJson: entry.exampleInputJson } : {}),1158 ...(entry?.exampleInputJson
1159 ? { exampleInputJson: entry.exampleInputJson }
1160 : {}),
1044 providerDataJson: JSON.stringify({1161 providerDataJson: JSON.stringify({
1045 kind: "graphql",1162 kind: "graphql",
1046 toolKind: entry?.kind ?? "request",1163 toolKind: entry?.kind ?? "request",
@@ -1051,11 +1168,196 @@ export const buildGraphqlToolPresentation = (input: { · 4 unmodified lines
1051 fieldName: input.definition.fieldName,1168 fieldName: input.definition.fieldName,
1052 operationType: input.definition.operationType,1169 operationType: input.definition.operationType,
1053 operationName: input.definition.operationName,1170 operationName: input.definition.operationName,
1171 operationDocument: input.definition.operationDocument,
1054 queryTypeName: input.manifest.queryTypeName,1172 queryTypeName: input.manifest.queryTypeName,
1055 mutationTypeName: input.manifest.mutationTypeName,1173 mutationTypeName: input.manifest.mutationTypeName,
1056 subscriptionTypeName: input.manifest.subscriptionTypeName,1174 subscriptionTypeName: input.manifest.subscriptionTypeName,
1175 } satisfies GraphqlToolProviderData),
1176 };
1177};
1178
1179const decodeGraphqlToolProviderDataJson = Schema.decodeUnknownEither(
1180 Schema.parseJson(GraphqlToolProviderDataSchema),
1181);
1182
1183export const decodeGraphqlSchemaRefTableJson = Schema.decodeUnknownEither(
1184 Schema.parseJson(
1185 Schema.Record({
1186 key: Schema.String,
1187 value: Schema.Unknown,
1057 }),1188 }),
1189 ),
1190);
1191
1192const setNestedSchemaProperty = (
1193 target: Record<string, unknown>,
1194 path: readonly string[],
1195 value: unknown,
1196): void => {
1197 if (path.length === 0) {
1198 return;
1199 }
1200
1201 const [head, ...rest] = path;
1202 if (!head) {
1203 return;
1204 }
1205
1206 if (rest.length === 0) {
1207 target[head] = value;
1208 return;
1209 }
1210
1211 const next = asRecord(target[head]);
1212 target[head] = next;
1213 setNestedSchemaProperty(next, rest, value);
1214};
1215
1216const materializeSchemaWithRefDefinitions = (input: {
1217 schemaJson: string | undefined;
1218 refTable?: Readonly<Record<string, unknown>>;
1219}): Record<string, unknown> => {
1220 if (!input.schemaJson) {
1221 return {};
1222 }
1223
1224 let rootSchema: Record<string, unknown>;
1225 try {
1226 rootSchema = asRecord(JSON.parse(input.schemaJson) as unknown);
1227 } catch {
1228 return {};
1229 }
1230
1231 if (!input.refTable || Object.keys(input.refTable).length === 0) {
1232 return rootSchema;
1233 }
1234
1235 const defsRoot = asRecord(rootSchema.$defs);
1236 for (const [ref, value] of Object.entries(input.refTable)) {
1237 if (!ref.startsWith("#/$defs/")) {
1238 continue;
1239 }
1240
1241 const materializedValue =
1242 typeof value === "string"
1243 ? (() => {
1244 try {
1245 return JSON.parse(value) as unknown;
1246 } catch {
1247 return value;
1248 }
1249 })()
1250 : value;
1251 const path = ref
1252 .slice("#/$defs/".length)
1253 .split("/")
1254 .filter((segment) => segment.length > 0);
1255 setNestedSchemaProperty(defsRoot, path, materializedValue);
1256 }
1257
1258 return Object.keys(defsRoot).length > 0
1259 ? { ...rootSchema, $defs: defsRoot }
1260 : rootSchema;
1261};
1262
1263export const createGraphqlToolFromPersistedOperation = (input: {
1264 path: string;
1265 sourceKey: string;
1266 endpoint: string;
1267 description?: string;
1268 inputSchemaJson?: string;
1269 outputSchemaJson?: string;
1270 exampleInputJson?: string;
1271 providerDataJson: string;
1272 schemaRefTable?: Readonly<Record<string, unknown>>;
1273 defaultHeaders?: Readonly<Record<string, string>>;
1274 credentialHeaders?: Readonly<Record<string, string>>;
1275}) => {
1276 const decodedProviderData = decodeGraphqlToolProviderDataJson(
1277 input.providerDataJson,
1278 );
1279 if (decodedProviderData._tag === "Left") {
1280 throw new Error("Invalid GraphQL provider data");
1281 }
1282
1283 const providerData = decodedProviderData.right;
1284 const inputSchema = materializeSchemaWithRefDefinitions({
1285 schemaJson: input.inputSchemaJson,
1286 refTable: input.schemaRefTable,
1287 });
1288
1289 const metadata: ToolMetadata = {
1290 interaction:
1291 providerData.toolKind === "request"
1292 ? "auto"
1293 : providerData.operationType === "query"
1294 ? "auto"
1295 : "required",
1296 inputType: typeSignatureFromSchemaJson(
1297 input.inputSchemaJson,
1298 "unknown",
1299 Infinity,
1300 ),
1301 outputType: typeSignatureFromSchemaJson(
1302 input.outputSchemaJson,
1303 "unknown",
1304 Infinity,
1305 ),
1306 ...(input.inputSchemaJson
1307 ? { inputSchemaJson: input.inputSchemaJson }
1308 : {}),
1309 ...(input.outputSchemaJson
1310 ? { outputSchemaJson: input.outputSchemaJson }
1311 : {}),
1312 ...(input.exampleInputJson
1313 ? { exampleInputJson: input.exampleInputJson }
1314 : {}),
1315 sourceKey: input.sourceKey,
1316 providerKind: "graphql",
1317 providerDataJson: input.providerDataJson,
1058 };1318 };
1319
1320 return toTool({
1321 tool: {
1322 description: input.description,
1323 inputSchema: standardSchemaFromJsonSchema(inputSchema),
1324 execute: (args: unknown) => {
1325 if (
1326 providerData.toolKind === "field" &&
1327 providerData.fieldName &&
1328 providerData.operationType &&
1329 providerData.operationName &&
1330 providerData.operationDocument
1331 ) {
1332 return Effect.runPromise(
1333 invokeGraphqlFieldTool({
1334 entry: {
1335 fieldName: providerData.fieldName,
1336 operationName: providerData.operationName,
1337 operationDocument: providerData.operationDocument,
1338 },
1339 endpoint: input.endpoint,
1340 path: input.path,
1341 defaultHeaders: input.defaultHeaders,
1342 credentialHeaders: input.credentialHeaders,
1343 args,
1344 }),
1345 );
1346 }
1347
1348 return Effect.runPromise(
1349 invokeRawGraphqlTool({
1350 endpoint: input.endpoint,
1351 path: input.path,
1352 defaultHeaders: input.defaultHeaders,
1353 credentialHeaders: input.credentialHeaders,
1354 args,
1355 }),
1356 );
1357 },
1358 },
1359 metadata,
1360 });
1059};1361};
1060 1362
1061export const graphqlToolDescriptorFromDefinition = (input: {1363export const graphqlToolDescriptorFromDefinition = (input: {
@@ -1074,7 +1376,8 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 12 unmodified lines
1074 path: asToolPath(input.path),1376 path: asToolPath(input.path),
1075 sourceKey: input.sourceKey,1377 sourceKey: input.sourceKey,
1076 description: input.definition.description,1378 description: input.definition.description,
1077 interaction: input.definition.operationType === "query" ? "auto" : "required",1379 interaction:
1380 input.definition.operationType === "query" ? "auto" : "required",
1078 inputType: presentation.inputType,1381 inputType: presentation.inputType,
1079 outputType: presentation.outputType,1382 outputType: presentation.outputType,
1080 ...(input.includeSchemas && presentation.inputSchemaJson1383 ...(input.includeSchemas && presentation.inputSchemaJson
@@ -1105,15 +1408,24 @@ const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => · 24 unmodified lines
1105 }),1408 }),
1106 body: JSON.stringify({1409 body: JSON.stringify({
1107 query: input.query,1410 query: input.query,
1108 ...(input.variables !== undefined ? { variables: input.variables } : {}),1411 ...(input.variables !== undefined
1109 ...(input.operationName ? { operationName: input.operationName } : {}),1412 ? { variables: input.variables }
1413 : {}),
1414 ...(input.operationName
1415 ? { operationName: input.operationName }
1416 : {}),
1110 }),1417 }),
1111 }),1418 }),
1112 catch: (cause) => graphqlToolError(`GraphQL request failed for ${input.path}`, cause),1419 catch: (cause) =>
1420 graphqlToolError(`GraphQL request failed for ${input.path}`, cause),
1113 });1421 });
1114 const body = yield* Effect.tryPromise({1422 const body = yield* Effect.tryPromise({
1115 try: () => parseGraphqlResponseBody(response),1423 try: () => parseGraphqlResponseBody(response),
1116 catch: (cause) => graphqlToolError(`Failed decoding GraphQL response for ${input.path}`, cause),1424 catch: (cause) =>
1425 graphqlToolError(
1426 `Failed decoding GraphQL response for ${input.path}`,
1427 cause,
1428 ),
1117 });1429 });
1118 1430
1119 const responseHeaders: Record<string, string> = {};1431 const responseHeaders: Record<string, string> = {};
@@ -1151,7 +1463,8 @@ const invokeRawGraphqlTool = (input: { · 31 unmodified lines
1151 defaultHeaders: input.defaultHeaders,1463 defaultHeaders: input.defaultHeaders,
1152 credentialHeaders: input.credentialHeaders,1464 credentialHeaders: input.credentialHeaders,
1153 query,1465 query,
1154 variables: record.variables !== undefined ? asRecord(record.variables) : undefined,1466 variables:
1467 record.variables !== undefined ? asRecord(record.variables) : undefined,
1155 operationName: asString(record.operationName) ?? undefined,1468 operationName: asString(record.operationName) ?? undefined,
1156 requestHeaders: asStringRecord(record.headers),1469 requestHeaders: asStringRecord(record.headers),
1157 });1470 });
@@ -1165,7 +1478,10 @@ const withoutUndefinedEntries = ( · 7 unmodified lines
1165 );1478 );
1166 1479
1167const invokeGraphqlFieldTool = (input: {1480const invokeGraphqlFieldTool = (input: {
1168 entry: GraphqlFieldToolManifestEntry;1481 entry: Pick<
1482 GraphqlFieldToolManifestEntry,
1483 "fieldName" | "operationName" | "operationDocument"
1484 >;
1169 endpoint: string;1485 endpoint: string;
1170 path: string;1486 path: string;
1171 defaultHeaders?: Readonly<Record<string, string>>;1487 defaultHeaders?: Readonly<Record<string, string>>;
@@ -1218,51 +1534,24 @@ export const createGraphqlToolsFromManifest = (input: { · 46 unmodified lines
1218 manifest: input.manifest,1534 manifest: input.manifest,
1219 definition,1535 definition,
1220 });1536 });
1221 const entry = input.manifest.tools.find((tool) => tool.toolId === definition.toolId);1537 const path = input.namespace
1222 const path = input.namespace ? `${input.namespace}.${definition.toolId}` : definition.toolId;1538 ? `${input.namespace}.${definition.toolId}`
1223 const inputSchema = presentation.inputSchemaJson1539 : definition.toolId;
1224 ? (JSON.parse(presentation.inputSchemaJson) as Record<string, unknown>)
1225 : {};
1226 const metadata: ToolMetadata = {
1227 interaction: "auto",
1228 inputType: presentation.inputType,
1229 outputType: presentation.outputType,
1230 ...(presentation.inputSchemaJson ? { inputSchemaJson: presentation.inputSchemaJson } : {}),
1231 ...(presentation.outputSchemaJson ? { outputSchemaJson: presentation.outputSchemaJson } : {}),
1232 ...(presentation.exampleInputJson ? { exampleInputJson: presentation.exampleInputJson } : {}),
1233 sourceKey: input.sourceKey,
1234 providerKind: "graphql",
1235 providerDataJson: presentation.providerDataJson,
1236 };
1237 1540
1238 return [1541 return [
1239 path,1542 path,
1240 toTool({1543 createGraphqlToolFromPersistedOperation({
1241 tool: {1544 path,
1242 description: definition.description,1545 sourceKey: input.sourceKey,
1243 inputSchema: standardSchemaFromJsonSchema(inputSchema),1546 endpoint,
1244 execute: (args: unknown) => {1547 description: definition.description,
1245 if (entry?.kind === "field") {1548 inputSchemaJson: presentation.inputSchemaJson,
1246 return Effect.runPromise(invokeGraphqlFieldTool({1549 outputSchemaJson: presentation.outputSchemaJson,
1247 entry,1550 exampleInputJson: presentation.exampleInputJson,
1248 endpoint,1551 providerDataJson: presentation.providerDataJson,
1249 path,1552 schemaRefTable: input.manifest.schemaRefTable,
1250 defaultHeaders: input.defaultHeaders,1553 defaultHeaders: input.defaultHeaders,
1251 credentialHeaders: input.credentialHeaders,1554 credentialHeaders: input.credentialHeaders,
1252 args,
1253 }));
1254 }
1255
1256 return Effect.runPromise(invokeRawGraphqlTool({
1257 endpoint,
1258 path,
1259 defaultHeaders: input.defaultHeaders,
1260 credentialHeaders: input.credentialHeaders,
1261 args,
1262 }));
1263 },
1264 },
1265 metadata,
1266 }),1555 }),
1267 ] as const;1556 ] as const;
1268 }),1557 }),
packages/control-plane/src/runtime/invocation-policy-engine.test.ts+4−5
@@ -27,9 +27,8 @@ const baseDescriptor: InvocationDescriptor = { · 26 unmodified lines
27 sourceKind: "openapi",27 sourceKind: "openapi",
28 sourceNamespace: "vercel.api.dns",28 sourceNamespace: "vercel.api.dns",
29 operationKind: "write",29 operationKind: "write",
30 httpMethod: "POST",30 interaction: "required",
31 httpPathTemplate: "/v10/domains/{domain}/records",31 approvalLabel: "POST /v10/domains/{domain}/records",
32 graphqlOperationType: null,
33};32};
34 33
35const basePolicy = (34const basePolicy = (
@@ -61,8 +60,8 @@ describe("invocation-policy-engine", () => { · 25 unmodified lines
61 ...baseDescriptor,60 ...baseDescriptor,
62 toolPath: "vercel.api.dns.getRecords",61 toolPath: "vercel.api.dns.getRecords",
63 operationKind: "read",62 operationKind: "read",
64 httpMethod: "GET",63 interaction: "auto",
65 httpPathTemplate: "/v4/domains/{domain}/records",64 approvalLabel: "GET /v4/domains/{domain}/records",
66 },65 },
67 args: {},66 args: {},
68 policies: [],67 policies: [],
packages/control-plane/src/runtime/invocation-policy-engine.ts+7−33
@@ -18,9 +18,8 @@ export type InvocationDescriptor = { · 17 unmodified lines
18 sourceKind: Source["kind"];18 sourceKind: Source["kind"];
19 sourceNamespace: string | null;19 sourceNamespace: string | null;
20 operationKind: InvocationOperationKind;20 operationKind: InvocationOperationKind;
21 httpMethod: string | null;21 interaction: "auto" | "required";
22 httpPathTemplate: string | null;22 approvalLabel: string | null;
23 graphqlOperationType: "query" | "mutation" | "subscription" | null;
24};23};
25 24
26export type InvocationPolicyContext = {25export type InvocationPolicyContext = {
@@ -223,42 +222,17 @@ const policySpecificity = ( · 196 unmodified lines
223const defaultDecisionForInvocation = (222const defaultDecisionForInvocation = (
224 descriptor: InvocationDescriptor,223 descriptor: InvocationDescriptor,
225): InvocationAuthorizationDecision => {224): InvocationAuthorizationDecision => {
226 if (descriptor.sourceKind === "openapi") {225 if (descriptor.interaction === "auto") {
227 const method = descriptor.httpMethod?.toUpperCase() ?? null;
228 if (method === "GET" || method === "HEAD") {
229 return {
230 kind: "allow",
231 reason: `${method} defaults to allow`,
232 matchedPolicyId: null,
233 };
234 }
235
236 return {
237 kind: "require_interaction",
238 reason: `${method ?? "unknown HTTP method"} defaults to approval`,
239 matchedPolicyId: null,
240 };
241 }
242
243 if (descriptor.sourceKind === "graphql") {
244 if (descriptor.graphqlOperationType === "query") {
245 return {
246 kind: "allow",
247 reason: "GraphQL query defaults to allow",
248 matchedPolicyId: null,
249 };
250 }
251
252 return {226 return {
253 kind: "require_interaction",227 kind: "allow",
254 reason: `${descriptor.graphqlOperationType ?? "Unknown GraphQL operation"} defaults to approval`,228 reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to allow`,
255 matchedPolicyId: null,229 matchedPolicyId: null,
256 };230 };
257 }231 }
258 232
259 return {233 return {
260 kind: "allow",234 kind: "require_interaction",
261 reason: "No invocation-specific approval required by default",235 reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to approval`,
262 matchedPolicyId: null,236 matchedPolicyId: null,
263 };237 };
264};238};
packages/control-plane/src/runtime/source-adapters/graphql.ts+526−0
@@ -0,0 +1,526 @@
1import {
2 makeToolInvokerFromTools,
3 typeSignatureFromSchemaJson,
4} from "@executor/codemode-core";
5import type { SqlControlPlaneRows } from "#persistence";
6import type {
7 GraphqlToolManifest,
8} from "../graphql-tools";
9import {
10 buildGraphqlToolPresentation,
11 compileGraphqlToolDefinitions,
12 createGraphqlToolFromPersistedOperation,
13 decodeGraphqlSchemaRefTableJson,
14 extractGraphqlManifest,
15 GraphqlToolProviderDataSchema,
16 GRAPHQL_INTROSPECTION_QUERY,
17} from "../graphql-tools";
18import type {
19 Source,
20 SourceRecipeRevisionId,
21 StoredSourceRecipeDocumentRecord,
22 StoredSourceRecipeOperationRecord,
23 StoredSourceRecipeSchemaBundleRecord,
24} from "#schema";
25import {
26 StringMapSchema,
27 SourceRecipeSchemaBundleIdSchema,
28} from "#schema";
29import * as Either from "effect/Either";
30import * as Effect from "effect/Effect";
31import * as Schema from "effect/Schema";
32
33import { namespaceFromSourceName } from "../source-names";
34import {
35 contentHash,
36 normalizeSearchText,
37} from "../source-recipe-support";
38import type {
39 SourceAdapter,
40 SourceAdapterMaterialization,
41 SourceAdapterRepairRevisionInput,
42} from "./types";
43import {
44 ConnectHttpAuthSchema,
45 ConnectHttpImportAuthSchema,
46 createStandardToolDescriptor,
47 decodeBindingConfig,
48 emptySourceBindingState,
49 encodeBindingConfig,
50 isSourceCredentialRequiredError,
51 OptionalNullableStringSchema,
52 parseJsonValue,
53 SourceCredentialRequiredError,
54 SourceConnectCommonFieldsSchema,
55} from "./shared";
56
57const GraphqlConnectPayloadSchema = Schema.extend(
58 SourceConnectCommonFieldsSchema,
59 Schema.extend(
60 ConnectHttpImportAuthSchema,
61 Schema.Struct({
62 kind: Schema.Literal("graphql"),
63 auth: Schema.optional(ConnectHttpAuthSchema),
64 }),
65 ),
66);
67
68const GraphqlExecutorAddInputSchema = Schema.extend(
69 ConnectHttpImportAuthSchema,
70 Schema.Struct({
71 kind: Schema.Literal("graphql"),
72 endpoint: Schema.String,
73 name: OptionalNullableStringSchema,
74 namespace: OptionalNullableStringSchema,
75 auth: Schema.optional(ConnectHttpAuthSchema),
76 }),
77);
78
79const GraphqlBindingConfigSchema = Schema.Struct({
80 adapterKey: Schema.Literal("graphql"),
81 defaultHeaders: Schema.NullOr(StringMapSchema),
82});
83
84const GRAPHQL_MATERIALIZATION_REVISION_ID = "src_recipe_rev_materialization" as SourceRecipeRevisionId;
85
86const decodeGraphqlToolProviderDataJson = Schema.decodeUnknownEither(
87 Schema.parseJson(GraphqlToolProviderDataSchema),
88);
89
90const fetchGraphqlIntrospectionDocumentWithHeaders = (input: {
91 url: string;
92 headers?: Readonly<Record<string, string>>;
93}): Effect.Effect<string, Error, never> =>
94 Effect.tryPromise({
95 try: async () => {
96 const response = await fetch(input.url, {
97 method: "POST",
98 headers: {
99 "content-type": "application/json",
100 ...(input.headers ?? {}),
101 },
102 body: JSON.stringify({
103 query: GRAPHQL_INTROSPECTION_QUERY,
104 operationName: "IntrospectionQuery",
105 }),
106 });
107 const text = await response.text();
108
109 let parsed: unknown;
110 try {
111 parsed = JSON.parse(text) as unknown;
112 } catch (cause) {
113 throw new Error(
114 `GraphQL introspection endpoint did not return JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
115 );
116 }
117
118 if (!response.ok) {
119 if (response.status === 401 || response.status === 403) {
120 throw new SourceCredentialRequiredError(
121 "import",
122 `GraphQL introspection requires credentials (status ${response.status})`,
123 );
124 }
125 throw new Error(
126 `GraphQL introspection failed with status ${response.status}`,
127 );
128 }
129
130 return JSON.stringify(parsed, null, 2);
131 },
132 catch: (cause) =>
133 cause instanceof Error ? cause : new Error(String(cause)),
134 });
135
136const toGraphqlSchemaBundleRecord = (input: {
137 recipeRevisionId: SourceRecipeRevisionId;
138 manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"];
139 now: number;
140}): StoredSourceRecipeSchemaBundleRecord | null => {
141 const refEntries = Object.entries(input.manifest.schemaRefTable ?? {});
142 if (refEntries.length === 0) {
143 return null;
144 }
145
146 const refs = Object.fromEntries(
147 refEntries.map(([ref, rawValue]) => {
148 try {
149 return [ref, JSON.parse(rawValue) as unknown];
150 } catch {
151 return [ref, rawValue];
152 }
153 }),
154 );
155 const refsJson = JSON.stringify(refs);
156
157 return {
158 id: SourceRecipeSchemaBundleIdSchema.make(
159 `src_recipe_bundle_${crypto.randomUUID()}`,
160 ),
161 recipeRevisionId: input.recipeRevisionId,
162 bundleKind: "json_schema_ref_map",
163 refsJson,
164 contentHash: contentHash(refsJson),
165 createdAt: input.now,
166 updatedAt: input.now,
167 };
168};
169
170const toGraphqlRecipeOperationRecord = (input: {
171 recipeRevisionId: SourceRecipeRevisionId;
172 definition: ReturnType<typeof compileGraphqlToolDefinitions>[number];
173 manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"];
174 now: number;
175}): StoredSourceRecipeOperationRecord => {
176 const presentation = buildGraphqlToolPresentation({
177 manifest: input.manifest,
178 definition: input.definition,
179 });
180
181 return {
182 id: `src_recipe_op_${crypto.randomUUID()}`,
183 recipeRevisionId: input.recipeRevisionId,
184 operationKey: input.definition.toolId,
185 transportKind: "graphql",
186 toolId: input.definition.toolId,
187 title: input.definition.name,
188 description: input.definition.description,
189 operationKind:
190 input.definition.operationType === "query"
191 ? "read"
192 : input.definition.operationType === "mutation"
193 ? "write"
194 : "unknown",
195 searchText: normalizeSearchText(
196 input.definition.toolId,
197 input.definition.name,
198 input.definition.description,
199 input.definition.rawToolId,
200 input.definition.group,
201 input.definition.leaf,
202 input.definition.fieldName,
203 input.definition.operationType,
204 input.definition.operationName,
205 input.definition.searchTerms.join(" "),
206 ),
207 inputSchemaJson: presentation.inputSchemaJson ?? null,
208 outputSchemaJson: presentation.outputSchemaJson ?? null,
209 providerKind: "graphql",
210 providerDataJson: presentation.providerDataJson,
211 createdAt: input.now,
212 updatedAt: input.now,
213 };
214};
215
216const primaryDocument = (
217 documents: readonly StoredSourceRecipeDocumentRecord[],
218): StoredSourceRecipeDocumentRecord | null =>
219 documents.find((document) => document.documentKind === "graphql_introspection")
220 ?? null;
221
222export const rebuildGraphqlRecipeRevisionContent = (input: {
223 rows: SqlControlPlaneRows;
224 sourceRecord: SourceAdapterRepairRevisionInput["sourceRecord"];
225 revision: SourceAdapterRepairRevisionInput["revision"];
226 documents: readonly StoredSourceRecipeDocumentRecord[];
227}): Effect.Effect<void, Error, never> =>
228 Effect.gen(function* () {
229 const manifest =
230 input.revision.manifestJson !== null
231 ? yield* parseJsonValue<GraphqlToolManifest>({
232 label: `GraphQL manifest for ${input.sourceRecord.id}`,
233 value: input.revision.manifestJson,
234 })
235 : null;
236 const document = primaryDocument(input.documents);
237 const resolvedManifest =
238 manifest
239 ?? (document !== null
240 ? yield* extractGraphqlManifest(
241 input.sourceRecord.name,
242 document.contentText,
243 )
244 : null);
245
246 if (resolvedManifest === null) {
247 return yield* Effect.fail(
248 new Error(
249 `Missing GraphQL manifest and document for ${input.sourceRecord.id}`,
250 ),
251 );
252 }
253
254 const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt);
255 const definitions = compileGraphqlToolDefinitions(resolvedManifest);
256 const schemaBundle = toGraphqlSchemaBundleRecord({
257 recipeRevisionId: input.revision.id,
258 manifest: resolvedManifest,
259 now,
260 });
261
262 yield* input.rows.sourceRecipeRevisions.update(input.revision.id, {
263 manifestJson: JSON.stringify(resolvedManifest),
264 manifestHash: resolvedManifest.sourceHash,
265 updatedAt: now,
266 });
267 yield* input.rows.sourceRecipeOperations.replaceForRevision({
268 recipeRevisionId: input.revision.id,
269 operations: definitions.map((definition) =>
270 toGraphqlRecipeOperationRecord({
271 recipeRevisionId: input.revision.id,
272 definition,
273 manifest: resolvedManifest,
274 now,
275 }),
276 ),
277 });
278 yield* input.rows.sourceRecipeSchemaBundles.replaceForRevision({
279 recipeRevisionId: input.revision.id,
280 bundles: schemaBundle ? [schemaBundle] : [],
281 });
282
283 if (input.sourceRecord.sourceHash !== resolvedManifest.sourceHash) {
284 yield* input.rows.sources.update(input.sourceRecord.workspaceId, input.sourceRecord.id, {
285 sourceHash: resolvedManifest.sourceHash,
286 updatedAt: now,
287 });
288 }
289 });
290
291export const graphqlSourceAdapter: SourceAdapter = {
292 key: "graphql",
293 displayName: "GraphQL",
294 family: "http_api",
295 providerKey: "generic_graphql",
296 defaultImportAuthPolicy: "reuse_runtime",
297 primaryDocumentKind: "graphql_introspection",
298 primarySchemaBundleKind: "json_schema_ref_map",
299 connectPayloadSchema: GraphqlConnectPayloadSchema,
300 executorAddInputSchema: GraphqlExecutorAddInputSchema,
301 executorAddHelpText: [
302 "endpoint is the GraphQL HTTP endpoint.",
303 ],
304 executorAddInputSignatureWidth: 320,
305 serializeBindingConfig: (source) =>
306 encodeBindingConfig(GraphqlBindingConfigSchema, {
307 adapterKey: "graphql",
308 defaultHeaders: source.defaultHeaders,
309 }),
310 deserializeBindingConfig: ({ id, bindingConfigJson }) =>
311 Effect.map(
312 decodeBindingConfig({
313 sourceId: id,
314 label: "GraphQL",
315 schema: GraphqlBindingConfigSchema,
316 value: bindingConfigJson,
317 }),
318 (bindingConfig) => ({
319 ...emptySourceBindingState,
320 defaultHeaders: bindingConfig.defaultHeaders,
321 }),
322 ),
323 sourceConfigFromSource: (source) => ({
324 kind: "graphql",
325 endpoint: source.endpoint,
326 defaultHeaders: source.defaultHeaders,
327 }),
328 validateSource: (source) =>
329 Effect.gen(function* () {
330 if (source.transport !== null || source.queryParams !== null || source.headers !== null) {
331 return yield* Effect.fail(
332 new Error("GraphQL sources cannot define MCP transport settings"),
333 );
334 }
335
336 if (source.specUrl !== null) {
337 return yield* Effect.fail(new Error("GraphQL sources cannot define specUrl"));
338 }
339
340 return source;
341 }),
342 shouldAutoProbe: (source) =>
343 source.enabled && (source.status === "draft" || source.status === "probing"),
344 parseManifest: ({ source, manifestJson }) =>
345 parseJsonValue<GraphqlToolManifest>({
346 label: `GraphQL manifest for ${source.id}`,
347 value: manifestJson,
348 }),
349 describePersistedOperation: ({ operation, path }) =>
350 Effect.gen(function* () {
351 const decoded = operation.providerDataJson
352 ? decodeGraphqlToolProviderDataJson(operation.providerDataJson)
353 : Either.left(new Error("Missing providerDataJson"));
354 if (Either.isLeft(decoded)) {
355 return yield* Effect.fail(
356 new Error(`Invalid GraphQL provider data for ${path}`),
357 );
358 }
359
360 const providerData = decoded.right;
361
362 return {
363 method: null,
364 pathTemplate: null,
365 rawToolId: providerData.rawToolId,
366 operationId: providerData.operationName,
367 group: providerData.group,
368 leaf: providerData.leaf,
369 tags: [],
370 searchText: normalizeSearchText(
371 path,
372 operation.toolId,
373 operation.title ?? undefined,
374 operation.description ?? undefined,
375 providerData.rawToolId ?? undefined,
376 providerData.group ?? undefined,
377 providerData.leaf ?? undefined,
378 providerData.fieldName ?? undefined,
379 providerData.operationType ?? undefined,
380 providerData.operationName ?? undefined,
381 operation.searchText,
382 ),
383 interaction: providerData.operationType === "query" ? "auto" : "required",
384 approvalLabel: providerData.operationType
385 ? `GraphQL ${providerData.operationType} ${path}`
386 : `GraphQL ${path}`,
387 } as const;
388 }),
389 searchNamespace: ({ source }) =>
390 source.namespace ?? namespaceFromSourceName(source.name),
391 createToolDescriptor: ({ source, operation, path, includeSchemas, schemaBundleId }) =>
392 createStandardToolDescriptor({
393 source,
394 operation,
395 path,
396 includeSchemas,
397 interaction: operation.operationKind === "read" ? "auto" : "required",
398 outputType: typeSignatureFromSchemaJson(
399 operation.outputSchemaJson ?? undefined,
400 "unknown",
401 320,
402 ),
403 schemaBundleId,
404 }),
405 materializeSource: ({ source, resolveAuthMaterialForSlot }) =>
406 Effect.gen(function* () {
407 const auth = yield* resolveAuthMaterialForSlot("import");
408 const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders(
409 {
410 url: source.endpoint,
411 headers: {
412 ...(source.defaultHeaders ?? {}),
413 ...auth.headers,
414 },
415 },
416 ).pipe(
417 Effect.mapError(
418 (cause) =>
419 isSourceCredentialRequiredError(cause)
420 ? cause
421 : new Error(
422 `Failed fetching GraphQL introspection for ${source.id}: ${cause.message}`,
423 ),
424 ),
425 );
426
427 const manifest = yield* extractGraphqlManifest(
428 source.name,
429 graphqlDocument,
430 ).pipe(
431 Effect.mapError((cause) =>
432 cause instanceof Error ? cause : new Error(String(cause)),
433 ),
434 );
435
436 const definitions = compileGraphqlToolDefinitions(manifest);
437 const now = Date.now();
438 const schemaBundle = toGraphqlSchemaBundleRecord({
439 recipeRevisionId: GRAPHQL_MATERIALIZATION_REVISION_ID,
440 manifest,
441 now,
442 });
443
444 return {
445 manifestJson: JSON.stringify(manifest),
446 manifestHash: manifest.sourceHash,
447 sourceHash: manifest.sourceHash,
448 documents: [
449 {
450 id: `src_recipe_doc_${crypto.randomUUID()}`,
451 recipeRevisionId: GRAPHQL_MATERIALIZATION_REVISION_ID,
452 documentKind: "graphql_introspection",
453 documentKey: source.endpoint,
454 contentText: graphqlDocument,
455 contentHash: contentHash(graphqlDocument),
456 fetchedAt: now,
457 createdAt: now,
458 updatedAt: now,
459 },
460 ],
461 schemaBundles: schemaBundle ? [schemaBundle] : [],
462 operations: definitions.map((definition) =>
463 toGraphqlRecipeOperationRecord({
464 recipeRevisionId: GRAPHQL_MATERIALIZATION_REVISION_ID,
465 definition,
466 manifest,
467 now,
468 }),
469 ),
470 } satisfies SourceAdapterMaterialization;
471 }),
472 invokePersistedTool: ({
473 source,
474 path,
475 operation,
476 schemaBundle,
477 auth,
478 args,
479 context,
480 onElicitation,
481 }) =>
482 Effect.gen(function* () {
483 const schemaRefTable = schemaBundle
484 ? decodeGraphqlSchemaRefTableJson(schemaBundle.refsJson)
485 : Either.right({});
486
487 if (Either.isLeft(schemaRefTable)) {
488 return yield* Effect.fail(
489 new Error(`Invalid GraphQL schema bundle for ${path}`),
490 );
491 }
492 if (!operation.providerDataJson) {
493 return yield* Effect.fail(
494 new Error(`Missing GraphQL provider data for ${path}`),
495 );
496 }
497
498 const tool = createGraphqlToolFromPersistedOperation({
499 path,
500 sourceKey: source.id,
501 endpoint: source.endpoint,
502 description: operation.description ?? operation.title ?? undefined,
503 inputSchemaJson: operation.inputSchemaJson ?? undefined,
504 outputSchemaJson: operation.outputSchemaJson ?? undefined,
505 providerDataJson: operation.providerDataJson,
506 schemaRefTable: schemaRefTable.right,
507 defaultHeaders: source.defaultHeaders ?? {},
508 credentialHeaders: auth.headers,
509 });
510
511 return yield* makeToolInvokerFromTools({
512 tools: {
513 [path]: tool,
514 },
515 onElicitation,
516 }).invoke({
517 path,
518 args,
519 context,
520 }).pipe(
521 Effect.mapError((cause) =>
522 cause instanceof Error ? cause : new Error(String(cause)),
523 ),
524 );
525 }),
526};
packages/control-plane/src/runtime/source-adapters/index.ts+68−0
@@ -0,0 +1,68 @@
1import type { Source, StoredSourceRecipeOperationRecord } from "#schema";
2import * as Schema from "effect/Schema";
3
4import { graphqlSourceAdapter } from "./graphql";
5import { internalSourceAdapter } from "./internal";
6import { mcpSourceAdapter } from "./mcp";
7import { openApiSourceAdapter } from "./openapi";
8import type { SourceAdapter } from "./types";
9
10export const builtInSourceAdapters = [
11 openApiSourceAdapter,
12 graphqlSourceAdapter,
13 mcpSourceAdapter,
14 internalSourceAdapter,
15] as const satisfies readonly SourceAdapter[];
16
17export const connectableSourceAdapters = [
18 mcpSourceAdapter,
19 openApiSourceAdapter,
20 graphqlSourceAdapter,
21] as const;
22
23export const ConnectSourcePayloadSchema = Schema.Union(
24 mcpSourceAdapter.connectPayloadSchema!,
25 openApiSourceAdapter.connectPayloadSchema!,
26 graphqlSourceAdapter.connectPayloadSchema!,
27);
28
29export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type;
30
31export const executorAddableSourceAdapters = [
32 mcpSourceAdapter,
33 openApiSourceAdapter,
34 graphqlSourceAdapter,
35] as const;
36
37export const ExecutorAddSourceInputSchema = Schema.Union(
38 mcpSourceAdapter.executorAddInputSchema!,
39 openApiSourceAdapter.executorAddInputSchema!,
40 graphqlSourceAdapter.executorAddInputSchema!,
41);
42
43export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type;
44
45const adaptersByKey = new Map<string, SourceAdapter>(
46 builtInSourceAdapters.map((adapter) => [adapter.key, adapter]),
47);
48
49export const getSourceAdapter = (key: string): SourceAdapter => {
50 const adapter = adaptersByKey.get(key);
51 if (!adapter) {
52 throw new Error(`Unsupported source adapter: ${key}`);
53 }
54
55 return adapter;
56};
57
58export const getSourceAdapterForSource = (source: Pick<Source, "kind">): SourceAdapter =>
59 getSourceAdapter(source.kind);
60
61export const getSourceAdapterForOperation = (
62 operation: Pick<StoredSourceRecipeOperationRecord, "providerKind">,
63): SourceAdapter => getSourceAdapter(operation.providerKind);
64
65export const hasSourceAdapterFamily = (
66 key: string,
67 family: SourceAdapter["family"],
68): boolean => getSourceAdapter(key).family === family;
packages/control-plane/src/runtime/source-adapters/internal.ts+99−0
@@ -0,0 +1,99 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import type { SourceAdapter } from "./types";
5import {
6 createStandardToolDescriptor,
7 decodeBindingConfig,
8 emptySourceBindingState,
9 encodeBindingConfig,
10} from "./shared";
11
12const InternalBindingConfigSchema = Schema.Struct({
13 adapterKey: Schema.String,
14});
15
16export const internalSourceAdapter: SourceAdapter = {
17 key: "internal",
18 displayName: "Internal",
19 family: "internal",
20 providerKey: "generic_internal",
21 defaultImportAuthPolicy: "none",
22 primaryDocumentKind: null,
23 primarySchemaBundleKind: null,
24 connectPayloadSchema: null,
25 executorAddInputSchema: null,
26 executorAddHelpText: null,
27 executorAddInputSignatureWidth: null,
28 serializeBindingConfig: (source) =>
29 encodeBindingConfig(InternalBindingConfigSchema, {
30 adapterKey: source.kind,
31 }),
32 deserializeBindingConfig: ({ id, bindingConfigJson }) =>
33 Effect.map(
34 decodeBindingConfig({
35 sourceId: id,
36 label: "internal",
37 schema: InternalBindingConfigSchema,
38 value: bindingConfigJson,
39 }),
40 () => emptySourceBindingState,
41 ),
42 sourceConfigFromSource: (source) => ({
43 kind: "internal",
44 endpoint: source.endpoint,
45 }),
46 validateSource: (source) =>
47 Effect.gen(function* () {
48 if (source.transport !== null || source.queryParams !== null || source.headers !== null) {
49 return yield* Effect.fail(
50 new Error(`${source.kind} sources cannot define MCP transport settings`),
51 );
52 }
53
54 if (source.specUrl !== null || source.defaultHeaders !== null) {
55 return yield* Effect.fail(
56 new Error(`${source.kind} sources cannot define HTTP source settings`),
57 );
58 }
59
60 return source;
61 }),
62 shouldAutoProbe: () => false,
63 parseManifest: () => Effect.succeed(null),
64 describePersistedOperation: ({ operation, path }) =>
65 Effect.succeed({
66 method: null,
67 pathTemplate: null,
68 rawToolId: null,
69 operationId: null,
70 group: null,
71 leaf: null,
72 tags: [],
73 searchText: [path, operation.toolId, operation.title ?? "", operation.description ?? "", operation.searchText]
74 .filter((part) => part.length > 0)
75 .join(" ")
76 .toLowerCase(),
77 interaction: "auto",
78 approvalLabel: null,
79 } as const),
80 createToolDescriptor: ({ source, operation, path, includeSchemas, schemaBundleId }) =>
81 createStandardToolDescriptor({
82 source,
83 operation,
84 path,
85 includeSchemas,
86 interaction: "auto",
87 schemaBundleId,
88 }),
89 materializeSource: () => Effect.succeed({
90 manifestJson: null,
91 manifestHash: null,
92 sourceHash: null,
93 documents: [],
94 schemaBundles: [],
95 operations: [],
96 }),
97 invokePersistedTool: ({ path }) =>
98 Effect.fail(new Error(`Unsupported stored tool provider for ${path}`)),
99};
packages/control-plane/src/runtime/source-adapters/mcp.ts+357−0
@@ -0,0 +1,357 @@
1import {
2 makeToolInvokerFromTools,
3} from "@executor/codemode-core";
4import {
5 createMcpToolsFromManifest,
6 createSdkMcpConnector,
7 discoverMcpToolsFromConnector,
8 type McpToolManifest,
9 type McpToolManifestEntry,
10} from "@executor/codemode-mcp";
11import type { SqlControlPlaneRows } from "#persistence";
12import type {
13 Source,
14 SourceRecipeRevisionId,
15 StoredSourceRecipeOperationRecord,
16} from "#schema";
17import {
18 SourceTransportSchema,
19 StringMapSchema,
20} from "#schema";
21import * as Effect from "effect/Effect";
22import * as Schema from "effect/Schema";
23
24import {
25 contentHash,
26 normalizeSearchText,
27 persistRecipeMaterialization,
28 type SourceRecipeMaterialization,
29} from "../source-recipe-support";
30import { namespaceFromSourceName } from "../source-names";
31import type { SourceAdapter, SourceAdapterMaterialization } from "./types";
32import {
33 createStandardToolDescriptor,
34 decodeBindingConfig,
35 emptySourceBindingState,
36 encodeBindingConfig,
37 McpConnectFieldsSchema,
38 OptionalNullableStringSchema,
39 parseJsonValue,
40 SourceConnectCommonFieldsSchema,
41} from "./shared";
42
43const McpConnectPayloadSchema = Schema.extend(
44 SourceConnectCommonFieldsSchema,
45 Schema.extend(
46 McpConnectFieldsSchema,
47 Schema.Struct({
48 kind: Schema.Literal("mcp"),
49 }),
50 ),
51);
52
53const McpExecutorAddInputSchema = Schema.Struct({
54 kind: Schema.optional(Schema.Literal("mcp")),
55 endpoint: Schema.String,
56 name: OptionalNullableStringSchema,
57 namespace: OptionalNullableStringSchema,
58});
59
60const McpBindingConfigSchema = Schema.Struct({
61 adapterKey: Schema.Literal("mcp"),
62 transport: Schema.NullOr(SourceTransportSchema),
63 queryParams: Schema.NullOr(StringMapSchema),
64 headers: Schema.NullOr(StringMapSchema),
65});
66
67const McpToolProviderDataSchema = Schema.Struct({
68 kind: Schema.Literal("mcp"),
69 toolId: Schema.String,
70 toolName: Schema.String,
71 description: Schema.NullOr(Schema.String),
72});
73
74const decodeMcpToolProviderDataJson = Schema.decodeUnknownEither(
75 Schema.parseJson(McpToolProviderDataSchema),
76);
77
78const toMcpRecipeOperationRecord = (input: {
79 recipeRevisionId: SourceRecipeRevisionId;
80 entry: McpToolManifestEntry;
81 now: number;
82}): StoredSourceRecipeOperationRecord => ({
83 id: `src_recipe_op_${crypto.randomUUID()}`,
84 recipeRevisionId: input.recipeRevisionId,
85 operationKey: input.entry.toolId,
86 transportKind: "mcp",
87 toolId: input.entry.toolId,
88 title: input.entry.toolName,
89 description: input.entry.description ?? null,
90 operationKind: "unknown",
91 searchText: normalizeSearchText(
92 input.entry.toolId,
93 input.entry.toolName,
94 input.entry.description ?? undefined,
95 "mcp",
96 ),
97 inputSchemaJson: input.entry.inputSchemaJson ?? null,
98 outputSchemaJson: input.entry.outputSchemaJson ?? null,
99 providerKind: "mcp",
100 providerDataJson: JSON.stringify({
101 kind: "mcp",
102 toolId: input.entry.toolId,
103 toolName: input.entry.toolName,
104 description: input.entry.description ?? null,
105 }),
106 createdAt: input.now,
107 updatedAt: input.now,
108});
109
110export const persistMcpRecipeRevisionFromManifestEntries = (input: {
111 rows: SqlControlPlaneRows;
112 source: Source;
113 manifestEntries: readonly McpToolManifestEntry[];
114}): Effect.Effect<void, Error, never> =>
115 Effect.gen(function* () {
116 yield* persistRecipeMaterialization({
117 rows: input.rows,
118 source: input.source,
119 materialization: materializationFromMcpManifestEntries({
120 recipeRevisionId: "src_recipe_rev_materialization" as SourceRecipeRevisionId,
121 endpoint: input.source.endpoint,
122 manifestEntries: input.manifestEntries,
123 }),
124 });
125 });
126
127const materializationFromMcpManifestEntries = (input: {
128 recipeRevisionId: SourceRecipeRevisionId;
129 endpoint: string;
130 manifestEntries: readonly McpToolManifestEntry[];
131}): SourceRecipeMaterialization => {
132 const now = Date.now();
133 const manifest: McpToolManifest = {
134 version: 1,
135 tools: input.manifestEntries,
136 };
137 const manifestJson = JSON.stringify(manifest);
138 const manifestHash = contentHash(manifestJson);
139
140 return {
141 manifestJson,
142 manifestHash,
143 sourceHash: manifestHash,
144 documents: [
145 {
146 id: `src_recipe_doc_${crypto.randomUUID()}`,
147 recipeRevisionId: input.recipeRevisionId,
148 documentKind: "mcp_manifest",
149 documentKey: input.endpoint,
150 contentText: manifestJson,
151 contentHash: manifestHash,
152 fetchedAt: now,
153 createdAt: now,
154 updatedAt: now,
155 },
156 ],
157 schemaBundles: [],
158 operations: input.manifestEntries.map((entry) =>
159 toMcpRecipeOperationRecord({
160 recipeRevisionId: input.recipeRevisionId,
161 entry,
162 now,
163 })
164 ),
165 };
166};
167
168export const mcpSourceAdapter: SourceAdapter = {
169 key: "mcp",
170 displayName: "MCP",
171 family: "mcp",
172 providerKey: "generic_mcp",
173 defaultImportAuthPolicy: "reuse_runtime",
174 primaryDocumentKind: "mcp_manifest",
175 primarySchemaBundleKind: null,
176 connectPayloadSchema: McpConnectPayloadSchema,
177 executorAddInputSchema: McpExecutorAddInputSchema,
178 executorAddHelpText: [
179 'Omit kind or set kind: "mcp". endpoint is the MCP server URL.',
180 ],
181 executorAddInputSignatureWidth: 240,
182 serializeBindingConfig: (source) =>
183 encodeBindingConfig(McpBindingConfigSchema, {
184 adapterKey: "mcp",
185 transport: source.transport,
186 queryParams: source.queryParams,
187 headers: source.headers,
188 }),
189 deserializeBindingConfig: ({ id, bindingConfigJson }) =>
190 Effect.map(
191 decodeBindingConfig({
192 sourceId: id,
193 label: "MCP",
194 schema: McpBindingConfigSchema,
195 value: bindingConfigJson,
196 }),
197 (bindingConfig) => ({
198 ...emptySourceBindingState,
199 transport: bindingConfig.transport,
200 queryParams: bindingConfig.queryParams,
201 headers: bindingConfig.headers,
202 }),
203 ),
204 sourceConfigFromSource: (source) => ({
205 kind: "mcp",
206 endpoint: source.endpoint,
207 transport: source.transport,
208 queryParams: source.queryParams,
209 headers: source.headers,
210 }),
211 validateSource: (source) =>
212 Effect.gen(function* () {
213 if (source.specUrl !== null) {
214 return yield* Effect.fail(new Error("MCP sources cannot define specUrl"));
215 }
216
217 if (source.defaultHeaders !== null) {
218 return yield* Effect.fail(
219 new Error("MCP sources cannot define HTTP default headers"),
220 );
221 }
222
223 return source;
224 }),
225 shouldAutoProbe: () => false,
226 parseManifest: ({ source, manifestJson }) =>
227 parseJsonValue<McpToolManifest>({
228 label: `MCP manifest for ${source.id}`,
229 value: manifestJson,
230 }),
231 describePersistedOperation: ({ operation, path }) =>
232 Effect.gen(function* () {
233 const decoded = operation.providerDataJson
234 ? decodeMcpToolProviderDataJson(operation.providerDataJson)
235 : null;
236 if (decoded && decoded._tag === "Left") {
237 return yield* Effect.fail(
238 new Error(`Invalid MCP provider data for ${path}`),
239 );
240 }
241
242 const providerData = decoded?._tag === "Right" ? decoded.right : null;
243
244 return {
245 method: null,
246 pathTemplate: null,
247 rawToolId: providerData?.toolId ?? null,
248 operationId: null,
249 group: null,
250 leaf: null,
251 tags: [],
252 searchText: normalizeSearchText(
253 path,
254 operation.toolId,
255 providerData?.toolName ?? operation.title ?? undefined,
256 providerData?.description ?? operation.description ?? undefined,
257 operation.searchText,
258 ),
259 interaction: "auto",
260 approvalLabel: null,
261 } as const;
262 }),
263 createToolDescriptor: ({ source, operation, path, includeSchemas, schemaBundleId }) =>
264 createStandardToolDescriptor({
265 source,
266 operation,
267 path,
268 includeSchemas,
269 interaction: "auto",
270 schemaBundleId,
271 }),
272 materializeSource: ({ source, resolveAuthMaterialForSlot }) =>
273 Effect.gen(function* () {
274 const auth = yield* resolveAuthMaterialForSlot("import");
275 const connector = yield* Effect.try({
276 try: () =>
277 createSdkMcpConnector({
278 endpoint: source.endpoint,
279 transport: source.transport ?? undefined,
280 queryParams: source.queryParams ?? undefined,
281 headers: {
282 ...(source.headers ?? {}),
283 ...auth.headers,
284 },
285 }),
286 catch: (cause) =>
287 cause instanceof Error ? cause : new Error(String(cause)),
288 });
289
290 const discovered = yield* discoverMcpToolsFromConnector({
291 connect: connector,
292 namespace: source.namespace ?? namespaceFromSourceName(source.name),
293 sourceKey: source.id,
294 }).pipe(
295 Effect.mapError(
296 (cause) =>
297 new Error(
298 `Failed discovering MCP tools for ${source.id}: ${cause.message}`,
299 ),
300 ),
301 );
302
303 return materializationFromMcpManifestEntries({
304 recipeRevisionId: "src_recipe_rev_materialization" as SourceRecipeRevisionId,
305 endpoint: source.endpoint,
306 manifestEntries: discovered.manifest.tools,
307 }) satisfies SourceAdapterMaterialization;
308 }),
309 invokePersistedTool: ({
310 source,
311 path,
312 manifestJson,
313 auth,
314 args,
315 context,
316 onElicitation,
317 }) =>
318 Effect.gen(function* () {
319 const manifest = yield* parseJsonValue<McpToolManifest>({
320 label: `MCP manifest for ${source.id}`,
321 value: manifestJson,
322 });
323 if (manifest === null) {
324 return yield* Effect.fail(
325 new Error(`Missing MCP manifest for ${source.id}`),
326 );
327 }
328
329 const tools = createMcpToolsFromManifest({
330 manifest,
331 connect: createSdkMcpConnector({
332 endpoint: source.endpoint,
333 transport: source.transport ?? undefined,
334 queryParams: source.queryParams ?? undefined,
335 headers: {
336 ...(source.headers ?? {}),
337 ...auth.headers,
338 },
339 }),
340 namespace: source.namespace ?? namespaceFromSourceName(source.name),
341 sourceKey: source.id,
342 });
343
344 return yield* makeToolInvokerFromTools({
345 tools,
346 onElicitation,
347 }).invoke({
348 path,
349 args,
350 context,
351 }).pipe(
352 Effect.mapError((cause) =>
353 cause instanceof Error ? cause : new Error(String(cause)),
354 ),
355 );
356 }),
357};
packages/control-plane/src/runtime/source-adapters/openapi.ts+569−0
@@ -0,0 +1,569 @@
1import {
2 FetchHttpClient,
3 HttpClient,
4 HttpClientRequest,
5} from "@effect/platform";
6import type { SqlControlPlaneRows } from "#persistence";
7import {
8 makeToolInvokerFromTools,
9} from "@executor/codemode-core";
10import {
11 buildOpenApiToolPresentation,
12 compileOpenApiToolDefinitions,
13 createOpenApiToolFromDefinition,
14 extractOpenApiManifest,
15 openApiOutputTypeSignatureFromSchemaJson,
16 OpenApiRefHintTableSchema,
17 OpenApiToolProviderDataSchema,
18 type OpenApiToolManifest,
19} from "@executor/codemode-openapi";
20import type {
21 Source,
22 SourceRecipeRevisionId,
23 StoredSourceRecipeDocumentRecord,
24 StoredSourceRecipeOperationRecord,
25 StoredSourceRecipeSchemaBundleRecord,
26} from "#schema";
27import {
28 StringMapSchema,
29 SourceRecipeSchemaBundleIdSchema,
30} from "#schema";
31import * as Either from "effect/Either";
32import * as Effect from "effect/Effect";
33import * as Schema from "effect/Schema";
34
35import {
36 contentHash,
37 normalizeSearchText,
38} from "../source-recipe-support";
39import type {
40 SourceAdapter,
41 SourceAdapterMaterialization,
42 SourceAdapterRepairRevisionInput,
43} from "./types";
44import {
45 ConnectHttpAuthSchema,
46 ConnectHttpImportAuthSchema,
47 createStandardToolDescriptor,
48 decodeBindingConfig,
49 emptySourceBindingState,
50 encodeBindingConfig,
51 isSourceCredentialRequiredError,
52 OptionalNullableStringSchema,
53 parseJsonValue,
54 SourceCredentialRequiredError,
55 SourceConnectCommonFieldsSchema,
56} from "./shared";
57
58const OpenApiConnectPayloadSchema = Schema.extend(
59 SourceConnectCommonFieldsSchema,
60 Schema.extend(
61 ConnectHttpImportAuthSchema,
62 Schema.Struct({
63 kind: Schema.Literal("openapi"),
64 specUrl: Schema.Trim.pipe(Schema.nonEmptyString()),
65 auth: Schema.optional(ConnectHttpAuthSchema),
66 }),
67 ),
68);
69
70const OpenApiExecutorAddInputSchema = Schema.extend(
71 ConnectHttpImportAuthSchema,
72 Schema.Struct({
73 kind: Schema.Literal("openapi"),
74 endpoint: Schema.String,
75 specUrl: Schema.String,
76 name: OptionalNullableStringSchema,
77 namespace: OptionalNullableStringSchema,
78 auth: Schema.optional(ConnectHttpAuthSchema),
79 }),
80);
81
82const OpenApiBindingConfigSchema = Schema.Struct({
83 adapterKey: Schema.Literal("openapi"),
84 specUrl: Schema.String,
85 defaultHeaders: Schema.NullOr(StringMapSchema),
86});
87
88const OPENAPI_MATERIALIZATION_REVISION_ID = "src_recipe_rev_materialization" as SourceRecipeRevisionId;
89
90const decodeOpenApiProviderDataJson = Schema.decodeUnknownEither(
91 Schema.parseJson(OpenApiToolProviderDataSchema),
92);
93
94const decodeOpenApiRefHintTableJson = Schema.decodeUnknownEither(
95 Schema.parseJson(OpenApiRefHintTableSchema),
96);
97
98const fetchOpenApiDocumentWithHeaders = (input: {
99 url: string;
100 headers?: Readonly<Record<string, string>>;
101}): Effect.Effect<string, Error, never> =>
102 Effect.gen(function* () {
103 const client = yield* HttpClient.HttpClient;
104 const request = HttpClientRequest.get(input.url).pipe(
105 HttpClientRequest.setHeaders(input.headers ?? {}),
106 );
107 const response = yield* client.execute(request).pipe(
108 Effect.mapError((cause) =>
109 cause instanceof Error ? cause : new Error(String(cause)),
110 ),
111 );
112
113 if (response.status === 401 || response.status === 403) {
114 return yield* Effect.fail(
115 new SourceCredentialRequiredError(
116 "import",
117 `OpenAPI spec fetch requires credentials (status ${response.status})`,
118 ),
119 );
120 }
121 if (response.status < 200 || response.status >= 300) {
122 return yield* Effect.fail(
123 new Error(`OpenAPI spec fetch failed with status ${response.status}`),
124 );
125 }
126
127 return yield* response.text.pipe(
128 Effect.mapError((cause) =>
129 cause instanceof Error ? cause : new Error(String(cause)),
130 ),
131 );
132 }).pipe(Effect.provide(FetchHttpClient.layer));
133
134const toOpenApiRecipeOperationRecord = (input: {
135 recipeRevisionId: SourceRecipeRevisionId;
136 definition: ReturnType<typeof compileOpenApiToolDefinitions>[number];
137 now: number;
138}): StoredSourceRecipeOperationRecord => {
139 const presentation = buildOpenApiToolPresentation({
140 definition: input.definition,
141 });
142 const method = input.definition.method.toUpperCase();
143
144 return {
145 id: `src_recipe_op_${crypto.randomUUID()}`,
146 recipeRevisionId: input.recipeRevisionId,
147 operationKey: input.definition.toolId,
148 transportKind: "http",
149 toolId: input.definition.toolId,
150 title: input.definition.name,
151 description: input.definition.description,
152 operationKind:
153 method === "GET" || method === "HEAD"
154 ? "read"
155 : method === "DELETE"
156 ? "delete"
157 : "write",
158 searchText: normalizeSearchText(
159 input.definition.toolId,
160 input.definition.name,
161 input.definition.description,
162 input.definition.rawToolId,
163 input.definition.operationId ?? undefined,
164 input.definition.method,
165 input.definition.path,
166 input.definition.group,
167 input.definition.leaf,
168 input.definition.tags.join(" "),
169 ),
170 inputSchemaJson: presentation.inputSchemaJson ?? null,
171 outputSchemaJson: presentation.outputSchemaJson ?? null,
172 providerKind: "openapi",
173 providerDataJson: presentation.providerDataJson,
174 createdAt: input.now,
175 updatedAt: input.now,
176};
177};
178
179const toOpenApiSchemaBundleRecord = (input: {
180 recipeRevisionId: SourceRecipeRevisionId;
181 refTable: Readonly<Record<string, string>> | undefined;
182 now: number;
183}): StoredSourceRecipeSchemaBundleRecord | null => {
184 const refEntries = Object.entries(input.refTable ?? {});
185 if (refEntries.length === 0) {
186 return null;
187 }
188
189 const refs = Object.fromEntries(
190 refEntries.map(([ref, rawValue]) => {
191 try {
192 return [ref, JSON.parse(rawValue) as unknown];
193 } catch {
194 return [ref, rawValue];
195 }
196 }),
197 );
198 const refsJson = JSON.stringify(refs);
199
200 return {
201 id: SourceRecipeSchemaBundleIdSchema.make(
202 `src_recipe_bundle_${crypto.randomUUID()}`,
203 ),
204 recipeRevisionId: input.recipeRevisionId,
205 bundleKind: "json_schema_ref_map",
206 refsJson,
207 contentHash: contentHash(refsJson),
208 createdAt: input.now,
209 updatedAt: input.now,
210 };
211};
212
213const openApiDefinitionFromPersistedOperation = (input: {
214 source: Source;
215 path: string;
216 operation: StoredSourceRecipeOperationRecord;
217}) => {
218 const decoded = input.operation.providerDataJson
219 ? decodeOpenApiProviderDataJson(input.operation.providerDataJson)
220 : Either.left(new Error("Missing providerDataJson"));
221
222 if (Either.isLeft(decoded)) {
223 throw new Error(`Invalid OpenAPI provider data for ${input.path}`);
224 }
225
226 return {
227 toolId: input.operation.toolId,
228 rawToolId: decoded.right.rawToolId,
229 ...(decoded.right.operationId
230 ? { operationId: decoded.right.operationId }
231 : {}),
232 name: input.operation.title ?? input.operation.toolId,
233 description:
234 input.operation.description ??
235 `${decoded.right.method.toUpperCase()} ${decoded.right.path}`,
236 group: decoded.right.group,
237 leaf: decoded.right.leaf,
238 tags: decoded.right.tags,
239 ...(decoded.right.versionSegment
240 ? { versionSegment: decoded.right.versionSegment }
241 : {}),
242 method: decoded.right.method,
243 path: decoded.right.path,
244 invocation: decoded.right.invocation,
245 operationHash: decoded.right.operationHash,
246 typing: {
247 ...(input.operation.inputSchemaJson
248 ? { inputSchemaJson: input.operation.inputSchemaJson }
249 : {}),
250 ...(input.operation.outputSchemaJson
251 ? { outputSchemaJson: input.operation.outputSchemaJson }
252 : {}),
253 },
254 ...(decoded.right.documentation
255 ? { documentation: decoded.right.documentation }
256 : {}),
257 };
258};
259
260const primaryDocument = (
261 documents: readonly StoredSourceRecipeDocumentRecord[],
262): StoredSourceRecipeDocumentRecord | null =>
263 documents.find((document) => document.documentKind === "openapi") ?? null;
264
265export const rebuildOpenApiRecipeRevisionContent = (input: {
266 rows: SqlControlPlaneRows;
267 sourceRecord: SourceAdapterRepairRevisionInput["sourceRecord"];
268 revision: SourceAdapterRepairRevisionInput["revision"];
269 documents: readonly StoredSourceRecipeDocumentRecord[];
270}): Effect.Effect<void, Error, never> =>
271 Effect.gen(function* () {
272 const manifest =
273 input.revision.manifestJson !== null
274 ? ((yield* parseJsonValue<OpenApiToolManifest>({
275 label: `OpenAPI manifest for ${input.sourceRecord.id}`,
276 value: input.revision.manifestJson,
277 })) as OpenApiToolManifest | null)
278 : null;
279 const document = primaryDocument(input.documents);
280 const resolvedManifest =
281 manifest
282 ?? (document !== null
283 ? yield* extractOpenApiManifest(
284 input.sourceRecord.name,
285 document.contentText,
286 )
287 : null);
288
289 if (resolvedManifest === null) {
290 return yield* Effect.fail(
291 new Error(
292 `Missing OpenAPI manifest and document for ${input.sourceRecord.id}`,
293 ),
294 );
295 }
296
297 const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt);
298 const definitions = compileOpenApiToolDefinitions(resolvedManifest);
299 const schemaBundle = toOpenApiSchemaBundleRecord({
300 recipeRevisionId: input.revision.id,
301 refTable: resolvedManifest.refHintTable,
302 now,
303 });
304
305 yield* input.rows.sourceRecipeRevisions.update(input.revision.id, {
306 manifestJson: JSON.stringify(resolvedManifest),
307 manifestHash: resolvedManifest.sourceHash,
308 updatedAt: now,
309 });
310 yield* input.rows.sourceRecipeOperations.replaceForRevision({
311 recipeRevisionId: input.revision.id,
312 operations: definitions.map((definition) =>
313 toOpenApiRecipeOperationRecord({
314 recipeRevisionId: input.revision.id,
315 definition,
316 now,
317 }),
318 ),
319 });
320 yield* input.rows.sourceRecipeSchemaBundles.replaceForRevision({
321 recipeRevisionId: input.revision.id,
322 bundles: schemaBundle ? [schemaBundle] : [],
323 });
324
325 if (input.sourceRecord.sourceHash !== resolvedManifest.sourceHash) {
326 yield* input.rows.sources.update(input.sourceRecord.workspaceId, input.sourceRecord.id, {
327 sourceHash: resolvedManifest.sourceHash,
328 updatedAt: now,
329 });
330 }
331 });
332
333export const openApiSourceAdapter: SourceAdapter = {
334 key: "openapi",
335 displayName: "OpenAPI",
336 family: "http_api",
337 providerKey: "generic_http",
338 defaultImportAuthPolicy: "reuse_runtime",
339 primaryDocumentKind: "openapi",
340 primarySchemaBundleKind: "json_schema_ref_map",
341 connectPayloadSchema: OpenApiConnectPayloadSchema,
342 executorAddInputSchema: OpenApiExecutorAddInputSchema,
343 executorAddHelpText: [
344 "endpoint is the base API URL. specUrl is the OpenAPI document URL.",
345 ],
346 executorAddInputSignatureWidth: 420,
347 serializeBindingConfig: (source) =>
348 encodeBindingConfig(OpenApiBindingConfigSchema, {
349 adapterKey: "openapi",
350 specUrl: source.specUrl ?? source.endpoint,
351 defaultHeaders: source.defaultHeaders,
352 }),
353 deserializeBindingConfig: ({ id, bindingConfigJson }) =>
354 Effect.map(
355 decodeBindingConfig({
356 sourceId: id,
357 label: "OpenAPI",
358 schema: OpenApiBindingConfigSchema,
359 value: bindingConfigJson,
360 }),
361 (bindingConfig) => ({
362 ...emptySourceBindingState,
363 specUrl: bindingConfig.specUrl,
364 defaultHeaders: bindingConfig.defaultHeaders,
365 }),
366 ),
367 sourceConfigFromSource: (source) => ({
368 kind: "openapi",
369 endpoint: source.endpoint,
370 specUrl: source.specUrl ?? source.endpoint,
371 defaultHeaders: source.defaultHeaders,
372 }),
373 validateSource: (source) =>
374 Effect.gen(function* () {
375 if (source.specUrl === null || source.specUrl.trim().length === 0) {
376 return yield* Effect.fail(new Error("OpenAPI sources require specUrl"));
377 }
378
379 if (source.transport !== null || source.queryParams !== null || source.headers !== null) {
380 return yield* Effect.fail(
381 new Error("OpenAPI sources cannot define MCP transport settings"),
382 );
383 }
384
385 return source;
386 }),
387 shouldAutoProbe: (source) =>
388 source.enabled
389 && !!source.specUrl
390 && (source.status === "draft" || source.status === "probing"),
391 parseManifest: ({ source, manifestJson }) =>
392 parseJsonValue<OpenApiToolManifest>({
393 label: `OpenAPI manifest for ${source.id}`,
394 value: manifestJson,
395 }),
396 describePersistedOperation: ({ operation, path }) =>
397 Effect.gen(function* () {
398 const decoded = operation.providerDataJson
399 ? decodeOpenApiProviderDataJson(operation.providerDataJson)
400 : Either.left(new Error("Missing providerDataJson"));
401 if (Either.isLeft(decoded)) {
402 return yield* Effect.fail(
403 new Error(`Invalid OpenAPI provider data for ${path}`),
404 );
405 }
406
407 const providerData = decoded.right;
408 const method = providerData.method.toUpperCase();
409
410 return {
411 method: providerData.method,
412 pathTemplate: providerData.path,
413 rawToolId: providerData.rawToolId,
414 operationId: providerData.operationId ?? null,
415 group: providerData.group,
416 leaf: providerData.leaf,
417 tags: providerData.tags,
418 searchText: normalizeSearchText(
419 path,
420 operation.toolId,
421 operation.title ?? undefined,
422 operation.description ?? undefined,
423 providerData.rawToolId,
424 providerData.operationId ?? undefined,
425 method,
426 providerData.path,
427 providerData.group,
428 providerData.leaf,
429 providerData.tags.join(" "),
430 operation.searchText,
431 ),
432 interaction: method === "GET" || method === "HEAD" ? "auto" : "required",
433 approvalLabel: `${method} ${providerData.path}`,
434 } as const;
435 }),
436 createToolDescriptor: ({ source, operation, path, includeSchemas, schemaBundleId }) =>
437 createStandardToolDescriptor({
438 source,
439 operation,
440 path,
441 includeSchemas,
442 interaction: operation.operationKind === "read" ? "auto" : "required",
443 outputType: openApiOutputTypeSignatureFromSchemaJson(
444 operation.outputSchemaJson ?? undefined,
445 320,
446 ),
447 schemaBundleId,
448 }),
449 materializeSource: ({ source, resolveAuthMaterialForSlot }) =>
450 Effect.gen(function* () {
451 if (!source.specUrl) {
452 return yield* Effect.fail(
453 new Error(`Missing OpenAPI specUrl for source ${source.id}`),
454 );
455 }
456
457 const auth = yield* resolveAuthMaterialForSlot("import");
458 const openApiDocument = yield* fetchOpenApiDocumentWithHeaders({
459 url: source.specUrl,
460 headers: {
461 ...(source.defaultHeaders ?? {}),
462 ...auth.headers,
463 },
464 }).pipe(
465 Effect.mapError(
466 (cause) =>
467 isSourceCredentialRequiredError(cause)
468 ? cause
469 : new Error(
470 `Failed fetching OpenAPI spec for ${source.id}: ${cause.message}`,
471 ),
472 ),
473 );
474
475 const manifest = yield* extractOpenApiManifest(
476 source.name,
477 openApiDocument,
478 ).pipe(
479 Effect.mapError((cause) =>
480 cause instanceof Error ? cause : new Error(String(cause)),
481 ),
482 );
483
484 const definitions = compileOpenApiToolDefinitions(manifest);
485 const now = Date.now();
486 const schemaBundle = toOpenApiSchemaBundleRecord({
487 recipeRevisionId: OPENAPI_MATERIALIZATION_REVISION_ID,
488 refTable: manifest.refHintTable,
489 now,
490 });
491
492 return {
493 manifestJson: JSON.stringify(manifest),
494 manifestHash: manifest.sourceHash,
495 sourceHash: manifest.sourceHash,
496 documents: [
497 {
498 id: `src_recipe_doc_${crypto.randomUUID()}`,
499 recipeRevisionId: OPENAPI_MATERIALIZATION_REVISION_ID,
500 documentKind: "openapi",
501 documentKey: source.specUrl ?? source.endpoint,
502 contentText: openApiDocument,
503 contentHash: contentHash(openApiDocument),
504 fetchedAt: now,
505 createdAt: now,
506 updatedAt: now,
507 },
508 ],
509 schemaBundles: schemaBundle ? [schemaBundle] : [],
510 operations: definitions.map((definition) =>
511 toOpenApiRecipeOperationRecord({
512 recipeRevisionId: OPENAPI_MATERIALIZATION_REVISION_ID,
513 definition,
514 now,
515 }),
516 ),
517 } satisfies SourceAdapterMaterialization;
518 }),
519 invokePersistedTool: ({
520 source,
521 path,
522 operation,
523 schemaBundle,
524 auth,
525 args,
526 context,
527 onElicitation,
528 }) =>
529 Effect.gen(function* () {
530 const refHintTable = schemaBundle
531 ? decodeOpenApiRefHintTableJson(schemaBundle.refsJson)
532 : Either.right({});
533
534 if (Either.isLeft(refHintTable)) {
535 return yield* Effect.fail(
536 new Error(`Invalid OpenAPI schema bundle for ${path}`),
537 );
538 }
539
540 const tool = createOpenApiToolFromDefinition({
541 definition: openApiDefinitionFromPersistedOperation({
542 source,
543 path,
544 operation,
545 }),
546 path,
547 sourceKey: source.id,
548 baseUrl: source.endpoint,
549 defaultHeaders: source.defaultHeaders ?? {},
550 credentialHeaders: auth.headers,
551 refHintTable: refHintTable.right,
552 });
553
554 return yield* makeToolInvokerFromTools({
555 tools: {
556 [path]: tool,
557 },
558 onElicitation,
559 }).invoke({
560 path,
561 args,
562 context,
563 }).pipe(
564 Effect.mapError((cause) =>
565 cause instanceof Error ? cause : new Error(String(cause)),
566 ),
567 );
568 }),
569};
packages/control-plane/src/runtime/source-adapters/shared.ts+184−0
@@ -0,0 +1,184 @@
1import {
2 type ToolDescriptor,
3 type ToolPath,
4 type ToolSchemaBundle,
5 typeSignatureFromSchemaJson,
6} from "@executor/codemode-core";
7import {
8 type CredentialSlot,
9 SecretRefSchema,
10 SourceImportAuthPolicySchema,
11 SourceTransportSchema,
12 StringMapSchema,
13} from "#schema";
14import type {
15 SecretRef,
16 Source,
17 SourceTransport,
18 StoredSourceRecipeOperationRecord,
19 StringMap,
20} from "#schema";
21import * as Effect from "effect/Effect";
22import * as Schema from "effect/Schema";
23
24import {
25 TrimmedNonEmptyStringSchema,
26} from "../../api/string-schemas";
27
28const asToolPath = (value: string): ToolPath => value as ToolPath;
29
30export const OptionalNullableStringSchema = Schema.optional(
31 Schema.NullOr(Schema.String),
32);
33
34export const ConnectBearerAuthSchema = Schema.Struct({
35 kind: Schema.Literal("bearer"),
36 headerName: OptionalNullableStringSchema,
37 prefix: OptionalNullableStringSchema,
38 token: OptionalNullableStringSchema,
39 tokenRef: Schema.optional(
40 Schema.NullOr(SecretRefSchema as Schema.Schema<SecretRef, SecretRef, never>),
41 ),
42});
43
44export const ConnectOAuth2AuthSchema = Schema.Struct({
45 kind: Schema.Literal("oauth2"),
46 headerName: OptionalNullableStringSchema,
47 prefix: OptionalNullableStringSchema,
48 accessToken: OptionalNullableStringSchema,
49 accessTokenRef: Schema.optional(
50 Schema.NullOr(SecretRefSchema as Schema.Schema<SecretRef, SecretRef, never>),
51 ),
52 refreshToken: OptionalNullableStringSchema,
53 refreshTokenRef: Schema.optional(
54 Schema.NullOr(SecretRefSchema as Schema.Schema<SecretRef, SecretRef, never>),
55 ),
56});
57
58export const ConnectHttpAuthSchema = Schema.Union(
59 Schema.Struct({ kind: Schema.Literal("none") }),
60 ConnectBearerAuthSchema,
61 ConnectOAuth2AuthSchema,
62);
63
64export const ConnectHttpImportAuthSchema = Schema.Struct({
65 importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema),
66 importAuth: Schema.optional(ConnectHttpAuthSchema),
67});
68
69export const SourceConnectCommonFieldsSchema = Schema.Struct({
70 endpoint: TrimmedNonEmptyStringSchema,
71 name: OptionalNullableStringSchema,
72 namespace: OptionalNullableStringSchema,
73});
74
75export const McpConnectFieldsSchema = Schema.Struct({
76 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
77 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
78 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
79});
80
81export const parseJsonValue = <T>(input: {
82 label: string;
83 value: string | null;
84}): Effect.Effect<T | null, Error, never> =>
85 input.value === null
86 ? Effect.succeed<T | null>(null)
87 : Effect.try({
88 try: () => JSON.parse(input.value!) as T,
89 catch: (cause) =>
90 cause instanceof Error
91 ? new Error(`Invalid ${input.label}: ${cause.message}`)
92 : new Error(`Invalid ${input.label}: ${String(cause)}`),
93 });
94
95export class SourceCredentialRequiredError extends Error {
96 readonly _tag = "SourceCredentialRequiredError";
97
98 constructor(
99 readonly slot: CredentialSlot,
100 message: string,
101 ) {
102 super(message);
103 }
104}
105
106export const isSourceCredentialRequiredError = (
107 error: unknown,
108): error is SourceCredentialRequiredError =>
109 error instanceof SourceCredentialRequiredError;
110
111export const emptySourceBindingState = {
112 transport: null,
113 queryParams: null,
114 headers: null,
115 specUrl: null,
116 defaultHeaders: null,
117} satisfies {
118 transport: SourceTransport | null;
119 queryParams: StringMap | null;
120 headers: StringMap | null;
121 specUrl: string | null;
122 defaultHeaders: StringMap | null;
123};
124
125export const encodeBindingConfig = (schema: Schema.Schema.AnyNoContext, value: unknown): string =>
126 Schema.encodeSync(Schema.parseJson(schema))(value);
127
128export const decodeBindingConfig = <A>(input: {
129 sourceId: string;
130 label: string;
131 schema: Schema.Schema<A, any, never>;
132 value: string | null;
133}): Effect.Effect<A, Error, never> =>
134 input.value === null
135 ? Effect.fail(
136 new Error(`Missing ${input.label} binding config for ${input.sourceId}`),
137 )
138 : Effect.try({
139 try: () => Schema.decodeUnknownSync(Schema.parseJson(input.schema))(input.value),
140 catch: (cause) =>
141 cause instanceof Error
142 ? new Error(`Invalid ${input.label} binding config for ${input.sourceId}: ${cause.message}`)
143 : new Error(`Invalid ${input.label} binding config for ${input.sourceId}: ${String(cause)}`),
144 });
145
146export const firstSchemaBundle = (input: {
147 schemaBundles: readonly ToolSchemaBundle[];
148 preferredKind: string | null;
149}): ToolSchemaBundle | null =>
150 (input.preferredKind
151 ? input.schemaBundles.find((schemaBundle) => schemaBundle.kind === input.preferredKind)
152 : null)
153 ?? input.schemaBundles[0]
154 ?? null;
155
156export const createStandardToolDescriptor = (input: {
157 source: Source;
158 operation: StoredSourceRecipeOperationRecord;
159 path: string;
160 includeSchemas: boolean;
161 interaction: "auto" | "required";
162 outputType?: string | undefined;
163 schemaBundleId?: string | null;
164}): ToolDescriptor => ({
165 path: asToolPath(input.path),
166 sourceKey: input.source.id,
167 description: input.operation.description ?? input.operation.title ?? undefined,
168 interaction: input.interaction,
169 inputType: typeSignatureFromSchemaJson(
170 input.operation.inputSchemaJson ?? undefined,
171 "unknown",
172 320,
173 ),
174 ...(input.outputType ? { outputType: input.outputType } : {}),
175 inputSchemaJson: input.includeSchemas ? input.operation.inputSchemaJson ?? undefined : undefined,
176 outputSchemaJson: input.includeSchemas ? input.operation.outputSchemaJson ?? undefined : undefined,
177 ...(input.schemaBundleId ? { schemaBundleId: input.schemaBundleId } : {}),
178 ...(input.operation.providerKind
179 ? { providerKind: input.operation.providerKind }
180 : {}),
181 ...(input.operation.providerDataJson
182 ? { providerDataJson: input.operation.providerDataJson }
183 : {}),
184});
packages/control-plane/src/runtime/source-adapters/types.ts+138−0
@@ -0,0 +1,138 @@
1import type {
2 OnElicitation,
3 ToolDescriptor,
4 ToolSchemaBundle,
5} from "@executor/codemode-core";
6import type { SqlControlPlaneRows } from "#persistence";
7import type {
8 AccountId,
9 CredentialSlot,
10 Source,
11 SourceImportAuthPolicy,
12 SourceTransport,
13 StoredSourceRecipeDocumentRecord,
14 StoredSourceRecipeOperationRecord,
15 StoredSourceRecipeSchemaBundleRecord,
16 StoredSourceRecipeRevisionRecord,
17 StoredSourceRecord,
18 StringMap,
19} from "#schema";
20import * as Effect from "effect/Effect";
21import * as Schema from "effect/Schema";
22
23import type { ResolvedSourceAuthMaterial } from "../source-auth-material";
24import type { ResolveSecretMaterial } from "../secret-material-providers";
25
26export type SourceAdapterFamily = "http_api" | "mcp" | "internal";
27export type SourceAdapterInputSchema = Schema.Schema<any, any, never>;
28export type SourceBindingState = {
29 transport: SourceTransport | null;
30 queryParams: StringMap | null;
31 headers: StringMap | null;
32 specUrl: string | null;
33 defaultHeaders: StringMap | null;
34};
35
36export type SourceAdapterSyncInput = {
37 source: Source;
38 resolveSecretMaterial: ResolveSecretMaterial;
39 resolveAuthMaterialForSlot: (slot: CredentialSlot) => Effect.Effect<
40 ResolvedSourceAuthMaterial,
41 Error,
42 never
43 >;
44};
45
46export type SourceAdapterMaterialization = {
47 manifestJson: string | null;
48 manifestHash: string | null;
49 sourceHash: string | null;
50 documents: readonly StoredSourceRecipeDocumentRecord[];
51 schemaBundles: readonly StoredSourceRecipeSchemaBundleRecord[];
52 operations: readonly StoredSourceRecipeOperationRecord[];
53};
54
55export type SourceAdapterPersistedOperationMetadata = {
56 method: string | null;
57 pathTemplate: string | null;
58 rawToolId: string | null;
59 operationId: string | null;
60 group: string | null;
61 leaf: string | null;
62 tags: readonly string[];
63 searchText: string;
64 interaction: "auto" | "required";
65 approvalLabel: string | null;
66};
67
68export type SourceAdapterRepairRevisionInput = {
69 rows: SqlControlPlaneRows;
70 sourceRecord: StoredSourceRecord;
71 revision: StoredSourceRecipeRevisionRecord;
72 documents: readonly StoredSourceRecipeDocumentRecord[];
73 operations: readonly StoredSourceRecipeOperationRecord[];
74 schemaBundles: readonly ToolSchemaBundle[];
75};
76
77export type SourceAdapterInvokePersistedToolInput = {
78 rows: SqlControlPlaneRows;
79 workspaceId: Source["workspaceId"];
80 accountId: AccountId;
81 source: Source;
82 path: string;
83 operation: StoredSourceRecipeOperationRecord;
84 schemaBundle: ToolSchemaBundle | null;
85 manifestJson: string | null;
86 auth: ResolvedSourceAuthMaterial;
87 args: unknown;
88 context?: Record<string, unknown>;
89 onElicitation?: OnElicitation;
90};
91
92export type SourceAdapter = {
93 key: string;
94 displayName: string;
95 family: SourceAdapterFamily;
96 providerKey: string;
97 defaultImportAuthPolicy: SourceImportAuthPolicy;
98 primaryDocumentKind: string | null;
99 primarySchemaBundleKind: string | null;
100 connectPayloadSchema: SourceAdapterInputSchema | null;
101 executorAddInputSchema: SourceAdapterInputSchema | null;
102 executorAddHelpText: readonly string[] | null;
103 executorAddInputSignatureWidth: number | null;
104 serializeBindingConfig: (source: Source) => string;
105 deserializeBindingConfig: (
106 input: Pick<StoredSourceRecord, "id" | "bindingConfigJson">,
107 ) => Effect.Effect<SourceBindingState, Error, never>;
108 sourceConfigFromSource: (source: Source) => Record<string, unknown>;
109 validateSource: (source: Source) => Effect.Effect<Source, Error, never>;
110 shouldAutoProbe: (source: Source) => boolean;
111 parseManifest: (input: {
112 source: Pick<Source, "id" | "kind">;
113 manifestJson: string | null;
114 }) => Effect.Effect<unknown | null, Error, never>;
115 describePersistedOperation: (input: {
116 source: Source;
117 path: string;
118 operation: StoredSourceRecipeOperationRecord;
119 }) => Effect.Effect<SourceAdapterPersistedOperationMetadata, Error, never>;
120 searchNamespace?: (input: {
121 source: Source;
122 path: string;
123 operation: StoredSourceRecipeOperationRecord;
124 }) => string;
125 createToolDescriptor: (input: {
126 source: Source;
127 operation: StoredSourceRecipeOperationRecord;
128 path: string;
129 schemaBundleId?: string | null;
130 includeSchemas: boolean;
131 }) => ToolDescriptor;
132 materializeSource: (
133 input: SourceAdapterSyncInput,
134 ) => Effect.Effect<SourceAdapterMaterialization, Error, never>;
135 invokePersistedTool: (
136 input: SourceAdapterInvokePersistedToolInput,
137 ) => Effect.Effect<unknown, Error, never>;
138};
packages/control-plane/src/runtime/source-auth-material.test.tsrenamed from packages/control-plane/src/runtime/tool-artifacts.test.ts+4−2
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
7 type Source,7 type Source,
8} from "#schema";8} from "#schema";
9 9
10import { resolveSourceAuthMaterial } from "./tool-artifacts";10import { resolveSourceAuthMaterial } from "./source-auth-material";
11 11
12const makeSource = (overrides: Partial<Source> = {}): Source => ({12const makeSource = (overrides: Partial<Source> = {}): Source => ({
13 id: SourceIdSchema.make("src_tool_artifacts"),13 id: SourceIdSchema.make("src_tool_artifacts"),
@@ -23,6 +23,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines
23 headers: null,23 headers: null,
24 specUrl: "https://api.github.com/openapi.json",24 specUrl: "https://api.github.com/openapi.json",
25 defaultHeaders: null,25 defaultHeaders: null,
26 importAuthPolicy: "reuse_runtime",
27 importAuth: { kind: "none" },
26 auth: { kind: "none" },28 auth: { kind: "none" },
27 sourceHash: null,29 sourceHash: null,
28 lastError: null,30 lastError: null,
@@ -31,7 +33,7 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 2 unmodified lines
31 ...overrides,33 ...overrides,
32});34});
33 35
34describe("tool-artifacts", () => {36describe("source-auth-material", () => {
35 describe("resolveSourceAuthMaterial", () => {37 describe("resolveSourceAuthMaterial", () => {
36 it("returns empty headers for auth.kind none", async () => {38 it("returns empty headers for auth.kind none", async () => {
37 const auth = await Effect.runPromise(resolveSourceAuthMaterial({39 const auth = await Effect.runPromise(resolveSourceAuthMaterial({
packages/control-plane/src/runtime/source-auth-material.ts+72−0
@@ -0,0 +1,72 @@
1import type { CredentialSlot, Source, SourceAuth } from "#schema";
2import * as Effect from "effect/Effect";
3
4import type {
5 ResolveSecretMaterial,
6 SecretMaterialResolveContext,
7} from "./secret-material-providers";
8
9export type ResolvedSourceAuthMaterial = {
10 headers: Readonly<Record<string, string>>;
11};
12
13export const resolveAuthMaterial = (input: {
14 auth: SourceAuth;
15 resolveSecretMaterial: ResolveSecretMaterial;
16 context?: SecretMaterialResolveContext;
17}): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>
18 Effect.gen(function* () {
19 if (input.auth.kind === "none") {
20 return { headers: {} } satisfies ResolvedSourceAuthMaterial;
21 }
22
23 const tokenRef =
24 input.auth.kind === "bearer"
25 ? input.auth.token
26 : input.auth.accessToken;
27
28 const token = yield* input.resolveSecretMaterial({
29 ref: tokenRef,
30 context: input.context,
31 });
32
33 return {
34 headers: {
35 [input.auth.headerName]: `${input.auth.prefix}${token}`,
36 },
37 } satisfies ResolvedSourceAuthMaterial;
38 });
39
40const authForSlot = (input: {
41 source: Source;
42 slot: CredentialSlot;
43}): SourceAuth => {
44 if (input.slot === "runtime") {
45 return input.source.auth;
46 }
47
48 if (input.source.importAuthPolicy === "reuse_runtime") {
49 return input.source.auth;
50 }
51
52 if (input.source.importAuthPolicy === "none") {
53 return { kind: "none" };
54 }
55
56 return input.source.importAuth;
57};
58
59export const resolveSourceAuthMaterial = (input: {
60 source: Source;
61 slot?: CredentialSlot;
62 resolveSecretMaterial: ResolveSecretMaterial;
63 context?: SecretMaterialResolveContext;
64}): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>
65 resolveAuthMaterial({
66 auth: authForSlot({
67 source: input.source,
68 slot: input.slot ?? "runtime",
69 }),
70 resolveSecretMaterial: input.resolveSecretMaterial,
71 context: input.context,
72 });
packages/control-plane/src/runtime/source-auth-service.test.ts+2−61
@@ -8,7 +8,6 @@ import { · 7 unmodified lines
8 8
9import {9import {
10 createTerminalSourceAuthSessionPatch,10 createTerminalSourceAuthSessionPatch,
11 shouldPromptForHttpCredentialSetup,
12} from "./source-auth-service";11} from "./source-auth-service";
13 12
14const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({13const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({
@@ -25,6 +24,8 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 10 unmodified lines
25 headers: null,24 headers: null,
26 specUrl: "https://example.com/openapi.json",25 specUrl: "https://example.com/openapi.json",
27 defaultHeaders: null,26 defaultHeaders: null,
27 importAuthPolicy: "reuse_runtime",
28 importAuth: { kind: "none" },
28 auth,29 auth,
29 sourceHash: null,30 sourceHash: null,
30 lastError: null,31 lastError: null,
@@ -88,64 +89,4 @@ describe("source-auth-service", () => { · 57 unmodified lines
88 updatedAt: 456,89 updatedAt: 456,
89 });90 });
90 });91 });
91
92 it("reuses existing non-none HTTP source auth without prompting again", () => {
93 expect(
94 shouldPromptForHttpCredentialSetup({
95 existing: makeExistingOpenApiSource({
96 kind: "bearer",
97 headerName: "Authorization",
98 prefix: "Bearer ",
99 token: {
100 providerId: "postgres",
101 handle: "sec_bearer",
102 },
103 }),
104 }),
105 ).toBe(false);
106
107 expect(
108 shouldPromptForHttpCredentialSetup({
109 existing: makeExistingOpenApiSource({
110 kind: "oauth2",
111 headerName: "Authorization",
112 prefix: "Bearer ",
113 accessToken: {
114 providerId: "postgres",
115 handle: "sec_access",
116 },
117 refreshToken: {
118 providerId: "postgres",
119 handle: "sec_refresh",
120 },
121 }),
122 }),
123 ).toBe(false);
124
125 expect(
126 shouldPromptForHttpCredentialSetup({
127 existing: makeExistingOpenApiSource({
128 kind: "none",
129 }),
130 }),
131 ).toBe(true);
132
133 expect(
134 shouldPromptForHttpCredentialSetup({
135 existing: {
136 ...makeExistingOpenApiSource({
137 kind: "bearer",
138 headerName: "Authorization",
139 prefix: "Bearer ",
140 token: {
141 providerId: "postgres",
142 handle: "sec_graphql",
143 },
144 }),
145 kind: "graphql",
146 specUrl: null,
147 },
148 }),
149 ).toBe(false);
150 });
151});92});
packages/control-plane/src/runtime/source-auth-service.ts+152−89
@@ -9,11 +9,13 @@ import { · 8 unmodified lines
9} from "#persistence";9} from "#persistence";
10import {10import {
11 AccountId,11 AccountId,
12 type CredentialSlot,
12 ExecutionIdSchema,13 ExecutionIdSchema,
13 McpSourceAuthSessionDataJsonSchema,14 McpSourceAuthSessionDataJsonSchema,
14 type McpSourceAuthSessionData,15 type McpSourceAuthSessionData,
15 type SecretMaterialPurpose,16 type SecretMaterialPurpose,
16 Source,17 Source,
18 type SourceImportAuthPolicy,
17 SourceAuthSession,19 SourceAuthSession,
18 SourceAuthSessionIdSchema,20 SourceAuthSessionIdSchema,
19 SourceIdSchema,21 SourceIdSchema,
@@ -41,6 +43,8 @@ import { · 21 unmodified lines
41 createSourceFromPayload,43 createSourceFromPayload,
42 updateSourceFromPayload,44 updateSourceFromPayload,
43} from "./source-definitions";45} from "./source-definitions";
46import { hasSourceAdapterFamily } from "./source-adapters";
47import { isSourceCredentialRequiredError } from "./source-adapters/shared";
44import {48import {
45 createDefaultSecretMaterialResolver,49 createDefaultSecretMaterialResolver,
46 createDefaultSecretMaterialStorer,50 createDefaultSecretMaterialStorer,
@@ -48,9 +52,9 @@ import { · 1 unmodified line
48 type StoreSecretMaterial,52 type StoreSecretMaterial,
49} from "./secret-material-providers";53} from "./secret-material-providers";
50import {54import {
51 persistMcpToolArtifactsFromManifest,55 persistMcpRecipeMaterializationFromManifest,
52 syncSourceToolArtifacts,56 syncSourceMaterialization,
53} from "./tool-artifacts";57} from "./source-materialization";
54import {58import {
55 loadSourceById,59 loadSourceById,
56 loadSourcesInWorkspace,60 loadSourcesInWorkspace,
@@ -255,6 +259,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 198 unmodified lines
255 status: Source["status"];259 status: Source["status"];
256 lastError?: string | null;260 lastError?: string | null;
257 auth?: Source["auth"];261 auth?: Source["auth"];
262 importAuth?: Source["importAuth"];
258}) =>263}) =>
259 Effect.gen(function* () {264 Effect.gen(function* () {
260 const latest = yield* loadSourceById(rows, {265 const latest = yield* loadSourceById(rows, {
@@ -268,6 +273,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 7 unmodified lines
268 status: input.status,273 status: input.status,
269 lastError: input.lastError ?? null,274 lastError: input.lastError ?? null,
270 auth: input.auth ?? latest.auth,275 auth: input.auth ?? latest.auth,
276 importAuth: input.importAuth ?? latest.importAuth,
271 updatedAt: Date.now(),277 updatedAt: Date.now(),
272 }, {278 }, {
273 actorAccountId: input.actorAccountId,279 actorAccountId: input.actorAccountId,
@@ -282,6 +288,7 @@ export type ExecutorSourceAddResult = · 8 unmodified lines
282 | {288 | {
283 kind: "credential_required";289 kind: "credential_required";
284 source: Source;290 source: Source;
291 credentialSlot: CredentialSlot;
285 }292 }
286 | {293 | {
287 kind: "oauth_required";294 kind: "oauth_required";
@@ -332,6 +339,8 @@ export type ExecutorAddSourceInput = · 44 unmodified lines
332 specUrl: string;339 specUrl: string;
333 name?: string | null;340 name?: string | null;
334 namespace?: string | null;341 namespace?: string | null;
342 importAuthPolicy?: SourceImportAuthPolicy | null;
343 importAuth?: ExecutorHttpSourceAuthInput | null;
335 auth?: ExecutorHttpSourceAuthInput | null;344 auth?: ExecutorHttpSourceAuthInput | null;
336 }345 }
337 | {346 | {
@@ -343,6 +352,8 @@ export type ExecutorAddSourceInput = · 5 unmodified lines
343 endpoint: string;352 endpoint: string;
344 name?: string | null;353 name?: string | null;
345 namespace?: string | null;354 namespace?: string | null;
355 importAuthPolicy?: SourceImportAuthPolicy | null;
356 importAuth?: ExecutorHttpSourceAuthInput | null;
346 auth?: ExecutorHttpSourceAuthInput | null;357 auth?: ExecutorHttpSourceAuthInput | null;
347 };358 };
348 359
@@ -390,20 +401,6 @@ export type CompleteSourceOAuthSessionResult = { · 41 unmodified lines
390 auth: Extract<Source["auth"], { kind: "oauth2" }>;401 auth: Extract<Source["auth"], { kind: "oauth2" }>;
391};402};
392 403
393export const shouldPromptForHttpCredentialSetup = (input: {
394 existing?: Source;
395 auth?: ExecutorHttpSourceAuthInput | null;
396}): boolean => {
397 if (input.auth !== undefined) {
398 return false;
399 }
400
401 return !(
402 (input.existing?.kind === "openapi" || input.existing?.kind === "graphql")
403 && input.existing.auth.kind !== "none"
404 );
405};
406
407const materializeSecretRefInput = (input: {404const materializeSecretRefInput = (input: {
408 rawValue?: string | null;405 rawValue?: string | null;
409 ref?: SecretRef | null;406 ref?: SecretRef | null;
@@ -436,11 +433,10 @@ const materializeExecutorHttpAuth = (input: { · 26 unmodified lines
436 storeSecretMaterial: StoreSecretMaterial;433 storeSecretMaterial: StoreSecretMaterial;
437}): Effect.Effect<Source["auth"], Error, never> =>434}): Effect.Effect<Source["auth"], Error, never> =>
438 Effect.gen(function* () {435 Effect.gen(function* () {
439 if (436 const existing = input.existing;
440 input.auth === undefined437
441 && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql")438 if (input.auth === undefined && existing && hasSourceAdapterFamily(existing.kind, "http_api")) {
442 ) {439 return existing.auth;
443 return input.existing.auth;
444 }440 }
445 441
446 const auth = input.auth ?? { kind: "none" } satisfies ExecutorHttpSourceAuthInput;442 const auth = input.auth ?? { kind: "none" } satisfies ExecutorHttpSourceAuthInput;
@@ -458,10 +454,11 @@ const materializeExecutorHttpAuth = (input: { · 11 unmodified lines
458 if (454 if (
459 token === null455 token === null
460 && tokenRefInput === null456 && tokenRefInput === null
461 && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql")457 && existing
462 && input.existing.auth.kind === "bearer"458 && hasSourceAdapterFamily(existing.kind, "http_api")
459 && existing.auth.kind === "bearer"
463 ) {460 ) {
464 return input.existing.auth;461 return existing.auth;
465 }462 }
466 463
467 const tokenRef = yield* materializeSecretRefInput({464 const tokenRef = yield* materializeSecretRefInput({
@@ -488,10 +485,11 @@ const materializeExecutorHttpAuth = (input: { · 20 unmodified lines
488 && auth.accessTokenRef == null485 && auth.accessTokenRef == null
489 && trimOrNull(auth.refreshToken) === null486 && trimOrNull(auth.refreshToken) === null
490 && auth.refreshTokenRef == null487 && auth.refreshTokenRef == null
491 && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql")488 && existing
492 && input.existing.auth.kind === "oauth2"489 && hasSourceAdapterFamily(existing.kind, "http_api")
490 && existing.auth.kind === "oauth2"
493 ) {491 ) {
494 return input.existing.auth;492 return existing.auth;
495 }493 }
496 494
497 const accessTokenRef = yield* materializeSecretRefInput({495 const accessTokenRef = yield* materializeSecretRefInput({
@@ -520,6 +518,61 @@ const materializeExecutorHttpAuth = (input: { · 22 unmodified lines
520 } satisfies Source["auth"];518 } satisfies Source["auth"];
521 });519 });
522 520
521const materializeExecutorHttpImportAuth = (input: {
522 sourceKind: "openapi" | "graphql";
523 existing?: Source;
524 importAuthPolicy?: SourceImportAuthPolicy | null;
525 importAuth?: ExecutorHttpSourceAuthInput | null;
526 storeSecretMaterial: StoreSecretMaterial;
527}): Effect.Effect<{
528 importAuthPolicy: SourceImportAuthPolicy;
529 importAuth: Source["importAuth"];
530}, Error, never> =>
531 Effect.gen(function* () {
532 const adapterDefault = input.sourceKind === "openapi" || input.sourceKind === "graphql"
533 ? "reuse_runtime"
534 : "none";
535 const importAuthPolicy = input.importAuthPolicy ?? input.existing?.importAuthPolicy ?? adapterDefault;
536
537 if (importAuthPolicy === "none" || importAuthPolicy === "reuse_runtime") {
538 return {
539 importAuthPolicy,
540 importAuth: { kind: "none" } satisfies Source["importAuth"],
541 };
542 }
543
544 if (
545 input.importAuth === undefined
546 && input.existing
547 && input.existing.importAuthPolicy === "separate"
548 ) {
549 return {
550 importAuthPolicy,
551 importAuth: input.existing.importAuth,
552 };
553 }
554
555 const importAuth = yield* materializeExecutorHttpAuth({
556 existing: undefined,
557 auth: input.importAuth ?? { kind: "none" },
558 storeSecretMaterial: input.storeSecretMaterial,
559 });
560
561 return {
562 importAuthPolicy,
563 importAuth,
564 };
565 });
566
567const shouldPromptForExecutorHttpRuntimeCredentialSetup = (input: {
568 existing?: Source;
569 explicitAuthProvided: boolean;
570 auth: Source["auth"];
571}): boolean =>
572 !input.explicitAuthProvided
573 && input.auth.kind === "none"
574 && (input.existing?.auth.kind ?? "none") === "none";
575
523const connectMcpSourceInternal = (input: {576const connectMcpSourceInternal = (input: {
524 rows: SqlControlPlaneRows;577 rows: SqlControlPlaneRows;
525 getLocalServerBaseUrl?: () => string | undefined;578 getLocalServerBaseUrl?: () => string | undefined;
@@ -549,7 +602,7 @@ const connectMcpSourceInternal = (input: { · 23 unmodified lines
549 actorAccountId: input.actorAccountId,602 actorAccountId: input.actorAccountId,
550 }).pipe(603 }).pipe(
551 Effect.flatMap((source) =>604 Effect.flatMap((source) =>
552 source.kind === "mcp"605 hasSourceAdapterFamily(source.kind, "mcp")
553 ? Effect.succeed(source)606 ? Effect.succeed(source)
554 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),607 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),
555 ),608 ),
@@ -560,7 +613,8 @@ const connectMcpSourceInternal = (input: { · 4 unmodified lines
560 Effect.map((sources) =>613 Effect.map((sources) =>
561 sources.find(614 sources.find(
562 (source) =>615 (source) =>
563 source.kind === "mcp" && normalizeEndpoint(source.endpoint) === normalizedEndpoint,616 hasSourceAdapterFamily(source.kind, "mcp")
617 && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
564 ),618 ),
565 ),619 ),
566 )620 )
@@ -593,6 +647,8 @@ const connectMcpSourceInternal = (input: { · 26 unmodified lines
593 transport: chosenTransport,647 transport: chosenTransport,
594 queryParams: chosenQueryParams,648 queryParams: chosenQueryParams,
595 headers: chosenHeaders,649 headers: chosenHeaders,
650 importAuthPolicy: "reuse_runtime",
651 importAuth: { kind: "none" },
596 auth: { kind: "none" },652 auth: { kind: "none" },
597 lastError: null,653 lastError: null,
598 },654 },
@@ -611,6 +667,8 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines
611 transport: chosenTransport,667 transport: chosenTransport,
612 queryParams: chosenQueryParams,668 queryParams: chosenQueryParams,
613 headers: chosenHeaders,669 headers: chosenHeaders,
670 importAuthPolicy: "reuse_runtime",
671 importAuth: { kind: "none" },
614 auth: { kind: "none" },672 auth: { kind: "none" },
615 },673 },
616 now,674 now,
@@ -619,7 +677,7 @@ const connectMcpSourceInternal = (input: { · 2 unmodified lines
619 const persistedDraft = yield* persistSource(input.rows, draftSource, {677 const persistedDraft = yield* persistSource(input.rows, draftSource, {
620 actorAccountId: input.actorAccountId,678 actorAccountId: input.actorAccountId,
621 });679 });
622 yield* syncSourceToolArtifacts({680 yield* syncSourceMaterialization({
623 rows: input.rows,681 rows: input.rows,
624 source: persistedDraft,682 source: persistedDraft,
625 resolveSecretMaterial: input.resolveSecretMaterial,683 resolveSecretMaterial: input.resolveSecretMaterial,
@@ -643,7 +701,7 @@ const connectMcpSourceInternal = (input: { · 17 unmodified lines
643 auth: { kind: "none" },701 auth: { kind: "none" },
644 });702 });
645 const indexed = yield* Effect.either(703 const indexed = yield* Effect.either(
646 persistMcpToolArtifactsFromManifest({704 persistMcpRecipeMaterializationFromManifest({
647 rows: input.rows,705 rows: input.rows,
648 source: connected,706 source: connected,
649 manifestEntries: result.manifest.tools,707 manifestEntries: result.manifest.tools,
@@ -704,6 +762,7 @@ const connectMcpSourceInternal = (input: { · 54 unmodified lines
704 workspaceId: input.workspaceId,762 workspaceId: input.workspaceId,
705 sourceId: authRequiredSource.id,763 sourceId: authRequiredSource.id,
706 actorAccountId: input.actorAccountId ?? null,764 actorAccountId: input.actorAccountId ?? null,
765 credentialSlot: "runtime",
707 executionId: input.executionId ?? null,766 executionId: input.executionId ?? null,
708 interactionId: input.interactionId ?? null,767 interactionId: input.interactionId ?? null,
709 providerKind: "mcp_oauth",768 providerKind: "mcp_oauth",
@@ -780,56 +839,18 @@ const addExecutorHttpSource = (input: { · 70 unmodified lines
780 ?? defaultNamespaceFromName(chosenName);839 ?? defaultNamespaceFromName(chosenName);
781 const now = Date.now();840 const now = Date.now();
782 841
783 if (shouldPromptForHttpCredentialSetup({
784 existing,
785 auth: input.sourceInput.auth,
786 })) {
787 const draftSource = existing
788 ? yield* updateSourceFromPayload({
789 source: existing,
790 payload: {
791 name: chosenName,
792 endpoint: normalizedEndpoint,
793 namespace: chosenNamespace,
794 kind: input.sourceInput.kind,
795 status: "auth_required",
796 enabled: true,
797 specUrl: normalizedSpecUrl,
798 auth: { kind: "none" },
799 lastError: null,
800 },
801 now,
802 })
803 : yield* createSourceFromPayload({
804 workspaceId: input.sourceInput.workspaceId,
805 sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
806 payload: {
807 name: chosenName,
808 kind: input.sourceInput.kind,
809 endpoint: normalizedEndpoint,
810 namespace: chosenNamespace,
811 status: "auth_required",
812 enabled: true,
813 specUrl: normalizedSpecUrl,
814 auth: { kind: "none" },
815 },
816 now,
817 });
818
819 const persistedDraft = yield* persistSource(input.rows, draftSource, {
820 actorAccountId: input.sourceInput.actorAccountId,
821 });
822 return {
823 kind: "credential_required",
824 source: persistedDraft,
825 } satisfies ExecutorSourceAddResult;
826 }
827
828 const auth = yield* materializeExecutorHttpAuth({842 const auth = yield* materializeExecutorHttpAuth({
829 existing,843 existing,
830 auth: input.sourceInput.auth,844 auth: input.sourceInput.auth,
831 storeSecretMaterial: input.storeSecretMaterial,845 storeSecretMaterial: input.storeSecretMaterial,
832 });846 });
847 const importAuth = yield* materializeExecutorHttpImportAuth({
848 sourceKind: input.sourceInput.kind,
849 existing,
850 importAuthPolicy: input.sourceInput.importAuthPolicy ?? null,
851 importAuth: input.sourceInput.importAuth ?? null,
852 storeSecretMaterial: input.storeSecretMaterial,
853 });
833 854
834 const draftSource = existing855 const draftSource = existing
835 ? yield* updateSourceFromPayload({856 ? yield* updateSourceFromPayload({
@@ -842,6 +863,8 @@ const addExecutorHttpSource = (input: { · 6 unmodified lines
842 status: "probing",863 status: "probing",
843 enabled: true,864 enabled: true,
844 specUrl: normalizedSpecUrl,865 specUrl: normalizedSpecUrl,
866 importAuthPolicy: importAuth.importAuthPolicy,
867 importAuth: importAuth.importAuth,
845 auth,868 auth,
846 lastError: null,869 lastError: null,
847 },870 },
@@ -858,6 +881,8 @@ const addExecutorHttpSource = (input: { · 10 unmodified lines
858 status: "probing",881 status: "probing",
859 enabled: true,882 enabled: true,
860 specUrl: normalizedSpecUrl,883 specUrl: normalizedSpecUrl,
884 importAuthPolicy: importAuth.importAuthPolicy,
885 importAuth: importAuth.importAuth,
861 auth,886 auth,
862 },887 },
863 now,888 now,
@@ -866,8 +891,27 @@ const addExecutorHttpSource = (input: { · 2 unmodified lines
866 const persistedDraft = yield* persistSource(input.rows, draftSource, {891 const persistedDraft = yield* persistSource(input.rows, draftSource, {
867 actorAccountId: input.sourceInput.actorAccountId,892 actorAccountId: input.sourceInput.actorAccountId,
868 });893 });
894
895 if (shouldPromptForExecutorHttpRuntimeCredentialSetup({
896 existing,
897 explicitAuthProvided: input.sourceInput.auth !== undefined,
898 auth: persistedDraft.auth,
899 })) {
900 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
901 actorAccountId: input.sourceInput.actorAccountId,
902 status: "auth_required",
903 lastError: null,
904 });
905
906 return {
907 kind: "credential_required",
908 source: authRequiredSource,
909 credentialSlot: "runtime",
910 } satisfies ExecutorSourceAddResult;
911 }
912
869 const synced = yield* Effect.either(913 const synced = yield* Effect.either(
870 syncSourceToolArtifacts({914 syncSourceMaterialization({
871 rows: input.rows,915 rows: input.rows,
872 source: {916 source: {
873 ...persistedDraft,917 ...persistedDraft,
@@ -879,13 +923,27 @@ const addExecutorHttpSource = (input: { · 5 unmodified lines
879 923
880 return yield* Either.match(synced, {924 return yield* Either.match(synced, {
881 onLeft: (error) =>925 onLeft: (error) =>
882 updateSourceStatus(input.rows, persistedDraft, {926 isSourceCredentialRequiredError(error)
883 actorAccountId: input.sourceInput.actorAccountId,927 ? updateSourceStatus(input.rows, persistedDraft, {
884 status: "error",928 actorAccountId: input.sourceInput.actorAccountId,
885 lastError: error.message,929 status: "auth_required",
886 }).pipe(930 lastError: null,
887 Effect.zipRight(Effect.fail(error)),931 }).pipe(
888 ),932 Effect.map((source) =>
933 ({
934 kind: "credential_required",
935 source,
936 credentialSlot: error.slot,
937 } satisfies ExecutorSourceAddResult)
938 ),
939 )
940 : updateSourceStatus(input.rows, persistedDraft, {
941 actorAccountId: input.sourceInput.actorAccountId,
942 status: "error",
943 lastError: error.message,
944 }).pipe(
945 Effect.zipRight(Effect.fail(error)),
946 ),
889 onRight: () =>947 onRight: () =>
890 updateSourceStatus(input.rows, persistedDraft, {948 updateSourceStatus(input.rows, persistedDraft, {
891 actorAccountId: input.sourceInput.actorAccountId,949 actorAccountId: input.sourceInput.actorAccountId,
@@ -972,10 +1030,13 @@ export const createRuntimeSourceAuthService = (input: { · 80 unmodified lines
972 }),1030 }),
973 1031
974 addExecutorSource: (sourceInput, options) =>1032 addExecutorSource: (sourceInput, options) =>
975 sourceInput.kind === "openapi" || sourceInput.kind === "graphql"1033 hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
976 ? addExecutorHttpSource({1034 ? addExecutorHttpSource({
977 rows: input.rows,1035 rows: input.rows,
978 sourceInput,1036 sourceInput: sourceInput as Extract<
1037 ExecutorAddSourceInput,
1038 { kind: "openapi" | "graphql" }
1039 >,
979 storeSecretMaterial,1040 storeSecretMaterial,
980 resolveSecretMaterial,1041 resolveSecretMaterial,
981 })1042 })
@@ -1043,6 +1104,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines
1043 workspaceId: oauthInput.workspaceId,1104 workspaceId: oauthInput.workspaceId,
1044 sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`),1105 sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`),
1045 actorAccountId: oauthInput.actorAccountId ?? null,1106 actorAccountId: oauthInput.actorAccountId ?? null,
1107 credentialSlot: "runtime",
1046 executionId: null,1108 executionId: null,
1047 interactionId: null,1109 interactionId: null,
1048 providerKind: "mcp_oauth",1110 providerKind: "mcp_oauth",
@@ -1257,7 +1319,7 @@ export const createRuntimeSourceAuthService = (input: { · 208 unmodified lines
1257 status: "error",1319 status: "error",
1258 lastError: reason,1320 lastError: reason,
1259 });1321 });
1260 yield* syncSourceToolArtifacts({1322 yield* syncSourceMaterialization({
1261 rows: input.rows,1323 rows: input.rows,
1262 source: failedSource,1324 source: failedSource,
1263 resolveSecretMaterial,1325 resolveSecretMaterial,
@@ -1328,7 +1390,7 @@ export const createRuntimeSourceAuthService = (input: { · 64 unmodified lines
1328 },1390 },
1329 });1391 });
1330 const indexed = yield* Effect.either(1392 const indexed = yield* Effect.either(
1331 syncSourceToolArtifacts({1393 syncSourceMaterialization({
1332 rows: input.rows,1394 rows: input.rows,
1333 source: connectedSource,1395 source: connectedSource,
1334 resolveSecretMaterial,1396 resolveSecretMaterial,
@@ -1410,6 +1472,7 @@ export const ExecutorAddSourceResultSchema = Schema.Union( · 75 unmodified lines
1410 Schema.Struct({1472 Schema.Struct({
1411 kind: Schema.Literal("credential_required"),1473 kind: Schema.Literal("credential_required"),
1412 source: SourceSchema,1474 source: SourceSchema,
1475 credentialSlot: Schema.Literal("runtime", "import"),
1413 }),1476 }),
1414 Schema.Struct({1477 Schema.Struct({
1415 kind: Schema.Literal("oauth_required"),1478 kind: Schema.Literal("oauth_required"),
packages/control-plane/src/runtime/source-definitions.test.ts+26−15
@@ -16,7 +16,7 @@ import { · 15 unmodified lines
16 stableSourceRecipeRevisionId,16 stableSourceRecipeRevisionId,
17 updateSourceFromPayload,17 updateSourceFromPayload,
18} from "./source-definitions";18} from "./source-definitions";
19import { namespaceFromSourceName } from "./tool-artifacts";19import { namespaceFromSourceName } from "./source-names";
20 20
21const makeSource = (overrides: Partial<Source> = {}): Source => ({21const makeSource = (overrides: Partial<Source> = {}): Source => ({
22 id: SourceIdSchema.make("src_source_definitions"),22 id: SourceIdSchema.make("src_source_definitions"),
@@ -32,6 +32,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines
32 headers: null,32 headers: null,
33 specUrl: "https://api.github.com/openapi.json",33 specUrl: "https://api.github.com/openapi.json",
34 defaultHeaders: null,34 defaultHeaders: null,
35 importAuthPolicy: "reuse_runtime",
36 importAuth: { kind: "none" },
35 auth: { kind: "none" },37 auth: { kind: "none" },
36 sourceHash: null,38 sourceHash: null,
37 lastError: null,39 lastError: null,
@@ -276,7 +278,7 @@ describe("source-definitions", () => { · 238 unmodified lines
276 },278 },
277 } as never,279 } as never,
278 now: 1234,280 now: 1234,
279 }))).rejects.toThrow("internal sources cannot define OpenAPI settings");281 }))).rejects.toThrow("internal sources cannot define HTTP source settings");
280 });282 });
281 283
282 it("normalizes new auth during updates", async () => {284 it("normalizes new auth during updates", async () => {
@@ -313,9 +315,12 @@ describe("source-definitions", () => { · 30 unmodified lines
313 describe("storage roundtrip", () => {315 describe("storage roundtrip", () => {
314 it("roundtrips bearer auth and serialized maps", async () => {316 it("roundtrips bearer auth and serialized maps", async () => {
315 const source = makeSource({317 const source = makeSource({
318 kind: "mcp",
319 specUrl: null,
320 defaultHeaders: null,
321 transport: "auto",
316 queryParams: { page: "1" },322 queryParams: { page: "1" },
317 headers: { "x-api-key": "secret" },323 headers: { "x-api-key": "secret" },
318 defaultHeaders: { accept: "application/json" },
319 auth: {324 auth: {
320 kind: "bearer",325 kind: "bearer",
321 headerName: "Authorization",326 headerName: "Authorization",
@@ -330,21 +335,25 @@ describe("source-definitions", () => { · 8 unmodified lines
330 const recipeRevisionId = stableSourceRecipeRevisionId(source);335 const recipeRevisionId = stableSourceRecipeRevisionId(source);
331 const existingCredentialId = CredentialIdSchema.make("cred_existing");336 const existingCredentialId = CredentialIdSchema.make("cred_existing");
332 337
333 const { sourceRecord, credential } = splitSourceForStorage({338 const { sourceRecord, runtimeCredential } = splitSourceForStorage({
334 source,339 source,
335 recipeId,340 recipeId,
336 recipeRevisionId,341 recipeRevisionId,
337 existingCredentialId,342 existingRuntimeCredentialId: existingCredentialId,
338 });343 });
339 344
340 expect(credential?.id).toBe(existingCredentialId);345 expect(runtimeCredential?.id).toBe(existingCredentialId);
341 expect(sourceRecord.queryParamsJson).toBe(JSON.stringify(source.queryParams));346 expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({
342 expect(sourceRecord.headersJson).toBe(JSON.stringify(source.headers));347 adapterKey: "mcp",
343 expect(sourceRecord.defaultHeadersJson).toBe(JSON.stringify(source.defaultHeaders));348 transport: source.transport,
349 queryParams: source.queryParams,
350 headers: source.headers,
351 });
344 352
345 const projected = await Effect.runPromise(projectSourceFromStorage({353 const projected = await Effect.runPromise(projectSourceFromStorage({
346 sourceRecord,354 sourceRecord,
347 credential: credential ?? null,355 runtimeCredential: runtimeCredential ?? null,
356 importCredential: null,
348 }));357 }));
349 358
350 expect(projected).toEqual(source);359 expect(projected).toEqual(source);
@@ -389,14 +398,15 @@ describe("source-definitions", () => { · 38 unmodified lines
389 });398 });
390 399
391 for (const source of [withRefresh, withoutRefresh]) {400 for (const source of [withRefresh, withoutRefresh]) {
392 const { sourceRecord, credential } = splitSourceForStorage({401 const { sourceRecord, runtimeCredential } = splitSourceForStorage({
393 source,402 source,
394 recipeId: stableSourceRecipeId(source),403 recipeId: stableSourceRecipeId(source),
395 recipeRevisionId: stableSourceRecipeRevisionId(source),404 recipeRevisionId: stableSourceRecipeRevisionId(source),
396 });405 });
397 const projected = await Effect.runPromise(projectSourceFromStorage({406 const projected = await Effect.runPromise(projectSourceFromStorage({
398 sourceRecord,407 sourceRecord,
399 credential: credential ?? null,408 runtimeCredential: runtimeCredential ?? null,
409 importCredential: null,
400 }));410 }));
401 411
402 expect(projected).toEqual(source);412 expect(projected).toEqual(source);
@@ -407,17 +417,18 @@ describe("source-definitions", () => { · 4 unmodified lines
407 const source = makeSource({417 const source = makeSource({
408 auth: { kind: "none" },418 auth: { kind: "none" },
409 });419 });
410 const { sourceRecord, credential } = splitSourceForStorage({420 const { sourceRecord, runtimeCredential } = splitSourceForStorage({
411 source,421 source,
412 recipeId: stableSourceRecipeId(source),422 recipeId: stableSourceRecipeId(source),
413 recipeRevisionId: stableSourceRecipeRevisionId(source),423 recipeRevisionId: stableSourceRecipeRevisionId(source),
414 });424 });
415 425
416 expect(credential).toBeNull();426 expect(runtimeCredential).toBeNull();
417 427
418 const projected = await Effect.runPromise(projectSourceFromStorage({428 const projected = await Effect.runPromise(projectSourceFromStorage({
419 sourceRecord,429 sourceRecord,
420 credential: null,430 runtimeCredential: null,
431 importCredential: null,
421 }));432 }));
422 433
423 expect(projected.auth).toEqual({ kind: "none" });434 expect(projected.auth).toEqual({ kind: "none" });
packages/control-plane/src/runtime/source-definitions.ts+173−272
@@ -7,8 +7,10 @@ import type { · 6 unmodified lines
7import type {7import type {
8 AccountId,8 AccountId,
9 Credential,9 Credential,
10 CredentialSlot,
10 Source,11 Source,
11 SourceAuth,12 SourceAuth,
13 SourceImportAuthPolicy,
12 SourceRecipeId,14 SourceRecipeId,
13 SourceRecipeImporterKind,15 SourceRecipeImporterKind,
14 SourceRecipeKind,16 SourceRecipeKind,
@@ -25,16 +27,8 @@ import { · 10 unmodified lines
25 SourceRecipeRevisionIdSchema,27 SourceRecipeRevisionIdSchema,
26} from "#schema";28} from "#schema";
27import * as Effect from "effect/Effect";29import * as Effect from "effect/Effect";
28import * as Schema from "effect/Schema";30
29 31import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters";
30const decodeStringMap = Schema.decodeUnknown(
31 Schema.NullOr(
32 Schema.Record({
33 key: Schema.String,
34 value: Schema.String,
35 }),
36 ),
37);
38 32
39const trimOrNull = (value: string | null | undefined): string | null => {33const trimOrNull = (value: string | null | undefined): string | null => {
40 if (value === null || value === undefined) {34 if (value === null || value === undefined) {
@@ -45,134 +39,22 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines
45 return trimmed.length > 0 ? trimmed : null;39 return trimmed.length > 0 ? trimmed : null;
46};40};
47 41
48const serializeStringMap = (value: StringMap | null): string | null =>42type SourceRecipeSourceConfig = Record<string, unknown>;
49 value === null ? null : JSON.stringify(value);
50
51const parseStringMapJson = (
52 fieldName: string,
53 value: string | null,
54): Effect.Effect<StringMap | null, Error, never> =>
55 value === null
56 ? Effect.succeed(null)
57 : Effect.try({
58 try: () => JSON.parse(value),
59 catch: (cause) =>
60 cause instanceof Error
61 ? new Error(`Invalid ${fieldName}: ${cause.message}`)
62 : new Error(`Invalid ${fieldName}: ${String(cause)}`),
63 }).pipe(
64 Effect.flatMap((parsed) => decodeStringMap(parsed)),
65 Effect.mapError((cause) =>
66 cause instanceof Error
67 ? new Error(`Invalid ${fieldName}: ${cause.message}`)
68 : new Error(`Invalid ${fieldName}: ${String(cause)}`),
69 ),
70 );
71 43
72type SourceRecipeSourceConfig =44const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig =>
73 | {45 getSourceAdapterForSource(source).sourceConfigFromSource(source);
74 kind: "mcp";
75 endpoint: string;
76 transport: Source["transport"];
77 queryParams: Source["queryParams"];
78 headers: Source["headers"];
79 }
80 | {
81 kind: "openapi";
82 endpoint: string;
83 specUrl: string;
84 defaultHeaders: Source["defaultHeaders"];
85 }
86 | {
87 kind: "graphql";
88 endpoint: string;
89 defaultHeaders: Source["defaultHeaders"];
90 }
91 | {
92 kind: "internal";
93 endpoint: string;
94 };
95
96const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig => {
97 if (source.kind === "mcp") {
98 return {
99 kind: "mcp",
100 endpoint: source.endpoint,
101 transport: source.transport,
102 queryParams: source.queryParams,
103 headers: source.headers,
104 };
105 }
106
107 if (source.kind === "openapi") {
108 return {
109 kind: "openapi",
110 endpoint: source.endpoint,
111 specUrl: source.specUrl ?? source.endpoint,
112 defaultHeaders: source.defaultHeaders,
113 };
114 }
115
116 if (source.kind === "graphql") {
117 return {
118 kind: "graphql",
119 endpoint: source.endpoint,
120 defaultHeaders: source.defaultHeaders,
121 };
122 }
123
124 return {
125 kind: "internal",
126 endpoint: source.endpoint,
127 };
128};
129 46
130const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => {47const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => {
131 if (source.kind === "mcp") {48 const adapter = getSourceAdapterForSource(source);
132 return "mcp_recipe";49 return adapter.family;
133 }
134
135 if (source.kind === "graphql") {
136 return "graphql_recipe";
137 }
138
139 if (source.kind === "openapi") {
140 return "http_recipe";
141 }
142
143 return "internal_recipe";
144};50};
145 51
146const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => {52const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => {
147 if (source.kind === "mcp") {53 return getSourceAdapterForSource(source).key;
148 return "mcp_manifest";
149 }
150
151 if (source.kind === "graphql") {
152 return "graphql_introspection";
153 }
154
155 if (source.kind === "openapi") {
156 return "openapi";
157 }
158
159 return "internal_manifest";
160};54};
161 55
162const sourceRecipeProviderKeyFromSource = (source: Source): string => {56const sourceRecipeProviderKeyFromSource = (source: Source): string => {
163 if (source.kind === "mcp") {57 return getSourceAdapterForSource(source).providerKey;
164 return "generic_mcp";
165 }
166
167 if (source.kind === "graphql") {
168 return "generic_graphql";
169 }
170
171 if (source.kind === "openapi") {
172 return "generic_http";
173 }
174
175 return "generic_internal";
176};58};
177 59
178const stableHash = (value: string): string =>60const stableHash = (value: string): string =>
@@ -260,58 +142,108 @@ const normalizeAuth = ( · 81 unmodified lines
260 } satisfies SourceAuth;142 } satisfies SourceAuth;
261 });143 });
262 144
263const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, never> =>145const normalizeImportAuthPolicy = (
264 Effect.gen(function* () {146 sourceKind: Source["kind"],
265 if (source.kind === "mcp") {147 policy: SourceImportAuthPolicy | undefined,
266 if (source.specUrl !== null) {148): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy;
267 return yield* Effect.fail(new Error("MCP sources cannot define specUrl"));
268 }
269 return source;
270 }
271 149
272 if (source.kind === "openapi") {150const credentialFromAuth = (input: {
273 if (trimOrNull(source.specUrl) === null) {151 source: Source;
274 return yield* Effect.fail(new Error("OpenAPI sources require specUrl"));152 auth: SourceAuth;
275 }153 slot: CredentialSlot;
276 154 actorAccountId?: AccountId | null;
277 if (source.transport !== null || source.queryParams !== null || source.headers !== null) {155 existingCredentialId?: Credential["id"] | null;
278 return yield* Effect.fail(156}): Credential | null => {
279 new Error("OpenAPI sources cannot define MCP transport settings"),157 if (input.auth.kind === "none") {
280 );158 return null;
281 }159 }
282 160
283 return source;161 const credentialId = input.existingCredentialId
284 }162 ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);
285 163
286 if (source.kind === "graphql") {164 return {
287 if (source.transport !== null || source.queryParams !== null || source.headers !== null) {165 id: credentialId,
288 return yield* Effect.fail(166 workspaceId: input.source.workspaceId,
289 new Error("GraphQL sources cannot define MCP transport settings"),167 sourceId: input.source.id,
290 );168 actorAccountId: input.actorAccountId ?? null,
291 }169 slot: input.slot,
170 authKind: input.auth.kind,
171 authHeaderName: input.auth.headerName,
172 authPrefix: input.auth.prefix,
173 tokenProviderId:
174 input.auth.kind === "bearer"
175 ? input.auth.token.providerId
176 : input.auth.accessToken.providerId,
177 tokenHandle:
178 input.auth.kind === "bearer"
179 ? input.auth.token.handle
180 : input.auth.accessToken.handle,
181 refreshTokenProviderId:
182 input.auth.kind === "oauth2" && input.auth.refreshToken !== null
183 ? input.auth.refreshToken.providerId
184 : null,
185 refreshTokenHandle:
186 input.auth.kind === "oauth2" && input.auth.refreshToken !== null
187 ? input.auth.refreshToken.handle
188 : null,
189 createdAt: input.source.createdAt,
190 updatedAt: input.source.updatedAt,
191 } satisfies Credential;
192};
292 193
293 if (source.specUrl !== null) {194const authFromCredential = (credential: Credential | null): SourceAuth => {
294 return yield* Effect.fail(new Error("GraphQL sources cannot define specUrl"));195 if (credential === null) {
295 }196 return { kind: "none" };
197 }
296 198
297 return source;199 if (credential.authKind === "bearer") {
298 }200 return {
201 kind: "bearer",
202 headerName: credential.authHeaderName,
203 prefix: credential.authPrefix,
204 token: {
205 providerId: credential.tokenProviderId,
206 handle: credential.tokenHandle,
207 },
208 };
209 }
299 210
300 if (source.transport !== null || source.queryParams !== null || source.headers !== null) {211 return {
301 return yield* Effect.fail(212 kind: "oauth2",
302 new Error(`${source.kind} sources cannot define MCP transport settings`),213 headerName: credential.authHeaderName,
303 );214 prefix: credential.authPrefix,
304 }215 accessToken: {
216 providerId: credential.tokenProviderId,
217 handle: credential.tokenHandle,
218 },
219 refreshToken:
220 credential.refreshTokenProviderId !== null
221 && credential.refreshTokenHandle !== null
222 ? {
223 providerId: credential.refreshTokenProviderId,
224 handle: credential.refreshTokenHandle,
225 }
226 : null,
227 };
228};
305 229
306 if (source.specUrl !== null || source.defaultHeaders !== null) {230const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> =>
231 Effect.gen(function* () {
232 if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") {
307 return yield* Effect.fail(233 return yield* Effect.fail(
308 new Error(`${source.kind} sources cannot define OpenAPI settings`),234 new Error("importAuth must be none unless importAuthPolicy is separate"),
309 );235 );
310 }236 }
311 237
312 return source;238 return source;
313 });239 });
314 240
241const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, never> =>
242 Effect.flatMap(
243 validateSourceImportAuth(source),
244 (validated) => getSourceAdapterForSource(validated).validateSource(validated),
245 );
246
315export const createSourceFromPayload = (input: {247export const createSourceFromPayload = (input: {
316 workspaceId: WorkspaceId;248 workspaceId: WorkspaceId;
317 sourceId: Source["id"];249 sourceId: Source["id"];
@@ -320,6 +252,11 @@ export const createSourceFromPayload = (input: { · 2 unmodified lines
320}): Effect.Effect<Source, Error, never> =>252}): Effect.Effect<Source, Error, never> =>
321 Effect.gen(function* () {253 Effect.gen(function* () {
322 const auth = yield* normalizeAuth(input.payload.auth);254 const auth = yield* normalizeAuth(input.payload.auth);
255 const importAuth = yield* normalizeAuth(input.payload.importAuth);
256 const importAuthPolicy = normalizeImportAuthPolicy(
257 input.payload.kind,
258 input.payload.importAuthPolicy,
259 );
323 260
324 return yield* validateSourceByKind({261 return yield* validateSourceByKind({
325 id: input.sourceId,262 id: input.sourceId,
@@ -335,6 +272,8 @@ export const createSourceFromPayload = (input: { · 9 unmodified lines
335 headers: input.payload.headers ?? null,272 headers: input.payload.headers ?? null,
336 specUrl: trimOrNull(input.payload.specUrl),273 specUrl: trimOrNull(input.payload.specUrl),
337 defaultHeaders: input.payload.defaultHeaders ?? null,274 defaultHeaders: input.payload.defaultHeaders ?? null,
275 importAuthPolicy,
276 importAuth,
338 auth,277 auth,
339 sourceHash: trimOrNull(input.payload.sourceHash),278 sourceHash: trimOrNull(input.payload.sourceHash),
340 lastError: trimOrNull(input.payload.lastError),279 lastError: trimOrNull(input.payload.lastError),
@@ -352,6 +291,13 @@ export const updateSourceFromPayload = (input: { · 11 unmodified lines
352 const nextAuth = input.payload.auth === undefined291 const nextAuth = input.payload.auth === undefined
353 ? input.source.auth292 ? input.source.auth
354 : yield* normalizeAuth(input.payload.auth);293 : yield* normalizeAuth(input.payload.auth);
294 const nextImportAuth = input.payload.importAuth === undefined
295 ? input.source.importAuth
296 : yield* normalizeAuth(input.payload.importAuth);
297 const nextImportAuthPolicy = normalizeImportAuthPolicy(
298 input.payload.kind ?? input.source.kind,
299 input.payload.importAuthPolicy ?? input.source.importAuthPolicy,
300 );
355 301
356 return yield* validateSourceByKind({302 return yield* validateSourceByKind({
357 ...input.source,303 ...input.source,
@@ -381,6 +327,8 @@ export const updateSourceFromPayload = (input: { · 23 unmodified lines
381 defaultHeaders: input.payload.defaultHeaders !== undefined327 defaultHeaders: input.payload.defaultHeaders !== undefined
382 ? input.payload.defaultHeaders328 ? input.payload.defaultHeaders
383 : input.source.defaultHeaders,329 : input.source.defaultHeaders,
330 importAuthPolicy: nextImportAuthPolicy,
331 importAuth: nextImportAuth,
384 auth: nextAuth,332 auth: nextAuth,
385 sourceHash: input.payload.sourceHash !== undefined333 sourceHash: input.payload.sourceHash !== undefined
386 ? trimOrNull(input.payload.sourceHash)334 ? trimOrNull(input.payload.sourceHash)
@@ -416,6 +364,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 29 unmodified lines
416 revisionNumber: number;364 revisionNumber: number;
417 manifestJson?: string | null;365 manifestJson?: string | null;
418 manifestHash?: string | null;366 manifestHash?: string | null;
367 materializationHash?: string | null;
419}): StoredSourceRecipeRevisionRecord => ({368}): StoredSourceRecipeRevisionRecord => ({
420 id:369 id:
421 input.recipeRevisionId370 input.recipeRevisionId
@@ -425,6 +374,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 3 unmodified lines
425 sourceConfigJson: sourceConfigSignature(input.source),374 sourceConfigJson: sourceConfigSignature(input.source),
426 manifestJson: input.manifestJson ?? null,375 manifestJson: input.manifestJson ?? null,
427 manifestHash: input.manifestHash ?? null,376 manifestHash: input.manifestHash ?? null,
377 materializationHash: input.materializationHash ?? null,
428 createdAt: input.source.createdAt,378 createdAt: input.source.createdAt,
429 updatedAt: input.source.updatedAt,379 updatedAt: input.source.updatedAt,
430});380});
@@ -434,10 +384,12 @@ export const splitSourceForStorage = (input: { · 3 unmodified lines
434 recipeId: SourceRecipeId;384 recipeId: SourceRecipeId;
435 recipeRevisionId: SourceRecipeRevisionId;385 recipeRevisionId: SourceRecipeRevisionId;
436 actorAccountId?: AccountId | null;386 actorAccountId?: AccountId | null;
437 existingCredentialId?: Credential["id"] | null;387 existingRuntimeCredentialId?: Credential["id"] | null;
388 existingImportCredentialId?: Credential["id"] | null;
438}): {389}): {
439 sourceRecord: StoredSourceRecord;390 sourceRecord: StoredSourceRecord;
440 credential: Credential | null;391 runtimeCredential: Credential | null;
392 importCredential: Credential | null;
441} => {393} => {
442 const sourceRecord: StoredSourceRecord = {394 const sourceRecord: StoredSourceRecord = {
443 id: input.source.id,395 id: input.source.id,
@@ -450,113 +402,46 @@ export const splitSourceForStorage = (input: { · 6 unmodified lines
450 status: input.source.status,402 status: input.source.status,
451 enabled: input.source.enabled,403 enabled: input.source.enabled,
452 namespace: input.source.namespace,404 namespace: input.source.namespace,
453 bindingConfigJson: null,405 importAuthPolicy: input.source.importAuthPolicy,
454 transport: input.source.transport,406 bindingConfigJson: getSourceAdapterForSource(input.source).serializeBindingConfig(input.source),
455 queryParamsJson: serializeStringMap(input.source.queryParams),
456 headersJson: serializeStringMap(input.source.headers),
457 specUrl: input.source.specUrl,
458 defaultHeadersJson: serializeStringMap(input.source.defaultHeaders),
459 sourceHash: input.source.sourceHash,407 sourceHash: input.source.sourceHash,
460 sourceDocumentText: null,
461 lastError: input.source.lastError,408 lastError: input.source.lastError,
462 createdAt: input.source.createdAt,409 createdAt: input.source.createdAt,
463 updatedAt: input.source.updatedAt,410 updatedAt: input.source.updatedAt,
464 };411 };
465 412
466 if (input.source.auth.kind === "none") {
467 return {
468 sourceRecord,
469 credential: null,
470 };
471 }
472
473 const credentialId = input.existingCredentialId
474 ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);
475
476 const credential: Credential = {
477 id: credentialId,
478 workspaceId: input.source.workspaceId,
479 sourceId: input.source.id,
480 actorAccountId: input.actorAccountId ?? null,
481 authKind: input.source.auth.kind,
482 authHeaderName: input.source.auth.headerName,
483 authPrefix: input.source.auth.prefix,
484 tokenProviderId:
485 input.source.auth.kind === "bearer"
486 ? input.source.auth.token.providerId
487 : input.source.auth.accessToken.providerId,
488 tokenHandle:
489 input.source.auth.kind === "bearer"
490 ? input.source.auth.token.handle
491 : input.source.auth.accessToken.handle,
492 refreshTokenProviderId:
493 input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
494 ? input.source.auth.refreshToken.providerId
495 : null,
496 refreshTokenHandle:
497 input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
498 ? input.source.auth.refreshToken.handle
499 : null,
500 createdAt: input.source.createdAt,
501 updatedAt: input.source.updatedAt,
502 };
503
504 return {413 return {
505 sourceRecord,414 sourceRecord,
506 credential,415 runtimeCredential: credentialFromAuth({
416 source: input.source,
417 auth: input.source.auth,
418 slot: "runtime",
419 actorAccountId: input.actorAccountId,
420 existingCredentialId: input.existingRuntimeCredentialId,
421 }),
422 importCredential: input.source.importAuthPolicy === "separate"
423 ? credentialFromAuth({
424 source: input.source,
425 auth: input.source.importAuth,
426 slot: "import",
427 actorAccountId: input.actorAccountId,
428 existingCredentialId: input.existingImportCredentialId,
429 })
430 : null,
507 };431 };
508};432};
509 433
510export const projectSourceFromStorage = (input: {434export const projectSourceFromStorage = (input: {
511 sourceRecord: StoredSourceRecord;435 sourceRecord: StoredSourceRecord;
512 credential: Credential | null;436 runtimeCredential: Credential | null;
437 importCredential: Credential | null;
513}): Effect.Effect<Source, Error, never> =>438}): Effect.Effect<Source, Error, never> =>
514 Effect.gen(function* () {439 Effect.gen(function* () {
515 const queryParams = yield* parseStringMapJson(440 const adapter = getSourceAdapter(input.sourceRecord.kind);
516 `queryParamsJson for ${input.sourceRecord.id}`,441 const bindingState = yield* adapter.deserializeBindingConfig({
517 input.sourceRecord.queryParamsJson,442 id: input.sourceRecord.id,
518 );443 bindingConfigJson: input.sourceRecord.bindingConfigJson,
519 const headers = yield* parseStringMapJson(444 });
520 `headersJson for ${input.sourceRecord.id}`,
521 input.sourceRecord.headersJson,
522 );
523 const defaultHeaders = yield* parseStringMapJson(
524 `defaultHeadersJson for ${input.sourceRecord.id}`,
525 input.sourceRecord.defaultHeadersJson,
526 );
527
528 let auth: SourceAuth = { kind: "none" };
529 if (input.credential !== null) {
530 if (input.credential.authKind === "bearer") {
531 auth = {
532 kind: "bearer",
533 headerName: input.credential.authHeaderName,
534 prefix: input.credential.authPrefix,
535 token: {
536 providerId: input.credential.tokenProviderId,
537 handle: input.credential.tokenHandle,
538 },
539 };
540 } else {
541 auth = {
542 kind: "oauth2",
543 headerName: input.credential.authHeaderName,
544 prefix: input.credential.authPrefix,
545 accessToken: {
546 providerId: input.credential.tokenProviderId,
547 handle: input.credential.tokenHandle,
548 },
549 refreshToken:
550 input.credential.refreshTokenProviderId !== null
551 && input.credential.refreshTokenHandle !== null
552 ? {
553 providerId: input.credential.refreshTokenProviderId,
554 handle: input.credential.refreshTokenHandle,
555 }
556 : null,
557 };
558 }
559 }
560 445
561 return {446 return {
562 id: input.sourceRecord.id,447 id: input.sourceRecord.id,
@@ -567,12 +452,17 @@ export const projectSourceFromStorage = (input: { · 4 unmodified lines
567 status: input.sourceRecord.status,452 status: input.sourceRecord.status,
568 enabled: input.sourceRecord.enabled,453 enabled: input.sourceRecord.enabled,
569 namespace: input.sourceRecord.namespace,454 namespace: input.sourceRecord.namespace,
570 transport: input.sourceRecord.transport,455 transport: bindingState.transport,
571 queryParams,456 queryParams: bindingState.queryParams,
572 headers,457 headers: bindingState.headers,
573 specUrl: input.sourceRecord.specUrl,458 specUrl: bindingState.specUrl,
574 defaultHeaders,459 defaultHeaders: bindingState.defaultHeaders,
575 auth,460 importAuthPolicy: input.sourceRecord.importAuthPolicy,
461 importAuth:
462 input.sourceRecord.importAuthPolicy === "separate"
463 ? authFromCredential(input.importCredential)
464 : { kind: "none" },
465 auth: authFromCredential(input.runtimeCredential),
576 sourceHash: input.sourceRecord.sourceHash,466 sourceHash: input.sourceRecord.sourceHash,
577 lastError: input.sourceRecord.lastError,467 lastError: input.sourceRecord.lastError,
578 createdAt: input.sourceRecord.createdAt,468 createdAt: input.sourceRecord.createdAt,
@@ -588,18 +478,29 @@ export const projectSourcesFromStorage = (input: { · 9 unmodified lines
588 sourceRecords: ReadonlyArray<StoredSourceRecord>;478 sourceRecords: ReadonlyArray<StoredSourceRecord>;
589 credentials: ReadonlyArray<Credential>;479 credentials: ReadonlyArray<Credential>;
590}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {480}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {
591 const credentialsBySourceId = new Map<string, Credential>();481 const credentialsBySourceId = new Map<string, {
482 runtime: Credential | null;
483 import: Credential | null;
484 }>();
592 485
593 for (const credential of input.credentials) {486 for (const credential of input.credentials) {
594 const existing = credentialsBySourceId.get(credential.sourceId);487 const existing = credentialsBySourceId.get(credential.sourceId) ?? {
595 if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) {488 runtime: null,
596 credentialsBySourceId.set(credential.sourceId, credential);489 import: null,
490 };
491 const current = credential.slot === "runtime" ? existing.runtime : existing.import;
492 if (current === null || (current.actorAccountId === null && credential.actorAccountId !== null)) {
493 credentialsBySourceId.set(credential.sourceId, {
494 ...existing,
495 [credential.slot]: credential,
496 });
597 }497 }
598 }498 }
599 499
600 return Effect.forEach(input.sourceRecords, (sourceRecord) =>500 return Effect.forEach(input.sourceRecords, (sourceRecord) =>
601 projectSourceFromStorage({501 projectSourceFromStorage({
602 sourceRecord,502 sourceRecord,
603 credential: credentialsBySourceId.get(sourceRecord.id) ?? null,503 runtimeCredential: credentialsBySourceId.get(sourceRecord.id)?.runtime ?? null,
504 importCredential: credentialsBySourceId.get(sourceRecord.id)?.import ?? null,
604 }));505 }));
605};506};
packages/control-plane/src/runtime/source-discovery.ts+1−1
@@ -19,7 +19,7 @@ import { · 18 unmodified lines
19import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
20import { getIntrospectionQuery } from "graphql";20import { getIntrospectionQuery } from "graphql";
21import { startMcpOAuthAuthorization } from "./mcp-oauth";21import { startMcpOAuthAuthorization } from "./mcp-oauth";
22import { namespaceFromSourceName } from "./tool-artifacts";22import { namespaceFromSourceName } from "./source-names";
23 23
24const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery();24const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery();
25 25
packages/control-plane/src/runtime/source-inspection.test.ts+309−0
@@ -0,0 +1,309 @@
1import { describe, expect, it } from "vitest";
2import * as Effect from "effect/Effect";
3
4import {
5 AccountIdSchema,
6 OrganizationIdSchema,
7 SourceIdSchema,
8 SourceRecipeIdSchema,
9 SourceRecipeRevisionIdSchema,
10 SourceRecipeSchemaBundleIdSchema,
11 WorkspaceIdSchema,
12 type Source,
13 type StoredSourceRecipeOperationRecord,
14} from "#schema";
15import {
16 createSqlControlPlanePersistence,
17 type SqlControlPlanePersistence,
18} from "#persistence";
19
20import {
21 getSourceInspection,
22 getSourceInspectionSchemaBundle,
23 getSourceInspectionToolDetail,
24} from "./source-inspection";
25import { ControlPlaneStore } from "./store";
26
27const makePersistence = () =>
28 Effect.runPromise(
29 createSqlControlPlanePersistence({
30 localDataDir: ":memory:",
31 }),
32 );
33
34const seedWorkspace = async (input: {
35 persistence: SqlControlPlanePersistence;
36 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
37 organizationId: ReturnType<typeof OrganizationIdSchema.make>;
38 accountId: ReturnType<typeof AccountIdSchema.make>;
39}) => {
40 await Effect.runPromise(input.persistence.rows.organizations.insert({
41 id: input.organizationId,
42 slug: `org-${input.organizationId}`,
43 name: `Org ${input.organizationId}`,
44 status: "active",
45 createdByAccountId: input.accountId,
46 createdAt: 1000,
47 updatedAt: 1000,
48 }));
49 await Effect.runPromise(input.persistence.rows.workspaces.insert({
50 id: input.workspaceId,
51 organizationId: input.organizationId,
52 name: `Workspace ${input.workspaceId}`,
53 createdByAccountId: input.accountId,
54 createdAt: 1000,
55 updatedAt: 1000,
56 }));
57};
58
59const makeSource = (input: {
60 workspaceId: Source["workspaceId"];
61 sourceId: Source["id"];
62}): Source => ({
63 id: input.sourceId,
64 workspaceId: input.workspaceId,
65 name: "Cloudflare API",
66 kind: "openapi",
67 endpoint: "https://api.cloudflare.com/client/v4",
68 status: "connected",
69 enabled: true,
70 namespace: "cloudflare.api",
71 transport: null,
72 queryParams: null,
73 headers: null,
74 specUrl: "https://example.com/openapi.json",
75 defaultHeaders: null,
76 importAuthPolicy: "reuse_runtime",
77 importAuth: { kind: "none" },
78 auth: { kind: "none" },
79 sourceHash: null,
80 lastError: null,
81 createdAt: 1000,
82 updatedAt: 1000,
83});
84
85const makeOperation = (
86 overrides: Partial<StoredSourceRecipeOperationRecord> = {},
87): StoredSourceRecipeOperationRecord => ({
88 id: "src_recipe_op_inspection",
89 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_inspection"),
90 operationKey: "zones.listZones",
91 transportKind: "http",
92 toolId: "zones.listZones",
93 title: "List zones",
94 description: "List Cloudflare zones",
95 operationKind: "read",
96 searchText: "zones list cloudflare",
97 inputSchemaJson: JSON.stringify({
98 type: "object",
99 properties: {
100 page: { type: "number" },
101 },
102 }),
103 outputSchemaJson: JSON.stringify({
104 type: "object",
105 properties: {
106 result: {
107 type: "array",
108 items: { type: "string" },
109 },
110 },
111 }),
112 providerKind: "openapi",
113 providerDataJson: JSON.stringify({
114 kind: "openapi",
115 toolId: "zones.listZones",
116 rawToolId: "zones_listZones",
117 operationId: "zones.listZones",
118 group: "zones",
119 leaf: "listZones",
120 tags: ["zones"],
121 method: "get",
122 path: "/zones",
123 operationHash: "hash",
124 invocation: {
125 method: "get",
126 pathTemplate: "/zones",
127 parameters: [],
128 requestBody: null,
129 },
130 }),
131 createdAt: 1000,
132 updatedAt: 1000,
133 ...overrides,
134});
135
136describe("source-inspection", () => {
137 it("returns a lightweight inspection bundle and loads rich detail on demand", async () => {
138 const persistence = await makePersistence();
139 try {
140 const workspaceId = WorkspaceIdSchema.make("ws_source_inspection");
141 const organizationId = OrganizationIdSchema.make("org_source_inspection");
142 const accountId = AccountIdSchema.make("acc_source_inspection");
143 const sourceId = SourceIdSchema.make("src_source_inspection");
144 const recipeId = SourceRecipeIdSchema.make("src_recipe_inspection");
145 const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_inspection");
146 const hugeDocument = JSON.stringify({
147 openapi: "3.0.3",
148 info: {
149 title: "Cloudflare API",
150 version: "1.0.0",
151 },
152 paths: Object.fromEntries(
153 Array.from({ length: 256 }, (_, index) => [
154 `/zones/${index}`,
155 {
156 get: {
157 operationId: `zones.listZones${index}`,
158 responses: { 200: { description: "ok" } },
159 },
160 },
161 ]),
162 ),
163 });
164
165 await seedWorkspace({
166 persistence,
167 workspaceId,
168 organizationId,
169 accountId,
170 });
171 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
172 id: recipeId,
173 kind: "http_api",
174 importerKind: "openapi",
175 providerKey: "generic_http",
176 name: "Cloudflare API",
177 summary: null,
178 visibility: "workspace",
179 latestRevisionId: recipeRevisionId,
180 createdAt: 1000,
181 updatedAt: 1000,
182 }));
183 await Effect.runPromise(persistence.rows.sourceRecipeRevisions.upsert({
184 id: recipeRevisionId,
185 recipeId,
186 revisionNumber: 1,
187 sourceConfigJson: JSON.stringify({
188 kind: "openapi",
189 endpoint: "https://api.cloudflare.com/client/v4",
190 specUrl: "https://example.com/openapi.json",
191 }),
192 manifestJson: JSON.stringify({
193 sourceHash: "manifest_hash",
194 }),
195 manifestHash: "manifest_hash",
196 materializationHash: "materialization_hash",
197 createdAt: 1000,
198 updatedAt: 1000,
199 }));
200 await Effect.runPromise(persistence.rows.sourceRecipeDocuments.replaceForRevision({
201 recipeRevisionId,
202 documents: [{
203 id: "src_recipe_doc_inspection",
204 recipeRevisionId,
205 documentKind: "openapi",
206 documentKey: "https://example.com/openapi.json",
207 contentText: hugeDocument,
208 contentHash: "doc_hash",
209 fetchedAt: 1000,
210 createdAt: 1000,
211 updatedAt: 1000,
212 }],
213 }));
214 await Effect.runPromise(persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
215 recipeRevisionId,
216 bundles: [{
217 id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_inspection"),
218 recipeRevisionId,
219 bundleKind: "json_schema_ref_map",
220 refsJson: JSON.stringify({
221 "#/components/schemas/Pagination": {
222 type: "object",
223 properties: {
224 page: { type: "number" },
225 },
226 },
227 }),
228 contentHash: "bundle_hash",
229 createdAt: 1000,
230 updatedAt: 1000,
231 }],
232 }));
233 await Effect.runPromise(persistence.rows.sourceRecipeOperations.replaceForRevision({
234 recipeRevisionId,
235 operations: [makeOperation()],
236 }));
237 await Effect.runPromise(persistence.rows.sources.insert({
238 id: sourceId,
239 workspaceId,
240 recipeId,
241 recipeRevisionId,
242 name: "Cloudflare API",
243 kind: "openapi",
244 endpoint: "https://api.cloudflare.com/client/v4",
245 status: "connected",
246 enabled: true,
247 namespace: "cloudflare.api",
248 importAuthPolicy: "reuse_runtime",
249 bindingConfigJson: JSON.stringify({
250 adapterKey: "openapi",
251 specUrl: "https://example.com/openapi.json",
252 defaultHeaders: null,
253 }),
254 sourceHash: null,
255 lastError: null,
256 createdAt: 1000,
257 updatedAt: 1000,
258 }));
259
260 const inspection = await Effect.runPromise(
261 getSourceInspection({
262 workspaceId,
263 sourceId,
264 }).pipe(
265 Effect.provideService(ControlPlaneStore, persistence.rows),
266 ),
267 );
268
269 expect(inspection.toolCount).toBe(1);
270 expect(inspection.tools[0]?.path).toBe("cloudflare.api.zones.listZones");
271 expect("manifestJson" in inspection).toBe(false);
272 expect("rawDocumentText" in inspection).toBe(false);
273 expect("definitionsJson" in inspection).toBe(false);
274
275 const detail = await Effect.runPromise(
276 getSourceInspectionToolDetail({
277 workspaceId,
278 sourceId,
279 toolPath: "cloudflare.api.zones.listZones",
280 }).pipe(
281 Effect.provideService(ControlPlaneStore, persistence.rows),
282 ),
283 );
284
285 expect(detail.summary.method).toBe("get");
286 expect(detail.summary.inputType).toContain("page");
287 expect(detail.summary.outputType).toContain("result");
288 expect(detail.providerDataJson).toContain("/zones");
289 expect(detail.inputSchemaJson).toContain("\"page\"");
290 expect(detail.outputSchemaJson).toContain("\"result\"");
291 expect(detail.schemaBundleId).toBe("src_recipe_bundle_inspection");
292
293 const schemaBundle = await Effect.runPromise(
294 getSourceInspectionSchemaBundle({
295 workspaceId,
296 sourceId,
297 schemaBundleId: "src_recipe_bundle_inspection",
298 }).pipe(
299 Effect.provideService(ControlPlaneStore, persistence.rows),
300 ),
301 );
302
303 expect(schemaBundle.kind).toBe("json_schema_ref_map");
304 expect(schemaBundle.refsJson).toContain("Pagination");
305 } finally {
306 await persistence.close();
307 }
308 });
309});
packages/control-plane/src/runtime/source-inspection.ts+194−366
@@ -1,11 +1,3 @@
1import { typeSignatureFromSchemaJson } from "@executor/codemode-core";
2import {
3 buildOpenApiToolPresentation,
4 compileOpenApiToolDefinitions,
5 openApiOutputTypeSignatureFromSchemaJson,
6 type OpenApiToolDefinition,
7 type OpenApiToolManifest,
8} from "@executor/codemode-openapi";
9import type {1import type {
10 Source,2 Source,
11 SourceId,3 SourceId,
@@ -13,10 +5,14 @@ import type { · 1 unmodified line
13 SourceInspectionDiscoverPayload,5 SourceInspectionDiscoverPayload,
14 SourceInspectionDiscoverResult,6 SourceInspectionDiscoverResult,
15 SourceInspectionDiscoverResultItem,7 SourceInspectionDiscoverResultItem,
8 SourceInspectionSchemaBundle,
9 SourceInspectionToolListItem,
16 SourceInspectionToolDetail,10 SourceInspectionToolDetail,
17 SourceInspectionToolSummary,11 SourceInspectionToolSummary,
12 StoredSourceRecipeOperationRecord,
18 WorkspaceId,13 WorkspaceId,
19} from "#schema";14} from "#schema";
15import { SourceRecipeSchemaBundleIdSchema } from "#schema";
20import {16import {
21 ControlPlaneNotFoundError,17 ControlPlaneNotFoundError,
22 ControlPlaneStorageError,18 ControlPlaneStorageError,
@@ -25,21 +21,18 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
25import * as Option from "effect/Option";21import * as Option from "effect/Option";
26 22
27import { operationErrors } from "./operation-errors";23import { operationErrors } from "./operation-errors";
28import { formatJsonIfNeeded, formatWithPrettier } from "./prettier-format";24import { formatWithPrettier } from "./prettier-format";
29import {25import {
30 buildGraphqlToolPresentation,26 getSourceAdapterForOperation,
31 compileGraphqlToolDefinitions,27} from "./source-adapters";
32 type GraphqlToolDefinition,
33 type GraphqlToolManifest,
34} from "./graphql-tools";
35import {28import {
36 loadSourceRecipe,29 recipeToolMetadata,
37 recipePrimaryDocumentText,
38 recipeToolPath,30 recipeToolPath,
39 type LoadedSourceRecipe,
40} from "./source-recipes-runtime";31} from "./source-recipes-runtime";
32import type { SourceAdapterPersistedOperationMetadata } from "./source-adapters/types";
33import { namespaceFromSourceName } from "./source-names";
34import { loadSourceById } from "./source-store";
41import { ControlPlaneStore } from "./store";35import { ControlPlaneStore } from "./store";
42import { namespaceFromSourceName } from "./tool-artifacts";
43 36
44const sourceInspectOps = {37const sourceInspectOps = {
45 bundle: operationErrors("sources.inspect.bundle"),38 bundle: operationErrors("sources.inspect.bundle"),
@@ -47,8 +40,6 @@ const sourceInspectOps = { · 1 unmodified line
47 discover: operationErrors("sources.inspect.discover"),40 discover: operationErrors("sources.inspect.discover"),
48} as const;41} as const;
49 42
50const asPrettyJson = (value: unknown): string => JSON.stringify(value, null, 2);
51
52const tokenize = (value: string): Array<string> =>43const tokenize = (value: string): Array<string> =>
53 value44 value
54 .trim()45 .trim()
@@ -56,26 +47,10 @@ const tokenize = (value: string): Array<string> => · 1 unmodified line
56 .split(/[^a-z0-9]+/)47 .split(/[^a-z0-9]+/)
57 .filter(Boolean);48 .filter(Boolean);
58 49
59const searchTextFromSummary = (summary: SourceInspectionToolSummary): string =>
60 [
61 summary.path,
62 summary.toolId,
63 summary.rawToolId ?? "",
64 summary.operationId ?? "",
65 summary.title ?? "",
66 summary.description ?? "",
67 summary.method ?? "",
68 summary.pathTemplate ?? "",
69 summary.tags.join(" "),
70 summary.inputType ?? "",
71 summary.outputType ?? "",
72 ]
73 .join(" ")
74 .toLowerCase();
75
76type InspectionToolRecord = {50type InspectionToolRecord = {
77 summary: SourceInspectionToolSummary;51 operation: StoredSourceRecipeOperationRecord;
78 detail: SourceInspectionToolDetail;52 metadata: SourceAdapterPersistedOperationMetadata;
53 listItem: SourceInspectionToolListItem;
79 searchText: string;54 searchText: string;
80};55};
81 56
@@ -83,9 +58,7 @@ type ResolvedSourceInspection = { · 1 unmodified line
83 source: Source;58 source: Source;
84 namespace: string;59 namespace: string;
85 pipelineKind: SourceInspection["pipelineKind"];60 pipelineKind: SourceInspection["pipelineKind"];
86 rawDocumentText: string | null;61 schemaBundleId: string | null;
87 manifestJson: string | null;
88 definitionsJson: string | null;
89 tools: ReadonlyArray<InspectionToolRecord>;62 tools: ReadonlyArray<InspectionToolRecord>;
90};63};
91 64
@@ -111,41 +84,26 @@ const formatToolSummary = (summary: SourceInspectionToolSummary) => · 19 unmodified lines
111 } satisfies SourceInspectionToolSummary)),84 } satisfies SourceInspectionToolSummary)),
112 );85 );
113 86
114const formatInspectionToolRecord = (record: InspectionToolRecord) =>87const formatInspectionToolDetail = (detail: SourceInspectionToolDetail) =>
115 Effect.gen(function* () {88 Effect.gen(function* () {
116 const summary = yield* formatToolSummary(record.summary);89 const summary = yield* formatToolSummary(detail.summary);
117 const detailFields = yield* Effect.all({90 const detailFields = yield* Effect.all({
118 definitionJson: formatOptionalJson(record.detail.definitionJson),91 definitionJson: formatOptionalJson(detail.definitionJson),
119 documentationJson: formatOptionalJson(record.detail.documentationJson),92 documentationJson: formatOptionalJson(detail.documentationJson),
120 providerDataJson: formatOptionalJson(record.detail.providerDataJson),93 providerDataJson: formatOptionalJson(detail.providerDataJson),
121 inputSchemaJson: formatOptionalJson(record.detail.inputSchemaJson),94 inputSchemaJson: formatOptionalJson(detail.inputSchemaJson),
122 outputSchemaJson: formatOptionalJson(record.detail.outputSchemaJson),95 outputSchemaJson: formatOptionalJson(detail.outputSchemaJson),
123 exampleInputJson: formatOptionalJson(record.detail.exampleInputJson),96 exampleInputJson: formatOptionalJson(detail.exampleInputJson),
124 exampleOutputJson: formatOptionalJson(record.detail.exampleOutputJson),97 exampleOutputJson: formatOptionalJson(detail.exampleOutputJson),
125 });98 });
126 99
127 return {100 return {
101 ...detail,
102 ...detailFields,
128 summary,103 summary,
129 detail: {104 } satisfies SourceInspectionToolDetail;
130 ...record.detail,
131 ...detailFields,
132 summary,
133 },
134 searchText: record.searchText,
135 } satisfies InspectionToolRecord;
136 });
137
138const loadStoredDocumentText = (recipe: LoadedSourceRecipe) => {
139 const rawDocumentText = recipePrimaryDocumentText({
140 source: recipe.source,
141 documents: recipe.documents,
142 });105 });
143 106
144 return rawDocumentText === null
145 ? Effect.succeed<string | null>(null)
146 : Effect.promise(() => formatJsonIfNeeded(rawDocumentText));
147};
148
149const loadSourceRecipeRecord = (input: {107const loadSourceRecipeRecord = (input: {
150 workspaceId: WorkspaceId;108 workspaceId: WorkspaceId;
151 sourceId: SourceId;109 sourceId: SourceId;
@@ -164,316 +122,146 @@ const loadSourceRecipeRecord = (input: { · 12 unmodified lines
164 );122 );
165 }123 }
166 124
167 const recipe = yield* loadSourceRecipe({125 const source = yield* loadSourceById(store, {
168 rows: store,
169 workspaceId: input.workspaceId,126 workspaceId: input.workspaceId,
170 sourceId: input.sourceId,127 sourceId: input.sourceId,
171 }).pipe(128 }).pipe(
172 Effect.mapError((cause) =>129 Effect.mapError((cause) =>
173 sourceInspectOps.bundle.unknownStorage(130 sourceInspectOps.bundle.unknownStorage(
174 cause,131 cause,
175 "Failed loading source recipe",132 "Failed loading source",
176 ),133 ),
177 ),134 ),
178 );135 );
136 const operations = yield* sourceInspectOps.bundle.child("operations").mapStorage(
137 store.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),
138 );
139 const schemaBundles = yield* sourceInspectOps.bundle.child("schema_bundles").mapStorage(
140 store.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId),
141 );
179 142
180 return {143 return {
181 store,144 source,
182 sourceRecord: sourceRecord.value,145 operations,
183 recipe,146 schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
147 ?? schemaBundles[0]?.id
148 ?? null,
184 };149 };
185 }),150 }),
186 );151 );
187 152
188const persistedToolSummaryFromRecipeOperation = (input: {153const inspectionToolListItemFromOperation = (input: {
189 source: Source;154 source: Source;
190 operation: LoadedSourceRecipe["operations"][number];155 operation: StoredSourceRecipeOperationRecord;
191}): SourceInspectionToolSummary => ({156 metadata: SourceAdapterPersistedOperationMetadata;
157}): SourceInspectionToolListItem => ({
192 path: recipeToolPath({158 path: recipeToolPath({
193 source: input.source,159 source: input.source,
194 operation: input.operation,160 operation: input.operation,
195 }),161 }),
196 sourceKey: input.source.id,162 method: input.metadata.method,
197 ...(input.operation.title ? { title: input.operation.title } : {}),
198 ...(input.operation.description ? { description: input.operation.description } : {}),
199 providerKind: input.operation.providerKind,
200 toolId: input.operation.toolId,
201 rawToolId: input.operation.openApiRawToolId,
202 operationId: input.operation.graphqlOperationName ?? input.operation.openApiOperationId,
203 group: null,
204 leaf: null,
205 tags: input.operation.openApiTagsJson
206 ? ((JSON.parse(input.operation.openApiTagsJson) as Array<string>) ?? [])
207 : [],
208 method: input.operation.openApiMethod,
209 pathTemplate: input.operation.openApiPathTemplate,
210 ...(input.operation.inputSchemaJson
211 ? {
212 inputType: typeSignatureFromSchemaJson(
213 input.operation.inputSchemaJson,
214 "unknown",
215 Infinity,
216 ),
217 }
218 : {}),
219 ...(input.operation.providerKind === "openapi"
220 ? {
221 outputType: openApiOutputTypeSignatureFromSchemaJson(
222 input.operation.outputSchemaJson ?? undefined,
223 Infinity,
224 ),
225 }
226 : input.operation.outputSchemaJson
227 ? {
228 outputType: typeSignatureFromSchemaJson(
229 input.operation.outputSchemaJson,
230 "unknown",
231 Infinity,
232 ),
233 }
234 : {}),
235});163});
236 164
237const openApiToolRecord = (input: {165const persistedToolSummaryFromRecipeOperation = (input: {
238 source: Source;166 source: Source;
239 namespace: string;167 operation: StoredSourceRecipeOperationRecord;
240 manifest: OpenApiToolManifest;168 metadata: SourceAdapterPersistedOperationMetadata;
241 definition: OpenApiToolDefinition;169 includeTypes: boolean;
242}): InspectionToolRecord => {170}): SourceInspectionToolSummary => {
243 const presentation = buildOpenApiToolPresentation({171 const path = inspectionToolListItemFromOperation(input).path;
244 manifest: input.manifest,172 const descriptor = input.includeTypes
245 definition: input.definition,173 ? getSourceAdapterForOperation(input.operation).createToolDescriptor({
246 });174 source: input.source,
247 const path = `${input.namespace}.${input.definition.toolId}`;175 operation: input.operation,
248 const summary: SourceInspectionToolSummary = {176 path,
249 path,177 includeSchemas: false,
250 sourceKey: input.source.id,178 })
251 title: input.definition.name,179 : null;
252 description: input.definition.description,
253 providerKind: "openapi",
254 toolId: input.definition.toolId,
255 rawToolId: input.definition.rawToolId,
256 operationId: input.definition.operationId ?? null,
257 group: input.definition.group,
258 leaf: input.definition.leaf,
259 tags: [...input.definition.tags],
260 method: input.definition.method,
261 pathTemplate: input.definition.path,
262 inputType: presentation.inputType,
263 outputType: presentation.outputType,
264 };
265 180
266 return {181 return {
267 summary,182 path,
268 detail: {183 sourceKey: input.source.id,
269 summary,184 ...(input.operation.title ? { title: input.operation.title } : {}),
270 definitionJson: asPrettyJson(input.definition),185 ...(input.operation.description ? { description: input.operation.description } : {}),
271 documentationJson: input.definition.documentation186 providerKind: input.operation.providerKind,
272 ? asPrettyJson(input.definition.documentation)187 toolId: input.operation.toolId,
273 : null,188 rawToolId: input.metadata.rawToolId,
274 providerDataJson: presentation.providerDataJson,189 operationId: input.metadata.operationId,
275 inputSchemaJson: presentation.inputSchemaJson ?? null,190 group: input.metadata.group,
276 outputSchemaJson: presentation.outputSchemaJson ?? null,191 leaf: input.metadata.leaf,
277 exampleInputJson: presentation.exampleInputJson ?? null,192 tags: [...input.metadata.tags],
278 exampleOutputJson: presentation.exampleOutputJson ?? null,193 method: input.metadata.method,
279 },194 pathTemplate: input.metadata.pathTemplate,
280 searchText: searchTextFromSummary(summary),195 ...(descriptor?.inputType ? { inputType: descriptor.inputType } : {}),
196 ...(descriptor?.outputType ? { outputType: descriptor.outputType } : {}),
281 };197 };
282};198};
283 199
284const graphqlToolRecord = (input: {200const inspectionToolDetailFromOperation = (input: {
285 source: Source;201 source: Source;
286 namespace: string;202 operation: StoredSourceRecipeOperationRecord;
287 manifest: GraphqlToolManifest;203 metadata: SourceAdapterPersistedOperationMetadata;
288 definition: GraphqlToolDefinition;204 schemaBundleId: string | null;
289}): InspectionToolRecord => {205}): SourceInspectionToolDetail => {
290 const presentation = buildGraphqlToolPresentation({206 const summary = persistedToolSummaryFromRecipeOperation({
291 manifest: input.manifest,207 source: input.source,
292 definition: input.definition,208 operation: input.operation,
209 metadata: input.metadata,
210 includeTypes: true,
293 });211 });
294 const path = `${input.namespace}.${input.definition.toolId}`;
295 const summary: SourceInspectionToolSummary = {
296 path,
297 sourceKey: input.source.id,
298 title: input.definition.name,
299 description: input.definition.description,
300 providerKind: "graphql",
301 toolId: input.definition.toolId,
302 rawToolId: input.definition.rawToolId,
303 operationId: input.definition.operationName,
304 group: input.definition.group,
305 leaf: input.definition.leaf,
306 tags: [],
307 method: null,
308 pathTemplate: null,
309 inputType: presentation.inputType,
310 outputType: presentation.outputType,
311 };
312 212
313 return {213 return {
314 summary,214 summary,
315 detail: {215 definitionJson: null,
316 summary,216 documentationJson: null,
317 definitionJson: asPrettyJson(input.definition),217 providerDataJson: input.operation.providerDataJson,
318 documentationJson: null,218 inputSchemaJson: input.operation.inputSchemaJson,
319 providerDataJson: presentation.providerDataJson,219 outputSchemaJson: input.operation.outputSchemaJson,
320 inputSchemaJson: presentation.inputSchemaJson ?? null,220 schemaBundleId: input.schemaBundleId,
321 outputSchemaJson: presentation.outputSchemaJson ?? null,221 exampleInputJson: null,
322 exampleInputJson: presentation.exampleInputJson ?? null,222 exampleOutputJson: null,
323 exampleOutputJson: null,223 } satisfies SourceInspectionToolDetail;
324 },
325 searchText: searchTextFromSummary(summary),
326 };
327};224};
328 225
329const loadPersistedInspection = (input: {
330 recipe: LoadedSourceRecipe;
331}): Effect.Effect<ResolvedSourceInspection, Error, never> =>
332 Effect.gen(function* () {
333 const namespace =
334 input.recipe.source.namespace ?? namespaceFromSourceName(input.recipe.source.name);
335 const tools = yield* Effect.forEach(input.recipe.operations, (operation) => {
336 const summary = persistedToolSummaryFromRecipeOperation({
337 source: input.recipe.source,
338 operation,
339 });
340 return formatInspectionToolRecord({
341 summary,
342 detail: {
343 summary,
344 definitionJson: null,
345 documentationJson: null,
346 providerDataJson: operation.providerDataJson,
347 inputSchemaJson: operation.inputSchemaJson,
348 outputSchemaJson: operation.outputSchemaJson,
349 exampleInputJson: null,
350 exampleOutputJson: null,
351 },
352 searchText: searchTextFromSummary(summary),
353 } satisfies InspectionToolRecord);
354 });
355 const rawDocumentText = yield* loadStoredDocumentText(input.recipe);
356 const manifestJson = yield* formatOptionalJson(input.recipe.revision.manifestJson);
357
358 return {
359 source: input.recipe.source,
360 namespace,
361 pipelineKind: "persisted",
362 rawDocumentText,
363 manifestJson,
364 definitionsJson: null,
365 tools,
366 } satisfies ResolvedSourceInspection;
367 });
368
369const loadOpenApiInspection = (input: {
370 recipe: LoadedSourceRecipe;
371}): Effect.Effect<ResolvedSourceInspection, Error, never> =>
372 Effect.gen(function* () {
373 if (input.recipe.manifest === null) {
374 return yield* Effect.fail(new Error("Missing stored OpenAPI manifest"));
375 }
376
377 const manifest = input.recipe.manifest as OpenApiToolManifest;
378 const definitions = compileOpenApiToolDefinitions(manifest);
379 const namespace =
380 input.recipe.source.namespace ?? namespaceFromSourceName(input.recipe.source.name);
381 const tools = yield* Effect.forEach(definitions, (definition) =>
382 formatInspectionToolRecord(openApiToolRecord({
383 source: input.recipe.source,
384 namespace,
385 manifest,
386 definition,
387 })),
388 );
389 const manifestJson = yield* formatOptionalJson(input.recipe.revision.manifestJson);
390 const definitionsJson = yield* Effect.promise(() =>
391 formatWithPrettier(asPrettyJson(definitions), "json"),
392 );
393 const rawDocumentText = yield* loadStoredDocumentText(input.recipe);
394
395 return {
396 source: input.recipe.source,
397 namespace,
398 pipelineKind: "openapi",
399 rawDocumentText,
400 manifestJson,
401 definitionsJson,
402 tools,
403 } satisfies ResolvedSourceInspection;
404 });
405
406const loadGraphqlInspection = (input: {
407 recipe: LoadedSourceRecipe;
408}): Effect.Effect<ResolvedSourceInspection, Error, never> =>
409 Effect.gen(function* () {
410 if (input.recipe.manifest === null) {
411 return yield* Effect.fail(new Error("Missing stored GraphQL manifest"));
412 }
413
414 const manifest = input.recipe.manifest as GraphqlToolManifest;
415 const definitions = compileGraphqlToolDefinitions(manifest);
416 const namespace =
417 input.recipe.source.namespace ?? namespaceFromSourceName(input.recipe.source.name);
418 const tools = yield* Effect.forEach(definitions, (definition) =>
419 formatInspectionToolRecord(graphqlToolRecord({
420 source: input.recipe.source,
421 namespace,
422 manifest,
423 definition,
424 })),
425 );
426 const manifestJson = yield* formatOptionalJson(input.recipe.revision.manifestJson);
427 const definitionsJson = yield* Effect.promise(() =>
428 formatWithPrettier(asPrettyJson(definitions), "json"),
429 );
430 const rawDocumentText = yield* loadStoredDocumentText(input.recipe);
431
432 return {
433 source: input.recipe.source,
434 namespace,
435 pipelineKind: "graphql",
436 rawDocumentText,
437 manifestJson,
438 definitionsJson,
439 tools,
440 } satisfies ResolvedSourceInspection;
441 });
442
443const resolveSourceInspection = (input: {226const resolveSourceInspection = (input: {
444 workspaceId: WorkspaceId;227 workspaceId: WorkspaceId;
445 sourceId: SourceId;228 sourceId: SourceId;
446}) =>229}) =>
447 Effect.gen(function* () {230 Effect.gen(function* () {
448 const { recipe } = yield* loadSourceRecipeRecord(input);231 const { source, operations, schemaBundleId } = yield* loadSourceRecipeRecord(input);
449 232 const namespace = source.namespace ?? namespaceFromSourceName(source.name);
450 if (recipe.source.kind === "openapi" && recipe.manifest !== null) {
451 return yield* loadOpenApiInspection({
452 recipe,
453 }).pipe(
454 Effect.catchAll(() =>
455 loadPersistedInspection({
456 recipe,
457 }),
458 ),
459 );
460 }
461
462 if (recipe.source.kind === "graphql" && recipe.manifest !== null) {
463 return yield* loadGraphqlInspection({
464 recipe,
465 }).pipe(
466 Effect.catchAll(() =>
467 loadPersistedInspection({
468 recipe,
469 }),
470 ),
471 );
472 }
473 233
474 return yield* loadPersistedInspection({234 return {
475 recipe,235 source,
476 });236 namespace,
237 pipelineKind: "persisted",
238 schemaBundleId,
239 tools: yield* Effect.forEach(operations, (operation) =>
240 Effect.gen(function* () {
241 const path = recipeToolPath({
242 source,
243 operation,
244 });
245 const metadata = yield* recipeToolMetadata({
246 source,
247 operation,
248 path,
249 });
250 const listItem = inspectionToolListItemFromOperation({
251 source,
252 operation,
253 metadata,
254 });
255
256 return {
257 operation,
258 metadata,
259 listItem,
260 searchText: metadata.searchText,
261 } satisfies InspectionToolRecord;
262 })
263 ),
264 } satisfies ResolvedSourceInspection;
477 });265 });
478 266
479const scoreTool = (input: {267const scoreTool = (input: {
@@ -482,15 +270,12 @@ const scoreTool = (input: { · 2 unmodified lines
482}): SourceInspectionDiscoverResultItem | null => {270}): SourceInspectionDiscoverResultItem | null => {
483 let score = 0;271 let score = 0;
484 const reasons: Array<string> = [];272 const reasons: Array<string> = [];
485 const pathTokens = tokenize(input.tool.summary.path);273 const pathTokens = tokenize(input.tool.listItem.path);
486 const titleTokens = tokenize(input.tool.summary.title ?? "");274 const titleTokens = tokenize(input.tool.operation.title ?? "");
487 const descriptionTokens = tokenize(input.tool.summary.description ?? "");275 const descriptionTokens = tokenize(input.tool.operation.description ?? "");
488 const tagTokens = input.tool.summary.tags.flatMap(tokenize);276 const tagTokens = input.tool.metadata.tags.flatMap(tokenize);
489 const typeTokens = tokenize(
490 `${input.tool.summary.inputType ?? ""} ${input.tool.summary.outputType ?? ""}`,
491 );
492 const methodPathTokens = tokenize(277 const methodPathTokens = tokenize(
493 `${input.tool.summary.method ?? ""} ${input.tool.summary.pathTemplate ?? ""}`,278 `${input.tool.listItem.method ?? ""} ${input.tool.metadata.pathTemplate ?? ""}`,
494 );279 );
495 280
496 for (const token of input.queryTokens) {281 for (const token of input.queryTokens) {
@@ -514,11 +299,6 @@ const scoreTool = (input: { · 17 unmodified lines
514 reasons.push(`method/path matches ${token} (+6)`);299 reasons.push(`method/path matches ${token} (+6)`);
515 continue;300 continue;
516 }301 }
517 if (typeTokens.includes(token)) {
518 score += 4;
519 reasons.push(`type signature matches ${token} (+4)`);
520 continue;
521 }
522 if (descriptionTokens.includes(token) || input.tool.searchText.includes(token)) {302 if (descriptionTokens.includes(token) || input.tool.searchText.includes(token)) {
523 score += 2;303 score += 2;
524 reasons.push(`description/text matches ${token} (+2)`);304 reasons.push(`description/text matches ${token} (+2)`);
@@ -530,16 +310,10 @@ const scoreTool = (input: { · 5 unmodified lines
530 }310 }
531 311
532 return {312 return {
533 path: input.tool.summary.path,313 path: input.tool.listItem.path,
534 score,314 score,
535 ...(input.tool.summary.description315 ...(input.tool.operation.description
536 ? { description: input.tool.summary.description }316 ? { description: input.tool.operation.description }
537 : {}),
538 ...(input.tool.summary.inputType
539 ? { inputType: input.tool.summary.inputType }
540 : {}),
541 ...(input.tool.summary.outputType
542 ? { outputType: input.tool.summary.outputType }
543 : {}),317 : {}),
544 reasons,318 reasons,
545 } satisfies SourceInspectionDiscoverResultItem;319 } satisfies SourceInspectionDiscoverResultItem;
@@ -575,10 +349,7 @@ export const getSourceInspection = (input: { · 29 unmodified lines
575 namespace: inspection.namespace,349 namespace: inspection.namespace,
576 pipelineKind: inspection.pipelineKind,350 pipelineKind: inspection.pipelineKind,
577 toolCount: inspection.tools.length,351 toolCount: inspection.tools.length,
578 rawDocumentText: inspection.rawDocumentText,352 tools: inspection.tools.map((tool) => tool.listItem),
579 manifestJson: inspection.manifestJson,
580 definitionsJson: inspection.definitionsJson,
581 tools: inspection.tools.map((tool) => tool.summary),
582 } satisfies SourceInspection;353 } satisfies SourceInspection;
583 }).pipe(354 }).pipe(
584 Effect.mapError((cause) =>355 Effect.mapError((cause) =>
@@ -599,7 +370,7 @@ export const getSourceInspectionToolDetail = (input: { · 14 unmodified lines
599 workspaceId: input.workspaceId,370 workspaceId: input.workspaceId,
600 sourceId: input.sourceId,371 sourceId: input.sourceId,
601 });372 });
602 const tool = inspection.tools.find((candidate) => candidate.summary.path === input.toolPath);373 const tool = inspection.tools.find((candidate) => candidate.listItem.path === input.toolPath);
603 374
604 if (!tool) {375 if (!tool) {
605 return yield* Effect.fail(376 return yield* Effect.fail(
@@ -610,7 +381,12 @@ export const getSourceInspectionToolDetail = (input: { · 4 unmodified lines
610 );381 );
611 }382 }
612 383
613 return tool.detail;384 return yield* formatInspectionToolDetail(inspectionToolDetailFromOperation({
385 source: inspection.source,
386 operation: tool.operation,
387 metadata: tool.metadata,
388 schemaBundleId: inspection.schemaBundleId,
389 }));
614 }).pipe(390 }).pipe(
615 Effect.mapError((cause) =>391 Effect.mapError((cause) =>
616 mapInspectionError(392 mapInspectionError(
@@ -620,6 +396,58 @@ export const getSourceInspectionToolDetail = (input: { · 3 unmodified lines
620 )),396 )),
621 );397 );
622 398
399export const getSourceInspectionSchemaBundle = (input: {
400 workspaceId: WorkspaceId;
401 sourceId: SourceId;
402 schemaBundleId: string;
403}) =>
404 Effect.flatMap(ControlPlaneStore, (store) =>
405 Effect.gen(function* () {
406 const sourceRecord = yield* sourceInspectOps.tool.child("bundle_record").mapStorage(
407 store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),
408 );
409 if (Option.isNone(sourceRecord)) {
410 return yield* Effect.fail(
411 sourceInspectOps.tool.notFound(
412 "Source not found",
413 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
414 ),
415 );
416 }
417
418 const schemaBundle = yield* sourceInspectOps.tool.child("schema_bundle").mapStorage(
419 store.sourceRecipeSchemaBundles.getById(
420 SourceRecipeSchemaBundleIdSchema.make(input.schemaBundleId),
421 ),
422 );
423 if (
424 Option.isNone(schemaBundle)
425 || schemaBundle.value.recipeRevisionId !== sourceRecord.value.recipeRevisionId
426 ) {
427 return yield* Effect.fail(
428 sourceInspectOps.tool.notFound(
429 "Schema bundle not found",
430 `workspaceId=${input.workspaceId} sourceId=${input.sourceId} schemaBundleId=${input.schemaBundleId}`,
431 ),
432 );
433 }
434
435 return {
436 id: schemaBundle.value.id,
437 kind: schemaBundle.value.bundleKind,
438 hash: schemaBundle.value.contentHash,
439 refsJson: schemaBundle.value.refsJson,
440 } satisfies SourceInspectionSchemaBundle;
441 }),
442 ).pipe(
443 Effect.mapError((cause) =>
444 mapInspectionError(
445 sourceInspectOps.tool,
446 cause,
447 "Failed loading source inspection schema bundle",
448 )),
449 );
450
623export const discoverSourceInspectionTools = (input: {451export const discoverSourceInspectionTools = (input: {
624 workspaceId: WorkspaceId;452 workspaceId: WorkspaceId;
625 sourceId: SourceId;453 sourceId: SourceId;
packages/control-plane/src/runtime/source-materialization.ts+57−0
@@ -0,0 +1,57 @@
1import type { SqlControlPlaneRows } from "#persistence";
2import type {
3 Source,
4} from "#schema";
5import * as Effect from "effect/Effect";
6
7import { resolveSourceAuthMaterial } from "./source-auth-material";
8import {
9 getSourceAdapterForSource,
10} from "./source-adapters";
11import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp";
12import type {
13 ResolveSecretMaterial as ResolveSourceSecretMaterial,
14} from "./secret-material-providers";
15import { persistRecipeMaterialization } from "./source-recipe-support";
16
17const shouldIndexSource = (source: Source): boolean =>
18 source.enabled
19 && source.status === "connected"
20 && getSourceAdapterForSource(source).family !== "internal";
21
22export const syncSourceMaterialization = (input: {
23 rows: SqlControlPlaneRows;
24 source: Source;
25 resolveSecretMaterial: ResolveSourceSecretMaterial;
26}): Effect.Effect<void, Error, never> =>
27 Effect.gen(function* () {
28 if (!shouldIndexSource(input.source)) {
29 return;
30 }
31
32 const adapter = getSourceAdapterForSource(input.source);
33 const materialization = yield* adapter.materializeSource({
34 source: input.source,
35 resolveSecretMaterial: input.resolveSecretMaterial,
36 resolveAuthMaterialForSlot: (slot) =>
37 resolveSourceAuthMaterial({
38 source: input.source,
39 slot,
40 resolveSecretMaterial: input.resolveSecretMaterial,
41 }),
42 });
43 yield* persistRecipeMaterialization({
44 rows: input.rows,
45 source: input.source,
46 materialization,
47 });
48 });
49
50export const persistMcpRecipeMaterializationFromManifest = (input: {
51 rows: SqlControlPlaneRows;
52 source: Source;
53 manifestEntries: Parameters<
54 typeof persistMcpRecipeRevisionFromManifestEntries
55 >[0]["manifestEntries"];
56}): Effect.Effect<void, Error, never> =>
57 persistMcpRecipeRevisionFromManifestEntries(input);
packages/control-plane/src/runtime/source-names.ts+9−0
@@ -0,0 +1,9 @@
1export const namespaceFromSourceName = (name: string): string => {
2 const normalized = name
3 .trim()
4 .toLowerCase()
5 .replace(/[^a-z0-9]+/g, ".")
6 .replace(/^\.+|\.+$/g, "");
7
8 return normalized.length > 0 ? normalized : "source";
9};
packages/control-plane/src/runtime/source-recipe-support.ts+374−0
@@ -0,0 +1,374 @@
1import { createHash } from "node:crypto";
2
3import type {
4 Source,
5 StoredSourceRecipeDocumentRecord,
6 StoredSourceRecipeOperationRecord,
7 StoredSourceRecipeSchemaBundleRecord,
8 StoredSourceRecipeRevisionRecord,
9} from "#schema";
10import type { SqlControlPlaneRows } from "#persistence";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13
14type SourceBindingRecord =
15 Awaited<
16 ReturnType<SqlControlPlaneRows["sources"]["getByWorkspaceAndId"]>
17 > extends Effect.Effect<Option.Option<infer T>, unknown, never>
18 ? T
19 : never;
20
21export const normalizeSearchText = (
22 ...parts: ReadonlyArray<string | null | undefined>
23): string =>
24 parts
25 .flatMap((part) => (part ? [part.trim()] : []))
26 .filter(Boolean)
27 .join(" ")
28 .toLowerCase();
29
30export const contentHash = (value: string): string =>
31 createHash("sha256").update(value).digest("hex");
32
33export const loadSourceBindingRecord = (input: {
34 rows: SqlControlPlaneRows;
35 source: Source;
36}): Effect.Effect<SourceBindingRecord, Error, never> =>
37 Effect.gen(function* () {
38 const sourceRecord = yield* input.rows.sources
39 .getByWorkspaceAndId(input.source.workspaceId, input.source.id)
40 .pipe(
41 Effect.mapError((cause) =>
42 cause instanceof Error ? cause : new Error(String(cause)),
43 ),
44 );
45
46 if (Option.isNone(sourceRecord)) {
47 return yield* Effect.fail(
48 new Error(
49 `Source disappeared while syncing recipe data for ${input.source.id}`,
50 ),
51 );
52 }
53
54 return sourceRecord.value;
55 });
56
57export type SourceRecipeMaterialization = {
58 manifestJson: string | null;
59 manifestHash: string | null;
60 sourceHash: string | null;
61 documents: readonly StoredSourceRecipeDocumentRecord[];
62 schemaBundles: readonly StoredSourceRecipeSchemaBundleRecord[];
63 operations: readonly StoredSourceRecipeOperationRecord[];
64};
65
66const canonicalMaterializationHash = (input: SourceRecipeMaterialization): string => {
67 const documents = [...input.documents]
68 .map((document) => ({
69 documentKind: document.documentKind,
70 documentKey: document.documentKey,
71 contentHash: document.contentHash,
72 }))
73 .sort((left, right) =>
74 left.documentKind.localeCompare(right.documentKind)
75 || left.documentKey.localeCompare(right.documentKey)
76 || left.contentHash.localeCompare(right.contentHash)
77 );
78 const schemaBundles = [...input.schemaBundles]
79 .map((bundle) => ({
80 bundleKind: bundle.bundleKind,
81 contentHash: bundle.contentHash,
82 }))
83 .sort((left, right) =>
84 left.bundleKind.localeCompare(right.bundleKind)
85 || left.contentHash.localeCompare(right.contentHash)
86 );
87 const operations = [...input.operations]
88 .map((operation) => ({
89 operationKey: operation.operationKey,
90 transportKind: operation.transportKind,
91 toolId: operation.toolId,
92 title: operation.title,
93 description: operation.description,
94 operationKind: operation.operationKind,
95 searchText: operation.searchText,
96 inputSchemaJson: operation.inputSchemaJson,
97 outputSchemaJson: operation.outputSchemaJson,
98 providerKind: operation.providerKind,
99 providerDataJson: operation.providerDataJson,
100 }))
101 .sort((left, right) => left.operationKey.localeCompare(right.operationKey));
102
103 return contentHash(JSON.stringify({
104 schemaVersion: 1,
105 manifestHash: input.manifestHash,
106 manifestJson: input.manifestJson,
107 documents,
108 schemaBundles,
109 operations,
110 }));
111};
112
113const replaceRevisionContent = (input: {
114 rows: SqlControlPlaneRows;
115 recipeRevisionId: StoredSourceRecipeRevisionRecord["id"];
116 materializationHash: string;
117 materialization: SourceRecipeMaterialization;
118 updatedAt: number;
119}) =>
120 Effect.gen(function* () {
121 const boundDocuments = input.materialization.documents.map((document) => ({
122 ...document,
123 recipeRevisionId: input.recipeRevisionId,
124 }));
125 const boundSchemaBundles = input.materialization.schemaBundles.map((bundle) => ({
126 ...bundle,
127 recipeRevisionId: input.recipeRevisionId,
128 }));
129 const boundOperations = input.materialization.operations.map((operation) => ({
130 ...operation,
131 recipeRevisionId: input.recipeRevisionId,
132 }));
133
134 yield* input.rows.sourceRecipeRevisions
135 .update(input.recipeRevisionId, {
136 manifestJson: input.materialization.manifestJson,
137 manifestHash: input.materialization.manifestHash,
138 materializationHash: input.materializationHash,
139 updatedAt: input.updatedAt,
140 })
141 .pipe(
142 Effect.mapError((cause) =>
143 cause instanceof Error ? cause : new Error(String(cause)),
144 ),
145 );
146
147 yield* input.rows.sourceRecipeDocuments
148 .replaceForRevision({
149 recipeRevisionId: input.recipeRevisionId,
150 documents: boundDocuments,
151 })
152 .pipe(
153 Effect.mapError((cause) =>
154 cause instanceof Error ? cause : new Error(String(cause)),
155 ),
156 );
157
158 yield* input.rows.sourceRecipeSchemaBundles
159 .replaceForRevision({
160 recipeRevisionId: input.recipeRevisionId,
161 bundles: boundSchemaBundles,
162 })
163 .pipe(
164 Effect.mapError((cause) =>
165 cause instanceof Error ? cause : new Error(String(cause)),
166 ),
167 );
168
169 yield* input.rows.sourceRecipeOperations
170 .replaceForRevision({
171 recipeRevisionId: input.recipeRevisionId,
172 operations: boundOperations,
173 })
174 .pipe(
175 Effect.mapError((cause) =>
176 cause instanceof Error ? cause : new Error(String(cause)),
177 ),
178 );
179 });
180
181const cleanupOrphanedRevision = (input: {
182 rows: SqlControlPlaneRows;
183 recipeRevisionId: StoredSourceRecipeRevisionRecord["id"];
184}) =>
185 Effect.gen(function* () {
186 const referenceCount = yield* input.rows.sources.countByRecipeRevisionId(input.recipeRevisionId);
187 if (referenceCount > 0) {
188 return;
189 }
190
191 yield* input.rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId);
192 yield* input.rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId);
193 yield* input.rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId);
194 yield* input.rows.sourceRecipeRevisions.removeById(input.recipeRevisionId);
195 });
196
197const updateRecipeLatestRevision = (input: {
198 rows: SqlControlPlaneRows;
199 recipeId: SourceBindingRecord["recipeId"];
200 latestRevisionId: StoredSourceRecipeRevisionRecord["id"];
201 updatedAt: number;
202}) =>
203 Effect.gen(function* () {
204 const recipe = yield* input.rows.sourceRecipes.getById(input.recipeId);
205 if (Option.isNone(recipe)) {
206 return yield* Effect.fail(
207 new Error(`Recipe disappeared while syncing recipe data for ${input.recipeId}`),
208 );
209 }
210
211 yield* input.rows.sourceRecipes.upsert({
212 ...recipe.value,
213 latestRevisionId: input.latestRevisionId,
214 updatedAt: input.updatedAt,
215 });
216 });
217
218export const persistRecipeMaterialization = (input: {
219 rows: SqlControlPlaneRows;
220 source: Source;
221 materialization: SourceRecipeMaterialization;
222}): Effect.Effect<void, Error, never> =>
223 Effect.gen(function* () {
224 const sourceRecord = yield* loadSourceBindingRecord({
225 rows: input.rows,
226 source: input.source,
227 });
228 const now = Date.now();
229 const materializationHash = canonicalMaterializationHash(input.materialization);
230 const currentRevision = yield* input.rows.sourceRecipeRevisions.getById(
231 sourceRecord.recipeRevisionId,
232 );
233
234 if (Option.isNone(currentRevision)) {
235 return yield* Effect.fail(
236 new Error(
237 `Recipe revision disappeared while syncing recipe data for ${input.source.id}`,
238 ),
239 );
240 }
241
242 const existingMaterializedRevision = yield* input.rows.sourceRecipeRevisions
243 .getByRecipeAndMaterializationHash({
244 recipeId: sourceRecord.recipeId,
245 materializationHash,
246 });
247
248 if (
249 Option.isSome(existingMaterializedRevision)
250 && existingMaterializedRevision.value.id !== currentRevision.value.id
251 ) {
252 const movedSource = yield* input.rows.sources.update(
253 input.source.workspaceId,
254 input.source.id,
255 {
256 recipeRevisionId: existingMaterializedRevision.value.id,
257 sourceHash: input.materialization.sourceHash,
258 updatedAt: now,
259 },
260 );
261 if (Option.isNone(movedSource)) {
262 return yield* Effect.fail(
263 new Error(`Source disappeared while updating recipe materialization for ${input.source.id}`),
264 );
265 }
266
267 yield* updateRecipeLatestRevision({
268 rows: input.rows,
269 recipeId: sourceRecord.recipeId,
270 latestRevisionId: existingMaterializedRevision.value.id,
271 updatedAt: now,
272 });
273 yield* cleanupOrphanedRevision({
274 rows: input.rows,
275 recipeRevisionId: currentRevision.value.id,
276 });
277 return;
278 }
279
280 const needsFreshRevision =
281 currentRevision.value.materializationHash !== null
282 && currentRevision.value.materializationHash !== materializationHash;
283
284 const nextRevisionNumber = needsFreshRevision
285 ? yield* input.rows.sourceRecipeRevisions.nextRevisionNumber(sourceRecord.recipeId)
286 : null;
287
288 const targetRevision = needsFreshRevision
289 ? createFreshTargetRevision({
290 currentRevision: currentRevision.value,
291 materializationHash,
292 now,
293 nextRevisionNumber: nextRevisionNumber!,
294 })
295 : {
296 revision: currentRevision.value,
297 movedFromRevisionId: null as StoredSourceRecipeRevisionRecord["id"] | null,
298 };
299
300 if (needsFreshRevision) {
301 yield* input.rows.sourceRecipeRevisions.upsert(targetRevision.revision);
302 const movedSource = yield* input.rows.sources.update(
303 input.source.workspaceId,
304 input.source.id,
305 {
306 recipeRevisionId: targetRevision.revision.id,
307 sourceHash: input.materialization.sourceHash,
308 updatedAt: now,
309 },
310 );
311 if (Option.isNone(movedSource)) {
312 return yield* Effect.fail(
313 new Error(`Source disappeared while switching recipe materialization for ${input.source.id}`),
314 );
315 }
316 } else {
317 const updatedSource = yield* input.rows.sources.update(
318 input.source.workspaceId,
319 input.source.id,
320 {
321 sourceHash: input.materialization.sourceHash,
322 updatedAt: now,
323 },
324 );
325 if (Option.isNone(updatedSource)) {
326 return yield* Effect.fail(
327 new Error(`Source disappeared while updating sync metadata for ${input.source.id}`),
328 );
329 }
330 }
331
332 yield* replaceRevisionContent({
333 rows: input.rows,
334 recipeRevisionId: targetRevision.revision.id,
335 materializationHash,
336 materialization: input.materialization,
337 updatedAt: now,
338 });
339 yield* updateRecipeLatestRevision({
340 rows: input.rows,
341 recipeId: sourceRecord.recipeId,
342 latestRevisionId: targetRevision.revision.id,
343 updatedAt: now,
344 });
345
346 if (targetRevision.movedFromRevisionId !== null) {
347 yield* cleanupOrphanedRevision({
348 rows: input.rows,
349 recipeRevisionId: targetRevision.movedFromRevisionId,
350 });
351 }
352 });
353
354const createFreshTargetRevision = (input: {
355 currentRevision: StoredSourceRecipeRevisionRecord;
356 materializationHash: string;
357 now: number;
358 nextRevisionNumber: number;
359}): {
360 revision: StoredSourceRecipeRevisionRecord;
361 movedFromRevisionId: StoredSourceRecipeRevisionRecord["id"] | null;
362} => ({
363 revision: {
364 ...input.currentRevision,
365 id: `src_recipe_rev_${crypto.randomUUID()}` as StoredSourceRecipeRevisionRecord["id"],
366 revisionNumber: input.nextRevisionNumber,
367 manifestJson: null,
368 manifestHash: null,
369 materializationHash: input.materializationHash,
370 createdAt: input.now,
371 updatedAt: input.now,
372 },
373 movedFromRevisionId: input.currentRevision.id,
374});
packages/control-plane/src/runtime/source-recipes-runtime.test.ts+84−99
@@ -38,6 +38,19 @@ const makePersistence = () => · 37 unmodified lines
38 }),38 }),
39 );39 );
40 40
41const openApiBindingConfigJson = (specUrl: string): string =>
42 JSON.stringify({
43 adapterKey: "openapi",
44 specUrl,
45 defaultHeaders: null,
46 });
47
48const graphqlBindingConfigJson = (): string =>
49 JSON.stringify({
50 adapterKey: "graphql",
51 defaultHeaders: null,
52 });
53
41const makeSource = (overrides: Partial<Source> = {}): Source => ({54const makeSource = (overrides: Partial<Source> = {}): Source => ({
42 id: SourceIdSchema.make("src_runtime_recipe"),55 id: SourceIdSchema.make("src_runtime_recipe"),
43 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),56 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),
@@ -52,6 +65,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 8 unmodified lines
52 headers: null,65 headers: null,
53 specUrl: "https://api.github.com/openapi.json",66 specUrl: "https://api.github.com/openapi.json",
54 defaultHeaders: null,67 defaultHeaders: null,
68 importAuthPolicy: "reuse_runtime",
69 importAuth: { kind: "none" },
55 auth: { kind: "none" },70 auth: { kind: "none" },
56 sourceHash: null,71 sourceHash: null,
57 lastError: null,72 lastError: null,
@@ -61,8 +76,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 3 unmodified lines
61});76});
62 77
63const makeOperation = (78const makeOperation = (
64 overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }>> = {},79 overrides: Partial<StoredSourceRecipeOperationRecord> = {},
65): Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }> => ({80): StoredSourceRecipeOperationRecord => ({
66 id: "src_recipe_op_runtime",81 id: "src_recipe_op_runtime",
67 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),82 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
68 operationKey: "getRepo",83 operationKey: "getRepo",
@@ -85,25 +100,30 @@ const makeOperation = ( · 16 unmodified lines
85 providerKind: "openapi",100 providerKind: "openapi",
86 providerDataJson: JSON.stringify({101 providerDataJson: JSON.stringify({
87 kind: "openapi",102 kind: "openapi",
103 toolId: "getRepo",
104 rawToolId: "repos_getRepo",
105 operationId: "repos.getRepo",
106 group: "repos",
107 leaf: "getRepo",
108 tags: ["repos"],
109 method: "get",
110 path: "/repos/{owner}/{repo}",
111 operationHash: "hash",
112 invocation: {
113 method: "get",
114 pathTemplate: "/repos/{owner}/{repo}",
115 parameters: [],
116 requestBody: null,
117 },
88 }),118 }),
89 mcpToolName: null,
90 openApiMethod: "get",
91 openApiPathTemplate: "/repos/{owner}/{repo}",
92 openApiOperationHash: "hash",
93 openApiRawToolId: "repos_getRepo",
94 openApiOperationId: "repos.getRepo",
95 openApiTagsJson: JSON.stringify(["repos"]),
96 openApiRequestBodyRequired: null,
97 graphqlOperationType: null,
98 graphqlOperationName: null,
99 createdAt: 1000,119 createdAt: 1000,
100 updatedAt: 1000,120 updatedAt: 1000,
101 ...overrides,121 ...overrides,
102});122});
103 123
104const makeGraphqlOperation = (124const makeGraphqlOperation = (
105 overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }>> = {},125 overrides: Partial<StoredSourceRecipeOperationRecord> = {},
106): Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }> => ({126): StoredSourceRecipeOperationRecord => ({
107 id: "src_recipe_op_graphql_runtime",127 id: "src_recipe_op_graphql_runtime",
108 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),128 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
109 operationKey: "viewer",129 operationKey: "viewer",
@@ -126,25 +146,27 @@ const makeGraphqlOperation = ( · 16 unmodified lines
126 providerKind: "graphql",146 providerKind: "graphql",
127 providerDataJson: JSON.stringify({147 providerDataJson: JSON.stringify({
128 kind: "graphql",148 kind: "graphql",
149 toolKind: "field",
150 toolId: "viewer",
151 rawToolId: "viewer",
152 group: "query",
153 leaf: "viewer",
154 fieldName: "viewer",
155 operationType: "query",
156 operationName: "viewer",
157 operationDocument: "query Viewer { viewer { login } }",
158 queryTypeName: "Query",
159 mutationTypeName: null,
160 subscriptionTypeName: null,
129 }),161 }),
130 mcpToolName: null,
131 openApiMethod: null,
132 openApiPathTemplate: null,
133 openApiOperationHash: null,
134 openApiRawToolId: null,
135 openApiOperationId: null,
136 openApiTagsJson: null,
137 openApiRequestBodyRequired: null,
138 graphqlOperationType: "query",
139 graphqlOperationName: "viewer",
140 createdAt: 1000,162 createdAt: 1000,
141 updatedAt: 1000,163 updatedAt: 1000,
142 ...overrides,164 ...overrides,
143});165});
144 166
145const makeMcpOperation = (167const makeMcpOperation = (
146 overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }>> = {},168 overrides: Partial<StoredSourceRecipeOperationRecord> = {},
147): Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }> => ({169): StoredSourceRecipeOperationRecord => ({
148 id: "src_recipe_op_mcp_runtime",170 id: "src_recipe_op_mcp_runtime",
149 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),171 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
150 operationKey: "echo",172 operationKey: "echo",
@@ -159,17 +181,10 @@ const makeMcpOperation = ( · 8 unmodified lines
159 providerKind: "mcp",181 providerKind: "mcp",
160 providerDataJson: JSON.stringify({182 providerDataJson: JSON.stringify({
161 kind: "mcp",183 kind: "mcp",
184 toolId: "echo",
185 toolName: "echo",
186 description: "Echo a value",
162 }),187 }),
163 mcpToolName: "echo",
164 openApiMethod: null,
165 openApiPathTemplate: null,
166 openApiOperationHash: null,
167 openApiRawToolId: null,
168 openApiOperationId: null,
169 openApiTagsJson: null,
170 openApiRequestBodyRequired: null,
171 graphqlOperationType: null,
172 graphqlOperationName: null,
173 createdAt: 1000,188 createdAt: 1000,
174 updatedAt: 1000,189 updatedAt: 1000,
175 ...overrides,190 ...overrides,
@@ -248,19 +263,14 @@ describe("source-recipes-runtime", () => { · 72 unmodified lines
248 expect(recipeToolSearchNamespace({263 expect(recipeToolSearchNamespace({
249 source: graphqlSource,264 source: graphqlSource,
250 path: "ignored.path",265 path: "ignored.path",
251 operation: makeGraphqlOperation({266 operation: makeGraphqlOperation(),
252 graphqlOperationType: "query",
253 graphqlOperationName: "viewer",
254 }),
255 })).toBe("issues");267 })).toBe("issues");
256 });268 });
257 269
258 it("computes interaction modes and descriptor fields correctly", () => {270 it("computes interaction modes and descriptor fields correctly", () => {
259 expect(recipeToolDescriptor({271 expect(recipeToolDescriptor({
260 source: makeSource(),272 source: makeSource(),
261 operation: makeOperation({273 operation: makeOperation(),
262 openApiMethod: "get",
263 }),
264 path: "github.getRepo",274 path: "github.getRepo",
265 includeSchemas: true,275 includeSchemas: true,
266 }).interaction).toBe("auto");276 }).interaction).toBe("auto");
@@ -268,7 +278,7 @@ describe("source-recipes-runtime", () => { · 1 unmodified line
268 expect(recipeToolDescriptor({278 expect(recipeToolDescriptor({
269 source: makeSource(),279 source: makeSource(),
270 operation: makeOperation({280 operation: makeOperation({
271 openApiMethod: "delete",281 operationKind: "delete",
272 }),282 }),
273 path: "github.deleteRepo",283 path: "github.deleteRepo",
274 includeSchemas: true,284 includeSchemas: true,
@@ -280,10 +290,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
280 endpoint: "https://example.com/graphql",290 endpoint: "https://example.com/graphql",
281 specUrl: null,291 specUrl: null,
282 }),292 }),
283 operation: makeGraphqlOperation({293 operation: makeGraphqlOperation(),
284 graphqlOperationType: "query",
285 graphqlOperationName: "viewer",
286 }),
287 path: "graphql.viewer",294 path: "graphql.viewer",
288 includeSchemas: true,295 includeSchemas: true,
289 }).interaction).toBe("auto");296 }).interaction).toBe("auto");
@@ -295,8 +302,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
295 specUrl: null,302 specUrl: null,
296 }),303 }),
297 operation: makeGraphqlOperation({304 operation: makeGraphqlOperation({
298 graphqlOperationType: "mutation",305 operationKind: "write",
299 graphqlOperationName: "createIssue",
300 }),306 }),
301 path: "graphql.createIssue",307 path: "graphql.createIssue",
302 includeSchemas: true,308 includeSchemas: true,
@@ -309,9 +315,7 @@ describe("source-recipes-runtime", () => { · 6 unmodified lines
309 specUrl: null,315 specUrl: null,
310 transport: "streamable-http",316 transport: "streamable-http",
311 }),317 }),
312 operation: makeMcpOperation({318 operation: makeMcpOperation(),
313 mcpToolName: "echo",
314 }),
315 path: "mcp.echo",319 path: "mcp.echo",
316 includeSchemas: true,320 includeSchemas: true,
317 }).interaction).toBe("auto");321 }).interaction).toBe("auto");
@@ -349,14 +353,9 @@ describe("source-recipes-runtime", () => { · 31 unmodified lines
349 status: "connected",353 status: "connected",
350 enabled: true,354 enabled: true,
351 namespace: "github",355 namespace: "github",
352 bindingConfigJson: null,356 importAuthPolicy: "reuse_runtime",
353 transport: null,357 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
354 queryParamsJson: null,
355 headersJson: null,
356 specUrl: "https://api.github.com/openapi.json",
357 defaultHeadersJson: null,
358 sourceHash: null,358 sourceHash: null,
359 sourceDocumentText: null,
360 lastError: null,359 lastError: null,
361 createdAt: 1000,360 createdAt: 1000,
362 updatedAt: 1000,361 updatedAt: 1000,
@@ -368,30 +367,36 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
368 sourceConfigJson: "{}",367 sourceConfigJson: "{}",
369 manifestJson: null,368 manifestJson: null,
370 manifestHash: null,369 manifestHash: null,
370 materializationHash: null,
371 createdAt: 1000,371 createdAt: 1000,
372 updatedAt: 1000,372 updatedAt: 1000,
373 },373 },
374 documents: [],374 documents: [],
375 schemaBundles: [],
375 operations: [makeOperation({376 operations: [makeOperation({
376 searchText: "",377 searchText: "",
377 })],378 })],
378 manifest: null,379 manifest: null,
379 };380 };
380 381
381 const expanded = expandRecipeTools({382 const expanded = Effect.runSync(
382 recipes: [recipe],383 expandRecipeTools({
383 includeSchemas: false,384 recipes: [recipe],
384 });385 includeSchemas: false,
386 }),
387 );
385 expect(expanded).toHaveLength(1);388 expect(expanded).toHaveLength(1);
386 expect(expanded[0]?.searchText).toBe("github.getrepo github.getrepo github api");389 expect(expanded[0]?.searchText).toBe(
390 "github.getrepo github.getrepo github api github.getrepo getrepo get repo read a repository repos_getrepo repos.getrepo get /repos/{owner}/{repo} repos getrepo repos",
391 );
387 392
388 expect(expandRecipeTools({393 expect(Effect.runSync(expandRecipeTools({
389 recipes: [{394 recipes: [{
390 ...recipe,395 ...recipe,
391 operations: [],396 operations: [],
392 }],397 }],
393 includeSchemas: false,398 includeSchemas: false,
394 })).toEqual([]);399 }))).toEqual([]);
395 });400 });
396 });401 });
397 402
@@ -440,7 +445,6 @@ describe("source-recipes-runtime", () => { · 42 unmodified lines
440 const manifest = await Effect.runPromise(extractOpenApiManifest("GitHub", openApiDocument));445 const manifest = await Effect.runPromise(extractOpenApiManifest("GitHub", openApiDocument));
441 const definition = compileOpenApiToolDefinitions(manifest)[0]!;446 const definition = compileOpenApiToolDefinitions(manifest)[0]!;
442 const presentation = buildOpenApiToolPresentation({447 const presentation = buildOpenApiToolPresentation({
443 manifest,
444 definition,448 definition,
445 });449 });
446 450
@@ -452,7 +456,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
452 });456 });
453 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({457 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
454 id: recipeId,458 id: recipeId,
455 kind: "http_recipe",459 kind: "http_api",
456 importerKind: "openapi",460 importerKind: "openapi",
457 providerKey: "generic_http",461 providerKey: "generic_http",
458 name: "GitHub",462 name: "GitHub",
@@ -474,6 +478,7 @@ describe("source-recipes-runtime", () => { · 15 unmodified lines
474 }),478 }),
475 manifestJson: JSON.stringify(manifest),479 manifestJson: JSON.stringify(manifest),
476 manifestHash: manifest.sourceHash,480 manifestHash: manifest.sourceHash,
481 materializationHash: manifest.sourceHash,
477 createdAt: 1000,482 createdAt: 1000,
478 updatedAt: 1000,483 updatedAt: 1000,
479 }));484 }));
@@ -504,13 +509,6 @@ describe("source-recipes-runtime", () => { · 24 unmodified lines
504 inputSchemaJson: presentation.inputSchemaJson ?? null,509 inputSchemaJson: presentation.inputSchemaJson ?? null,
505 outputSchemaJson: presentation.outputSchemaJson ?? null,510 outputSchemaJson: presentation.outputSchemaJson ?? null,
506 providerDataJson: presentation.providerDataJson,511 providerDataJson: presentation.providerDataJson,
507 openApiMethod: definition.method,
508 openApiPathTemplate: definition.path,
509 openApiOperationHash: definition.operationHash,
510 openApiRawToolId: definition.rawToolId,
511 openApiOperationId: definition.operationId ?? null,
512 openApiTagsJson: JSON.stringify(definition.tags),
513 openApiRequestBodyRequired: definition.invocation.requestBody?.required ?? null,
514 })],512 })],
515 }));513 }));
516 514
@@ -526,14 +524,9 @@ describe("source-recipes-runtime", () => { · 9 unmodified lines
526 status: "connected",524 status: "connected",
527 enabled: true,525 enabled: true,
528 namespace: "github",526 namespace: "github",
529 bindingConfigJson: null,527 importAuthPolicy: "reuse_runtime",
530 transport: null,528 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
531 queryParamsJson: null,
532 headersJson: null,
533 specUrl: "https://api.github.com/openapi.json",
534 defaultHeadersJson: null,
535 sourceHash: manifest.sourceHash,529 sourceHash: manifest.sourceHash,
536 sourceDocumentText: null,
537 lastError: null,530 lastError: null,
538 createdAt: 1000 + index,531 createdAt: 1000 + index,
539 updatedAt: 1000 + index,532 updatedAt: 1000 + index,
@@ -573,7 +566,7 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines
573 });566 });
574 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({567 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
575 id: recipeId,568 id: recipeId,
576 kind: "graphql_recipe",569 kind: "http_api",
577 importerKind: "graphql_introspection",570 importerKind: "graphql_introspection",
578 providerKey: "generic_graphql",571 providerKey: "generic_graphql",
579 name: "GraphQL Demo",572 name: "GraphQL Demo",
@@ -594,6 +587,7 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines
594 }),587 }),
595 manifestJson: null,588 manifestJson: null,
596 manifestHash: null,589 manifestHash: null,
590 materializationHash: null,
597 createdAt: 1000,591 createdAt: 1000,
598 updatedAt: 1000,592 updatedAt: 1000,
599 }));593 }));
@@ -608,14 +602,9 @@ describe("source-recipes-runtime", () => { · 8 unmodified lines
608 status: "connected",602 status: "connected",
609 enabled: true,603 enabled: true,
610 namespace: "graphql",604 namespace: "graphql",
611 bindingConfigJson: null,605 importAuthPolicy: "reuse_runtime",
612 transport: null,606 bindingConfigJson: graphqlBindingConfigJson(),
613 queryParamsJson: null,
614 headersJson: null,
615 specUrl: null,
616 defaultHeadersJson: null,
617 sourceHash: null,607 sourceHash: null,
618 sourceDocumentText: null,
619 lastError: null,608 lastError: null,
620 createdAt: 1000,609 createdAt: 1000,
621 updatedAt: 1000,610 updatedAt: 1000,
@@ -659,7 +648,7 @@ describe("source-recipes-runtime", () => { · 37 unmodified lines
659 });648 });
660 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({649 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
661 id: recipeId,650 id: recipeId,
662 kind: "http_recipe",651 kind: "http_api",
663 importerKind: "openapi",652 importerKind: "openapi",
664 providerKey: "generic_http",653 providerKey: "generic_http",
665 name: "Broken GitHub",654 name: "Broken GitHub",
@@ -680,14 +669,9 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines
680 status: "connected",669 status: "connected",
681 enabled: true,670 enabled: true,
682 namespace: "github",671 namespace: "github",
683 bindingConfigJson: null,672 importAuthPolicy: "reuse_runtime",
684 transport: null,673 bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
685 queryParamsJson: null,
686 headersJson: null,
687 specUrl: "https://api.github.com/openapi.json",
688 defaultHeadersJson: null,
689 sourceHash: null,674 sourceHash: null,
690 sourceDocumentText: null,
691 lastError: null,675 lastError: null,
692 createdAt: 1000,676 createdAt: 1000,
693 updatedAt: 1000,677 updatedAt: 1000,
@@ -711,6 +695,7 @@ describe("source-recipes-runtime", () => { · 17 unmodified lines
711 }),695 }),
712 manifestJson: "{bad-json",696 manifestJson: "{bad-json",
713 manifestHash: null,697 manifestHash: null,
698 materializationHash: null,
714 createdAt: 1000,699 createdAt: 1000,
715 updatedAt: 1000,700 updatedAt: 1000,
716 }));701 }));
packages/control-plane/src/runtime/source-recipes-runtime.ts+318−134
@@ -1,15 +1,6 @@
1import {1import {
2 type ToolDescriptor,2 type ToolDescriptor,
3 type ToolPath,
4 typeSignatureFromSchemaJson,
5} from "@executor/codemode-core";3} from "@executor/codemode-core";
6import type {
7 McpToolManifest,
8} from "@executor/codemode-mcp";
9import {
10 openApiOutputTypeSignatureFromSchemaJson,
11 type OpenApiToolManifest,
12} from "@executor/codemode-openapi";
13import type { SqlControlPlaneRows } from "#persistence";4import type { SqlControlPlaneRows } from "#persistence";
14import type {5import type {
15 AccountId,6 AccountId,
@@ -17,29 +8,30 @@ import type { · 1 unmodified line
17 StoredSourceRecord,8 StoredSourceRecord,
18 StoredSourceRecipeDocumentRecord,9 StoredSourceRecipeDocumentRecord,
19 StoredSourceRecipeOperationRecord,10 StoredSourceRecipeOperationRecord,
11 StoredSourceRecipeSchemaBundleRecord,
20 StoredSourceRecipeRevisionRecord,12 StoredSourceRecipeRevisionRecord,
21 WorkspaceId,13 WorkspaceId,
22} from "#schema";14} from "#schema";
23import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
24import * as Option from "effect/Option";16import * as Option from "effect/Option";
25 17
26import type { GraphqlToolManifest } from "./graphql-tools";18import { namespaceFromSourceName } from "./source-names";
19import {
20 getSourceAdapterForOperation,
21 getSourceAdapterForSource,
22} from "./source-adapters";
23import type { SourceAdapterPersistedOperationMetadata } from "./source-adapters/types";
24import { firstSchemaBundle } from "./source-adapters/shared";
27import { loadSourceById, loadSourcesInWorkspace } from "./source-store";25import { loadSourceById, loadSourcesInWorkspace } from "./source-store";
28import { namespaceFromSourceName } from "./tool-artifacts";
29
30type RecipeManifest =
31 | OpenApiToolManifest
32 | GraphqlToolManifest
33 | McpToolManifest
34 | null;
35 26
36const asToolPath = (value: string): ToolPath => value as ToolPath;27type RecipeManifest = unknown | null;
37 28
38export type LoadedSourceRecipe = {29export type LoadedSourceRecipe = {
39 source: Source;30 source: Source;
40 sourceRecord: StoredSourceRecord;31 sourceRecord: StoredSourceRecord;
41 revision: StoredSourceRecipeRevisionRecord;32 revision: StoredSourceRecipeRevisionRecord;
42 documents: readonly StoredSourceRecipeDocumentRecord[];33 documents: readonly StoredSourceRecipeDocumentRecord[];
34 schemaBundles: readonly StoredSourceRecipeSchemaBundleRecord[];
43 operations: readonly StoredSourceRecipeOperationRecord[];35 operations: readonly StoredSourceRecipeOperationRecord[];
44 manifest: RecipeManifest;36 manifest: RecipeManifest;
45};37};
@@ -52,58 +44,31 @@ export type LoadedSourceRecipeTool = { · 6 unmodified lines
52 sourceRecord: StoredSourceRecord;44 sourceRecord: StoredSourceRecord;
53 revision: StoredSourceRecipeRevisionRecord;45 revision: StoredSourceRecipeRevisionRecord;
54 operation: StoredSourceRecipeOperationRecord;46 operation: StoredSourceRecipeOperationRecord;
47 metadata: SourceAdapterPersistedOperationMetadata;
48 schemaBundleId: string | null;
55 manifest: RecipeManifest;49 manifest: RecipeManifest;
56 descriptor: ToolDescriptor;50 descriptor: ToolDescriptor;
57};51};
58 52
59const parseJson = <T>(input: {53export type LoadedSourceRecipeToolIndexEntry = {
60 label: string;54 path: string;
61 value: string | null;55 searchNamespace: string;
62}): Effect.Effect<T | null, Error, never> =>56 searchText: string;
63 input.value === null57 source: Source;
64 ? Effect.succeed<T | null>(null)58 sourceRecord: StoredSourceRecord;
65 : Effect.try({59 operation: StoredSourceRecipeOperationRecord;
66 try: () => {60 metadata: SourceAdapterPersistedOperationMetadata;
67 const value = input.value!;61 schemaBundleId: string | null;
68 return JSON.parse(value) as T;62 descriptor: ToolDescriptor;
69 },63};
70 catch: (cause) =>
71 cause instanceof Error
72 ? new Error(`Invalid ${input.label}: ${cause.message}`)
73 : new Error(`Invalid ${input.label}: ${String(cause)}`),
74 });
75 64
76const parseManifestForRecipe = (input: {65const parseManifestForRecipe = (input: {
77 source: Source;66 source: Source;
78 revision: StoredSourceRecipeRevisionRecord;67 revision: StoredSourceRecipeRevisionRecord;
79}): Effect.Effect<RecipeManifest, Error, never> =>68}): Effect.Effect<RecipeManifest, Error, never> =>
80 Effect.gen(function* () {69 getSourceAdapterForSource(input.source).parseManifest({
81 if (input.revision.manifestJson === null) {70 source: input.source,
82 return null;71 manifestJson: input.revision.manifestJson,
83 }
84
85 if (input.source.kind === "openapi") {
86 return yield* parseJson<OpenApiToolManifest>({
87 label: `OpenAPI manifest for ${input.source.id}`,
88 value: input.revision.manifestJson,
89 });
90 }
91
92 if (input.source.kind === "graphql") {
93 return yield* parseJson<GraphqlToolManifest>({
94 label: `GraphQL manifest for ${input.source.id}`,
95 value: input.revision.manifestJson,
96 });
97 }
98
99 if (input.source.kind === "mcp") {
100 return yield* parseJson<McpToolManifest>({
101 label: `MCP manifest for ${input.source.id}`,
102 value: input.revision.manifestJson,
103 });
104 }
105
106 return null;
107 });72 });
108 73
109const catalogNamespaceFromPath = (path: string): string => {74const catalogNamespaceFromPath = (path: string): string => {
@@ -124,74 +89,34 @@ export const recipeToolSearchNamespace = (input: { · 14 unmodified lines
124 path: string;89 path: string;
125 operation: StoredSourceRecipeOperationRecord;90 operation: StoredSourceRecipeOperationRecord;
126}): string =>91}): string =>
127 input.operation.providerKind === "graphql"92 getSourceAdapterForOperation(input.operation).searchNamespace?.({
128 ? (input.source.namespace ?? namespaceFromSourceName(input.source.name))93 source: input.source,
129 : catalogNamespaceFromPath(input.path);94 path: input.path,
95 operation: input.operation,
96 })
97 ?? catalogNamespaceFromPath(input.path);
130 98
131export const recipeToolDescriptor = (input: {99export const recipeToolDescriptor = (input: {
132 source: Source;100 source: Source;
133 operation: StoredSourceRecipeOperationRecord;101 operation: StoredSourceRecipeOperationRecord;
134 path: string;102 path: string;
103 schemaBundleId?: string | null;
135 includeSchemas: boolean;104 includeSchemas: boolean;
136}): ToolDescriptor => {105}): ToolDescriptor =>
137 const interaction =106 getSourceAdapterForOperation(input.operation).createToolDescriptor(input);
138 input.operation.providerKind === "openapi"107
139 ? (108export const recipeToolMetadata = (input: {
140 input.operation.openApiMethod?.toUpperCase() === "GET"109 source: Source;
141 || input.operation.openApiMethod?.toUpperCase() === "HEAD"110 operation: StoredSourceRecipeOperationRecord;
142 ? "auto"111 path: string;
143 : "required"112}): Effect.Effect<SourceAdapterPersistedOperationMetadata, Error, never> =>
144 )113 getSourceAdapterForOperation(input.operation).describePersistedOperation(input);
145 : input.operation.providerKind === "graphql"
146 ? input.operation.graphqlOperationType === "query"
147 ? "auto"
148 : "required"
149 : "auto";
150
151 return {
152 path: asToolPath(input.path),
153 sourceKey: input.source.id,
154 description: input.operation.description ?? input.operation.title ?? undefined,
155 interaction,
156 inputType: typeSignatureFromSchemaJson(
157 input.operation.inputSchemaJson ?? undefined,
158 "unknown",
159 320,
160 ),
161 outputType:
162 input.operation.providerKind === "openapi"
163 ? openApiOutputTypeSignatureFromSchemaJson(
164 input.operation.outputSchemaJson ?? undefined,
165 320,
166 )
167 : typeSignatureFromSchemaJson(
168 input.operation.outputSchemaJson ?? undefined,
169 "unknown",
170 320,
171 ),
172 inputSchemaJson: input.includeSchemas ? input.operation.inputSchemaJson ?? undefined : undefined,
173 outputSchemaJson: input.includeSchemas ? input.operation.outputSchemaJson ?? undefined : undefined,
174 ...(input.operation.providerKind
175 ? { providerKind: input.operation.providerKind }
176 : {}),
177 ...(input.operation.providerDataJson
178 ? { providerDataJson: input.operation.providerDataJson }
179 : {}),
180 };
181};
182 114
183const sourceRecipeDocumentForSource = (input: {115const sourceRecipeDocumentForSource = (input: {
184 source: Source;116 source: Source;
185 documents: readonly StoredSourceRecipeDocumentRecord[];117 documents: readonly StoredSourceRecipeDocumentRecord[];
186}): StoredSourceRecipeDocumentRecord | null => {118}): StoredSourceRecipeDocumentRecord | null => {
187 const preferredKind =119 const preferredKind = getSourceAdapterForSource(input.source).primaryDocumentKind;
188 input.source.kind === "openapi"
189 ? "openapi"
190 : input.source.kind === "graphql"
191 ? "graphql_introspection"
192 : input.source.kind === "mcp"
193 ? "mcp_manifest"
194 : null;
195 120
196 if (preferredKind === null) {121 if (preferredKind === null) {
197 return null;122 return null;
@@ -206,6 +131,25 @@ export const recipePrimaryDocumentText = (input: { · 8 unmodified lines
206}): string | null =>131}): string | null =>
207 sourceRecipeDocumentForSource(input)?.contentText ?? null;132 sourceRecipeDocumentForSource(input)?.contentText ?? null;
208 133
134const primarySchemaBundleForRevision = (input: {
135 source: Source;
136 schemaBundles: readonly StoredSourceRecipeSchemaBundleRecord[];
137}): StoredSourceRecipeSchemaBundleRecord | null => {
138 const selected = firstSchemaBundle({
139 schemaBundles: input.schemaBundles.map((schemaBundle) => ({
140 id: schemaBundle.id,
141 kind: schemaBundle.bundleKind,
142 hash: schemaBundle.contentHash,
143 refsJson: schemaBundle.refsJson,
144 })),
145 preferredKind: getSourceAdapterForSource(input.source).primarySchemaBundleKind,
146 });
147
148 return selected
149 ? input.schemaBundles.find((schemaBundle) => schemaBundle.id === selected.id) ?? null
150 : null;
151};
152
209export const loadWorkspaceSourceRecipes = (input: {153export const loadWorkspaceSourceRecipes = (input: {
210 rows: SqlControlPlaneRows;154 rows: SqlControlPlaneRows;
211 workspaceId: WorkspaceId;155 workspaceId: WorkspaceId;
@@ -224,12 +168,19 @@ export const loadWorkspaceSourceRecipes = (input: { · 12 unmodified lines
224 const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds);168 const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds);
225 const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));169 const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));
226 const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds);170 const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds);
171 const schemaBundles = yield* input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds);
227 const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();172 const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();
228 for (const document of documents) {173 for (const document of documents) {
229 const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];174 const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
230 existing.push(document);175 existing.push(document);
231 documentsByRevisionId.set(document.recipeRevisionId, existing);176 documentsByRevisionId.set(document.recipeRevisionId, existing);
232 }177 }
178 const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
179 for (const schemaBundle of schemaBundles) {
180 const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
181 existing.push(schemaBundle);
182 schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
183 }
233 const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds);184 const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds);
234 const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();185 const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();
235 for (const operation of operations) {186 for (const operation of operations) {
@@ -264,6 +215,7 @@ export const loadWorkspaceSourceRecipes = (input: { · 28 unmodified lines
264 sourceRecord,215 sourceRecord,
265 revision,216 revision,
266 documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],217 documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
218 schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
267 operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],219 operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
268 manifest,220 manifest,
269 } satisfies LoadedSourceRecipe;221 } satisfies LoadedSourceRecipe;
@@ -299,8 +251,9 @@ export const loadSourceRecipe = (input: { · 29 unmodified lines
299 new Error(`Recipe revision missing for source ${input.sourceId}`),251 new Error(`Recipe revision missing for source ${input.sourceId}`),
300 );252 );
301 }253 }
302 const [documents, operations, manifest] = yield* Effect.all([254 const [documents, schemaBundles, operations, manifest] = yield* Effect.all([
303 input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId),255 input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId),
256 input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId),
304 input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),257 input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),
305 parseManifestForRecipe({258 parseManifestForRecipe({
306 source,259 source,
@@ -313,6 +266,7 @@ export const loadSourceRecipe = (input: { · 6 unmodified lines
313 sourceRecord: sourceRecord.value,266 sourceRecord: sourceRecord.value,
314 revision: revision.value,267 revision: revision.value,
315 documents,268 documents,
269 schemaBundles,
316 operations,270 operations,
317 manifest,271 manifest,
318 } satisfies LoadedSourceRecipe;272 } satisfies LoadedSourceRecipe;
@@ -321,18 +275,245 @@ export const loadSourceRecipe = (input: { · 2 unmodified lines
321export const expandRecipeTools = (input: {275export const expandRecipeTools = (input: {
322 recipes: readonly LoadedSourceRecipe[];276 recipes: readonly LoadedSourceRecipe[];
323 includeSchemas: boolean;277 includeSchemas: boolean;
324}): readonly LoadedSourceRecipeTool[] =>278}): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> =>
325 input.recipes.flatMap((recipe) =>279 Effect.map(
326 recipe.operations.map((operation) => {280 Effect.forEach(input.recipes, (recipe) =>
281 Effect.forEach(recipe.operations, (operation) =>
282 Effect.gen(function* () {
283 const path = recipeToolPath({
284 source: recipe.source,
285 operation,
286 });
287 const searchNamespace = recipeToolSearchNamespace({
288 source: recipe.source,
289 path,
290 operation,
291 });
292 const schemaBundleId = primarySchemaBundleForRevision({
293 source: recipe.source,
294 schemaBundles: recipe.schemaBundles,
295 })?.id ?? null;
296 const metadata = yield* recipeToolMetadata({
297 source: recipe.source,
298 operation,
299 path,
300 });
301
302 return {
303 path,
304 searchNamespace,
305 searchText: [
306 path,
307 searchNamespace,
308 recipe.source.name,
309 metadata.searchText,
310 ]
311 .filter((part) => part.length > 0)
312 .join(" ")
313 .toLowerCase(),
314 source: recipe.source,
315 sourceRecord: recipe.sourceRecord,
316 revision: recipe.revision,
317 operation,
318 metadata,
319 schemaBundleId,
320 manifest: recipe.manifest,
321 descriptor: recipeToolDescriptor({
322 source: recipe.source,
323 operation,
324 path,
325 schemaBundleId,
326 includeSchemas: input.includeSchemas,
327 }),
328 } satisfies LoadedSourceRecipeTool;
329 })
330 ),
331 ),
332 (recipes) => recipes.flat(),
333 );
334
335export const loadWorkspaceSourceRecipeToolIndex = (input: {
336 rows: SqlControlPlaneRows;
337 workspaceId: WorkspaceId;
338 actorAccountId?: AccountId | null;
339 includeSchemas: boolean;
340}): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> =>
341 Effect.gen(function* () {
342 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
343 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
344 actorAccountId: input.actorAccountId,
345 });
346 const sourceById = new Map(sources.map((source) => [source.id, source]));
347 const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
348 const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))];
349 const [operations, schemaBundles] = yield* Effect.all([
350 input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),
351 input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),
352 ]);
353
354 const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();
355 for (const operation of operations) {
356 const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? [];
357 existing.push(operation);
358 operationsByRevisionId.set(operation.recipeRevisionId, existing);
359 }
360
361 const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
362 for (const schemaBundle of schemaBundles) {
363 const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
364 existing.push(schemaBundle);
365 schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
366 }
367
368 const entryGroups = yield* Effect.forEach(
369 relevantSourceRecords,
370 (sourceRecord) => {
371 const source = sourceById.get(sourceRecord.id);
372 if (!source) {
373 return Effect.succeed<readonly LoadedSourceRecipeToolIndexEntry[]>([]);
374 }
375
376 return Effect.forEach(
377 operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
378 (operation) =>
379 Effect.gen(function* () {
380 const path = recipeToolPath({
381 source,
382 operation,
383 });
384 const searchNamespace = recipeToolSearchNamespace({
385 source,
386 path,
387 operation,
388 });
389 const schemaBundleId = primarySchemaBundleForRevision({
390 source,
391 schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
392 })?.id ?? null;
393 const metadata = yield* recipeToolMetadata({
394 source,
395 operation,
396 path,
397 });
398
399 return {
400 path,
401 searchNamespace,
402 searchText: [
403 path,
404 searchNamespace,
405 source.name,
406 metadata.searchText,
407 ]
408 .filter((part) => part.length > 0)
409 .join(" ")
410 .toLowerCase(),
411 source,
412 sourceRecord,
413 operation,
414 metadata,
415 schemaBundleId,
416 descriptor: recipeToolDescriptor({
417 source,
418 operation,
419 path,
420 schemaBundleId,
421 includeSchemas: input.includeSchemas,
422 }),
423 } satisfies LoadedSourceRecipeToolIndexEntry;
424 }),
425 );
426 },
427 );
428
429 return entryGroups.flat();
430 });
431
432export const loadWorkspaceSourceRecipeToolByPath = (input: {
433 rows: SqlControlPlaneRows;
434 workspaceId: WorkspaceId;
435 path: string;
436 actorAccountId?: AccountId | null;
437 includeSchemas: boolean;
438}): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> =>
439 Effect.gen(function* () {
440 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId);
441 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, {
442 actorAccountId: input.actorAccountId,
443 });
444 const sourceById = new Map(sources.map((source) => [source.id, source]));
445 const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id));
446
447 const candidates = relevantSourceRecords.flatMap((sourceRecord) => {
448 const source = sourceById.get(sourceRecord.id);
449 if (!source) {
450 return [];
451 }
452
453 const namespace = source.namespace ?? namespaceFromSourceName(source.name);
454 if (namespace.length > 0) {
455 if (!input.path.startsWith(`${namespace}.`)) {
456 return [];
457 }
458
459 return [{
460 source,
461 sourceRecord,
462 toolId: input.path.slice(namespace.length + 1),
463 }];
464 }
465
466 return [{
467 source,
468 sourceRecord,
469 toolId: input.path,
470 }];
471 });
472
473 if (candidates.length === 0) {
474 return null;
475 }
476
477 const revisionIds = [...new Set(candidates.map((candidate) => candidate.sourceRecord.recipeRevisionId))];
478 const [operations, schemaBundles] = yield* Effect.all([
479 input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds),
480 input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds),
481 ]);
482 const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>();
483 for (const schemaBundle of schemaBundles) {
484 const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? [];
485 existing.push(schemaBundle);
486 schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing);
487 }
488
489 for (const candidate of candidates) {
490 const operation = operations.find((entry) =>
491 entry.recipeRevisionId === candidate.sourceRecord.recipeRevisionId
492 && entry.toolId === candidate.toolId
493 );
494 if (!operation) {
495 continue;
496 }
497
327 const path = recipeToolPath({498 const path = recipeToolPath({
328 source: recipe.source,499 source: candidate.source,
329 operation,500 operation,
330 });501 });
331 const searchNamespace = recipeToolSearchNamespace({502 const searchNamespace = recipeToolSearchNamespace({
332 source: recipe.source,503 source: candidate.source,
333 path,504 path,
334 operation,505 operation,
335 });506 });
507 const schemaBundleId = primarySchemaBundleForRevision({
508 source: candidate.source,
509 schemaBundles:
510 schemaBundlesByRevisionId.get(candidate.sourceRecord.recipeRevisionId) ?? [],
511 })?.id ?? null;
512 const metadata = yield* recipeToolMetadata({
513 source: candidate.source,
514 operation,
515 path,
516 });
336 517
337 return {518 return {
338 path,519 path,
@@ -340,23 +521,26 @@ export const expandRecipeTools = (input: { · 1 unmodified line
340 searchText: [521 searchText: [
341 path,522 path,
342 searchNamespace,523 searchNamespace,
343 recipe.source.name,524 candidate.source.name,
344 operation.searchText,525 metadata.searchText,
345 ]526 ]
346 .filter((part) => part.length > 0)527 .filter((part) => part.length > 0)
347 .join(" ")528 .join(" ")
348 .toLowerCase(),529 .toLowerCase(),
349 source: recipe.source,530 source: candidate.source,
350 sourceRecord: recipe.sourceRecord,531 sourceRecord: candidate.sourceRecord,
351 revision: recipe.revision,
352 operation,532 operation,
353 manifest: recipe.manifest,533 metadata,
534 schemaBundleId,
354 descriptor: recipeToolDescriptor({535 descriptor: recipeToolDescriptor({
355 source: recipe.source,536 source: candidate.source,
356 operation,537 operation,
357 path,538 path,
539 schemaBundleId,
358 includeSchemas: input.includeSchemas,540 includeSchemas: input.includeSchemas,
359 }),541 }),
360 } satisfies LoadedSourceRecipeTool;542 } satisfies LoadedSourceRecipeToolIndexEntry;
361 })543 }
362 );544
545 return null;
546 });
packages/control-plane/src/runtime/source-store.test.ts+39−22
@@ -62,6 +62,8 @@ const makeOpenApiSource = (input: { · 61 unmodified lines
62 headers: null,62 headers: null,
63 specUrl: input.specUrl ?? "https://example.com/openapi.json",63 specUrl: input.specUrl ?? "https://example.com/openapi.json",
64 defaultHeaders: null,64 defaultHeaders: null,
65 importAuthPolicy: "reuse_runtime",
66 importAuth: { kind: "none" },
65 auth: input.auth,67 auth: input.auth,
66 sourceHash: null,68 sourceHash: null,
67 lastError: null,69 lastError: null,
@@ -142,6 +144,7 @@ describe("source-store", () => { · 74 unmodified lines
142 executionId: null,144 executionId: null,
143 interactionId: null,145 interactionId: null,
144 providerKind: "mcp_oauth",146 providerKind: "mcp_oauth",
147 credentialSlot: "runtime",
145 status: "pending",148 status: "pending",
146 state: "state_source_store",149 state: "state_source_store",
147 sessionDataJson: encodeSessionDataJson({150 sessionDataJson: encodeSessionDataJson({
@@ -341,17 +344,24 @@ describe("source-store", () => { · 193 unmodified lines
341 inputSchemaJson: null,344 inputSchemaJson: null,
342 outputSchemaJson: null,345 outputSchemaJson: null,
343 providerKind: "openapi",346 providerKind: "openapi",
344 providerDataJson: null,347 providerDataJson: JSON.stringify({
345 mcpToolName: null,348 kind: "openapi",
346 openApiMethod: "get",349 toolId: "getRepo",
347 openApiPathTemplate: "/repos/{owner}/{repo}",350 rawToolId: "repos_getRepo",
348 openApiOperationHash: "hash_recipe_rewrite",351 operationId: "repos.getRepo",
349 openApiRawToolId: "repos_getRepo",352 group: "repos",
350 openApiOperationId: "repos.getRepo",353 leaf: "getRepo",
351 openApiTagsJson: JSON.stringify(["repos"]),354 tags: ["repos"],
352 openApiRequestBodyRequired: null,355 method: "get",
353 graphqlOperationType: null,356 path: "/repos/{owner}/{repo}",
354 graphqlOperationName: null,357 operationHash: "hash_recipe_rewrite",
358 invocation: {
359 method: "get",
360 pathTemplate: "/repos/{owner}/{repo}",
361 parameters: [],
362 requestBody: null,
363 },
364 }),
355 createdAt: now,365 createdAt: now,
356 updatedAt: now,366 updatedAt: now,
357 }],367 }],
@@ -465,17 +475,24 @@ describe("source-store", () => { · 107 unmodified lines
465 inputSchemaJson: null,475 inputSchemaJson: null,
466 outputSchemaJson: null,476 outputSchemaJson: null,
467 providerKind: "openapi",477 providerKind: "openapi",
468 providerDataJson: null,478 providerDataJson: JSON.stringify({
469 mcpToolName: null,479 kind: "openapi",
470 openApiMethod: "get",480 toolId: "getRepo",
471 openApiPathTemplate: "/repos/{owner}/{repo}",481 rawToolId: "repos_getRepo",
472 openApiOperationHash: "hash_shared_recipe",482 operationId: "repos.getRepo",
473 openApiRawToolId: "repos_getRepo",483 group: "repos",
474 openApiOperationId: "repos.getRepo",484 leaf: "getRepo",
475 openApiTagsJson: JSON.stringify(["repos"]),485 tags: ["repos"],
476 openApiRequestBodyRequired: null,486 method: "get",
477 graphqlOperationType: null,487 path: "/repos/{owner}/{repo}",
478 graphqlOperationName: null,488 operationHash: "hash_shared_recipe",
489 invocation: {
490 method: "get",
491 pathTemplate: "/repos/{owner}/{repo}",
492 parameters: [],
493 requestBody: null,
494 },
495 }),
479 createdAt: now,496 createdAt: now,
480 updatedAt: now,497 updatedAt: now,
481 }],498 }],
packages/control-plane/src/runtime/source-store.ts+64−11
@@ -1,6 +1,7 @@
1import type {1import type {
2 AccountId,2 AccountId,
3 Credential,3 Credential,
4 CredentialSlot,
4 SecretRef,5 SecretRef,
5 Source,6 Source,
6 SourceRecipeId,7 SourceRecipeId,
@@ -64,23 +65,29 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 57 unmodified lines
64const selectPreferredCredential = (input: {65const selectPreferredCredential = (input: {
65 credentials: ReadonlyArray<Credential>;66 credentials: ReadonlyArray<Credential>;
66 actorAccountId?: AccountId | null;67 actorAccountId?: AccountId | null;
68 slot: CredentialSlot;
67}): Credential | null => {69}): Credential | null => {
70 const matchingSlot = input.credentials.filter((credential) => credential.slot === input.slot);
71
68 if (input.actorAccountId !== undefined) {72 if (input.actorAccountId !== undefined) {
69 const exact = input.credentials.find((credential) => credential.actorAccountId === input.actorAccountId);73 const exact = matchingSlot.find((credential) => credential.actorAccountId === input.actorAccountId);
70 if (exact) {74 if (exact) {
71 return exact;75 return exact;
72 }76 }
73 }77 }
74 78
75 return input.credentials.find((credential) => credential.actorAccountId === null) ?? null;79 return matchingSlot.find((credential) => credential.actorAccountId === null) ?? null;
76};80};
77 81
78const selectExactCredential = (input: {82const selectExactCredential = (input: {
79 credentials: ReadonlyArray<Credential>;83 credentials: ReadonlyArray<Credential>;
80 actorAccountId?: AccountId | null;84 actorAccountId?: AccountId | null;
85 slot: CredentialSlot;
81}): Credential | null =>86}): Credential | null =>
82 input.credentials.find(87 input.credentials.find(
83 (credential) => credential.actorAccountId === (input.actorAccountId ?? null),88 (credential) =>
89 credential.slot === input.slot
90 && credential.actorAccountId === (input.actorAccountId ?? null),
84 ) ?? null;91 ) ?? null;
85 92
86export const loadSourcesInWorkspace = (93export const loadSourcesInWorkspace = (
@@ -98,8 +105,16 @@ export const loadSourcesInWorkspace = ( · 11 unmodified lines
98 const preferred = selectPreferredCredential({105 const preferred = selectPreferredCredential({
99 credentials: matches,106 credentials: matches,
100 actorAccountId: options.actorAccountId,107 actorAccountId: options.actorAccountId,
108 slot: "runtime",
109 });
110 const preferredImport = selectPreferredCredential({
111 credentials: matches,
112 actorAccountId: options.actorAccountId,
113 slot: "import",
101 });114 });
102 return preferred ? [preferred] : [];115 return [preferred, preferredImport].filter(
116 (credential): credential is Credential => credential !== null,
117 );
103 });118 });
104 119
105 return yield* projectSourcesFromStorage({120 return yield* projectSourcesFromStorage({
@@ -132,11 +147,18 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 26 unmodified lines
132 const credential = selectPreferredCredential({147 const credential = selectPreferredCredential({
133 credentials,148 credentials,
134 actorAccountId: input.actorAccountId,149 actorAccountId: input.actorAccountId,
150 slot: "runtime",
151 });
152 const importCredential = selectPreferredCredential({
153 credentials,
154 actorAccountId: input.actorAccountId,
155 slot: "import",
135 });156 });
136 157
137 return yield* projectSourceFromStorage({158 return yield* projectSourceFromStorage({
138 sourceRecord: sourceRecord.value,159 sourceRecord: sourceRecord.value,
139 credential,160 runtimeCredential: credential,
161 importCredential,
140 });162 });
141 });163 });
142 164
@@ -178,6 +200,7 @@ const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { · 35 unmodified lines
178 );200 );
179 if (revisionReferenceCount === 0) {201 if (revisionReferenceCount === 0) {
180 yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId);202 yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId);
203 yield* rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId);
181 yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId);204 yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId);
182 }205 }
183 206
@@ -192,6 +215,7 @@ const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { · 8 unmodified lines
192 (recipeRevision) =>215 (recipeRevision) =>
193 Effect.all([216 Effect.all([
194 rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id),217 rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id),
218 rows.sourceRecipeSchemaBundles.removeByRevisionId(recipeRevision.id),
195 rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id),219 rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id),
196 ]),220 ]),
197 { discard: true },221 { discard: true },
@@ -248,10 +272,18 @@ export const persistSource = ( · 50 unmodified lines
248 const existingCredential = selectExactCredential({272 const existingCredential = selectExactCredential({
249 credentials: existingCredentials,273 credentials: existingCredentials,
250 actorAccountId: options.actorAccountId,274 actorAccountId: options.actorAccountId,
275 slot: "runtime",
276 });
277 const existingImportCredential = selectExactCredential({
278 credentials: existingCredentials,
279 actorAccountId: options.actorAccountId,
280 slot: "import",
251 });281 });
252 282
253 const nextRecipeId = stableSourceRecipeId(source);283 const nextRecipeId = stableSourceRecipeId(source);
254 const nextRecipeRevisionId = stableSourceRecipeRevisionId(source);284 const nextRecipeRevisionId = Option.isSome(existing) && existing.value.recipeId === nextRecipeId
285 ? existing.value.recipeRevisionId
286 : stableSourceRecipeRevisionId(source);
255 const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId);287 const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId);
256 const nextRevision = createSourceRecipeRevisionRecord({288 const nextRevision = createSourceRecipeRevisionRecord({
257 source,289 source,
@@ -266,6 +298,9 @@ export const persistSource = ( · 8 unmodified lines
266 manifestHash: Option.isSome(existingTargetRevision)298 manifestHash: Option.isSome(existingTargetRevision)
267 ? existingTargetRevision.value.manifestHash299 ? existingTargetRevision.value.manifestHash
268 : null,300 : null,
301 materializationHash: Option.isSome(existingTargetRevision)
302 ? existingTargetRevision.value.materializationHash
303 : null,
269 });304 });
270 305
271 const nextRecipe = createSourceRecipeRecord({306 const nextRecipe = createSourceRecipeRecord({
@@ -274,12 +309,13 @@ export const persistSource = ( · 2 unmodified lines
274 latestRevisionId: nextRevision.id,309 latestRevisionId: nextRevision.id,
275 });310 });
276 311
277 const { sourceRecord, credential } = splitSourceForStorage({312 const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({
278 source,313 source,
279 recipeId: nextRecipe.id,314 recipeId: nextRecipe.id,
280 recipeRevisionId: nextRevision.id,315 recipeRevisionId: nextRevision.id,
281 actorAccountId: options.actorAccountId,316 actorAccountId: options.actorAccountId,
282 existingCredentialId: existingCredential?.id ?? null,317 existingRuntimeCredentialId: existingCredential?.id ?? null,
318 existingImportCredentialId: existingImportCredential?.id ?? null,
283 });319 });
284 320
285 if (Option.isNone(existing)) {321 if (Option.isNone(existing)) {
@@ -310,19 +346,36 @@ export const persistSource = ( · 24 unmodified lines
310 });346 });
311 }347 }
312 348
313 if (credential === null) {349 if (runtimeCredential === null) {
314 yield* rows.credentials.removeByWorkspaceSourceAndActor({350 yield* rows.credentials.removeByWorkspaceSourceAndActor({
315 workspaceId: source.workspaceId,351 workspaceId: source.workspaceId,
316 sourceId: source.id,352 sourceId: source.id,
317 actorAccountId: options.actorAccountId ?? null,353 actorAccountId: options.actorAccountId ?? null,
354 slot: "runtime",
318 });355 });
319 } else {356 } else {
320 yield* rows.credentials.upsert(credential);357 yield* rows.credentials.upsert(runtimeCredential);
321 }358 }
322 359
323 yield* cleanupCredentialSecretRefs(rows, {360 yield* cleanupCredentialSecretRefs(rows, {
324 previous: existingCredential ?? null,361 previous: existingCredential ?? null,
325 next: credential,362 next: runtimeCredential,
363 });
364
365 if (importCredential === null) {
366 yield* rows.credentials.removeByWorkspaceSourceAndActor({
367 workspaceId: source.workspaceId,
368 sourceId: source.id,
369 actorAccountId: options.actorAccountId ?? null,
370 slot: "import",
371 });
372 } else {
373 yield* rows.credentials.upsert(importCredential);
374 }
375
376 yield* cleanupCredentialSecretRefs(rows, {
377 previous: existingImportCredential ?? null,
378 next: importCredential,
326 });379 });
327 380
328 return source;381 return source;
packages/control-plane/src/runtime/sources-operations.ts+4−8
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18 createSourceFromPayload,18 createSourceFromPayload,
19 updateSourceFromPayload,19 updateSourceFromPayload,
20} from "./source-definitions";20} from "./source-definitions";
21import { getSourceAdapterForSource } from "./source-adapters";
21import {22import {
22 mapPersistenceError,23 mapPersistenceError,
23} from "./operations-shared";24} from "./operations-shared";
@@ -26,7 +27,7 @@ import { · 2 unmodified lines
26} from "./operation-errors";27} from "./operation-errors";
27import { createDefaultSecretMaterialResolver } from "./secret-material-providers";28import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
28import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";29import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
29import { syncSourceToolArtifacts } from "./tool-artifacts";30import { syncSourceMaterialization } from "./source-materialization";
30import {31import {
31 loadSourceById,32 loadSourceById,
32 loadSourcesInWorkspace,33 loadSourcesInWorkspace,
@@ -43,12 +44,7 @@ const sourceOps = { · 10 unmodified lines
43} as const;44} as const;
44 45
45const shouldAutoProbeSource = (source: Source): boolean =>46const shouldAutoProbeSource = (source: Source): boolean =>
46 source.enabled47 getSourceAdapterForSource(source).shouldAutoProbe(source);
47 && (
48 (source.kind === "openapi" && !!source.specUrl)
49 || source.kind === "graphql"
50 )
51 && (source.status === "draft" || source.status === "probing");
52 48
53const syncArtifactsForSource = (input: {49const syncArtifactsForSource = (input: {
54 store: ControlPlaneStoreShape;50 store: ControlPlaneStoreShape;
@@ -73,7 +69,7 @@ const syncArtifactsForSource = (input: { · 18 unmodified lines
73 : input.source;69 : input.source;
74 70
75 const synced = yield* Effect.either(71 const synced = yield* Effect.either(
76 syncSourceToolArtifacts({72 syncSourceMaterialization({
77 rows: input.store,73 rows: input.store,
78 source: sourceForSync,74 source: sourceForSync,
79 resolveSecretMaterial,75 resolveSecretMaterial,
packages/control-plane/src/runtime/tool-artifacts.ts+0−736
@@ -1,736 +0,0 @@
1import { createHash } from "node:crypto";
2
3import {
4 FetchHttpClient,
5 HttpClient,
6 HttpClientRequest,
7} from "@effect/platform";
8import {
9 type McpToolManifest,
10 createSdkMcpConnector,
11 discoverMcpToolsFromConnector,
12 type McpToolManifestEntry,
13} from "@executor/codemode-mcp";
14import {
15 buildOpenApiToolPresentation,
16 compileOpenApiToolDefinitions,
17 extractOpenApiManifest,
18} from "@executor/codemode-openapi";
19import type { SqlControlPlaneRows } from "#persistence";
20import {
21 type SecretRef,
22 type Source,
23 type StoredSourceRecipeDocumentRecord,
24 type StoredSourceRecipeOperationRecord,
25} from "#schema";
26import * as Effect from "effect/Effect";
27import * as Option from "effect/Option";
28
29import {
30 buildGraphqlToolPresentation,
31 compileGraphqlToolDefinitions,
32 extractGraphqlManifest,
33 GRAPHQL_INTROSPECTION_QUERY,
34} from "./graphql-tools";
35import type {
36 ResolveSecretMaterial as ResolveSourceSecretMaterial,
37 SecretMaterialResolveContext,
38} from "./secret-material-providers";
39
40export type ResolvedSourceAuthMaterial = {
41 headers: Readonly<Record<string, string>>;
42};
43
44const trim = (value: string | undefined): string | undefined => {
45 const candidate = value?.trim();
46 return candidate && candidate.length > 0 ? candidate : undefined;
47};
48
49const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
50 parts
51 .flatMap((part) => (part ? [part.trim()] : []))
52 .filter(Boolean)
53 .join(" ")
54 .toLowerCase();
55
56const contentHash = (value: string): string =>
57 createHash("sha256").update(value).digest("hex");
58
59type SourceBindingRecord = Awaited<
60 ReturnType<SqlControlPlaneRows["sources"]["getByWorkspaceAndId"]>
61> extends Effect.Effect<Option.Option<infer T>, unknown, never>
62 ? T
63 : never;
64
65const loadSourceBindingRecord = (input: {
66 rows: SqlControlPlaneRows;
67 source: Source;
68}): Effect.Effect<SourceBindingRecord, Error, never> =>
69 Effect.gen(function* () {
70 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
71 input.source.workspaceId,
72 input.source.id,
73 ).pipe(
74 Effect.mapError((cause) =>
75 cause instanceof Error ? cause : new Error(String(cause)),
76 ),
77 );
78
79 if (Option.isNone(sourceRecord)) {
80 return yield* Effect.fail(
81 new Error(`Source disappeared while syncing recipe data for ${input.source.id}`),
82 );
83 }
84
85 return sourceRecord.value;
86 });
87
88const replaceRecipeRevisionContent = (input: {
89 rows: SqlControlPlaneRows;
90 source: Source;
91 manifestJson: string | null;
92 manifestHash: string | null;
93 documents: readonly StoredSourceRecipeDocumentRecord[];
94 operations: readonly StoredSourceRecipeOperationRecord[];
95 sourcePatch?: Partial<Pick<SourceBindingRecord, "sourceHash">>;
96}): Effect.Effect<void, Error, never> =>
97 Effect.gen(function* () {
98 const sourceRecord = yield* loadSourceBindingRecord({
99 rows: input.rows,
100 source: input.source,
101 });
102 const now = Date.now();
103
104 yield* input.rows.sourceRecipeRevisions.update(sourceRecord.recipeRevisionId, {
105 manifestJson: input.manifestJson,
106 manifestHash: input.manifestHash,
107 updatedAt: now,
108 }).pipe(
109 Effect.mapError((cause) =>
110 cause instanceof Error ? cause : new Error(String(cause)),
111 ),
112 );
113
114 yield* input.rows.sourceRecipeDocuments.replaceForRevision({
115 recipeRevisionId: sourceRecord.recipeRevisionId,
116 documents: input.documents,
117 }).pipe(
118 Effect.mapError((cause) =>
119 cause instanceof Error ? cause : new Error(String(cause)),
120 ),
121 );
122
123 yield* input.rows.sourceRecipeOperations.replaceForRevision({
124 recipeRevisionId: sourceRecord.recipeRevisionId,
125 operations: input.operations,
126 }).pipe(
127 Effect.mapError((cause) =>
128 cause instanceof Error ? cause : new Error(String(cause)),
129 ),
130 );
131
132 if (input.sourcePatch) {
133 const updatedSource = yield* input.rows.sources.update(
134 input.source.workspaceId,
135 input.source.id,
136 {
137 ...input.sourcePatch,
138 updatedAt: now,
139 },
140 ).pipe(
141 Effect.mapError((cause) =>
142 cause instanceof Error ? cause : new Error(String(cause)),
143 ),
144 );
145
146 if (Option.isNone(updatedSource)) {
147 return yield* Effect.fail(
148 new Error(`Source disappeared while updating sync metadata for ${input.source.id}`),
149 );
150 }
151 }
152 });
153
154const toOpenApiRecipeOperationRecord = (input: {
155 recipeRevisionId: SourceBindingRecord["recipeRevisionId"];
156 definition: ReturnType<typeof compileOpenApiToolDefinitions>[number];
157 manifest: Parameters<typeof buildOpenApiToolPresentation>[0]["manifest"];
158 now: number;
159}): StoredSourceRecipeOperationRecord => {
160 const presentation = buildOpenApiToolPresentation({
161 manifest: input.manifest,
162 definition: input.definition,
163 });
164 const method = input.definition.method.toUpperCase();
165
166 return {
167 id: `src_recipe_op_${crypto.randomUUID()}`,
168 recipeRevisionId: input.recipeRevisionId,
169 operationKey: input.definition.toolId,
170 transportKind: "http",
171 toolId: input.definition.toolId,
172 title: input.definition.name,
173 description: input.definition.description,
174 operationKind:
175 method === "GET" || method === "HEAD"
176 ? "read"
177 : method === "DELETE"
178 ? "delete"
179 : "write",
180 searchText: normalizeSearchText(
181 input.definition.toolId,
182 input.definition.name,
183 input.definition.description,
184 input.definition.rawToolId,
185 input.definition.operationId ?? undefined,
186 input.definition.method,
187 input.definition.path,
188 input.definition.group,
189 input.definition.leaf,
190 input.definition.tags.join(" "),
191 ),
192 inputSchemaJson: presentation.inputSchemaJson ?? null,
193 outputSchemaJson: presentation.outputSchemaJson ?? null,
194 providerKind: "openapi",
195 providerDataJson: presentation.providerDataJson,
196 mcpToolName: null,
197 openApiMethod: input.definition.method,
198 openApiPathTemplate: input.definition.path,
199 openApiOperationHash: input.definition.operationHash,
200 openApiRawToolId: input.definition.rawToolId,
201 openApiOperationId: input.definition.operationId ?? null,
202 openApiTagsJson: JSON.stringify(input.definition.tags),
203 openApiRequestBodyRequired: input.definition.invocation.requestBody?.required ?? null,
204 graphqlOperationType: null,
205 graphqlOperationName: null,
206 createdAt: input.now,
207 updatedAt: input.now,
208 };
209};
210
211const toGraphqlRecipeOperationRecord = (input: {
212 recipeRevisionId: SourceBindingRecord["recipeRevisionId"];
213 definition: ReturnType<typeof compileGraphqlToolDefinitions>[number];
214 manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"];
215 now: number;
216}): StoredSourceRecipeOperationRecord => {
217 const presentation = buildGraphqlToolPresentation({
218 manifest: input.manifest,
219 definition: input.definition,
220 });
221
222 return {
223 id: `src_recipe_op_${crypto.randomUUID()}`,
224 recipeRevisionId: input.recipeRevisionId,
225 operationKey: input.definition.toolId,
226 transportKind: "graphql",
227 toolId: input.definition.toolId,
228 title: input.definition.name,
229 description: input.definition.description,
230 operationKind:
231 input.definition.operationType === "query"
232 ? "read"
233 : input.definition.operationType === "mutation"
234 ? "write"
235 : "unknown",
236 searchText: normalizeSearchText(
237 input.definition.toolId,
238 input.definition.name,
239 input.definition.description,
240 input.definition.rawToolId,
241 input.definition.group,
242 input.definition.leaf,
243 input.definition.fieldName,
244 input.definition.operationType,
245 input.definition.operationName,
246 input.definition.searchTerms.join(" "),
247 ),
248 inputSchemaJson: presentation.inputSchemaJson ?? null,
249 outputSchemaJson: presentation.outputSchemaJson ?? null,
250 providerKind: "graphql",
251 providerDataJson: presentation.providerDataJson,
252 mcpToolName: null,
253 openApiMethod: null,
254 openApiPathTemplate: null,
255 openApiOperationHash: null,
256 openApiRawToolId: null,
257 openApiOperationId: null,
258 openApiTagsJson: null,
259 openApiRequestBodyRequired: null,
260 graphqlOperationType: input.definition.operationType,
261 graphqlOperationName: input.definition.operationName,
262 createdAt: input.now,
263 updatedAt: input.now,
264 };
265};
266
267const toMcpRecipeOperationRecord = (input: {
268 recipeRevisionId: SourceBindingRecord["recipeRevisionId"];
269 entry: McpToolManifestEntry;
270 now: number;
271}): StoredSourceRecipeOperationRecord => ({
272 id: `src_recipe_op_${crypto.randomUUID()}`,
273 recipeRevisionId: input.recipeRevisionId,
274 operationKey: input.entry.toolId,
275 transportKind: "mcp",
276 toolId: input.entry.toolId,
277 title: input.entry.toolName,
278 description: input.entry.description ?? null,
279 operationKind: "unknown",
280 searchText: normalizeSearchText(
281 input.entry.toolId,
282 input.entry.toolName,
283 input.entry.description ?? undefined,
284 "mcp",
285 ),
286 inputSchemaJson: input.entry.inputSchemaJson ?? null,
287 outputSchemaJson: input.entry.outputSchemaJson ?? null,
288 providerKind: "mcp",
289 providerDataJson: JSON.stringify({
290 kind: "mcp",
291 toolId: input.entry.toolId,
292 toolName: input.entry.toolName,
293 description: input.entry.description ?? null,
294 }),
295 mcpToolName: input.entry.toolName,
296 openApiMethod: null,
297 openApiPathTemplate: null,
298 openApiOperationHash: null,
299 openApiRawToolId: null,
300 openApiOperationId: null,
301 openApiTagsJson: null,
302 openApiRequestBodyRequired: null,
303 graphqlOperationType: null,
304 graphqlOperationName: null,
305 createdAt: input.now,
306 updatedAt: input.now,
307});
308
309const persistMcpRecipeRevision = (input: {
310 rows: SqlControlPlaneRows;
311 source: Source;
312 manifestEntries: readonly McpToolManifestEntry[];
313}): Effect.Effect<void, Error, never> =>
314 Effect.gen(function* () {
315 const sourceRecord = yield* loadSourceBindingRecord({
316 rows: input.rows,
317 source: input.source,
318 });
319 const now = Date.now();
320 const manifest: McpToolManifest = {
321 version: 1,
322 tools: input.manifestEntries,
323 };
324 const manifestJson = JSON.stringify(manifest);
325 const manifestHash = contentHash(manifestJson);
326
327 yield* replaceRecipeRevisionContent({
328 rows: input.rows,
329 source: input.source,
330 manifestJson,
331 manifestHash,
332 documents: [{
333 id: `src_recipe_doc_${crypto.randomUUID()}`,
334 recipeRevisionId: sourceRecord.recipeRevisionId,
335 documentKind: "mcp_manifest",
336 documentKey: input.source.endpoint,
337 contentText: manifestJson,
338 contentHash: manifestHash,
339 fetchedAt: now,
340 createdAt: now,
341 updatedAt: now,
342 }],
343 operations: input.manifestEntries.map((entry) =>
344 toMcpRecipeOperationRecord({
345 recipeRevisionId: sourceRecord.recipeRevisionId,
346 entry,
347 now,
348 })
349 ),
350 sourcePatch: {
351 sourceHash: manifestHash,
352 },
353 });
354 });
355
356
357const shouldIndexSource = (source: Source): boolean =>
358 source.enabled
359 && source.status === "connected"
360 && (source.kind === "mcp" || source.kind === "openapi" || source.kind === "graphql");
361
362export const namespaceFromSourceName = (name: string): string => {
363 const normalized = name
364 .trim()
365 .toLowerCase()
366 .replace(/[^a-z0-9]+/g, ".")
367 .replace(/^\.+|\.+$/g, "");
368
369 return normalized.length > 0 ? normalized : "source";
370};
371
372export const resolveSourceAuthMaterial = (input: {
373 source: Source;
374 resolveSecretMaterial: ResolveSourceSecretMaterial;
375 context?: SecretMaterialResolveContext;
376}): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>
377 Effect.gen(function* () {
378 if (input.source.auth.kind === "none") {
379 return { headers: {} } satisfies ResolvedSourceAuthMaterial;
380 }
381
382 const tokenRef =
383 input.source.auth.kind === "bearer"
384 ? input.source.auth.token
385 : input.source.auth.accessToken;
386
387 const token = yield* input.resolveSecretMaterial({
388 ref: tokenRef,
389 context: input.context,
390 });
391
392 return {
393 headers: {
394 [input.source.auth.headerName]: `${input.source.auth.prefix}${token}`,
395 },
396 } satisfies ResolvedSourceAuthMaterial;
397 });
398
399const fetchOpenApiDocumentWithHeaders = (input: {
400 url: string;
401 headers?: Readonly<Record<string, string>>;
402}): Effect.Effect<string, Error, never> =>
403 Effect.gen(function* () {
404 const client = (yield* HttpClient.HttpClient).pipe(
405 HttpClient.filterStatusOk,
406 );
407 const request = HttpClientRequest.get(input.url).pipe(
408 HttpClientRequest.setHeaders(input.headers ?? {}),
409 );
410 const response = yield* client.execute(request).pipe(
411 Effect.mapError((cause) =>
412 cause instanceof Error ? cause : new Error(String(cause)),
413 ),
414 );
415
416 return yield* response.text.pipe(
417 Effect.mapError((cause) =>
418 cause instanceof Error ? cause : new Error(String(cause)),
419 ),
420 );
421 }).pipe(
422 Effect.provide(FetchHttpClient.layer),
423 );
424
425const fetchGraphqlIntrospectionDocumentWithHeaders = (input: {
426 url: string;
427 headers?: Readonly<Record<string, string>>;
428}): Effect.Effect<string, Error, never> =>
429 Effect.tryPromise({
430 try: async () => {
431 const response = await fetch(input.url, {
432 method: "POST",
433 headers: {
434 "content-type": "application/json",
435 ...(input.headers ?? {}),
436 },
437 body: JSON.stringify({
438 query: GRAPHQL_INTROSPECTION_QUERY,
439 operationName: "IntrospectionQuery",
440 }),
441 });
442 const text = await response.text();
443
444 let parsed: unknown;
445 try {
446 parsed = JSON.parse(text) as unknown;
447 } catch (cause) {
448 throw new Error(
449 `GraphQL introspection endpoint did not return JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
450 );
451 }
452
453 if (!response.ok) {
454 throw new Error(
455 `GraphQL introspection failed with status ${response.status}`,
456 );
457 }
458
459 return JSON.stringify(parsed, null, 2);
460 },
461 catch: (cause) =>
462 cause instanceof Error ? cause : new Error(String(cause)),
463 });
464
465const indexMcpSourceToolArtifacts = (input: {
466 rows: SqlControlPlaneRows;
467 source: Source;
468 manifestEntries: readonly McpToolManifestEntry[];
469}): Effect.Effect<void, Error, never> =>
470 Effect.gen(function* () {
471 yield* persistMcpRecipeRevision(input);
472 yield* input.rows.toolArtifacts.removeByWorkspaceAndSourceId(
473 input.source.workspaceId,
474 input.source.id,
475 ).pipe(
476 Effect.mapError((cause) =>
477 cause instanceof Error ? cause : new Error(String(cause)),
478 ),
479 );
480 });
481
482const discoverAndIndexMcpSourceToolArtifacts = (input: {
483 rows: SqlControlPlaneRows;
484 source: Source;
485 auth: ResolvedSourceAuthMaterial;
486}): Effect.Effect<void, Error, never> =>
487 Effect.gen(function* () {
488 const connector = yield* Effect.try({
489 try: () =>
490 createSdkMcpConnector({
491 endpoint: input.source.endpoint,
492 transport: input.source.transport ?? undefined,
493 queryParams: input.source.queryParams ?? undefined,
494 headers: {
495 ...(input.source.headers ?? {}),
496 ...input.auth.headers,
497 },
498 }),
499 catch: (cause) =>
500 cause instanceof Error ? cause : new Error(String(cause)),
501 });
502
503 const discovered = yield* discoverMcpToolsFromConnector({
504 connect: connector,
505 namespace: input.source.namespace ?? namespaceFromSourceName(input.source.name),
506 sourceKey: input.source.id,
507 }).pipe(
508 Effect.mapError(
509 (cause) =>
510 new Error(
511 `Failed discovering MCP tools for ${input.source.id}: ${cause.message}`,
512 ),
513 ),
514 );
515
516 return yield* indexMcpSourceToolArtifacts({
517 rows: input.rows,
518 source: input.source,
519 manifestEntries: discovered.manifest.tools,
520 });
521 });
522
523const indexOpenApiSourceToolArtifacts = (input: {
524 rows: SqlControlPlaneRows;
525 source: Source;
526 auth: ResolvedSourceAuthMaterial;
527}): Effect.Effect<void, Error, never> =>
528 Effect.gen(function* () {
529 if (!input.source.specUrl) {
530 return yield* Effect.fail(
531 new Error(`Missing OpenAPI specUrl for source ${input.source.id}`),
532 );
533 }
534
535 const openApiDocument = yield* fetchOpenApiDocumentWithHeaders({
536 url: input.source.specUrl,
537 headers: {
538 ...(input.source.defaultHeaders ?? {}),
539 ...input.auth.headers,
540 },
541 }).pipe(
542 Effect.mapError((cause) =>
543 new Error(
544 `Failed fetching OpenAPI spec for ${input.source.id}: ${cause.message}`,
545 ),
546 ),
547 );
548
549 const manifest = yield* extractOpenApiManifest(
550 input.source.name,
551 openApiDocument,
552 ).pipe(
553 Effect.mapError((cause) =>
554 cause instanceof Error
555 ? cause
556 : new Error(String(cause)),
557 ),
558 );
559
560 const sourceRecord = yield* loadSourceBindingRecord({
561 rows: input.rows,
562 source: input.source,
563 });
564 const definitions = compileOpenApiToolDefinitions(manifest);
565 const now = Date.now();
566
567 yield* replaceRecipeRevisionContent({
568 rows: input.rows,
569 source: input.source,
570 manifestJson: JSON.stringify(manifest),
571 manifestHash: manifest.sourceHash,
572 documents: [{
573 id: `src_recipe_doc_${crypto.randomUUID()}`,
574 recipeRevisionId: sourceRecord.recipeRevisionId,
575 documentKind: "openapi",
576 documentKey: input.source.specUrl ?? input.source.endpoint,
577 contentText: openApiDocument,
578 contentHash: contentHash(openApiDocument),
579 fetchedAt: now,
580 createdAt: now,
581 updatedAt: now,
582 }],
583 operations: definitions.map((definition) =>
584 toOpenApiRecipeOperationRecord({
585 recipeRevisionId: sourceRecord.recipeRevisionId,
586 definition,
587 manifest,
588 now,
589 })
590 ),
591 sourcePatch: {
592 sourceHash: manifest.sourceHash,
593 },
594 });
595
596 yield* input.rows.toolArtifacts.removeByWorkspaceAndSourceId(
597 input.source.workspaceId,
598 input.source.id,
599 ).pipe(
600 Effect.mapError((cause) =>
601 cause instanceof Error ? cause : new Error(String(cause)),
602 ),
603 );
604 });
605
606const indexGraphqlSourceToolArtifacts = (input: {
607 rows: SqlControlPlaneRows;
608 source: Source;
609 auth: ResolvedSourceAuthMaterial;
610}): Effect.Effect<void, Error, never> =>
611 Effect.gen(function* () {
612 const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({
613 url: input.source.endpoint,
614 headers: {
615 ...(input.source.defaultHeaders ?? {}),
616 ...input.auth.headers,
617 },
618 }).pipe(
619 Effect.mapError((cause) =>
620 new Error(
621 `Failed fetching GraphQL introspection for ${input.source.id}: ${cause.message}`,
622 ),
623 ),
624 );
625
626 const manifest = yield* extractGraphqlManifest(
627 input.source.name,
628 graphqlDocument,
629 ).pipe(
630 Effect.mapError((cause) =>
631 cause instanceof Error ? cause : new Error(String(cause)),
632 ),
633 );
634
635 const sourceRecord = yield* loadSourceBindingRecord({
636 rows: input.rows,
637 source: input.source,
638 });
639 const definitions = compileGraphqlToolDefinitions(manifest);
640 const now = Date.now();
641
642 yield* replaceRecipeRevisionContent({
643 rows: input.rows,
644 source: input.source,
645 manifestJson: JSON.stringify(manifest),
646 manifestHash: manifest.sourceHash,
647 documents: [{
648 id: `src_recipe_doc_${crypto.randomUUID()}`,
649 recipeRevisionId: sourceRecord.recipeRevisionId,
650 documentKind: "graphql_introspection",
651 documentKey: input.source.endpoint,
652 contentText: graphqlDocument,
653 contentHash: contentHash(graphqlDocument),
654 fetchedAt: now,
655 createdAt: now,
656 updatedAt: now,
657 }],
658 operations: definitions.map((definition) =>
659 toGraphqlRecipeOperationRecord({
660 recipeRevisionId: sourceRecord.recipeRevisionId,
661 definition,
662 manifest,
663 now,
664 })
665 ),
666 sourcePatch: {
667 sourceHash: manifest.sourceHash,
668 },
669 });
670
671 yield* input.rows.toolArtifacts.removeByWorkspaceAndSourceId(
672 input.source.workspaceId,
673 input.source.id,
674 ).pipe(
675 Effect.mapError((cause) =>
676 cause instanceof Error ? cause : new Error(String(cause)),
677 ),
678 );
679 });
680
681export const syncSourceToolArtifacts = (input: {
682 rows: SqlControlPlaneRows;
683 source: Source;
684 resolveSecretMaterial: ResolveSourceSecretMaterial;
685}): Effect.Effect<void, Error, never> =>
686 Effect.gen(function* () {
687 if (!shouldIndexSource(input.source)) {
688 yield* input.rows.toolArtifacts.removeByWorkspaceAndSourceId(
689 input.source.workspaceId,
690 input.source.id,
691 ).pipe(
692 Effect.mapError((cause) =>
693 cause instanceof Error ? cause : new Error(String(cause)),
694 ),
695 );
696 return;
697 }
698
699 const auth = yield* resolveSourceAuthMaterial({
700 source: input.source,
701 resolveSecretMaterial: input.resolveSecretMaterial,
702 });
703
704 if (input.source.kind === "mcp") {
705 return yield* discoverAndIndexMcpSourceToolArtifacts({
706 rows: input.rows,
707 source: input.source,
708 auth,
709 });
710 }
711
712 if (input.source.kind === "openapi") {
713 return yield* indexOpenApiSourceToolArtifacts({
714 rows: input.rows,
715 source: input.source,
716 auth,
717 });
718 }
719
720 if (input.source.kind === "graphql") {
721 return yield* indexGraphqlSourceToolArtifacts({
722 rows: input.rows,
723 source: input.source,
724 auth,
725 });
726 }
727
728 return;
729 });
730
731export const persistMcpToolArtifactsFromManifest = (input: {
732 rows: SqlControlPlaneRows;
733 source: Source;
734 manifestEntries: readonly McpToolManifestEntry[];
735}): Effect.Effect<void, Error, never> =>
736 indexMcpSourceToolArtifacts(input);
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+704−486
@@ -36,6 +36,7 @@ import { · 35 unmodified lines
36 ExecutionIdSchema,36 ExecutionIdSchema,
37 SecretMaterialIdSchema,37 SecretMaterialIdSchema,
38 SourceIdSchema,38 SourceIdSchema,
39 SourceRecipeSchemaBundleIdSchema,
39 WorkspaceIdSchema,40 WorkspaceIdSchema,
40 type Source,41 type Source,
41} from "#schema";42} from "#schema";
@@ -60,7 +61,7 @@ import { · 18 unmodified lines
60} from "./source-auth-service";61} from "./source-auth-service";
61import { createLiveExecutionManager } from "./live-execution";62import { createLiveExecutionManager } from "./live-execution";
62import { persistSource } from "./source-store";63import { persistSource } from "./source-store";
63import { persistMcpToolArtifactsFromManifest } from "./tool-artifacts";64import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization";
64import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";65import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
65 66
66type CountedMcpServer = {67type CountedMcpServer = {
@@ -85,7 +86,9 @@ type GraphqlServer = { · 18 unmodified lines
85 close: () => Promise<void>;86 close: () => Promise<void>;
86};87};
87 88
88const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>89const normalizeSearchText = (
90 ...parts: ReadonlyArray<string | null | undefined>
91): string =>
89 parts92 parts
90 .flatMap((part) => (part ? [part.trim()] : []))93 .flatMap((part) => (part ? [part.trim()] : []))
91 .filter(Boolean)94 .filter(Boolean)
@@ -263,7 +266,10 @@ const GraphqlMutationType = new GraphQLObjectType({ · 171 unmodified lines
263 type: new GraphQLNonNull(GraphqlCreateIssueInputType),266 type: new GraphQLNonNull(GraphqlCreateIssueInputType),
264 },267 },
265 },268 },
266 resolve: (_source, args: { input: { title: string; teamId?: string | null } }) => ({269 resolve: (
270 _source,
271 args: { input: { title: string; teamId?: string | null } },
272 ) => ({
267 id: "iss_created",273 id: "iss_created",
268 identifier: "ENG-999",274 identifier: "ENG-999",
269 title: args.input.title,275 title: args.input.title,
@@ -285,17 +291,21 @@ const testGraphqlSchema = new GraphQLSchema({ · 15 unmodified lines
285const closeScope = (scope: Scope.CloseableScope) =>291const closeScope = (scope: Scope.CloseableScope) =>
286 Scope.close(scope, Exit.void).pipe(Effect.orDie);292 Scope.close(scope, Exit.void).pipe(Effect.orDie);
287 293
288const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =294const makePersistence: Effect.Effect<
289 Effect.acquireRelease(295 SqlControlPlanePersistence,
296 unknown,
297 Scope.Scope
298> = Effect.acquireRelease(
290 createSqlControlPlanePersistence({299 createSqlControlPlanePersistence({
291 localDataDir: ":memory:",300 localDataDir: ":memory:",
292 }),301 }),
293 (persistence) =>302 (persistence) =>
294 Effect.tryPromise({303 Effect.tryPromise({
295 try: () => persistence.close(),304 try: () => persistence.close(),
296 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),305 catch: (cause) =>
306 cause instanceof Error ? cause : new Error(String(cause)),
297 }).pipe(Effect.orDie),307 }).pipe(Effect.orDie),
298 );308);
299 309
300const makeCountedMcpServer = Effect.acquireRelease(310const makeCountedMcpServer = Effect.acquireRelease(
301 Effect.promise<CountedMcpServer>(311 Effect.promise<CountedMcpServer>(
@@ -425,37 +435,38 @@ const makeCountedMcpServer = Effect.acquireRelease( · 123 unmodified lines
425const ownerParam = HttpApiSchema.param("owner", Schema.String);435const ownerParam = HttpApiSchema.param("owner", Schema.String);
426const repoParam = HttpApiSchema.param("repo", Schema.String);436const repoParam = HttpApiSchema.param("repo", Schema.String);
427 437
428class GeneratedReposApi extends HttpApiGroup.make("repos")438class GeneratedReposApi extends HttpApiGroup.make("repos").add(
429 .add(439 HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`.addSuccess(
430 HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`440 Schema.Struct({
431 .addSuccess(441 ok: Schema.Boolean,
432 Schema.Struct({442 full_name: Schema.String,
433 ok: Schema.Boolean,443 }),
434 full_name: Schema.String,444 ),
435 }),445) {}
436 ),
437 ) {}
438 446
439class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {}447class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {}
440 448
441const makeOpenApiSpecServer = Effect.acquireRelease(449const makeOpenApiSpecServer = Effect.acquireRelease(
442 Effect.gen(function* () {450 Effect.gen(function* () {
443 const seenAuthHeaders: OpenApiSpecServer["seenAuthHeaders"] = [];451 const seenAuthHeaders: OpenApiSpecServer["seenAuthHeaders"] = [];
444 const handlersLayer = HttpApiBuilder.group(GeneratedApi, "repos", (handlers) =>452 const handlersLayer = HttpApiBuilder.group(
445 handlers.handle("getRepo", ({ path, request }) =>453 GeneratedApi,
446 Effect.sync(() => {454 "repos",
447 seenAuthHeaders.push(455 (handlers) =>
448 typeof request.headers.authorization === "string"456 handlers.handle("getRepo", ({ path, request }) =>
449 ? request.headers.authorization457 Effect.sync(() => {
450 : null,458 seenAuthHeaders.push(
451 );459 typeof request.headers.authorization === "string"
460 ? request.headers.authorization
461 : null,
462 );
452 463
453 return {464 return {
454 ok: true,465 ok: true,
455 full_name: `${path.owner}/${path.repo}`,466 full_name: `${path.owner}/${path.repo}`,
456 };467 };
457 })468 }),
458 )469 ),
459 );470 );
460 const apiLayer = HttpApiBuilder.api(GeneratedApi).pipe(471 const apiLayer = HttpApiBuilder.api(GeneratedApi).pipe(
461 Layer.provide(handlersLayer),472 Layer.provide(handlersLayer),
@@ -468,9 +479,8 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 6 unmodified lines
468 const scope = yield* Scope.make();479 const scope = yield* Scope.make();
469 const context = yield* Layer.buildWithScope(serverLayer, scope).pipe(480 const context = yield* Layer.buildWithScope(serverLayer, scope).pipe(
470 Effect.catchAll((error) =>481 Effect.catchAll((error) =>
471 closeScope(scope).pipe(482 closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
472 Effect.zipRight(Effect.fail(error)),483 ),
473 )),
474 );484 );
475 const server = Context.get(context, HttpServer.HttpServer);485 const server = Context.get(context, HttpServer.HttpServer);
476 const baseUrl = HttpServer.formatAddress(server.address);486 const baseUrl = HttpServer.formatAddress(server.address);
@@ -485,8 +495,9 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 8 unmodified lines
485 (server) =>495 (server) =>
486 Effect.tryPromise({496 Effect.tryPromise({
487 try: () => server.close(),497 try: () => server.close(),
488 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),498 catch: (cause) =>
489 }).pipe(Effect.orDie),499 cause instanceof Error ? cause : new Error(String(cause)),
500 }).pipe(Effect.orDie),
490);501);
491 502
492const makeGraphqlServer = Effect.acquireRelease(503const makeGraphqlServer = Effect.acquireRelease(
@@ -518,7 +529,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 25 unmodified lines
518 529
519 const result = await graphql({530 const result = await graphql({
520 schema: testGraphqlSchema,531 schema: testGraphqlSchema,
521 source: typeof parsedBody.query === "string" ? parsedBody.query : "",532 source:
533 typeof parsedBody.query === "string" ? parsedBody.query : "",
522 variableValues:534 variableValues:
523 parsedBody.variables && typeof parsedBody.variables === "object"535 parsedBody.variables && typeof parsedBody.variables === "object"
524 ? (parsedBody.variables as Record<string, unknown>)536 ? (parsedBody.variables as Record<string, unknown>)
@@ -561,7 +573,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 36 unmodified lines
561 (server) =>573 (server) =>
562 Effect.tryPromise({574 Effect.tryPromise({
563 try: () => server.close(),575 try: () => server.close(),
564 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),576 catch: (cause) =>
577 cause instanceof Error ? cause : new Error(String(cause)),
565 }).pipe(Effect.orDie),578 }).pipe(Effect.orDie),
566);579);
567 580
@@ -587,6 +600,8 @@ const persistConnectedEchoTool = (input: { · 19 unmodified lines
587 headers: null,600 headers: null,
588 specUrl: null,601 specUrl: null,
589 defaultHeaders: null,602 defaultHeaders: null,
603 importAuthPolicy: "reuse_runtime",
604 importAuth: { kind: "none" },
590 auth: { kind: "none" },605 auth: { kind: "none" },
591 sourceHash: null,606 sourceHash: null,
592 lastError: null,607 lastError: null,
@@ -595,24 +610,26 @@ const persistConnectedEchoTool = (input: { · 2 unmodified lines
595 };610 };
596 611
597 yield* persistSource(input.persistence.rows, source);612 yield* persistSource(input.persistence.rows, source);
598 yield* persistMcpToolArtifactsFromManifest({613 yield* persistMcpRecipeMaterializationFromManifest({
599 rows: input.persistence.rows,614 rows: input.persistence.rows,
600 source,615 source,
601 manifestEntries: [{616 manifestEntries: [
602 toolId: "echo",617 {
603 toolName: "echo",618 toolId: "echo",
604 description: "Echo the provided string",619 toolName: "echo",
605 inputSchemaJson: JSON.stringify({620 description: "Echo the provided string",
606 type: "object",621 inputSchemaJson: JSON.stringify({
607 properties: {622 type: "object",
608 value: {623 properties: {
609 type: "string",624 value: {
625 type: "string",
626 },
610 },627 },
611 },628 required: ["value"],
612 required: ["value"],629 additionalProperties: false,
613 additionalProperties: false,630 }),
614 }),631 },
615 }],632 ],
616 });633 });
617 });634 });
618 635
@@ -635,31 +652,52 @@ const persistConnectedGithubOpenApiSource = (input: { · 16 unmodified lines
635 version: "1.0.0",652 version: "1.0.0",
636 },653 },
637 paths: {654 paths: {
638 "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}": {655 "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}":
639 get: {656 {
640 operationId: "actions/get-environment-secret",657 get: {
641 tags: ["actions"],658 operationId: "actions/get-environment-secret",
642 summary: "Get an environment secret",659 tags: ["actions"],
643 description:660 summary: "Get an environment secret",
644 "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.",661 description:
645 parameters: [662 "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.",
646 { name: "owner", in: "path", required: true, schema: { type: "string" } },663 parameters: [
647 { name: "repo", in: "path", required: true, schema: { type: "string" } },664 {
648 { name: "environment_name", in: "path", required: true, schema: { type: "string" } },665 name: "owner",
649 { name: "secret_name", in: "path", required: true, schema: { type: "string" } },666 in: "path",
650 ],667 required: true,
651 responses: {668 schema: { type: "string" },
652 200: {669 },
653 description: "ok",670 {
654 content: {671 name: "repo",
655 "application/json": {672 in: "path",
656 schema: { type: "object" },673 required: true,
674 schema: { type: "string" },
675 },
676 {
677 name: "environment_name",
678 in: "path",
679 required: true,
680 schema: { type: "string" },
681 },
682 {
683 name: "secret_name",
684 in: "path",
685 required: true,
686 schema: { type: "string" },
687 },
688 ],
689 responses: {
690 200: {
691 description: "ok",
692 content: {
693 "application/json": {
694 schema: { $ref: "#/components/schemas/Repository" },
695 },
657 },696 },
658 },697 },
659 },698 },
660 },699 },
661 },700 },
662 },
663 "/orgs/{org}/actions/runners/{runner_id}": {701 "/orgs/{org}/actions/runners/{runner_id}": {
664 get: {702 get: {
665 operationId: "actions/get-self-hosted-runner-for-org",703 operationId: "actions/get-self-hosted-runner-for-org",
@@ -668,8 +706,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 2 unmodified lines
668 description:706 description:
669 "Gets a specific self-hosted runner configured in an organization. Authenticated users must have admin access to the organization to use this endpoint.",707 "Gets a specific self-hosted runner configured in an organization. Authenticated users must have admin access to the organization to use this endpoint.",
670 parameters: [708 parameters: [
671 { name: "org", in: "path", required: true, schema: { type: "string" } },709 {
672 { name: "runner_id", in: "path", required: true, schema: { type: "integer" } },710 name: "org",
711 in: "path",
712 required: true,
713 schema: { type: "string" },
714 },
715 {
716 name: "runner_id",
717 in: "path",
718 required: true,
719 schema: { type: "integer" },
720 },
673 ],721 ],
674 responses: {722 responses: {
675 200: {723 200: {
@@ -690,8 +738,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 14 unmodified lines
690 summary: "Get a repository",738 summary: "Get a repository",
691 description: "Gets a repository by owner and name.",739 description: "Gets a repository by owner and name.",
692 parameters: [740 parameters: [
693 { name: "owner", in: "path", required: true, schema: { type: "string" } },741 {
694 { name: "repo", in: "path", required: true, schema: { type: "string" } },742 name: "owner",
743 in: "path",
744 required: true,
745 schema: { type: "string" },
746 },
747 {
748 name: "repo",
749 in: "path",
750 required: true,
751 schema: { type: "string" },
752 },
695 ],753 ],
696 responses: {754 responses: {
697 200: {755 200: {
@@ -725,6 +783,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 27 unmodified lines
725 },783 },
726 },784 },
727 },785 },
786 components: {
787 schemas: {
788 Repository: {
789 type: "object",
790 properties: {
791 full_name: { type: "string" },
792 private: { type: "boolean" },
793 },
794 required: ["full_name"],
795 },
796 },
797 },
728 });798 });
729 799
730 const source: Source = {800 const source: Source = {
@@ -741,6 +811,8 @@ const persistConnectedGithubOpenApiSource = (input: { · 10 unmodified lines
741 headers: null,811 headers: null,
742 specUrl: "https://example.com/github-openapi.json",812 specUrl: "https://example.com/github-openapi.json",
743 defaultHeaders: null,813 defaultHeaders: null,
814 importAuthPolicy: "reuse_runtime",
815 importAuth: { kind: "none" },
744 auth: input.auth816 auth: input.auth
745 ? {817 ? {
746 kind: "bearer",818 kind: "bearer",
@@ -769,12 +841,15 @@ const persistConnectedGithubOpenApiSource = (input: { · 22 unmodified lines
769 ),841 ),
770 );842 );
771 const definitions = compileOpenApiToolDefinitions(manifest);843 const definitions = compileOpenApiToolDefinitions(manifest);
772 const sourceRecord = yield* input.persistence.rows.sources.getByWorkspaceAndId(844 const sourceRecord =
773 input.workspaceId,845 yield* input.persistence.rows.sources.getByWorkspaceAndId(
774 input.sourceId,846 input.workspaceId,
775 );847 input.sourceId,
848 );
776 if (Option.isNone(sourceRecord)) {849 if (Option.isNone(sourceRecord)) {
777 return yield* Effect.fail(new Error(`Missing stored source ${input.sourceId}`));850 return yield* Effect.fail(
851 new Error(`Missing stored source ${input.sourceId}`),
852 );
778 }853 }
779 854
780 yield* input.persistence.rows.sourceRecipeRevisions.update(855 yield* input.persistence.rows.sourceRecipeRevisions.update(
@@ -787,23 +862,54 @@ const persistConnectedGithubOpenApiSource = (input: { · 6 unmodified lines
787 );862 );
788 yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({863 yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({
789 recipeRevisionId: sourceRecord.value.recipeRevisionId,864 recipeRevisionId: sourceRecord.value.recipeRevisionId,
790 documents: [{865 documents: [
791 id: `src_recipe_doc_${randomUUID()}`,866 {
792 recipeRevisionId: sourceRecord.value.recipeRevisionId,867 id: `src_recipe_doc_${randomUUID()}`,
793 documentKind: "openapi",868 recipeRevisionId: sourceRecord.value.recipeRevisionId,
794 documentKey: source.specUrl ?? source.endpoint,869 documentKind: "openapi",
795 contentText: openApiDocumentText,870 documentKey: source.specUrl ?? source.endpoint,
796 contentHash: manifest.sourceHash,871 contentText: openApiDocumentText,
797 fetchedAt: now,872 contentHash: manifest.sourceHash,
798 createdAt: now,873 fetchedAt: now,
799 updatedAt: now,874 createdAt: now,
800 }],875 updatedAt: now,
876 },
877 ],
878 });
879 const refEntries = Object.entries(manifest.refHintTable ?? {});
880 yield* input.persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({
881 recipeRevisionId: sourceRecord.value.recipeRevisionId,
882 bundles:
883 refEntries.length > 0
884 ? [
885 {
886 id: SourceRecipeSchemaBundleIdSchema.make(
887 `src_recipe_bundle_${randomUUID()}`,
888 ),
889 recipeRevisionId: sourceRecord.value.recipeRevisionId,
890 bundleKind: "json_schema_ref_map",
891 refsJson: JSON.stringify(
892 Object.fromEntries(
893 refEntries.map(([ref, rawValue]) => {
894 try {
895 return [ref, JSON.parse(rawValue) as unknown];
896 } catch {
897 return [ref, rawValue];
898 }
899 }),
900 ),
901 ),
902 contentHash: manifest.sourceHash,
903 createdAt: now,
904 updatedAt: now,
905 },
906 ]
907 : [],
801 });908 });
802 yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({909 yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({
803 recipeRevisionId: sourceRecord.value.recipeRevisionId,910 recipeRevisionId: sourceRecord.value.recipeRevisionId,
804 operations: definitions.map((definition) => {911 operations: definitions.map((definition) => {
805 const presentation = buildOpenApiToolPresentation({912 const presentation = buildOpenApiToolPresentation({
806 manifest,
807 definition,913 definition,
808 });914 });
809 const method = definition.method.toUpperCase();915 const method = definition.method.toUpperCase();
@@ -838,25 +944,19 @@ const persistConnectedGithubOpenApiSource = (input: { · 28 unmodified lines
838 outputSchemaJson: presentation.outputSchemaJson ?? null,944 outputSchemaJson: presentation.outputSchemaJson ?? null,
839 providerKind: "openapi",945 providerKind: "openapi",
840 providerDataJson: presentation.providerDataJson,946 providerDataJson: presentation.providerDataJson,
841 mcpToolName: null,
842 openApiMethod: definition.method,
843 openApiPathTemplate: definition.path,
844 openApiOperationHash: definition.operationHash,
845 openApiRawToolId: definition.rawToolId,
846 openApiOperationId: definition.operationId ?? null,
847 openApiTagsJson: JSON.stringify(definition.tags),
848 openApiRequestBodyRequired: definition.invocation.requestBody?.required ?? null,
849 graphqlOperationType: null,
850 graphqlOperationName: null,
851 createdAt: now,947 createdAt: now,
852 updatedAt: now,948 updatedAt: now,
853 };949 };
854 }),950 }),
855 });951 });
856 yield* input.persistence.rows.sources.update(input.workspaceId, input.sourceId, {952 yield* input.persistence.rows.sources.update(
857 sourceHash: manifest.sourceHash,953 input.workspaceId,
858 updatedAt: now,954 input.sourceId,
859 });955 {
956 sourceHash: manifest.sourceHash,
957 updatedAt: now,
958 },
959 );
860 960
861 return;961 return;
862 });962 });
@@ -866,17 +966,24 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 3 unmodified lines
866 rows: persistence.rows,966 rows: persistence.rows,
867 sourceAuthService: {967 sourceAuthService: {
868 getLocalServerBaseUrl: () => null,968 getLocalServerBaseUrl: () => null,
869 storeSecretMaterial: () => Effect.fail(new Error("not implemented in test")),969 storeSecretMaterial: () =>
970 Effect.fail(new Error("not implemented in test")),
870 getSourceById: () => Effect.fail(new Error("not implemented in test")),971 getSourceById: () => Effect.fail(new Error("not implemented in test")),
871 addExecutorSource: () => Effect.fail(new Error("not implemented in test")),972 addExecutorSource: () =>
973 Effect.fail(new Error("not implemented in test")),
872 connectMcpSource: () => Effect.fail(new Error("not implemented in test")),974 connectMcpSource: () => Effect.fail(new Error("not implemented in test")),
873 startSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")),975 startSourceOAuthSession: () =>
874 completeSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")),976 Effect.fail(new Error("not implemented in test")),
875 completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")),977 completeSourceOAuthSession: () =>
978 Effect.fail(new Error("not implemented in test")),
979 completeSourceCredentialSetup: () =>
980 Effect.fail(new Error("not implemented in test")),
876 } as RuntimeSourceAuthService,981 } as RuntimeSourceAuthService,
877 });982 });
878 983
879const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) =>984const makeResolverWithLiveSourceAuth = (
985 persistence: SqlControlPlanePersistence,
986) =>
880 createWorkspaceExecutionEnvironmentResolver({987 createWorkspaceExecutionEnvironmentResolver({
881 rows: persistence.rows,988 rows: persistence.rows,
882 sourceAuthService: createRuntimeSourceAuthService({989 sourceAuthService: createRuntimeSourceAuthService({
@@ -902,7 +1009,8 @@ describe("workspace-execution-environment", () => { · 19 unmodified lines
902 1009
903 yield* Effect.tryPromise({1010 yield* Effect.tryPromise({
904 try: () => server.close(),1011 try: () => server.close(),
905 catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),1012 catch: (cause) =>
1013 cause instanceof Error ? cause : new Error(String(cause)),
906 }).pipe(Effect.orDie);1014 }).pipe(Effect.orDie);
907 1015
908 const resolveEnvironment = makeResolver(persistence);1016 const resolveEnvironment = makeResolver(persistence);
@@ -931,52 +1039,58 @@ describe("workspace-execution-environment", () => { · 22 unmodified lines
931 }),1039 }),
932 );1040 );
933 1041
934 it.scoped("prefers namespace and path matches over noisy GitHub boilerplate", () =>1042 it.scoped(
935 Effect.gen(function* () {1043 "prefers namespace and path matches over noisy GitHub boilerplate",
936 const persistence = yield* makePersistence;1044 () =>
937 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");1045 Effect.gen(function* () {
938 const sourceId = SourceIdSchema.make("src_github_discovery");1046 const persistence = yield* makePersistence;
939 yield* persistConnectedGithubOpenApiSource({1047 const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");
940 persistence,1048 const sourceId = SourceIdSchema.make("src_github_discovery");
941 workspaceId,1049 yield* persistConnectedGithubOpenApiSource({
942 sourceId,1050 persistence,
943 });1051 workspaceId,
1052 sourceId,
1053 });
944 1054
945 const resolveEnvironment = makeResolver(persistence);1055 const resolveEnvironment = makeResolver(persistence);
946 const environment = yield* resolveEnvironment({1056 const environment = yield* resolveEnvironment({
947 workspaceId,1057 workspaceId,
948 accountId: AccountIdSchema.make("acc_github_discovery"),1058 accountId: AccountIdSchema.make("acc_github_discovery"),
949 executionId: ExecutionIdSchema.make("exec_github_discovery"),1059 executionId: ExecutionIdSchema.make("exec_github_discovery"),
950 });1060 });
951 1061
952 const firstQuery = (yield* environment.toolInvoker.invoke({1062 const firstQuery = (yield* environment.toolInvoker.invoke({
953 path: "discover",1063 path: "discover",
954 args: {1064 args: {
955 query: "current authenticated user profile",1065 query: "current authenticated user profile",
956 limit: 5,1066 limit: 5,
957 },1067 },
958 })) as {1068 })) as {
959 bestPath: string | null;1069 bestPath: string | null;
960 results: Array<{ path: string }>;1070 results: Array<{ path: string }>;
961 };1071 };
962 1072
963 expect(firstQuery.bestPath).toBe("github.users.getAuthenticated");1073 expect(firstQuery.bestPath).toBe("github.users.getAuthenticated");
964 expect(firstQuery.results[0]?.path).toBe("github.users.getAuthenticated");1074 expect(firstQuery.results[0]?.path).toBe(
1075 "github.users.getAuthenticated",
1076 );
965 1077
966 const secondQuery = (yield* environment.toolInvoker.invoke({1078 const secondQuery = (yield* environment.toolInvoker.invoke({
967 path: "discover",1079 path: "discover",
968 args: {1080 args: {
969 query: "users get myself current user profile",1081 query: "users get myself current user profile",
970 limit: 5,1082 limit: 5,
971 },1083 },
972 })) as {1084 })) as {
973 bestPath: string | null;1085 bestPath: string | null;
974 results: Array<{ path: string }>;1086 results: Array<{ path: string }>;
975 };1087 };
976 1088
977 expect(secondQuery.bestPath).toBe("github.users.getAuthenticated");1089 expect(secondQuery.bestPath).toBe("github.users.getAuthenticated");
978 expect(secondQuery.results[0]?.path).toBe("github.users.getAuthenticated");1090 expect(secondQuery.results[0]?.path).toBe(
979 }),1091 "github.users.getAuthenticated",
1092 );
1093 }),
980 );1094 );
981 1095
982 it.scoped("invokes persisted MCP tools on demand without re-listing", () =>1096 it.scoped("invokes persisted MCP tools on demand without re-listing", () =>
@@ -1014,338 +1128,381 @@ describe("workspace-execution-environment", () => { · 31 unmodified lines
1014 }),1128 }),
1015 );1129 );
1016 1130
1017 it.scoped("adds an OpenAPI source through executor.sources.add with elicited credentials", () =>1131 it.scoped(
1018 Effect.gen(function* () {1132 "adds an OpenAPI source through executor.sources.add with elicited credentials",
1019 const specServer = yield* makeOpenApiSpecServer;1133 () =>
1020 const persistence = yield* makePersistence;1134 Effect.gen(function* () {
1021 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");1135 const specServer = yield* makeOpenApiSpecServer;
1022 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);1136 const persistence = yield* makePersistence;
1023 const now = Date.now();1137 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
1024 const tokenSecretMaterialId = SecretMaterialIdSchema.make(1138 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1025 "sec_test_openapi_bearer",1139 const now = Date.now();
1026 );1140 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1027 yield* persistence.rows.secretMaterials.upsert({1141 "sec_test_openapi_bearer",
1028 id: tokenSecretMaterialId,1142 );
1029 name: null,1143 yield* persistence.rows.secretMaterials.upsert({
1030 purpose: "auth_material",1144 id: tokenSecretMaterialId,
1031 value: "ghp_test_token",1145 name: null,
1032 createdAt: now,1146 purpose: "auth_material",
1033 updatedAt: now,1147 value: "ghp_test_token",
1034 });1148 createdAt: now,
1035 let capturedElicitation:1149 updatedAt: now,
1036 | {1150 });
1037 mode?: string;1151 let capturedElicitation: {
1038 url?: string;1152 mode?: string;
1039 elicitationId?: string;1153 url?: string;
1040 }1154 elicitationId?: string;
1041 | null = null;1155 } | null = null;
1042 1156
1043 const environment = yield* resolveEnvironment({1157 const environment = yield* resolveEnvironment({
1044 workspaceId,1158 workspaceId,
1045 accountId: AccountIdSchema.make("acc_add_openapi"),1159 accountId: AccountIdSchema.make("acc_add_openapi"),
1046 executionId: ExecutionIdSchema.make("exec_add_openapi"),1160 executionId: ExecutionIdSchema.make("exec_add_openapi"),
1047 onElicitation: (request) =>1161 onElicitation: (request) =>
1048 Effect.sync(() => {1162 Effect.sync(() => {
1049 capturedElicitation = request.elicitation as {1163 capturedElicitation = request.elicitation as {
1050 mode?: string;1164 mode?: string;
1051 url?: string;1165 url?: string;
1052 elicitationId?: string;1166 elicitationId?: string;
1053 };1167 };
1054 1168
1055 return {1169 return {
1056 action: "accept" as const,1170 action: "accept" as const,
1057 content: {1171 content: {
1058 authKind: "bearer",1172 authKind: "bearer",
1059 tokenRef: {1173 tokenRef: {
1060 providerId: "postgres",1174 providerId: "postgres",
1061 handle: tokenSecretMaterialId,1175 handle: tokenSecretMaterialId,
1176 },
1062 },1177 },
1063 },1178 };
1064 };1179 }),
1065 }),1180 });
1066 });
1067 1181
1068 const added = (yield* environment.toolInvoker.invoke({1182 const added = (yield* environment.toolInvoker.invoke({
1069 path: "executor.sources.add",1183 path: "executor.sources.add",
1070 args: {1184 args: {
1071 kind: "openapi",1185 kind: "openapi",
1072 endpoint: specServer.baseUrl,1186 endpoint: specServer.baseUrl,
1073 specUrl: specServer.specUrl,1187 specUrl: specServer.specUrl,
1074 name: "GitHub",1188 name: "GitHub",
1075 namespace: "github",1189 namespace: "github",
1076 },1190 },
1077 context: {1191 context: {
1078 runId: "exec_add_openapi",1192 runId: "exec_add_openapi",
1079 },1193 },
1080 })) as {1194 })) as {
1081 id: SourceId;1195 id: SourceId;
1082 kind: string;
1083 status: string;
1084 auth: {
1085 kind: string;1196 kind: string;
1086 token?: {1197 status: string;
1087 providerId: string;1198 auth: {
1199 kind: string;
1200 token?: {
1201 providerId: string;
1202 };
1088 };1203 };
1089 };1204 };
1090 };
1091 1205
1092 expect(added.kind).toBe("openapi");1206 expect(added.kind).toBe("openapi");
1093 expect(added.status).toBe("connected");1207 expect(added.status).toBe("connected");
1094 expect(added.auth.kind).toBe("bearer");1208 expect(added.auth.kind).toBe("bearer");
1095 expect(added.auth.token?.providerId).toBe("postgres");1209 expect(added.auth.token?.providerId).toBe("postgres");
1096 expect(capturedElicitation).toMatchObject({1210 expect(capturedElicitation).toMatchObject({
1097 mode: "url",1211 mode: "url",
1098 url: expect.stringContaining("/v1/workspaces/"),1212 url: expect.stringContaining("/v1/workspaces/"),
1099 elicitationId: expect.stringContaining("executor.sources.add:"),1213 elicitationId: expect.stringContaining("executor.sources.add:"),
1100 });1214 });
1101 expect((capturedElicitation as { url?: string } | null)?.url).toContain(1215 expect((capturedElicitation as { url?: string } | null)?.url).toContain(
1102 encodeURIComponent("exec_add_openapi:executor.sources.add:"),1216 encodeURIComponent("exec_add_openapi:executor.sources.add:"),
1103 );
1104
1105 const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId(
1106 workspaceId,
1107 added.id,
1108 );
1109 expect(Option.isSome(storedSource)).toBe(true);
1110 if (Option.isSome(storedSource)) {
1111 const documents = yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(
1112 storedSource.value.recipeRevisionId,
1113 );1217 );
1114 expect(documents[0]?.contentText).toContain('"operationId":"repos.getRepo"');
1115 }
1116 1218
1117 const freshEnvironment = yield* resolveEnvironment({1219 const storedSource =
1118 workspaceId,1220 yield* persistence.rows.sources.getByWorkspaceAndId(
1119 accountId: AccountIdSchema.make("acc_add_openapi"),1221 workspaceId,
1120 executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"),1222 added.id,
1121 });1223 );
1224 expect(Option.isSome(storedSource)).toBe(true);
1225 if (Option.isSome(storedSource)) {
1226 const documents =
1227 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(
1228 storedSource.value.recipeRevisionId,
1229 );
1230 expect(documents[0]?.contentText).toContain(
1231 '"operationId":"repos.getRepo"',
1232 );
1233 }
1122 1234
1123 const discovered = (yield* freshEnvironment.toolInvoker.invoke({1235 const freshEnvironment = yield* resolveEnvironment({
1124 path: "discover",1236 workspaceId,
1125 args: {1237 accountId: AccountIdSchema.make("acc_add_openapi"),
1126 query: "github repo",1238 executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"),
1127 limit: 5,1239 });
1128 },
1129 })) as {
1130 bestPath: string | null;
1131 };
1132 1240
1133 expect(discovered.bestPath).toBe("github.repos.getRepo");1241 const discovered = (yield* freshEnvironment.toolInvoker.invoke({
1242 path: "discover",
1243 args: {
1244 query: "github repo",
1245 limit: 5,
1246 },
1247 })) as {
1248 bestPath: string | null;
1249 };
1134 1250
1135 const invoked = (yield* freshEnvironment.toolInvoker.invoke({1251 expect(discovered.bestPath).toBe("github.repos.getRepo");
1136 path: "github.repos.getRepo",
1137 args: {
1138 owner: "vercel",
1139 repo: "ai",
1140 },
1141 })) as {
1142 ok: boolean;
1143 full_name: string;
1144 };
1145 1252
1146 expect(invoked).toEqual({1253 const invoked = (yield* freshEnvironment.toolInvoker.invoke({
1147 ok: true,1254 path: "github.repos.getRepo",
1148 full_name: "vercel/ai",1255 args: {
1149 });1256 owner: "vercel",
1150 expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]);1257 repo: "ai",
1151 }),1258 },
1259 })) as {
1260 ok: boolean;
1261 full_name: string;
1262 };
1263
1264 expect(invoked).toEqual({
1265 ok: true,
1266 full_name: "vercel/ai",
1267 });
1268 expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]);
1269 }),
1152 );1270 );
1153 1271
1154 it.scoped("adds a GraphQL source through executor.sources.add with elicited credentials", () =>1272 it.scoped(
1155 Effect.gen(function* () {1273 "adds a GraphQL source through executor.sources.add with elicited credentials",
1156 const graphqlServer = yield* makeGraphqlServer;1274 () =>
1157 const persistence = yield* makePersistence;1275 Effect.gen(function* () {
1158 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");1276 const graphqlServer = yield* makeGraphqlServer;
1159 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);1277 const persistence = yield* makePersistence;
1160 const now = Date.now();1278 const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");
1161 const tokenSecretMaterialId = SecretMaterialIdSchema.make(1279 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1162 "sec_test_graphql_bearer",1280 const now = Date.now();
1163 );1281 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1164 yield* persistence.rows.secretMaterials.upsert({1282 "sec_test_graphql_bearer",
1165 id: tokenSecretMaterialId,1283 );
1166 name: null,1284 yield* persistence.rows.secretMaterials.upsert({
1167 purpose: "auth_material",1285 id: tokenSecretMaterialId,
1168 value: "linear_test_token",1286 name: null,
1169 createdAt: now,1287 purpose: "auth_material",
1170 updatedAt: now,1288 value: "linear_test_token",
1171 });1289 createdAt: now,
1290 updatedAt: now,
1291 });
1172 1292
1173 const environment = yield* resolveEnvironment({1293 const environment = yield* resolveEnvironment({
1174 workspaceId,1294 workspaceId,
1175 accountId: AccountIdSchema.make("acc_add_graphql"),1295 accountId: AccountIdSchema.make("acc_add_graphql"),
1176 executionId: ExecutionIdSchema.make("exec_add_graphql"),1296 executionId: ExecutionIdSchema.make("exec_add_graphql"),
1177 onElicitation: () =>1297 onElicitation: () =>
1178 Effect.succeed({1298 Effect.succeed({
1179 action: "accept" as const,1299 action: "accept" as const,
1180 content: {1300 content: {
1181 authKind: "bearer",1301 authKind: "bearer",
1182 tokenRef: {1302 tokenRef: {
1183 providerId: "postgres",1303 providerId: "postgres",
1184 handle: tokenSecretMaterialId,1304 handle: tokenSecretMaterialId,
1305 },
1185 },1306 },
1186 },1307 }),
1187 }),1308 });
1188 });
1189 1309
1190 const added = (yield* environment.toolInvoker.invoke({1310 const added = (yield* environment.toolInvoker.invoke({
1191 path: "executor.sources.add",1311 path: "executor.sources.add",
1192 args: {1312 args: {
1193 kind: "graphql",1313 kind: "graphql",
1194 endpoint: graphqlServer.endpoint,1314 endpoint: graphqlServer.endpoint,
1195 name: "Linear",1315 name: "Linear",
1196 namespace: "linear",1316 namespace: "linear",
1197 },1317 },
1198 context: {1318 context: {
1199 runId: "exec_add_graphql",1319 runId: "exec_add_graphql",
1200 },1320 },
1201 })) as {1321 })) as {
1202 id: SourceId;1322 id: SourceId;
1203 kind: string;
1204 status: string;
1205 auth: {
1206 kind: string;1323 kind: string;
1207 token?: {1324 status: string;
1208 providerId: string;1325 auth: {
1326 kind: string;
1327 token?: {
1328 providerId: string;
1329 };
1209 };1330 };
1210 };1331 };
1211 };
1212 1332
1213 expect(added.kind).toBe("graphql");1333 expect(added.kind).toBe("graphql");
1214 expect(added.status).toBe("connected");1334 expect(added.status).toBe("connected");
1215 expect(added.auth.kind).toBe("bearer");1335 expect(added.auth.kind).toBe("bearer");
1216 expect(added.auth.token?.providerId).toBe("postgres");1336 expect(added.auth.token?.providerId).toBe("postgres");
1217 1337
1218 const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId(1338 const storedSource =
1219 workspaceId,1339 yield* persistence.rows.sources.getByWorkspaceAndId(
1220 added.id,1340 workspaceId,
1221 );1341 added.id,
1222 expect(Option.isSome(storedSource)).toBe(true);1342 );
1223 if (Option.isSome(storedSource)) {1343 expect(Option.isSome(storedSource)).toBe(true);
1224 const documents = yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(1344 if (Option.isSome(storedSource)) {
1225 storedSource.value.recipeRevisionId,1345 const documents =
1346 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(
1347 storedSource.value.recipeRevisionId,
1348 );
1349 expect(documents[0]?.contentText).toContain('"__schema"');
1350 expect(
1351 (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(
1352 storedSource.value.recipeRevisionId,
1353 )).length,
1354 ).toBe(1);
1355 }
1356
1357 const connectedSourceRecord = Option.getOrThrow(storedSource);
1358 yield* persistence.rows.sourceRecipeRevisions.update(
1359 connectedSourceRecord.recipeRevisionId,
1360 {
1361 manifestJson: null,
1362 updatedAt: Date.now(),
1363 },
1226 );1364 );
1227 expect(documents[0]?.contentText).toContain('"__schema"');
1228 }
1229 1365
1230 const freshEnvironment = yield* resolveEnvironment({1366 const freshEnvironment = yield* resolveEnvironment({
1231 workspaceId,1367 workspaceId,
1232 accountId: AccountIdSchema.make("acc_add_graphql"),1368 accountId: AccountIdSchema.make("acc_add_graphql"),
1233 executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"),1369 executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"),
1234 onElicitation: () =>1370 onElicitation: () =>
1235 Effect.succeed({1371 Effect.succeed({
1236 action: "accept" as const,1372 action: "accept" as const,
1237 }),1373 }),
1238 });1374 });
1239 1375
1240 const discovered = (yield* freshEnvironment.toolInvoker.invoke({1376 const discovered = (yield* freshEnvironment.toolInvoker.invoke({
1241 path: "discover",1377 path: "discover",
1242 args: {1378 args: {
1243 query: "linear current user viewer",1379 query: "linear current user viewer",
1244 limit: 5,1380 limit: 5,
1245 },1381 },
1246 })) as {1382 })) as {
1247 bestPath: string | null;1383 bestPath: string | null;
1248 };1384 };
1249 1385
1250 expect(discovered.bestPath).toBe("linear.viewer");1386 expect(discovered.bestPath).toBe("linear.viewer");
1251 1387
1252 const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke({1388 const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke(
1253 path: "describe.tool",1389 {
1254 args: {1390 path: "describe.tool",
1255 path: "linear.createIssue",1391 args: {
1256 includeSchemas: true,1392 path: "linear.createIssue",
1257 },1393 includeSchemas: true,
1258 })) as {1394 },
1259 path: string;1395 },
1260 inputType?: string;1396 )) as {
1261 inputSchemaJson?: string;1397 path: string;
1262 } | null;1398 inputType?: string;
1263 1399 inputSchemaJson?: string;
1264 expect(describeCreateIssue?.path).toBe("linear.createIssue");1400 schemaBundleId?: string;
1265 expect(describeCreateIssue?.inputType).toContain("input");1401 } | null;
1266 expect(describeCreateIssue?.inputType).toContain("title");1402
1267 1403 expect(describeCreateIssue?.path).toBe("linear.createIssue");
1268 const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({1404 expect(describeCreateIssue?.inputType).toContain("input");
1269 path: "linear.viewer",1405 expect(describeCreateIssue?.inputType).toContain("CreateIssueInput");
1270 args: {},1406 expect(describeCreateIssue?.inputSchemaJson).toContain("$ref");
1271 })) as {1407 expect(describeCreateIssue?.schemaBundleId).toBeDefined();
1272 data: {1408
1273 id: string;1409 const graphqlSchemaBundle = (yield* freshEnvironment.toolInvoker.invoke(
1274 name: string;1410 {
1275 __typename: string;1411 path: "describe.schemaBundle",
1412 args: {
1413 id: describeCreateIssue?.schemaBundleId,
1414 },
1415 },
1416 )) as {
1417 kind: string;
1418 refsJson: string;
1419 } | null;
1420
1421 expect(graphqlSchemaBundle?.kind).toBe("json_schema_ref_map");
1422 expect(graphqlSchemaBundle?.refsJson).toContain("CreateIssueInput");
1423 expect(graphqlSchemaBundle?.refsJson).toContain('"title"');
1424
1425 const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({
1426 path: "linear.viewer",
1427 args: {},
1428 })) as {
1429 data: {
1430 id: string;
1431 name: string;
1432 __typename: string;
1433 };
1434 errors: unknown[];
1276 };1435 };
1277 errors: unknown[];
1278 };
1279 1436
1280 expect(viewerResult).toMatchObject({1437 expect(viewerResult).toMatchObject({
1281 data: {1438 data: {
1282 id: "usr_123",1439 id: "usr_123",
1283 name: "Rhys",1440 name: "Rhys",
1284 __typename: "User",1441 __typename: "User",
1285 },1442 },
1286 errors: [],1443 errors: [],
1287 });1444 });
1288 1445
1289 const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({1446 const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({
1290 path: "linear.createIssue",1447 path: "linear.createIssue",
1291 args: {1448 args: {
1292 input: {1449 input: {
1293 title: "Ship GraphQL field tools",1450 title: "Ship GraphQL field tools",
1451 },
1294 },1452 },
1295 },1453 })) as {
1296 })) as {1454 data: {
1297 data: {1455 id: string;
1298 id: string;1456 identifier: string;
1299 identifier: string;1457 title: string;
1300 title: string;1458 __typename: string;
1301 __typename: string;1459 };
1460 errors: unknown[];
1302 };1461 };
1303 errors: unknown[];
1304 };
1305 1462
1306 expect(createIssueResult).toMatchObject({1463 expect(createIssueResult).toMatchObject({
1307 data: {
1308 id: "iss_created",
1309 identifier: "ENG-999",
1310 title: "Ship GraphQL field tools",
1311 __typename: "Issue",
1312 },
1313 errors: [],
1314 });
1315
1316 const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({
1317 path: "linear.request",
1318 args: {
1319 query: "query Viewer { viewer { id name } }",
1320 },
1321 })) as {
1322 status: number;
1323 body: {
1324 data: {1464 data: {
1325 viewer: {1465 id: "iss_created",
1326 id: string;1466 identifier: "ENG-999",
1327 name: string;1467 title: "Ship GraphQL field tools",
1468 __typename: "Issue",
1469 },
1470 errors: [],
1471 });
1472
1473 const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({
1474 path: "linear.request",
1475 args: {
1476 query: "query Viewer { viewer { id name } }",
1477 },
1478 })) as {
1479 status: number;
1480 body: {
1481 data: {
1482 viewer: {
1483 id: string;
1484 name: string;
1485 };
1328 };1486 };
1329 };1487 };
1330 };1488 };
1331 };
1332 1489
1333 expect(rawRequestResult.status).toBe(200);1490 expect(rawRequestResult.status).toBe(200);
1334 expect(rawRequestResult.body).toEqual({1491 expect(rawRequestResult.body).toEqual({
1335 data: {1492 data: {
1336 viewer: {1493 viewer: {
1337 id: "usr_123",1494 id: "usr_123",
1338 name: "Rhys",1495 name: "Rhys",
1496 },
1339 },1497 },
1340 },1498 });
1341 });1499 expect(graphqlServer.seenAuthHeaders).toEqual([
1342 expect(graphqlServer.seenAuthHeaders).toEqual([1500 "Bearer linear_test_token",
1343 "Bearer linear_test_token",1501 "Bearer linear_test_token",
1344 "Bearer linear_test_token",1502 "Bearer linear_test_token",
1345 "Bearer linear_test_token",1503 "Bearer linear_test_token",
1346 "Bearer linear_test_token",1504 ]);
1347 ]);1505 }),
1348 }),
1349 );1506 );
1350 1507
1351 it.scoped("resolves params-backed source auth from invocation context", () =>1508 it.scoped("resolves params-backed source auth from invocation context", () =>
@@ -1397,44 +1554,105 @@ describe("workspace-execution-environment", () => { · 45 unmodified lines
1397 }),1554 }),
1398 );1555 );
1399 1556
1400 it.scoped("describes executor.sources.add with derived type info and schemas", () =>1557 it.scoped(
1401 Effect.gen(function* () {1558 "describes persisted OpenAPI tools with compact schemas and shared bundle ids",
1402 const persistence = yield* makePersistence;1559 () =>
1403 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);1560 Effect.gen(function* () {
1561 const persistence = yield* makePersistence;
1562 const workspaceId = WorkspaceIdSchema.make(
1563 "ws_openapi_describe_bundle",
1564 );
1565 const sourceId = SourceIdSchema.make("src_openapi_describe_bundle");
1404 1566
1405 const environment = yield* resolveEnvironment({1567 yield* persistConnectedGithubOpenApiSource({
1406 workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"),1568 persistence,
1407 accountId: AccountIdSchema.make("acc_describe_add_source"),1569 workspaceId,
1408 executionId: ExecutionIdSchema.make("exec_describe_add_source"),1570 sourceId,
1409 });1571 });
1410 1572
1411 const described = (yield* environment.toolInvoker.invoke({1573 const resolveEnvironment = makeResolver(persistence);
1412 path: "describe.tool",1574 const environment = yield* resolveEnvironment({
1413 args: {1575 workspaceId,
1414 path: "executor.sources.add",1576 accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),
1415 includeSchemas: true,1577 executionId: ExecutionIdSchema.make("exec_openapi_describe_bundle"),
1416 },1578 });
1417 })) as {1579
1418 path: string;1580 const described = (yield* environment.toolInvoker.invoke({
1419 description?: string;1581 path: "describe.tool",
1420 inputType?: string;1582 args: {
1421 inputSchemaJson?: string;1583 path: "github.repos.getRepo",
1422 } | null;1584 includeSchemas: true,
1423 1585 },
1424 expect(described?.path).toBe("executor.sources.add");1586 })) as {
1425 expect(described?.description).toContain("Source add input shapes:");1587 path: string;
1426 expect(described?.description).toContain("specUrl");1588 inputSchemaJson?: string;
1427 expect(described?.description).toContain("credential setup");1589 outputSchemaJson?: string;
1428 expect(described?.inputType).toContain("endpoint");1590 schemaBundleId?: string;
1429 expect(described?.inputType).not.toContain("auth");1591 } | null;
1430 1592
1431 const inputSchema = described?.inputSchemaJson1593 expect(described?.path).toBe("github.repos.getRepo");
1432 ? JSON.parse(described.inputSchemaJson) as Record<string, unknown>1594 expect(described?.inputSchemaJson).toContain('"owner"');
1433 : null;1595 expect(described?.outputSchemaJson).toContain('"type"');
1434 expect(inputSchema).not.toBeNull();1596 expect(described?.schemaBundleId).toBeDefined();
1435 expect(JSON.stringify(inputSchema)).toContain("specUrl");1597
1436 expect(JSON.stringify(inputSchema)).toContain("endpoint");1598 const schemaBundle = (yield* environment.toolInvoker.invoke({
1437 expect(JSON.stringify(inputSchema)).not.toContain("\"auth\"");1599 path: "describe.schemaBundle",
1438 }),1600 args: {
1601 id: described?.schemaBundleId,
1602 },
1603 })) as {
1604 id: string;
1605 kind: string;
1606 refsJson: string;
1607 } | null;
1608
1609 expect(schemaBundle?.id).toBe(described?.schemaBundleId);
1610 expect(schemaBundle?.kind).toBe("json_schema_ref_map");
1611 expect(schemaBundle?.refsJson).toContain("Repository");
1612 }),
1613 );
1614
1615 it.scoped(
1616 "describes executor.sources.add with derived type info and schemas",
1617 () =>
1618 Effect.gen(function* () {
1619 const persistence = yield* makePersistence;
1620 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1621
1622 const environment = yield* resolveEnvironment({
1623 workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"),
1624 accountId: AccountIdSchema.make("acc_describe_add_source"),
1625 executionId: ExecutionIdSchema.make("exec_describe_add_source"),
1626 });
1627
1628 const described = (yield* environment.toolInvoker.invoke({
1629 path: "describe.tool",
1630 args: {
1631 path: "executor.sources.add",
1632 includeSchemas: true,
1633 },
1634 })) as {
1635 path: string;
1636 description?: string;
1637 inputType?: string;
1638 inputSchemaJson?: string;
1639 } | null;
1640
1641 expect(described?.path).toBe("executor.sources.add");
1642 expect(described?.description).toContain("Source add input shapes:");
1643 expect(described?.description).toContain("specUrl");
1644 expect(described?.description).toContain("credential setup");
1645 expect(described?.inputType).toContain("endpoint");
1646 expect(described?.inputType).toContain("importAuth");
1647
1648 const inputSchema = described?.inputSchemaJson
1649 ? (JSON.parse(described.inputSchemaJson) as Record<string, unknown>)
1650 : null;
1651 expect(inputSchema).not.toBeNull();
1652 expect(JSON.stringify(inputSchema)).toContain("specUrl");
1653 expect(JSON.stringify(inputSchema)).toContain("endpoint");
1654 expect(JSON.stringify(inputSchema)).toContain('"auth"');
1655 expect(JSON.stringify(inputSchema)).toContain('"importAuth"');
1656 }),
1439 );1657 );
1440});1658});
packages/control-plane/src/runtime/workspace-execution-environment.ts+249−234
@@ -7,27 +7,19 @@ import { · 6 unmodified lines
7 type ToolInvoker,7 type ToolInvoker,
8 type ToolNamespace,8 type ToolNamespace,
9 type ToolPath,9 type ToolPath,
10 type ToolSchemaBundle,
10} from "@executor/codemode-core";11} from "@executor/codemode-core";
11import {12import {
12 createSdkMcpConnector,13 isDenoAvailable,
13 createMcpToolsFromManifest,14 makeDenoSubprocessExecutor,
14 type McpToolManifest,15} from "@executor/runtime-deno-subprocess";
15} from "@executor/codemode-mcp";
16import {
17 createOpenApiToolsFromManifest,
18 type OpenApiToolManifest,
19} from "@executor/codemode-openapi";
20import { isDenoAvailable,
21 makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
22import { makeSesExecutor } from "@executor/runtime-ses";16import { makeSesExecutor } from "@executor/runtime-ses";
23import {17import {
24 SqlControlPlaneRowsService,18 SqlControlPlaneRowsService,
25 type SqlControlPlaneRows,19 type SqlControlPlaneRows,
26} from "#persistence";20} from "#persistence";
27import type {21import type { AccountId, Source, SourceRecipeSchemaBundleId } from "#schema";
28 AccountId,22import { SourceRecipeSchemaBundleIdSchema } from "#schema";
29 Source,
30} from "#schema";
31import * as Context from "effect/Context";23import * as Context from "effect/Context";
32import * as Either from "effect/Either";24import * as Either from "effect/Either";
33import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
@@ -40,28 +32,23 @@ import type { · 6 unmodified lines
40 ResolveExecutionEnvironment,32 ResolveExecutionEnvironment,
41} from "./execution-state";33} from "./execution-state";
42import { createExecutorToolMap } from "./executor-tools";34import { createExecutorToolMap } from "./executor-tools";
43import {
44 createGraphqlToolsFromManifest,
45 type GraphqlToolManifest,
46} from "./graphql-tools";
47import {35import {
48 RuntimeSourceAuthServiceTag,36 RuntimeSourceAuthServiceTag,
49 type RuntimeSourceAuthService,37 type RuntimeSourceAuthService,
50} from "./source-auth-service";38} from "./source-auth-service";
51import {39import {
52 expandRecipeTools,40 loadWorkspaceSourceRecipeToolByPath,
53 loadWorkspaceSourceRecipes,41 loadWorkspaceSourceRecipeToolIndex,
54 type LoadedSourceRecipeTool,42 type LoadedSourceRecipeToolIndexEntry,
55} from "./source-recipes-runtime";43} from "./source-recipes-runtime";
44import { resolveSourceAuthMaterial } from "./source-auth-material";
45import { namespaceFromSourceName } from "./source-names";
46import { getSourceAdapterForOperation } from "./source-adapters";
56import {47import {
57 createDefaultSecretMaterialResolver,48 createDefaultSecretMaterialResolver,
58 type ResolveSecretMaterial,49 type ResolveSecretMaterial,
59 type SecretMaterialResolveContext,50 type SecretMaterialResolveContext,
60} from "./secret-material-providers";51} from "./secret-material-providers";
61import {
62 namespaceFromSourceName,
63 resolveSourceAuthMaterial,
64} from "./tool-artifacts";
65import {52import {
66 evaluateInvocationPolicy,53 evaluateInvocationPolicy,
67 type InvocationDescriptor,54 type InvocationDescriptor,
@@ -76,7 +63,6 @@ const tokenize = (value: string): string[] => · 8 unmodified lines
76 .split(/[^a-z0-9]+/)63 .split(/[^a-z0-9]+/)
77 .filter(Boolean);64 .filter(Boolean);
78 65
79
80const LOW_SIGNAL_QUERY_TOKENS = new Set([66const LOW_SIGNAL_QUERY_TOKENS = new Set([
81 "a",67 "a",
82 "an",68 "an",
@@ -101,15 +87,15 @@ const LOW_SIGNAL_QUERY_TOKENS = new Set([ · 18 unmodified lines
101]);87]);
102 88
103const singularizeToken = (value: string): string =>89const singularizeToken = (value: string): string =>
104 value.length > 3 && value.endsWith("s")90 value.length > 3 && value.endsWith("s") ? value.slice(0, -1) : value;
105 ? value.slice(0, -1)
106 : value;
107 91
108const tokenEquals = (left: string, right: string): boolean =>92const tokenEquals = (left: string, right: string): boolean =>
109 left === right || singularizeToken(left) === singularizeToken(right);93 left === right || singularizeToken(left) === singularizeToken(right);
110 94
111const hasTokenMatch = (tokens: readonly string[], queryToken: string): boolean =>95const hasTokenMatch = (
112 tokens.some((token) => tokenEquals(token, queryToken));96 tokens: readonly string[],
97 queryToken: string,
98): boolean => tokens.some((token) => tokenEquals(token, queryToken));
113 99
114const hasSubstringMatch = (value: string, queryToken: string): boolean => {100const hasSubstringMatch = (value: string, queryToken: string): boolean => {
115 if (value.includes(queryToken)) {101 if (value.includes(queryToken)) {
@@ -132,7 +118,6 @@ const SecretResolutionContextEnvelopeSchema = Schema.Struct({ · 16 unmodified lines
132const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither(118const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither(
133 SecretResolutionContextEnvelopeSchema,119 SecretResolutionContextEnvelopeSchema,
134);120);
135
136const toSecretResolutionContext = (121const toSecretResolutionContext = (
137 value: unknown,122 value: unknown,
138): SecretMaterialResolveContext | undefined => {123): SecretMaterialResolveContext | undefined => {
@@ -154,37 +139,89 @@ const loadWorkspaceRecipeTools = (input: { · 15 unmodified lines
154 workspaceId: Source["workspaceId"];139 workspaceId: Source["workspaceId"];
155 accountId: AccountId;140 accountId: AccountId;
156 includeSchemas: boolean;141 includeSchemas: boolean;
157}): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> =>142}): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> =>
158 Effect.gen(function* () {143 Effect.map(
159 const recipes = yield* loadWorkspaceSourceRecipes({144 loadWorkspaceSourceRecipeToolIndex({
160 rows: input.rows,145 rows: input.rows,
161 workspaceId: input.workspaceId,146 workspaceId: input.workspaceId,
162 actorAccountId: input.accountId,147 actorAccountId: input.accountId,
163 });
164
165 return expandRecipeTools({
166 recipes: recipes.filter((recipe) =>
167 recipe.source.enabled && recipe.source.status === "connected"
168 ),
169 includeSchemas: input.includeSchemas,148 includeSchemas: input.includeSchemas,
170 });149 }),
150 (tools) =>
151 tools.filter(
152 (tool) => tool.source.enabled && tool.source.status === "connected",
153 ),
154 );
155
156const loadWorkspaceRecipeToolByPath = (input: {
157 rows: SqlControlPlaneRows;
158 workspaceId: Source["workspaceId"];
159 accountId: AccountId;
160 path: string;
161 includeSchemas: boolean;
162}): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> =>
163 loadWorkspaceSourceRecipeToolByPath({
164 rows: input.rows,
165 workspaceId: input.workspaceId,
166 path: input.path,
167 actorAccountId: input.accountId,
168 includeSchemas: input.includeSchemas,
169 }).pipe(
170 Effect.map((tool) =>
171 tool && tool.source.enabled && tool.source.status === "connected"
172 ? tool
173 : null,
174 ),
175 );
176
177const loadWorkspaceSchemaBundle = (input: {
178 rows: SqlControlPlaneRows;
179 workspaceId: Source["workspaceId"];
180 id: SourceRecipeSchemaBundleId;
181}): Effect.Effect<ToolSchemaBundle | null, Error, never> =>
182 Effect.gen(function* () {
183 const bundle = yield* input.rows.sourceRecipeSchemaBundles.getById(
184 input.id,
185 );
186 if (Option.isNone(bundle)) {
187 return null;
188 }
189
190 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(
191 input.workspaceId,
192 );
193 if (
194 !sourceRecords.some(
195 (sourceRecord) =>
196 sourceRecord.recipeRevisionId === bundle.value.recipeRevisionId,
197 )
198 ) {
199 return null;
200 }
201
202 return {
203 id: bundle.value.id,
204 kind: bundle.value.bundleKind,
205 hash: bundle.value.contentHash,
206 refsJson: bundle.value.refsJson,
207 };
171 });208 });
172 209
173const scoreRecipeTool = (210const scoreRecipeTool = (
174 queryTokens: readonly string[],211 queryTokens: readonly string[],
175 tool: LoadedSourceRecipeTool,212 tool: LoadedSourceRecipeToolIndexEntry,
176): number => {213): number => {
177 const pathText = tool.path.toLowerCase();214 const pathText = tool.path.toLowerCase();
178 const namespaceText = tool.searchNamespace.toLowerCase();215 const namespaceText = tool.searchNamespace.toLowerCase();
179 const toolIdText = tool.operation.toolId.toLowerCase();216 const toolIdText = tool.operation.toolId.toLowerCase();
180 const titleText = tool.operation.title?.toLowerCase() ?? "";217 const titleText = tool.operation.title?.toLowerCase() ?? "";
181 const descriptionText = tool.operation.description?.toLowerCase() ?? "";218 const descriptionText = tool.operation.description?.toLowerCase() ?? "";
182 const templateText = tool.operation.openApiPathTemplate?.toLowerCase() ?? "";219 const templateText = tool.metadata.pathTemplate?.toLowerCase() ?? "";
183 220
184 const pathTokens = tokenize(`${tool.path} ${tool.operation.toolId}`);221 const pathTokens = tokenize(`${tool.path} ${tool.operation.toolId}`);
185 const namespaceTokens = tokenize(tool.searchNamespace);222 const namespaceTokens = tokenize(tool.searchNamespace);
186 const titleTokens = tokenize(tool.operation.title ?? "");223 const titleTokens = tokenize(tool.operation.title ?? "");
187 const templateTokens = tokenize(tool.operation.openApiPathTemplate ?? "");224 const templateTokens = tokenize(tool.metadata.pathTemplate ?? "");
188 225
189 let score = 0;226 let score = 0;
190 let structuralHits = 0;227 let structuralHits = 0;
@@ -220,7 +257,10 @@ const scoreRecipeTool = ( · 29 unmodified lines
220 continue;257 continue;
221 }258 }
222 259
223 if (hasSubstringMatch(pathText, token) || hasSubstringMatch(toolIdText, token)) {260 if (
261 hasSubstringMatch(pathText, token) ||
262 hasSubstringMatch(toolIdText, token)
263 ) {
224 score += 6 * weight;264 score += 6 * weight;
225 structuralHits += 1;265 structuralHits += 1;
226 pathHits += 1;266 pathHits += 1;
@@ -234,7 +274,10 @@ const scoreRecipeTool = ( · 7 unmodified lines
234 continue;274 continue;
235 }275 }
236 276
237 if (hasSubstringMatch(titleText, token) || hasSubstringMatch(templateText, token)) {277 if (
278 hasSubstringMatch(titleText, token) ||
279 hasSubstringMatch(templateText, token)
280 ) {
238 score += 4 * weight;281 score += 4 * weight;
239 structuralHits += 1;282 structuralHits += 1;
240 continue;283 continue;
@@ -245,7 +288,9 @@ const scoreRecipeTool = ( · 4 unmodified lines
245 }288 }
246 }289 }
247 290
248 const strongTokens = queryTokens.filter((token) => queryTokenWeight(token) >= 1);291 const strongTokens = queryTokens.filter(
292 (token) => queryTokenWeight(token) >= 1,
293 );
249 if (strongTokens.length >= 2) {294 if (strongTokens.length >= 2) {
250 for (let index = 0; index < strongTokens.length - 1; index += 1) {295 for (let index = 0; index < strongTokens.length - 1; index += 1) {
251 const current = strongTokens[index]!;296 const current = strongTokens[index]!;
@@ -256,7 +301,12 @@ const scoreRecipeTool = ( · 4 unmodified lines
256 `${current}/${next}`,301 `${current}/${next}`,
257 ];302 ];
258 303
259 if (phrases.some((phrase) => pathText.includes(phrase) || templateText.includes(phrase))) {304 if (
305 phrases.some(
306 (phrase) =>
307 pathText.includes(phrase) || templateText.includes(phrase),
308 )
309 ) {
260 score += 10;310 score += 10;
261 }311 }
262 }312 }
@@ -285,39 +335,29 @@ const approvalSchema = { · 22 unmodified lines
285 additionalProperties: false,335 additionalProperties: false,
286} satisfies Record<string, unknown>;336} satisfies Record<string, unknown>;
287 337
288const approvalMessageForInvocation = (descriptor: InvocationDescriptor): string => {338const approvalMessageForInvocation = (
289 if (descriptor.httpMethod && descriptor.httpPathTemplate) {339 descriptor: InvocationDescriptor,
290 return `Allow ${descriptor.httpMethod.toUpperCase()} ${descriptor.httpPathTemplate}?`;340): string => {
291 }341 if (descriptor.approvalLabel) {
292 342 return `Allow ${descriptor.approvalLabel}?`;
293 if (descriptor.graphqlOperationType) {
294 return `Allow GraphQL ${descriptor.graphqlOperationType} ${descriptor.toolPath}?`;
295 }343 }
296 344
297 return `Allow tool call: ${descriptor.toolPath}?`;345 return `Allow tool call: ${descriptor.toolPath}?`;
298};346};
299 347
300const toGraphqlInvocationOperationType = (
301 value: string | null,
302): InvocationDescriptor["graphqlOperationType"] =>
303 value === "query" || value === "mutation" || value === "subscription"
304 ? value
305 : null;
306
307const toInvocationDescriptorFromRecipeTool = (input: {348const toInvocationDescriptorFromRecipeTool = (input: {
308 tool: LoadedSourceRecipeTool;349 tool: LoadedSourceRecipeToolIndexEntry;
309}): InvocationDescriptor => ({350}): InvocationDescriptor => ({
310 toolPath: input.tool.path,351 toolPath: input.tool.path,
311 sourceId: input.tool.source.id,352 sourceId: input.tool.source.id,
312 sourceName: input.tool.source.name,353 sourceName: input.tool.source.name,
313 sourceKind: input.tool.source.kind,354 sourceKind: input.tool.source.kind,
314 sourceNamespace: input.tool.source.namespace ?? namespaceFromSourceName(input.tool.source.name),355 sourceNamespace:
356 input.tool.source.namespace ??
357 namespaceFromSourceName(input.tool.source.name),
315 operationKind: input.tool.operation.operationKind,358 operationKind: input.tool.operation.operationKind,
316 httpMethod: input.tool.operation.openApiMethod?.toUpperCase() ?? null,359 interaction: input.tool.metadata.interaction,
317 httpPathTemplate: input.tool.operation.openApiPathTemplate,360 approvalLabel: input.tool.metadata.approvalLabel,
318 graphqlOperationType: toGraphqlInvocationOperationType(
319 input.tool.operation.graphqlOperationType,
320 ),
321});361});
322 362
323const authorizePersistedToolInvocation = (input: {363const authorizePersistedToolInvocation = (input: {
@@ -328,23 +368,29 @@ const authorizePersistedToolInvocation = (input: { · 4 unmodified lines
328 args: unknown;368 args: unknown;
329 source: Source;369 source: Source;
330 context?: Record<string, unknown>;370 context?: Record<string, unknown>;
331 onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"];371 onElicitation?: Parameters<
372 typeof makeToolInvokerFromTools
373 >[0]["onElicitation"];
332}): Effect.Effect<void, Error, never> =>374}): Effect.Effect<void, Error, never> =>
333 Effect.gen(function* () {375 Effect.gen(function* () {
334 const workspace = yield* input.rows.workspaces.getById(input.workspaceId).pipe(376 const workspace = yield* input.rows.workspaces
335 Effect.mapError((cause) =>377 .getById(input.workspaceId)
336 cause instanceof Error ? cause : new Error(String(cause)),378 .pipe(
337 ),
338 );
339 const policies = Option.isSome(workspace)
340 ? yield* input.rows.policies.listForWorkspaceContext({
341 organizationId: workspace.value.organizationId,
342 workspaceId: input.workspaceId,
343 }).pipe(
344 Effect.mapError((cause) =>379 Effect.mapError((cause) =>
345 cause instanceof Error ? cause : new Error(String(cause)),380 cause instanceof Error ? cause : new Error(String(cause)),
346 ),381 ),
347 )382 );
383 const policies = Option.isSome(workspace)
384 ? yield* input.rows.policies
385 .listForWorkspaceContext({
386 organizationId: workspace.value.organizationId,
387 workspaceId: input.workspaceId,
388 })
389 .pipe(
390 Effect.mapError((cause) =>
391 cause instanceof Error ? cause : new Error(String(cause)),
392 ),
393 )
348 : [];394 : [];
349 395
350 const decision = evaluateInvocationPolicy({396 const decision = evaluateInvocationPolicy({
@@ -358,8 +404,8 @@ const authorizePersistedToolInvocation = (input: { · 7 unmodified lines
358 : ("org_unknown" as never),404 : ("org_unknown" as never),
359 accountId: input.accountId,405 accountId: input.accountId,
360 clientId:406 clientId:
361 typeof input.context?.clientId === "string"407 typeof input.context?.clientId === "string" &&
362 && input.context.clientId.length > 0408 input.context.clientId.length > 0
363 ? input.context.clientId409 ? input.context.clientId
364 : null,410 : null,
365 },411 },
@@ -375,45 +421,52 @@ const authorizePersistedToolInvocation = (input: { · 9 unmodified lines
375 421
376 if (!input.onElicitation) {422 if (!input.onElicitation) {
377 return yield* Effect.fail(423 return yield* Effect.fail(
378 new Error(`Approval required for ${input.descriptor.toolPath}, but no elicitation-capable host is available`),424 new Error(
425 `Approval required for ${input.descriptor.toolPath}, but no elicitation-capable host is available`,
426 ),
379 );427 );
380 }428 }
381 429
382 const interactionId = typeof input.context?.callId === "string" && input.context.callId.length > 0430 const interactionId =
383 ? `tool_execution_gate:${input.context.callId}`431 typeof input.context?.callId === "string" &&
384 : `tool_execution_gate:${crypto.randomUUID()}`;432 input.context.callId.length > 0
385 const response = yield* input.onElicitation({433 ? `tool_execution_gate:${input.context.callId}`
386 interactionId,434 : `tool_execution_gate:${crypto.randomUUID()}`;
387 path: asToolPath(input.descriptor.toolPath),435 const response = yield* input
388 sourceKey: input.source.id,436 .onElicitation({
389 args: input.args,437 interactionId,
390 context: {438 path: asToolPath(input.descriptor.toolPath),
391 ...(input.context ?? {}),439 sourceKey: input.source.id,
392 interactionPurpose: "tool_execution_gate",440 args: input.args,
393 interactionReason: decision.reason,441 context: {
394 invocationDescriptor: {442 ...(input.context ?? {}),
395 operationKind: input.descriptor.operationKind,443 interactionPurpose: "tool_execution_gate",
396 httpMethod: input.descriptor.httpMethod,444 interactionReason: decision.reason,
397 httpPathTemplate: input.descriptor.httpPathTemplate,445 invocationDescriptor: {
398 graphqlOperationType: input.descriptor.graphqlOperationType,446 operationKind: input.descriptor.operationKind,
399 sourceId: input.source.id,447 interaction: input.descriptor.interaction,
400 sourceName: input.source.name,448 approvalLabel: input.descriptor.approvalLabel,
449 sourceId: input.source.id,
450 sourceName: input.source.name,
451 },
401 },452 },
402 },453 elicitation: {
403 elicitation: {454 mode: "form",
404 mode: "form",455 message: approvalMessageForInvocation(input.descriptor),
405 message: approvalMessageForInvocation(input.descriptor),456 requestedSchema: approvalSchema,
406 requestedSchema: approvalSchema,457 },
407 },458 })
408 }).pipe(459 .pipe(
409 Effect.mapError((cause) =>460 Effect.mapError((cause) =>
410 cause instanceof Error ? cause : new Error(String(cause)),461 cause instanceof Error ? cause : new Error(String(cause)),
411 ),462 ),
412 );463 );
413 464
414 if (response.action !== "accept") {465 if (response.action !== "accept") {
415 return yield* Effect.fail(466 return yield* Effect.fail(
416 new Error(`Tool invocation not approved for ${input.descriptor.toolPath}`),467 new Error(
468 `Tool invocation not approved for ${input.descriptor.toolPath}`,
469 ),
417 );470 );
418 }471 }
419 });472 });
@@ -450,7 +503,8 @@ const createWorkspaceToolCatalog = (input: { · 30 unmodified lines
450 namespace: namespace.namespace,503 namespace: namespace.namespace,
451 displayName: namespace.displayName ?? existing?.displayName,504 displayName: namespace.displayName ?? existing?.displayName,
452 toolCount:505 toolCount:
453 namespace.toolCount !== undefined || existing?.toolCount === undefined506 namespace.toolCount !== undefined ||
507 existing?.toolCount === undefined
454 ? namespace.toolCount508 ? namespace.toolCount
455 : existing.toolCount,509 : existing.toolCount,
456 });510 });
@@ -478,7 +532,9 @@ const createWorkspaceToolCatalog = (input: { · 21 unmodified lines
478 if (!query) {532 if (!query) {
479 return true;533 return true;
480 }534 }
481 return tokenize(query).every((token) => tool.searchText.includes(token));535 return tokenize(query).every((token) =>
536 tool.searchText.includes(token),
537 );
482 }),538 }),
483 ),539 ),
484 ),540 ),
@@ -490,10 +546,7 @@ const createWorkspaceToolCatalog = (input: { · 5 unmodified lines
490 }),546 }),
491 ]);547 ]);
492 548
493 return [549 return [...recipeTools.map((tool) => tool.descriptor), ...executor]
494 ...recipeTools.map((tool) => tool.descriptor),
495 ...executor,
496 ]
497 .sort((left, right) => left.path.localeCompare(right.path))550 .sort((left, right) => left.path.localeCompare(right.path))
498 .slice(0, limit);551 .slice(0, limit);
499 }),552 }),
@@ -508,13 +561,21 @@ const createWorkspaceToolCatalog = (input: { · 8 unmodified lines
508 return executor;561 return executor;
509 }562 }
510 563
511 const recipeTools = yield* loadWorkspaceRecipeTools({564 const recipeTool = yield* loadWorkspaceRecipeToolByPath({
512 rows: input.rows,565 rows: input.rows,
513 workspaceId: input.workspaceId,566 workspaceId: input.workspaceId,
514 accountId: input.accountId,567 accountId: input.accountId,
568 path,
515 includeSchemas,569 includeSchemas,
516 });570 });
517 return recipeTools.find((tool) => tool.path === path)?.descriptor ?? null;571 return recipeTool?.descriptor ?? null;
572 }),
573
574 getSchemaBundle: ({ id }) =>
575 loadWorkspaceSchemaBundle({
576 rows: input.rows,
577 workspaceId: input.workspaceId,
578 id: SourceRecipeSchemaBundleIdSchema.make(id),
518 }),579 }),
519 580
520 searchTools: ({ query, namespace, limit }) =>581 searchTools: ({ query, namespace, limit }) =>
@@ -528,7 +589,9 @@ const createWorkspaceToolCatalog = (input: { · 7 unmodified lines
528 includeSchemas: false,589 includeSchemas: false,
529 }).pipe(590 }).pipe(
530 Effect.map((tools) =>591 Effect.map((tools) =>
531 tools.filter((tool) => !namespace || tool.searchNamespace === namespace),592 tools.filter(
593 (tool) => !namespace || tool.searchNamespace === namespace,
594 ),
532 ),595 ),
533 ),596 ),
534 input.executorCatalog.searchTools({597 input.executorCatalog.searchTools({
@@ -546,8 +609,9 @@ const createWorkspaceToolCatalog = (input: { · 11 unmodified lines
546 .filter((hit) => hit.score > 0);609 .filter((hit) => hit.score > 0);
547 610
548 return [...recipeHits, ...executor]611 return [...recipeHits, ...executor]
549 .sort((left, right) =>612 .sort(
550 right.score - left.score || left.path.localeCompare(right.path),613 (left, right) =>
614 right.score - left.score || left.path.localeCompare(right.path),
551 )615 )
552 .slice(0, limit);616 .slice(0, limit);
553 }),617 }),
@@ -559,11 +623,13 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines
559 rows: SqlControlPlaneRows;623 rows: SqlControlPlaneRows;
560 resolveSecretMaterial: ResolveSecretMaterial;624 resolveSecretMaterial: ResolveSecretMaterial;
561 sourceAuthService: RuntimeSourceAuthService;625 sourceAuthService: RuntimeSourceAuthService;
562 onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"];626 onElicitation?: Parameters<
627 typeof makeToolInvokerFromTools
628 >[0]["onElicitation"];
563}): {629}): {
564 catalog: ToolCatalog;630 catalog: ToolCatalog;
565 toolInvoker: ToolInvoker;631 toolInvoker: ToolInvoker;
566 } => {632} => {
567 const executorTools = createExecutorToolMap({633 const executorTools = createExecutorToolMap({
568 workspaceId: input.workspaceId,634 workspaceId: input.workspaceId,
569 accountId: input.accountId,635 accountId: input.accountId,
@@ -596,15 +662,17 @@ const createWorkspaceToolInvoker = (input: { · 26 unmodified lines
596 context?: Record<string, unknown>;662 context?: Record<string, unknown>;
597 }) =>663 }) =>
598 Effect.gen(function* () {664 Effect.gen(function* () {
599 const recipeTools = yield* loadWorkspaceRecipeTools({665 const recipeTool = yield* loadWorkspaceRecipeToolByPath({
600 rows: input.rows,666 rows: input.rows,
601 workspaceId: input.workspaceId,667 workspaceId: input.workspaceId,
602 accountId: input.accountId,668 accountId: input.accountId,
669 path: invocation.path,
603 includeSchemas: false,670 includeSchemas: false,
604 });671 });
605 const recipeTool = recipeTools.find((tool) => tool.path === invocation.path);
606 if (!recipeTool) {672 if (!recipeTool) {
607 return yield* Effect.fail(new Error(`Unknown tool path: ${invocation.path}`));673 return yield* Effect.fail(
674 new Error(`Unknown tool path: ${invocation.path}`),
675 );
608 }676 }
609 677
610 yield* authorizePersistedToolInvocation({678 yield* authorizePersistedToolInvocation({
@@ -623,94 +691,40 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines
623 resolveSecretMaterial: input.resolveSecretMaterial,691 resolveSecretMaterial: input.resolveSecretMaterial,
624 context: toSecretResolutionContext(invocation.context),692 context: toSecretResolutionContext(invocation.context),
625 });693 });
626 694 const schemaBundle = recipeTool.schemaBundleId
627 if (recipeTool.operation.providerKind === "openapi") {695 ? yield* loadWorkspaceSchemaBundle({
628 if (recipeTool.manifest === null) {696 rows: input.rows,
629 return yield* Effect.fail(697 workspaceId: input.workspaceId,
630 new Error(`Missing OpenAPI manifest for ${recipeTool.source.id}`),698 id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),
631 );699 })
632 }700 : null;
633 701 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
634 const tools = createOpenApiToolsFromManifest({702 input.workspaceId,
635 manifest: recipeTool.manifest as OpenApiToolManifest,703 recipeTool.source.id,
636 baseUrl: recipeTool.source.endpoint,704 );
637 namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name),705 let manifestJson: string | null = null;
638 sourceKey: recipeTool.source.id,706 if (Option.isSome(sourceRecord)) {
639 defaultHeaders: recipeTool.source.defaultHeaders ?? {},707 const revision = yield* input.rows.sourceRecipeRevisions.getById(
640 credentialHeaders: auth.headers,708 sourceRecord.value.recipeRevisionId,
641 });709 );
642 710 manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null;
643 return yield* makeToolInvokerFromTools({
644 tools,
645 onElicitation: input.onElicitation,
646 }).invoke({
647 path: invocation.path,
648 args: invocation.args,
649 context: invocation.context,
650 });
651 }711 }
652 712
653 if (recipeTool.operation.providerKind === "graphql") {713 return yield* getSourceAdapterForOperation(recipeTool.operation)
654 if (recipeTool.manifest === null) {714 .invokePersistedTool({
655 return yield* Effect.fail(715 rows: input.rows,
656 new Error(`Missing GraphQL manifest for ${recipeTool.source.id}`),716 workspaceId: input.workspaceId,
657 );717 accountId: input.accountId,
658 }718 source: recipeTool.source,
659
660 const tools = createGraphqlToolsFromManifest({
661 manifest: recipeTool.manifest as GraphqlToolManifest,
662 endpoint: recipeTool.source.endpoint,
663 namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name),
664 sourceKey: recipeTool.source.id,
665 defaultHeaders: recipeTool.source.defaultHeaders ?? {},
666 credentialHeaders: auth.headers,
667 });
668
669 return yield* makeToolInvokerFromTools({
670 tools,
671 onElicitation: input.onElicitation,
672 }).invoke({
673 path: invocation.path,719 path: invocation.path,
720 operation: recipeTool.operation,
721 schemaBundle,
722 manifestJson,
723 auth,
674 args: invocation.args,724 args: invocation.args,
675 context: invocation.context,725 context: invocation.context,
676 });
677 }
678
679 if (recipeTool.operation.providerKind === "mcp") {
680 if (recipeTool.manifest === null) {
681 return yield* Effect.fail(
682 new Error(`Missing MCP manifest for ${recipeTool.source.id}`),
683 );
684 }
685
686 const tools = createMcpToolsFromManifest({
687 manifest: recipeTool.manifest as McpToolManifest,
688 connect: createSdkMcpConnector({
689 endpoint: recipeTool.source.endpoint,
690 transport: recipeTool.source.transport ?? undefined,
691 queryParams: recipeTool.source.queryParams ?? undefined,
692 headers: {
693 ...(recipeTool.source.headers ?? {}),
694 ...auth.headers,
695 },
696 }),
697 namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name),
698 sourceKey: recipeTool.source.id,
699 });
700
701 return yield* makeToolInvokerFromTools({
702 tools,
703 onElicitation: input.onElicitation,726 onElicitation: input.onElicitation,
704 }).invoke({
705 path: invocation.path,
706 args: invocation.args,
707 context: invocation.context,
708 });727 });
709 }
710
711 return yield* Effect.fail(
712 new Error(`Unsupported stored tool provider for ${invocation.path}`),
713 );
714 });728 });
715 729
716 return {730 return {
@@ -732,8 +746,8 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 15 unmodified lines
732 sourceAuthService: RuntimeSourceAuthService;746 sourceAuthService: RuntimeSourceAuthService;
733}): ResolveExecutionEnvironment => {747}): ResolveExecutionEnvironment => {
734 const resolveSecretMaterial =748 const resolveSecretMaterial =
735 input.resolveSecretMaterial749 input.resolveSecretMaterial ??
736 ?? createDefaultSecretMaterialResolver({750 createDefaultSecretMaterialResolver({
737 rows: input.rows,751 rows: input.rows,
738 });752 });
739 753
@@ -765,25 +779,26 @@ export class RuntimeExecutionResolverService extends Context.Tag( · 25 unmodified lines
765)<779)<
766 RuntimeExecutionResolverService,780 RuntimeExecutionResolverService,
767 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>781 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
768>() {782>() {}
769}783
770 784export const RuntimeExecutionResolverLive = (
771export const RuntimeExecutionResolverLive = (input: {785 input: {
772 executionResolver?: ResolveExecutionEnvironment;786 executionResolver?: ResolveExecutionEnvironment;
773 resolveSecretMaterial?: ResolveSecretMaterial;787 resolveSecretMaterial?: ResolveSecretMaterial;
774} = {}) =>788 } = {},
789) =>
775 input.executionResolver790 input.executionResolver
776 ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver)791 ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver)
777 : Layer.effect(792 : Layer.effect(
778 RuntimeExecutionResolverService,793 RuntimeExecutionResolverService,
779 Effect.gen(function* () {794 Effect.gen(function* () {
780 const rows = yield* SqlControlPlaneRowsService;795 const rows = yield* SqlControlPlaneRowsService;
781 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;796 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
782 797
783 return createWorkspaceExecutionEnvironmentResolver({798 return createWorkspaceExecutionEnvironmentResolver({
784 rows,799 rows,
785 sourceAuthService,800 sourceAuthService,
786 resolveSecretMaterial: input.resolveSecretMaterial,801 resolveSecretMaterial: input.resolveSecretMaterial,
787 });802 });
788 }),803 }),
789 );804 );
packages/control-plane/src/schema/ids.ts+4−0
@@ -11,6 +11,9 @@ export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecip · 10 unmodified lines
11export const SourceRecipeRevisionIdSchema = Schema.String.pipe(11export const SourceRecipeRevisionIdSchema = Schema.String.pipe(
12 Schema.brand("SourceRecipeRevisionId"),12 Schema.brand("SourceRecipeRevisionId"),
13);13);
14export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe(
15 Schema.brand("SourceRecipeSchemaBundleId"),
16);
14export const SourceAuthSessionIdSchema = Schema.String.pipe(17export const SourceAuthSessionIdSchema = Schema.String.pipe(
15 Schema.brand("SourceAuthSessionId"),18 Schema.brand("SourceAuthSessionId"),
16);19);
@@ -37,6 +40,7 @@ export type WorkspaceId = typeof WorkspaceIdSchema.Type; · 20 unmodified lines
37export type SourceId = typeof SourceIdSchema.Type;40export type SourceId = typeof SourceIdSchema.Type;
38export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;41export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;
39export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;42export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;
43export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type;
40export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;44export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
41export type CredentialId = typeof CredentialIdSchema.Type;45export type CredentialId = typeof CredentialIdSchema.Type;
42export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;46export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
packages/control-plane/src/schema/index.ts+1−1
@@ -8,7 +8,6 @@ export * from "./models/source"; · 7 unmodified lines
8export * from "./models/source-recipe";8export * from "./models/source-recipe";
9export * from "./models/credential";9export * from "./models/credential";
10export * from "./models/source-oauth-client";10export * from "./models/source-oauth-client";
11export * from "./models/tool-artifact";
12export * from "./models/secret-material";11export * from "./models/secret-material";
13export * from "./models/source-auth-session";12export * from "./models/source-auth-session";
14export * from "./models/policy";13export * from "./models/policy";
@@ -16,3 +15,4 @@ export * from "./models/local-installation"; · 1 unmodified line
16export * from "./models/execution";15export * from "./models/execution";
17export * from "./models/source-inspection";16export * from "./models/source-inspection";
18export * from "./models/source-discovery";17export * from "./models/source-discovery";
18export * from "./models/data-migration";
packages/control-plane/src/schema/models/credential.ts+3−0
@@ -11,12 +11,14 @@ import { · 10 unmodified lines
11} from "../ids";11} from "../ids";
12 12
13export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2");13export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2");
14export const CredentialSlotSchema = Schema.Literal("runtime", "import");
14 15
15const credentialSchemaOverrides = {16const credentialSchemaOverrides = {
16 id: CredentialIdSchema,17 id: CredentialIdSchema,
17 workspaceId: WorkspaceIdSchema,18 workspaceId: WorkspaceIdSchema,
18 sourceId: SourceIdSchema,19 sourceId: SourceIdSchema,
19 actorAccountId: Schema.NullOr(AccountIdSchema),20 actorAccountId: Schema.NullOr(AccountIdSchema),
21 slot: CredentialSlotSchema,
20 authKind: CredentialAuthKindSchema,22 authKind: CredentialAuthKindSchema,
21 authHeaderName: Schema.String,23 authHeaderName: Schema.String,
22 authPrefix: Schema.String,24 authPrefix: Schema.String,
@@ -34,4 +36,5 @@ export const CredentialSchema = createSelectSchema( · 11 unmodified lines
34);36);
35 37
36export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;38export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;
39export type CredentialSlot = typeof CredentialSlotSchema.Type;
37export type Credential = typeof CredentialSchema.Type;40export type Credential = typeof CredentialSchema.Type;
packages/control-plane/src/schema/models/data-migration.ts+14−0
@@ -0,0 +1,14 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";
2
3import { dataMigrationsTable } from "../../persistence/schema";
4import { TimestampMsSchema } from "../common";
5
6export const StoredDataMigrationRecordSchema = createSelectSchema(
7 dataMigrationsTable,
8 {
9 appliedAt: TimestampMsSchema,
10 },
11);
12
13export type StoredDataMigrationRecord =
14 typeof StoredDataMigrationRecordSchema.Type;
packages/control-plane/src/schema/models/source-auth-session.ts+2−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 SourceIdSchema,11 SourceIdSchema,
12 WorkspaceIdSchema,12 WorkspaceIdSchema,
13} from "../ids";13} from "../ids";
14import { CredentialSlotSchema } from "./credential";
14 15
15export const SourceAuthSessionProviderKindSchema = Schema.Literal(16export const SourceAuthSessionProviderKindSchema = Schema.Literal(
16 "mcp_oauth",17 "mcp_oauth",
@@ -78,6 +79,7 @@ const sourceAuthSessionSchemaOverrides = { · 61 unmodified lines
78 workspaceId: WorkspaceIdSchema,79 workspaceId: WorkspaceIdSchema,
79 sourceId: SourceIdSchema,80 sourceId: SourceIdSchema,
80 actorAccountId: Schema.NullOr(AccountIdSchema),81 actorAccountId: Schema.NullOr(AccountIdSchema),
82 credentialSlot: CredentialSlotSchema,
81 executionId: Schema.NullOr(ExecutionIdSchema),83 executionId: Schema.NullOr(ExecutionIdSchema),
82 interactionId: Schema.NullOr(ExecutionInteractionIdSchema),84 interactionId: Schema.NullOr(ExecutionInteractionIdSchema),
83 providerKind: SourceAuthSessionProviderKindSchema,85 providerKind: SourceAuthSessionProviderKindSchema,
packages/control-plane/src/schema/models/source-inspection.ts+19−6
@@ -1,4 +1,3 @@
1import { OpenApiHttpMethodSchema } from "@executor/codemode-openapi";
2import { Schema } from "effect";1import { Schema } from "effect";
3 2
4import { SourceSchema } from "./source";3import { SourceSchema } from "./source";
@@ -21,21 +20,23 @@ export const SourceInspectionToolSummarySchema = Schema.Struct({ · 16 unmodified lines
21 group: Schema.NullOr(Schema.String),20 group: Schema.NullOr(Schema.String),
22 leaf: Schema.NullOr(Schema.String),21 leaf: Schema.NullOr(Schema.String),
23 tags: Schema.Array(Schema.String),22 tags: Schema.Array(Schema.String),
24 method: Schema.NullOr(OpenApiHttpMethodSchema),23 method: Schema.NullOr(Schema.String),
25 pathTemplate: Schema.NullOr(Schema.String),24 pathTemplate: Schema.NullOr(Schema.String),
26 inputType: Schema.optional(Schema.String),25 inputType: Schema.optional(Schema.String),
27 outputType: Schema.optional(Schema.String),26 outputType: Schema.optional(Schema.String),
28});27});
29 28
29export const SourceInspectionToolListItemSchema = Schema.Struct({
30 path: Schema.String,
31 method: Schema.NullOr(Schema.String),
32});
33
30export const SourceInspectionSchema = Schema.Struct({34export const SourceInspectionSchema = Schema.Struct({
31 source: SourceSchema,35 source: SourceSchema,
32 namespace: Schema.String,36 namespace: Schema.String,
33 pipelineKind: SourceInspectionPipelineKindSchema,37 pipelineKind: SourceInspectionPipelineKindSchema,
34 toolCount: Schema.Number,38 toolCount: Schema.Number,
35 rawDocumentText: Schema.NullOr(Schema.String),39 tools: Schema.Array(SourceInspectionToolListItemSchema),
36 manifestJson: Schema.NullOr(Schema.String),
37 definitionsJson: Schema.NullOr(Schema.String),
38 tools: Schema.Array(SourceInspectionToolSummarySchema),
39});40});
40 41
41export const SourceInspectionToolDetailSchema = Schema.Struct({42export const SourceInspectionToolDetailSchema = Schema.Struct({
@@ -45,10 +46,18 @@ export const SourceInspectionToolDetailSchema = Schema.Struct({ · 3 unmodified lines
45 providerDataJson: Schema.NullOr(Schema.String),46 providerDataJson: Schema.NullOr(Schema.String),
46 inputSchemaJson: Schema.NullOr(Schema.String),47 inputSchemaJson: Schema.NullOr(Schema.String),
47 outputSchemaJson: Schema.NullOr(Schema.String),48 outputSchemaJson: Schema.NullOr(Schema.String),
49 schemaBundleId: Schema.NullOr(Schema.String),
48 exampleInputJson: Schema.NullOr(Schema.String),50 exampleInputJson: Schema.NullOr(Schema.String),
49 exampleOutputJson: Schema.NullOr(Schema.String),51 exampleOutputJson: Schema.NullOr(Schema.String),
50});52});
51 53
54export const SourceInspectionSchemaBundleSchema = Schema.Struct({
55 id: Schema.String,
56 kind: Schema.String,
57 hash: Schema.String,
58 refsJson: Schema.String,
59});
60
52export const SourceInspectionDiscoverPayloadSchema = Schema.Struct({61export const SourceInspectionDiscoverPayloadSchema = Schema.Struct({
53 query: Schema.String,62 query: Schema.String,
54 limit: Schema.optional(Schema.Number),63 limit: Schema.optional(Schema.Number),
@@ -75,9 +84,13 @@ export type SourceInspectionPipelineKind = · 20 unmodified lines
75 typeof SourceInspectionPipelineKindSchema.Type;84 typeof SourceInspectionPipelineKindSchema.Type;
76export type SourceInspectionToolSummary =85export type SourceInspectionToolSummary =
77 typeof SourceInspectionToolSummarySchema.Type;86 typeof SourceInspectionToolSummarySchema.Type;
87export type SourceInspectionToolListItem =
88 typeof SourceInspectionToolListItemSchema.Type;
78export type SourceInspection = typeof SourceInspectionSchema.Type;89export type SourceInspection = typeof SourceInspectionSchema.Type;
79export type SourceInspectionToolDetail =90export type SourceInspectionToolDetail =
80 typeof SourceInspectionToolDetailSchema.Type;91 typeof SourceInspectionToolDetailSchema.Type;
92export type SourceInspectionSchemaBundle =
93 typeof SourceInspectionSchemaBundleSchema.Type;
81export type SourceInspectionDiscoverPayload =94export type SourceInspectionDiscoverPayload =
82 typeof SourceInspectionDiscoverPayloadSchema.Type;95 typeof SourceInspectionDiscoverPayloadSchema.Type;
83export type SourceInspectionDiscoverResultItem =96export type SourceInspectionDiscoverResultItem =
packages/control-plane/src/schema/models/source-recipe.ts+29−144
@@ -4,6 +4,7 @@ import { Schema } from "effect"; · 3 unmodified lines
4import {4import {
5 sourceRecipeDocumentsTable,5 sourceRecipeDocumentsTable,
6 sourceRecipeOperationsTable,6 sourceRecipeOperationsTable,
7 sourceRecipeSchemaBundlesTable,
7 sourceRecipeRevisionsTable,8 sourceRecipeRevisionsTable,
8 sourceRecipesTable,9 sourceRecipesTable,
9} from "../../persistence/schema";10} from "../../persistence/schema";
@@ -11,24 +12,16 @@ import { TimestampMsSchema } from "../common"; · 1 unmodified line
11import {12import {
12 SourceRecipeIdSchema,13 SourceRecipeIdSchema,
13 SourceRecipeRevisionIdSchema,14 SourceRecipeRevisionIdSchema,
15 SourceRecipeSchemaBundleIdSchema,
14} from "../ids";16} from "../ids";
15 17
16export const SourceRecipeKindSchema = Schema.Literal(18export const SourceRecipeKindSchema = Schema.Literal(
17 "http_recipe",19 "http_api",
18 "graphql_recipe",20 "mcp",
19 "mcp_recipe",21 "internal",
20 "internal_recipe",
21);22);
22 23
23export const SourceRecipeImporterKindSchema = Schema.Literal(24export const SourceRecipeImporterKindSchema = Schema.String;
24 "openapi",
25 "google_discovery",
26 "postman_collection",
27 "snippet_bundle",
28 "graphql_introspection",
29 "mcp_manifest",
30 "internal_manifest",
31);
32 25
33export const SourceRecipeVisibilitySchema = Schema.Literal(26export const SourceRecipeVisibilitySchema = Schema.Literal(
34 "private",27 "private",
@@ -37,14 +30,9 @@ export const SourceRecipeVisibilitySchema = Schema.Literal( · 2 unmodified lines
37 "public",30 "public",
38);31);
39 32
40export const SourceRecipeDocumentKindSchema = Schema.Literal(33export const SourceRecipeDocumentKindSchema = Schema.String;
41 "google_discovery",34
42 "openapi",35export const SourceRecipeSchemaBundleKindSchema = Schema.String;
43 "postman_collection",
44 "postman_environment",
45 "graphql_introspection",
46 "mcp_manifest",
47);
48 36
49export const SourceRecipeTransportKindSchema = Schema.Literal(37export const SourceRecipeTransportKindSchema = Schema.Literal(
50 "http",38 "http",
@@ -60,29 +48,7 @@ export const SourceRecipeOperationKindSchema = Schema.Literal( · 9 unmodified lines
60 "unknown",48 "unknown",
61);49);
62 50
63export const SourceRecipeOperationProviderKindSchema = Schema.Literal(51export const SourceRecipeOperationProviderKindSchema = Schema.String;
64 "mcp",
65 "openapi",
66 "graphql",
67 "internal",
68);
69
70export const SourceRecipeOpenApiMethodSchema = Schema.Literal(
71 "get",
72 "put",
73 "post",
74 "delete",
75 "patch",
76 "head",
77 "options",
78 "trace",
79);
80
81export const SourceRecipeGraphqlOperationTypeSchema = Schema.Literal(
82 "query",
83 "mutation",
84 "subscription",
85);
86 52
87const recipeRowSchemaOverrides = {53const recipeRowSchemaOverrides = {
88 id: SourceRecipeIdSchema,54 id: SourceRecipeIdSchema,
@@ -98,6 +64,7 @@ const recipeRevisionRowSchemaOverrides = { · 9 unmodified lines
98 id: SourceRecipeRevisionIdSchema,64 id: SourceRecipeRevisionIdSchema,
99 recipeId: SourceRecipeIdSchema,65 recipeId: SourceRecipeIdSchema,
100 revisionNumber: Schema.Number,66 revisionNumber: Schema.Number,
67 materializationHash: Schema.NullOr(Schema.String),
101 createdAt: TimestampMsSchema,68 createdAt: TimestampMsSchema,
102 updatedAt: TimestampMsSchema,69 updatedAt: TimestampMsSchema,
103} as const;70} as const;
@@ -110,13 +77,19 @@ const recipeDocumentRowSchemaOverrides = { · 6 unmodified lines
110 updatedAt: TimestampMsSchema,77 updatedAt: TimestampMsSchema,
111} as const;78} as const;
112 79
80const recipeSchemaBundleRowSchemaOverrides = {
81 id: SourceRecipeSchemaBundleIdSchema,
82 recipeRevisionId: SourceRecipeRevisionIdSchema,
83 bundleKind: SourceRecipeSchemaBundleKindSchema,
84 createdAt: TimestampMsSchema,
85 updatedAt: TimestampMsSchema,
86} as const;
87
113const sourceRecipeOperationRowSchemaOverrides = {88const sourceRecipeOperationRowSchemaOverrides = {
114 recipeRevisionId: SourceRecipeRevisionIdSchema,89 recipeRevisionId: SourceRecipeRevisionIdSchema,
115 transportKind: SourceRecipeTransportKindSchema,90 transportKind: SourceRecipeTransportKindSchema,
116 operationKind: SourceRecipeOperationKindSchema,91 operationKind: SourceRecipeOperationKindSchema,
117 providerKind: SourceRecipeOperationProviderKindSchema,92 providerKind: SourceRecipeOperationProviderKindSchema,
118 openApiMethod: Schema.NullOr(SourceRecipeOpenApiMethodSchema),
119 graphqlOperationType: Schema.NullOr(SourceRecipeGraphqlOperationTypeSchema),
120 createdAt: TimestampMsSchema,93 createdAt: TimestampMsSchema,
121 updatedAt: TimestampMsSchema,94 updatedAt: TimestampMsSchema,
122} as const;95} as const;
@@ -136,97 +109,16 @@ export const StoredSourceRecipeDocumentRecordSchema = createSelectSchema( · 13 unmodified lines
136 recipeDocumentRowSchemaOverrides,109 recipeDocumentRowSchemaOverrides,
137);110);
138 111
112export const StoredSourceRecipeSchemaBundleRecordSchema = createSelectSchema(
113 sourceRecipeSchemaBundlesTable,
114 recipeSchemaBundleRowSchemaOverrides,
115);
116
139export const StoredSourceRecipeOperationRowSchema = createSelectSchema(117export const StoredSourceRecipeOperationRowSchema = createSelectSchema(
140 sourceRecipeOperationsTable,118 sourceRecipeOperationsTable,
141 sourceRecipeOperationRowSchemaOverrides,119 sourceRecipeOperationRowSchemaOverrides,
142);120);
143 121export const StoredSourceRecipeOperationRecordSchema = StoredSourceRecipeOperationRowSchema.annotations({
144const sourceRecipeOperationCommonFields = {
145 id: Schema.String,
146 recipeRevisionId: SourceRecipeRevisionIdSchema,
147 operationKey: Schema.String,
148 toolId: Schema.String,
149 title: Schema.NullOr(Schema.String),
150 description: Schema.NullOr(Schema.String),
151 operationKind: SourceRecipeOperationKindSchema,
152 searchText: Schema.String,
153 inputSchemaJson: Schema.NullOr(Schema.String),
154 outputSchemaJson: Schema.NullOr(Schema.String),
155 providerDataJson: Schema.NullOr(Schema.String),
156 createdAt: TimestampMsSchema,
157 updatedAt: TimestampMsSchema,
158} as const;
159
160export const StoredOpenApiSourceRecipeOperationRecordSchema = Schema.Struct({
161 ...sourceRecipeOperationCommonFields,
162 transportKind: Schema.Literal("http"),
163 providerKind: Schema.Literal("openapi"),
164 mcpToolName: Schema.Null,
165 openApiMethod: SourceRecipeOpenApiMethodSchema,
166 openApiPathTemplate: Schema.String,
167 openApiOperationHash: Schema.String,
168 openApiRawToolId: Schema.String,
169 openApiOperationId: Schema.NullOr(Schema.String),
170 openApiTagsJson: Schema.String,
171 openApiRequestBodyRequired: Schema.NullOr(Schema.Boolean),
172 graphqlOperationType: Schema.Null,
173 graphqlOperationName: Schema.Null,
174});
175
176export const StoredGraphqlSourceRecipeOperationRecordSchema = Schema.Struct({
177 ...sourceRecipeOperationCommonFields,
178 transportKind: Schema.Literal("graphql"),
179 providerKind: Schema.Literal("graphql"),
180 mcpToolName: Schema.Null,
181 openApiMethod: Schema.Null,
182 openApiPathTemplate: Schema.Null,
183 openApiOperationHash: Schema.Null,
184 openApiRawToolId: Schema.Null,
185 openApiOperationId: Schema.Null,
186 openApiTagsJson: Schema.Null,
187 openApiRequestBodyRequired: Schema.Null,
188 graphqlOperationType: Schema.NullOr(SourceRecipeGraphqlOperationTypeSchema),
189 graphqlOperationName: Schema.NullOr(Schema.String),
190});
191
192export const StoredMcpSourceRecipeOperationRecordSchema = Schema.Struct({
193 ...sourceRecipeOperationCommonFields,
194 transportKind: Schema.Literal("mcp"),
195 providerKind: Schema.Literal("mcp"),
196 mcpToolName: Schema.String,
197 openApiMethod: Schema.Null,
198 openApiPathTemplate: Schema.Null,
199 openApiOperationHash: Schema.Null,
200 openApiRawToolId: Schema.Null,
201 openApiOperationId: Schema.Null,
202 openApiTagsJson: Schema.Null,
203 openApiRequestBodyRequired: Schema.Null,
204 graphqlOperationType: Schema.Null,
205 graphqlOperationName: Schema.Null,
206});
207
208export const StoredInternalSourceRecipeOperationRecordSchema = Schema.Struct({
209 ...sourceRecipeOperationCommonFields,
210 transportKind: Schema.Literal("internal"),
211 providerKind: Schema.Literal("internal"),
212 mcpToolName: Schema.Null,
213 openApiMethod: Schema.Null,
214 openApiPathTemplate: Schema.Null,
215 openApiOperationHash: Schema.Null,
216 openApiRawToolId: Schema.Null,
217 openApiOperationId: Schema.Null,
218 openApiTagsJson: Schema.Null,
219 openApiRequestBodyRequired: Schema.Null,
220 graphqlOperationType: Schema.Null,
221 graphqlOperationName: Schema.Null,
222});
223
224export const StoredSourceRecipeOperationRecordSchema = Schema.Union(
225 StoredOpenApiSourceRecipeOperationRecordSchema,
226 StoredGraphqlSourceRecipeOperationRecordSchema,
227 StoredMcpSourceRecipeOperationRecordSchema,
228 StoredInternalSourceRecipeOperationRecordSchema,
229).annotations({
230 identifier: "StoredSourceRecipeOperationRecord",122 identifier: "StoredSourceRecipeOperationRecord",
231});123});
232 124
@@ -234,24 +126,17 @@ export type SourceRecipeKind = typeof SourceRecipeKindSchema.Type; · 1 unmodified line
234export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type;126export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type;
235export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type;127export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type;
236export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type;128export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type;
129export type SourceRecipeSchemaBundleKind =
130 typeof SourceRecipeSchemaBundleKindSchema.Type;
237export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type;131export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type;
238export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type;132export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type;
239export type SourceRecipeOperationProviderKind =133export type SourceRecipeOperationProviderKind =
240 typeof SourceRecipeOperationProviderKindSchema.Type;134 typeof SourceRecipeOperationProviderKindSchema.Type;
241export type SourceRecipeOpenApiMethod = typeof SourceRecipeOpenApiMethodSchema.Type;
242export type SourceRecipeGraphqlOperationType =
243 typeof SourceRecipeGraphqlOperationTypeSchema.Type;
244export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type;135export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type;
245export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type;136export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type;
246export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type;137export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type;
138export type StoredSourceRecipeSchemaBundleRecord =
139 typeof StoredSourceRecipeSchemaBundleRecordSchema.Type;
247export type StoredSourceRecipeOperationRow = typeof StoredSourceRecipeOperationRowSchema.Type;140export type StoredSourceRecipeOperationRow = typeof StoredSourceRecipeOperationRowSchema.Type;
248export type StoredOpenApiSourceRecipeOperationRecord =
249 typeof StoredOpenApiSourceRecipeOperationRecordSchema.Type;
250export type StoredGraphqlSourceRecipeOperationRecord =
251 typeof StoredGraphqlSourceRecipeOperationRecordSchema.Type;
252export type StoredMcpSourceRecipeOperationRecord =
253 typeof StoredMcpSourceRecipeOperationRecordSchema.Type;
254export type StoredInternalSourceRecipeOperationRecord =
255 typeof StoredInternalSourceRecipeOperationRecordSchema.Type;
256export type StoredSourceRecipeOperationRecord =141export type StoredSourceRecipeOperationRecord =
257 typeof StoredSourceRecipeOperationRecordSchema.Type;142 typeof StoredSourceRecipeOperationRecordSchema.Type;
packages/control-plane/src/schema/models/source.ts+15−26
@@ -12,12 +12,7 @@ import { · 11 unmodified lines
12 WorkspaceIdSchema,12 WorkspaceIdSchema,
13} from "../ids";13} from "../ids";
14 14
15export const SourceKindSchema = Schema.Literal(15export const SourceKindSchema = Schema.String;
16 "mcp",
17 "openapi",
18 "graphql",
19 "internal",
20);
21 16
22export const SourceStatusSchema = Schema.Literal(17export const SourceStatusSchema = Schema.Literal(
23 "draft",18 "draft",
@@ -33,6 +28,12 @@ export const SourceTransportSchema = Schema.Literal( · 9 unmodified lines
33 "sse",28 "sse",
34);29);
35 30
31export const SourceImportAuthPolicySchema = Schema.Literal(
32 "none",
33 "reuse_runtime",
34 "separate",
35);
36
36export const SecretRefSchema = Schema.Struct({37export const SecretRefSchema = Schema.Struct({
37 providerId: Schema.String,38 providerId: Schema.String,
38 handle: Schema.String,39 handle: Schema.String,
@@ -69,7 +70,7 @@ const sourceRowSchemaOverrides = { · 30 unmodified lines
69 recipeRevisionId: SourceRecipeRevisionIdSchema,70 recipeRevisionId: SourceRecipeRevisionIdSchema,
70 kind: SourceKindSchema,71 kind: SourceKindSchema,
71 status: SourceStatusSchema,72 status: SourceStatusSchema,
72 transport: Schema.NullOr(SourceTransportSchema),73 importAuthPolicy: SourceImportAuthPolicySchema,
73 createdAt: TimestampMsSchema,74 createdAt: TimestampMsSchema,
74 updatedAt: TimestampMsSchema,75 updatedAt: TimestampMsSchema,
75} as const;76} as const;
@@ -89,14 +90,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 13 unmodified lines
89 status: SourceStatusSchema,90 status: SourceStatusSchema,
90 enabled: Schema.Boolean,91 enabled: Schema.Boolean,
91 namespace: Schema.NullOr(Schema.String),92 namespace: Schema.NullOr(Schema.String),
92 bindingConfigJson: Schema.NullOr(Schema.String),93 importAuthPolicy: SourceImportAuthPolicySchema,
93 transport: Schema.NullOr(SourceTransportSchema),94 bindingConfigJson: Schema.String,
94 queryParamsJson: Schema.NullOr(Schema.String),
95 headersJson: Schema.NullOr(Schema.String),
96 specUrl: Schema.NullOr(Schema.String),
97 defaultHeadersJson: Schema.NullOr(Schema.String),
98 sourceHash: Schema.NullOr(Schema.String),95 sourceHash: Schema.NullOr(Schema.String),
99 sourceDocumentText: Schema.NullOr(Schema.String),
100 lastError: Schema.NullOr(Schema.String),96 lastError: Schema.NullOr(Schema.String),
101 createdAt: TimestampMsSchema,97 createdAt: TimestampMsSchema,
102 updatedAt: TimestampMsSchema,98 updatedAt: TimestampMsSchema,
@@ -114,14 +110,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 11 unmodified lines
114 status: row.status,110 status: row.status,
115 enabled: row.enabled,111 enabled: row.enabled,
116 namespace: row.namespace,112 namespace: row.namespace,
113 importAuthPolicy: row.importAuthPolicy,
117 bindingConfigJson: row.bindingConfigJson,114 bindingConfigJson: row.bindingConfigJson,
118 transport: row.transport,
119 queryParamsJson: row.queryParamsJson,
120 headersJson: row.headersJson,
121 specUrl: row.specUrl,
122 defaultHeadersJson: row.defaultHeadersJson,
123 sourceHash: row.sourceHash,115 sourceHash: row.sourceHash,
124 sourceDocumentText: row.sourceDocumentText,
125 lastError: row.lastError,116 lastError: row.lastError,
126 createdAt: row.createdAt,117 createdAt: row.createdAt,
127 updatedAt: row.updatedAt,118 updatedAt: row.updatedAt,
@@ -137,14 +128,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 9 unmodified lines
137 status: source.status,128 status: source.status,
138 enabled: source.enabled,129 enabled: source.enabled,
139 namespace: source.namespace,130 namespace: source.namespace,
131 importAuthPolicy: source.importAuthPolicy,
140 bindingConfigJson: source.bindingConfigJson,132 bindingConfigJson: source.bindingConfigJson,
141 transport: source.transport,
142 queryParamsJson: source.queryParamsJson,
143 headersJson: source.headersJson,
144 specUrl: source.specUrl,
145 defaultHeadersJson: source.defaultHeadersJson,
146 sourceHash: source.sourceHash,133 sourceHash: source.sourceHash,
147 sourceDocumentText: source.sourceDocumentText,
148 lastError: source.lastError,134 lastError: source.lastError,
149 createdAt: source.createdAt,135 createdAt: source.createdAt,
150 updatedAt: source.updatedAt,136 updatedAt: source.updatedAt,
@@ -166,6 +152,8 @@ export const SourceSchema = Schema.Struct({ · 15 unmodified lines
166 headers: Schema.NullOr(StringMapSchema),152 headers: Schema.NullOr(StringMapSchema),
167 specUrl: Schema.NullOr(Schema.String),153 specUrl: Schema.NullOr(Schema.String),
168 defaultHeaders: Schema.NullOr(StringMapSchema),154 defaultHeaders: Schema.NullOr(StringMapSchema),
155 importAuthPolicy: SourceImportAuthPolicySchema,
156 importAuth: SourceAuthSchema,
169 auth: SourceAuthSchema,157 auth: SourceAuthSchema,
170 sourceHash: Schema.NullOr(Schema.String),158 sourceHash: Schema.NullOr(Schema.String),
171 lastError: Schema.NullOr(Schema.String),159 lastError: Schema.NullOr(Schema.String),
@@ -176,6 +164,7 @@ export const SourceSchema = Schema.Struct({ · 4 unmodified lines
176export type SourceKind = typeof SourceKindSchema.Type;164export type SourceKind = typeof SourceKindSchema.Type;
177export type SourceStatus = typeof SourceStatusSchema.Type;165export type SourceStatus = typeof SourceStatusSchema.Type;
178export type SourceTransport = typeof SourceTransportSchema.Type;166export type SourceTransport = typeof SourceTransportSchema.Type;
167export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;
179export type SecretRef = typeof SecretRefSchema.Type;168export type SecretRef = typeof SecretRefSchema.Type;
180export type SourceAuth = typeof SourceAuthSchema.Type;169export type SourceAuth = typeof SourceAuthSchema.Type;
181export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;170export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;
packages/control-plane/src/schema/models/tool-artifact.ts+0−168
@@ -1,168 +0,0 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";
2import { Schema } from "effect";
3
4import {
5 toolArtifactParametersTable,
6 toolArtifactRefHintKeysTable,
7 toolArtifactRequestBodyContentTypesTable,
8 toolArtifactsTable,
9} from "../../persistence/schema";
10import { TimestampMsSchema } from "../common";
11import { SourceIdSchema, WorkspaceIdSchema } from "../ids";
12
13export const ToolArtifactProviderKindSchema = Schema.Literal(
14 "mcp",
15 "openapi",
16);
17
18export const ToolArtifactParameterLocationSchema = Schema.Literal(
19 "path",
20 "query",
21 "header",
22 "cookie",
23);
24
25const toolArtifactRowSchemaOverrides = {
26 workspaceId: WorkspaceIdSchema,
27 sourceId: SourceIdSchema,
28 providerKind: ToolArtifactProviderKindSchema,
29 openApiMethod: Schema.NullOr(
30 Schema.Literal("get", "put", "post", "delete", "patch", "head", "options", "trace"),
31 ),
32 createdAt: TimestampMsSchema,
33 updatedAt: TimestampMsSchema,
34} as const;
35
36const ToolArtifactStorageRowSchema = createSelectSchema(
37 toolArtifactsTable,
38 toolArtifactRowSchemaOverrides,
39);
40
41export const StoredToolArtifactRecordSchema = Schema.Struct({
42 workspaceId: WorkspaceIdSchema,
43 path: Schema.String,
44 toolId: Schema.String,
45 sourceId: SourceIdSchema,
46 title: Schema.NullOr(Schema.String),
47 description: Schema.NullOr(Schema.String),
48 searchNamespace: Schema.String,
49 searchText: Schema.String,
50 inputSchemaJson: Schema.NullOr(Schema.String),
51 outputSchemaJson: Schema.NullOr(Schema.String),
52 providerKind: ToolArtifactProviderKindSchema,
53 mcpToolName: Schema.NullOr(Schema.String),
54 openApiMethod: Schema.NullOr(
55 Schema.Literal("get", "put", "post", "delete", "patch", "head", "options", "trace"),
56 ),
57 openApiPathTemplate: Schema.NullOr(Schema.String),
58 openApiOperationHash: Schema.NullOr(Schema.String),
59 openApiRawToolId: Schema.NullOr(Schema.String),
60 openApiOperationId: Schema.NullOr(Schema.String),
61 openApiTagsJson: Schema.NullOr(Schema.String),
62 openApiRequestBodyRequired: Schema.NullOr(Schema.Boolean),
63 createdAt: TimestampMsSchema,
64 updatedAt: TimestampMsSchema,
65}).annotations({
66 identifier: "StoredToolArtifactRecord",
67});
68
69export const ToolArtifactStorageSchema = Schema.transform(
70 ToolArtifactStorageRowSchema,
71 StoredToolArtifactRecordSchema,
72 {
73 strict: false,
74 decode: (row) => ({
75 workspaceId: row.workspaceId,
76 path: row.path,
77 toolId: row.toolId,
78 sourceId: row.sourceId,
79 title: row.title,
80 description: row.description,
81 searchNamespace: row.searchNamespace,
82 searchText: row.searchText,
83 inputSchemaJson: row.inputSchemaJson,
84 outputSchemaJson: row.outputSchemaJson,
85 providerKind: row.providerKind,
86 mcpToolName: row.mcpToolName,
87 openApiMethod: row.openApiMethod,
88 openApiPathTemplate: row.openApiPathTemplate,
89 openApiOperationHash: row.openApiOperationHash,
90 openApiRawToolId: row.openApiRawToolId,
91 openApiOperationId: row.openApiOperationId,
92 openApiTagsJson: row.openApiTagsJson,
93 openApiRequestBodyRequired: row.openApiRequestBodyRequired,
94 createdAt: row.createdAt,
95 updatedAt: row.updatedAt,
96 }),
97 encode: (record) => ({
98 workspaceId: record.workspaceId,
99 path: record.path,
100 toolId: record.toolId,
101 sourceId: record.sourceId,
102 title: record.title,
103 description: record.description,
104 searchNamespace: record.searchNamespace,
105 searchText: record.searchText,
106 inputSchemaJson: record.inputSchemaJson,
107 outputSchemaJson: record.outputSchemaJson,
108 providerKind: record.providerKind,
109 mcpToolName: record.mcpToolName,
110 openApiMethod: record.openApiMethod,
111 openApiPathTemplate: record.openApiPathTemplate,
112 openApiOperationHash: record.openApiOperationHash,
113 openApiRawToolId: record.openApiRawToolId,
114 openApiOperationId: record.openApiOperationId,
115 openApiTagsJson: record.openApiTagsJson,
116 openApiRequestBodyRequired: record.openApiRequestBodyRequired,
117 createdAt: record.createdAt,
118 updatedAt: record.updatedAt,
119 }),
120 },
121);
122
123const toolArtifactParameterRowSchemaOverrides = {
124 workspaceId: WorkspaceIdSchema,
125 location: ToolArtifactParameterLocationSchema,
126 position: Schema.Number,
127} as const;
128
129export const StoredToolArtifactParameterRecordSchema = createSelectSchema(
130 toolArtifactParametersTable,
131 toolArtifactParameterRowSchemaOverrides,
132).annotations({
133 identifier: "StoredToolArtifactParameterRecord",
134});
135
136const toolArtifactRequestBodyContentTypeRowSchemaOverrides = {
137 workspaceId: WorkspaceIdSchema,
138 position: Schema.Number,
139} as const;
140
141export const StoredToolArtifactRequestBodyContentTypeRecordSchema = createSelectSchema(
142 toolArtifactRequestBodyContentTypesTable,
143 toolArtifactRequestBodyContentTypeRowSchemaOverrides,
144).annotations({
145 identifier: "StoredToolArtifactRequestBodyContentTypeRecord",
146});
147
148const toolArtifactRefHintKeyRowSchemaOverrides = {
149 workspaceId: WorkspaceIdSchema,
150 position: Schema.Number,
151} as const;
152
153export const StoredToolArtifactRefHintKeyRecordSchema = createSelectSchema(
154 toolArtifactRefHintKeysTable,
155 toolArtifactRefHintKeyRowSchemaOverrides,
156).annotations({
157 identifier: "StoredToolArtifactRefHintKeyRecord",
158});
159
160export type ToolArtifactProviderKind = typeof ToolArtifactProviderKindSchema.Type;
161export type ToolArtifactParameterLocation = typeof ToolArtifactParameterLocationSchema.Type;
162export type StoredToolArtifactRecord = typeof StoredToolArtifactRecordSchema.Type;
163export type StoredToolArtifactParameterRecord =
164 typeof StoredToolArtifactParameterRecordSchema.Type;
165export type StoredToolArtifactRequestBodyContentTypeRecord =
166 typeof StoredToolArtifactRequestBodyContentTypeRecordSchema.Type;
167export type StoredToolArtifactRefHintKeyRecord =
168 typeof StoredToolArtifactRefHintKeyRecordSchema.Type;
packages/control-plane/src/schema/schema.test.ts+12−28
@@ -16,10 +16,10 @@ describe("control-plane-schema", () => { · 15 unmodified lines
16 expect(RolePermissions.owner).toContain("policies:manage");16 expect(RolePermissions.owner).toContain("policies:manage");
17 });17 });
18 18
19 it("enforces provider-specific source recipe operation shapes", () => {19 it("accepts canonical source recipe operation rows and rejects invalid transport kinds", () => {
20 const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema);20 const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema);
21 21
22 const openApiRecord = decode({22 const operationRecord = decode({
23 id: "src_recipe_op_1",23 id: "src_recipe_op_1",
24 recipeRevisionId: "src_recipe_rev_1",24 recipeRevisionId: "src_recipe_rev_1",
25 operationKey: "getRepo",25 operationKey: "getRepo",
@@ -32,28 +32,20 @@ describe("control-plane-schema", () => { · 6 unmodified lines
32 inputSchemaJson: null,32 inputSchemaJson: null,
33 outputSchemaJson: null,33 outputSchemaJson: null,
34 providerKind: "openapi",34 providerKind: "openapi",
35 providerDataJson: null,35 providerDataJson: JSON.stringify({
36 mcpToolName: null,36 kind: "openapi",
37 openApiMethod: "get",37 }),
38 openApiPathTemplate: "/repos/{owner}/{repo}",
39 openApiOperationHash: "hash",
40 openApiRawToolId: "repos_getRepo",
41 openApiOperationId: "repos.getRepo",
42 openApiTagsJson: "[]",
43 openApiRequestBodyRequired: null,
44 graphqlOperationType: null,
45 graphqlOperationName: null,
46 createdAt: 1,38 createdAt: 1,
47 updatedAt: 1,39 updatedAt: 1,
48 });40 });
49 41
50 expect(Either.isRight(openApiRecord)).toBe(true);42 expect(Either.isRight(operationRecord)).toBe(true);
51 43
52 const invalidGraphqlRecord = decode({44 const invalidRecord = decode({
53 id: "src_recipe_op_2",45 id: "src_recipe_op_2",
54 recipeRevisionId: "src_recipe_rev_1",46 recipeRevisionId: "src_recipe_rev_1",
55 operationKey: "viewer",47 operationKey: "viewer",
56 transportKind: "graphql",48 transportKind: "ftp",
57 toolId: "viewer",49 toolId: "viewer",
58 title: "Viewer",50 title: "Viewer",
59 description: null,51 description: null,
@@ -62,21 +54,13 @@ describe("control-plane-schema", () => { · 2 unmodified lines
62 inputSchemaJson: null,54 inputSchemaJson: null,
63 outputSchemaJson: null,55 outputSchemaJson: null,
64 providerKind: "graphql",56 providerKind: "graphql",
65 providerDataJson: null,57 providerDataJson: JSON.stringify({
66 mcpToolName: null,58 kind: "graphql",
67 openApiMethod: "get",59 }),
68 openApiPathTemplate: null,
69 openApiOperationHash: null,
70 openApiRawToolId: null,
71 openApiOperationId: null,
72 openApiTagsJson: null,
73 openApiRequestBodyRequired: null,
74 graphqlOperationType: "query",
75 graphqlOperationName: "viewer",
76 createdAt: 1,60 createdAt: 1,
77 updatedAt: 1,61 updatedAt: 1,
78 });62 });
79 63
80 expect(Either.isLeft(invalidGraphqlRecord)).toBe(true);64 expect(Either.isLeft(invalidRecord)).toBe(true);
81 });65 });
82});66});
packages/kitchen-sink/src/source-runtime.test.ts+35−13
@@ -16,7 +16,6 @@ import { · 15 unmodified lines
16 type SecretRef,16 type SecretRef,
17 type Source,17 type Source,
18 type SourceId,18 type SourceId,
19 type StoredToolArtifactRecord,
20 type WorkspaceId,19 type WorkspaceId,
21} from "@executor/control-plane";20} from "@executor/control-plane";
22import {21import {
@@ -59,6 +58,29 @@ const catalogNamespaceFromPath = (path: string): string => { · 36 unmodified lines
59 return second ? `${first}.${second}` : first;58 return second ? `${first}.${second}` : first;
60};59};
61 60
61type StoredToolArtifactRecord = {
62 workspaceId: WorkspaceId;
63 path: string;
64 toolId: string;
65 sourceId: SourceId;
66 title: string | null;
67 description: string | null;
68 searchNamespace: string;
69 searchText: string;
70 inputSchemaJson: string | null;
71 outputSchemaJson: string | null;
72 providerKind: "openapi";
73 openApiMethod: "get" | "put" | "post" | "delete" | "patch" | "head" | "options" | "trace";
74 openApiPathTemplate: string | null;
75 openApiOperationHash: string | null;
76 openApiRawToolId: string | null;
77 openApiOperationId: string | null;
78 openApiTagsJson: string | null;
79 openApiRequestBodyRequired: boolean | null;
80 createdAt: number;
81 updatedAt: number;
82};
83
62const toDescriptor = (84const toDescriptor = (
63 artifact: StoredToolArtifactRecord,85 artifact: StoredToolArtifactRecord,
64 includeSchemas: boolean,86 includeSchemas: boolean,
@@ -282,15 +304,11 @@ const resolveSourceCallContext = (input: { · 217 unmodified lines
282 304
283const createProviderInvoker = (): ProviderInvoker => ({305const createProviderInvoker = (): ProviderInvoker => ({
284 async invoke({ source, artifact, args, runtime, context }) {306 async invoke({ source, artifact, args, runtime, context }) {
285 const invocation = artifact.providerKind === "mcp"307 const invocation = {
286 ? {308 method: artifact.openApiMethod,
287 toolName: artifact.mcpToolName ?? artifact.title ?? artifact.toolId,309 pathTemplate: artifact.openApiPathTemplate,
288 }310 operationHash: artifact.openApiOperationHash,
289 : {311 };
290 method: artifact.openApiMethod,
291 pathTemplate: artifact.openApiPathTemplate,
292 operationHash: artifact.openApiOperationHash,
293 };
294 312
295 return {313 return {
296 sourceId: source.id,314 sourceId: source.id,
@@ -337,6 +355,7 @@ const createWorkspaceToolCatalog = (input: { · 40 unmodified lines
337 path,355 path,
338 }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null))356 }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null))
339 ),357 ),
358 getSchemaBundle: () => Effect.succeed(null),
340 searchTools: ({ query, namespace, limit }) =>359 searchTools: ({ query, namespace, limit }) =>
341 Effect.promise(() =>360 Effect.promise(() =>
342 input.toolStore.list({361 input.toolStore.list({
@@ -480,6 +499,8 @@ const openApiSource = (input: { · 137 unmodified lines
480 headers: null,499 headers: null,
481 specUrl: input.specUrl ?? null,500 specUrl: input.specUrl ?? null,
482 defaultHeaders: null,501 defaultHeaders: null,
502 importAuthPolicy: "reuse_runtime",
503 importAuth: { kind: "none" },
483 auth: input.auth,504 auth: input.auth,
484 sourceHash: null,505 sourceHash: null,
485 lastError: null,506 lastError: null,
@@ -523,7 +544,6 @@ const openApiArtifact = (input: { · 37 unmodified lines
523 inputSchemaJson: input.inputSchemaJson ?? null,544 inputSchemaJson: input.inputSchemaJson ?? null,
524 outputSchemaJson: input.outputSchemaJson ?? null,545 outputSchemaJson: input.outputSchemaJson ?? null,
525 providerKind: "openapi",546 providerKind: "openapi",
526 mcpToolName: null,
527 openApiMethod: input.method,547 openApiMethod: input.method,
528 openApiPathTemplate: input.pathTemplate,548 openApiPathTemplate: input.pathTemplate,
529 openApiOperationHash: input.operationHash ?? input.toolId,549 openApiOperationHash: input.operationHash ?? input.toolId,
@@ -759,7 +779,8 @@ describe("source runtime", () => { · 229 unmodified lines
759 "Workflow:",779 "Workflow:",
760 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',780 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
761 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",781 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
762 "3) Call selected tools.<path>(input).",782 "3) If details.schemaBundleId is present, fetch it once with tools.describe.schemaBundle({ id }).",
783 "4) Call selected tools.<path>(input).",
763 "Do not use fetch; use tools.* only.",784 "Do not use fetch; use tools.* only.",
764 ].join("\n"),785 ].join("\n"),
765 );786 );
@@ -828,7 +849,8 @@ describe("source runtime", () => { · 62 unmodified lines
828 "Workflow:",849 "Workflow:",
829 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',850 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
830 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",851 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
831 "3) Call selected tools.<path>(input).",852 "3) If details.schemaBundleId is present, fetch it once with tools.describe.schemaBundle({ id }).",
853 "4) Call selected tools.<path>(input).",
832 "Do not use fetch; use tools.* only.",854 "Do not use fetch; use tools.* only.",
833 ].join("\n"),855 ].join("\n"),
834 );856 );
packages/react/src/index.test.tsx+35−0
@@ -702,4 +702,39 @@ describe("executor-react source hooks", () => { · 701 unmodified lines
702 }702 }
703 }),703 }),
704 );704 );
705
706 it.effect("surfaces missing sources as errors instead of staying loading", () =>
707 Effect.promise(async () => {
708 const apiServer = await startControlPlaneServer();
709 setExecutorApiBaseUrl(apiServer.baseUrl);
710
711 try {
712 const harness = await renderSourceHarness("src_missing");
713
714 try {
715 const missing = await waitForValue(
716 () => harness.current,
717 (value) =>
718 isReady(value.sources)
719 && value.sources.data.length === 0
720 && value.source.status === "error"
721 && value.inspection.status === "error",
722 );
723
724 expect(missing.source.status).toBe("error");
725 expect(missing.inspection.status).toBe("error");
726 if (missing.source.status === "error") {
727 expect(missing.source.error.message).toContain("Source not found");
728 }
729 if (missing.inspection.status === "error") {
730 expect(missing.inspection.error.message).toContain("Source not found");
731 }
732 } finally {
733 await harness.unmount();
734 }
735 } finally {
736 await apiServer.close();
737 }
738 }),
739 );
705});740});
packages/react/src/index.ts+70−0
@@ -19,6 +19,7 @@ import type { · 18 unmodified lines
19 SourceDiscoveryResult,19 SourceDiscoveryResult,
20 SourceInspection,20 SourceInspection,
21 SourceInspectionDiscoverResult,21 SourceInspectionDiscoverResult,
22 SourceInspectionSchemaBundle,
22 SourceInspectionToolDetail,23 SourceInspectionToolDetail,
23 StartSourceOAuthPayload,24 StartSourceOAuthPayload,
24 StartSourceOAuthResult,25 StartSourceOAuthResult,
@@ -58,6 +59,13 @@ type SourceToolDetailKeyParts = readonly [ · 33 unmodified lines
58 Source["id"],59 Source["id"],
59 string | null,60 string | null,
60];61];
62type SourceSchemaBundleKeyParts = readonly [
63 boolean,
64 Source["workspaceId"],
65 string,
66 Source["id"],
67 string | null,
68];
61type SourceDiscoveryKeyParts = readonly [69type SourceDiscoveryKeyParts = readonly [
62 boolean,70 boolean,
63 Source["workspaceId"],71 Source["workspaceId"],
@@ -78,6 +86,7 @@ type ActiveQueryCollections = { · 14 unmodified lines
78 sources: Set<string>;86 sources: Set<string>;
79 inspections: Set<string>;87 inspections: Set<string>;
80 toolDetails: Set<string>;88 toolDetails: Set<string>;
89 schemaBundles: Set<string>;
81 discoveries: Set<string>;90 discoveries: Set<string>;
82};91};
83 92
@@ -153,6 +162,17 @@ const encodeToolDetailKey = ( · 69 unmodified lines
153): string =>162): string =>
154 encodeAtomKey([enabled, workspaceId, accountId, sourceId, toolPath] satisfies SourceToolDetailKeyParts);163 encodeAtomKey([enabled, workspaceId, accountId, sourceId, toolPath] satisfies SourceToolDetailKeyParts);
155 164
165const encodeSchemaBundleKey = (
166 enabled: boolean,
167 workspaceId: Source["workspaceId"],
168 accountId: string,
169 sourceId: Source["id"],
170 schemaBundleId: string | null,
171): string =>
172 encodeAtomKey(
173 [enabled, workspaceId, accountId, sourceId, schemaBundleId] satisfies SourceSchemaBundleKeyParts,
174 );
175
156const encodeDiscoveryKey = (176const encodeDiscoveryKey = (
157 enabled: boolean,177 enabled: boolean,
158 workspaceId: Source["workspaceId"],178 workspaceId: Source["workspaceId"],
@@ -335,6 +355,25 @@ const sourceInspectionToolAtom = Atom.family((key: string) => { · 176 unmodified lines
335 ).pipe(Atom.keepAlive);355 ).pipe(Atom.keepAlive);
336});356});
337 357
358const sourceInspectionSchemaBundleAtom = Atom.family((key: string) => {
359 const [enabled, workspaceId, accountId, sourceId, schemaBundleId] = decodeAtomKey<SourceSchemaBundleKeyParts>(key);
360
361 return Atom.make(
362 enabled && schemaBundleId
363 ? controlPlaneRequest({
364 accountId,
365 execute: (client) => client.sources.inspectionSchemaBundle({
366 path: {
367 workspaceId,
368 sourceId,
369 schemaBundleId,
370 },
371 }),
372 })
373 : Effect.succeed<SourceInspectionSchemaBundle | null>(null),
374 ).pipe(Atom.keepAlive);
375});
376
338const sourceDiscoveryAtom = Atom.family((key: string) => {377const sourceDiscoveryAtom = Atom.family((key: string) => {
339 const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key);378 const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key);
340 379
@@ -496,6 +535,7 @@ const createActiveQueryCollections = (): ActiveQueryCollections => ({ · 155 unmodified lines
496 sources: new Set(),535 sources: new Set(),
497 inspections: new Set(),536 inspections: new Set(),
498 toolDetails: new Set(),537 toolDetails: new Set(),
538 schemaBundles: new Set(),
499 discoveries: new Set(),539 discoveries: new Set(),
500});540});
501 541
@@ -550,6 +590,13 @@ const invalidateTrackedQueries = ( · 48 unmodified lines
550 }590 }
551 });591 });
552 592
593 activeQueries.schemaBundles.forEach((key) => {
594 const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceSchemaBundleKeyParts>(key);
595 if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
596 registry.refresh(sourceInspectionSchemaBundleAtom(key));
597 }
598 });
599
553 activeQueries.discoveries.forEach((key) => {600 activeQueries.discoveries.forEach((key) => {
554 const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key);601 const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key);
555 if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {602 if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
@@ -625,6 +672,8 @@ const createOptimisticSource = (input: { · 69 unmodified lines
625 headers: input.payload.headers ?? null,672 headers: input.payload.headers ?? null,
626 specUrl: input.payload.specUrl ?? null,673 specUrl: input.payload.specUrl ?? null,
627 defaultHeaders: input.payload.defaultHeaders ?? null,674 defaultHeaders: input.payload.defaultHeaders ?? null,
675 importAuthPolicy: input.payload.importAuthPolicy ?? "reuse_runtime",
676 importAuth: input.payload.importAuth ?? { kind: "none" },
628 auth: input.payload.auth ?? { kind: "none" },677 auth: input.payload.auth ?? { kind: "none" },
629 sourceHash: input.payload.sourceHash ?? null,678 sourceHash: input.payload.sourceHash ?? null,
630 lastError: input.payload.lastError ?? null,679 lastError: input.payload.lastError ?? null,
@@ -937,6 +986,27 @@ export const useSourceToolDetail = ( · 306 unmodified lines
937 return workspace.enabled ? detail : pendingLoadable(workspace.workspace);986 return workspace.enabled ? detail : pendingLoadable(workspace.workspace);
938};987};
939 988
989export const useSourceSchemaBundle = (
990 sourceId: string,
991 schemaBundleId: string | null,
992): Loadable<SourceInspectionSchemaBundle | null> => {
993 const workspace = useWorkspaceRequestContext();
994 const requestedSourceId = workspace.enabled
995 ? (sourceId as Source["id"])
996 : PLACEHOLDER_SOURCE_ID;
997 const key = encodeSchemaBundleKey(
998 workspace.enabled,
999 workspace.workspaceId,
1000 workspace.accountId,
1001 requestedSourceId,
1002 schemaBundleId,
1003 );
1004 useTrackActiveKey("schemaBundles", key, workspace.enabled && schemaBundleId !== null);
1005 const schemaBundle = useLoadableAtom(sourceInspectionSchemaBundleAtom(key));
1006
1007 return workspace.enabled ? schemaBundle : pendingLoadable(workspace.workspace);
1008};
1009
940export const useSourceDiscovery = (input: {1010export const useSourceDiscovery = (input: {
941 sourceId: string;1011 sourceId: string;
942 query: string;1012 query: string;