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
13721372 const manifestJson = JSON.stringify(manifest);
13731373 yield* server.runtime.persistence.rows.sourceRecipes.upsert({
13741374 id: recipeId,
1375− kind: "mcp_recipe",
1375+ kind: "mcp",
13761376 importerKind: "mcp_manifest",
13771377 providerKey: "generic_mcp",
13781378 name: "Demo",
@@ -1395,6 +1395,7 @@ describe("local-executor-server", () => { · 16 unmodified lines
13951395 }),
13961396 manifestJson,
13971397 manifestHash: null,
1398+ materializationHash: null,
13981399 createdAt: now,
13991400 updatedAt: now,
14001401 });
@@ -1409,14 +1410,14 @@ describe("local-executor-server", () => { · 8 unmodified lines
14091410 status: "connected",
14101411 enabled: true,
14111412 namespace: "demo",
1412− transport: "streamable-http",
1413− bindingConfigJson: null,
1414− queryParamsJson: null,
1415− headersJson: null,
1416− specUrl: null,
1417− defaultHeadersJson: null,
1413+ importAuthPolicy: "reuse_runtime",
1414+ bindingConfigJson: JSON.stringify({
1415+ adapterKey: "mcp",
1416+ transport: "streamable-http",
1417+ queryParams: null,
1418+ headers: null,
1419+ }),
14181420 sourceHash: null,
1419− sourceDocumentText: null,
14201421 lastError: null,
14211422 createdAt: now,
14221423 updatedAt: now,
@@ -1450,17 +1451,12 @@ describe("local-executor-server", () => { · 27 unmodified lines
14501451 inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null,
14511452 outputSchemaJson: null,
14521453 providerKind: "mcp",
1453− providerDataJson: null,
1454− mcpToolName: "gated_echo",
1455− openApiMethod: null,
1456− openApiPathTemplate: null,
1457− openApiOperationHash: null,
1458− openApiRawToolId: null,
1459− openApiOperationId: null,
1460− openApiTagsJson: null,
1461− openApiRequestBodyRequired: null,
1462− graphqlOperationType: null,
1463− graphqlOperationName: null,
1454+ providerDataJson: JSON.stringify({
1455+ kind: "mcp",
1456+ toolId: "gated_echo",
1457+ toolName: "gated_echo",
1458+ description: "Asks for approval before echoing a value",
1459+ }),
14641460 createdAt: now,
14651461 updatedAt: now,
14661462 }],
apps/web/src/components/source-not-found-state.tsx+38−0
@@ -0,0 +1,38 @@
1+import { Link } from "@tanstack/react-router";
2+
3+import { IconEmpty } from "./icons";
4+import { cn } from "../lib/utils";
5+
6+export 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
2222 // ---------------------------------------------------------------------------
2323
2424 type SourceRouteSearch = {
25− tab: "model" | "discover" | "manifest" | "definitions" | "raw";
25+ tab: "model" | "discover";
2626 tool?: string;
2727 query?: string;
2828 };
2929
30−const sourceTabs = ["model", "discover", "manifest", "definitions", "raw"] as const;
30+const sourceTabs = ["model", "discover"] as const;
3131
3232 // ---------------------------------------------------------------------------
3333 // Routes
apps/web/src/views/add-source.tsx+1−1
@@ -217,7 +217,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 216 unmodified lines
217217
218218 const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({
219219 ...defaultConnectForm(),
220− kind: template.kind,
220+ kind: template.kind as ConnectFormState["kind"],
221221 endpoint: template.endpoint,
222222 specUrl: "specUrl" in template ? template.specUrl : "",
223223 name: template.name,
apps/web/src/views/source-detail.tsx+201−57
@@ -1,11 +1,14 @@
11 import { useState, useEffect, useCallback, useRef, useMemo } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import {
4+ useSource,
5+ useSources,
46 useSourceInspection,
7+ useSourceSchemaBundle,
58 useSourceToolDetail,
69 useSourceDiscovery,
7− usePrefetchToolDetail,
810 type Loadable,
11+ type Source,
912 type SourceInspection,
1013 type SourceInspectionToolDetail,
1114 type SourceInspectionDiscoverResult,
@@ -15,6 +18,7 @@ import { Badge, MethodBadge } from "../components/ui/badge"; · 3 unmodified lines
1518 import { Button } from "../components/ui/button";
1619 import { LoadableBlock, EmptyState } from "../components/loadable";
1720 import { DocumentPanel } from "../components/document-panel";
21+import { SourceNotFoundState } from "../components/source-not-found-state";
1822 import {
1923 IconSearch,
2024 IconChevron,
@@ -32,7 +36,7 @@ import { Markdown } from "../components/markdown"; · 11 unmodified lines
3236 // ---------------------------------------------------------------------------
3337
3438 type SourceRouteSearch = {
35− tab: "model" | "discover" | "manifest" | "definitions" | "raw";
39+ tab: "model" | "discover";
3640 tool?: string;
3741 query?: string;
3842 };
@@ -42,6 +46,122 @@ const visibleTabs: Array<{ id: SourceRouteSearch["tab"]; label: string }> = [ · 3 unmodified lines
4246 { id: "discover", label: "Search" },
4347 ];
4448
49+const isSourceNotFoundError = (loadable: Loadable<unknown>): boolean =>
50+ loadable.status === "error"
51+ && loadable.error.message.toLowerCase().includes("source not found");
52+
53+const listExcludesSource = (
54+ sources: Loadable<ReadonlyArray<Source>>,
55+ sourceId: string,
56+): boolean => sources.status === "ready" && !sources.data.some((source) => source.id === sourceId);
57+
58+const isJsonObject = (value: unknown): value is Record<string, unknown> =>
59+ typeof value === "object" && value !== null && !Array.isArray(value);
60+
61+const parseJson = (value: string): unknown | null => {
62+ try {
63+ return JSON.parse(value) as unknown;
64+ } catch {
65+ return null;
66+ }
67+};
68+
69+const 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+
138+const 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+
45165 // ---------------------------------------------------------------------------
46166 // SourceDetailPage (main export)
47167 // ---------------------------------------------------------------------------
@@ -52,7 +172,13 @@ export function SourceDetailPage(props: { · 4 unmodified lines
52172 navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void;
53173 }) {
54174 const { sourceId, search, navigate } = props;
175+ const sources = useSources();
176+ const source = useSource(sourceId);
55177 const inspection = useSourceInspection(sourceId);
178+ const missingSource =
179+ listExcludesSource(sources, sourceId)
180+ || isSourceNotFoundError(source)
181+ || isSourceNotFoundError(inspection);
56182
57183 const selectedToolPath =
58184 search.tool
@@ -77,6 +203,10 @@ export function SourceDetailPage(props: { · 18 unmodified lines
77203 void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true });
78204 }, [inspection, navigate, search.tab, search.tool]);
79205
206+ if (missingSource) {
207+ return <SourceNotFoundState />;
208+ }
209+
80210 return (
81211 <LoadableBlock loadable={inspection} loading="Loading source...">
82212 {(bundle) => {
@@ -151,21 +281,6 @@ export function SourceDetailPage(props: { · 68 unmodified lines
151281 }
152282 />
153283 )}
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− )}
169284 </div>
170285 </div>
171286 );
@@ -191,7 +306,7 @@ function ModelView(props: { · 19 unmodified lines
191306
192307 const filteredTools = props.bundle.tools.filter((tool) => {
193308 if (terms.length === 0) return true;
194− const corpus = [tool.path, tool.description ?? "", tool.title ?? "", tool.method ?? ""]
309+ const corpus = [tool.path, tool.method ?? ""]
195310 .join(" ")
196311 .toLowerCase();
197312 return terms.every((t) => corpus.includes(t));
@@ -258,7 +373,6 @@ function ModelView(props: { · 60 unmodified lines
258373 onSelectTool={props.onSelectTool}
259374 search={search}
260375 isFiltered={terms.length > 0}
261− sourceId={props.sourceId}
262376 />
263377 </div>
264378 )}
@@ -270,7 +384,7 @@ function ModelView(props: { · 5 unmodified lines
270384 <LoadableBlock loadable={props.detail} loading="Loading tool...">
271385 {(detail) =>
272386 detail ? (
273− <ToolDetailPanel detail={detail} />
387+ <ToolDetailPanel sourceId={props.sourceId} detail={detail} />
274388 ) : (
275389 <EmptyState
276390 title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"}
@@ -316,10 +430,8 @@ function ToolTree(props: { · 39 unmodified lines
316430 onSelectTool: (path: string) => void;
317431 search: string;
318432 isFiltered: boolean;
319− sourceId: string;
320433 }) {
321434 const tree = useMemo(() => buildToolTree(props.tools), [props.tools]);
322− const prefetch = usePrefetchToolDetail();
323435 const entries = [...tree.children.values()].sort((a, b) =>
324436 a.segment.localeCompare(b.segment),
325437 );
@@ -335,8 +447,6 @@ function ToolTree(props: { · 9 unmodified lines
335447 onSelectTool={props.onSelectTool}
336448 search={props.search}
337449 defaultOpen={props.isFiltered}
338− sourceId={props.sourceId}
339− prefetch={prefetch}
340450 />
341451 ))}
342452 </div>
@@ -350,10 +460,8 @@ function ToolTreeNodeView(props: { · 7 unmodified lines
350460 onSelectTool: (path: string) => void;
351461 search: string;
352462 defaultOpen: boolean;
353− sourceId: string;
354− prefetch: (sourceId: string, toolPath: string) => () => void;
355463 }) {
356− const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen, sourceId, prefetch } = props;
464+ const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen } = props;
357465 const hasChildren = node.children.size > 0;
358466 const isLeaf = !!node.tool && !hasChildren;
359467
@@ -383,8 +491,6 @@ function ToolTreeNodeView(props: { · 23 unmodified lines
383491 onSelect={() => onSelectTool(node.tool!.path)}
384492 search={search}
385493 depth={depth}
386− sourceId={sourceId}
387− prefetch={prefetch}
388494 />
389495 );
390496 }
@@ -420,8 +526,6 @@ function ToolTreeNodeView(props: { · 29 unmodified lines
420526 search={search}
421527 depth={-1}
422528 className="flex-1 pl-1"
423− sourceId={sourceId}
424− prefetch={prefetch}
425529 />
426530 </div>
427531 ) : (
@@ -469,8 +573,6 @@ function ToolTreeNodeView(props: { · 41 unmodified lines
469573 onSelectTool={onSelectTool}
470574 search={search}
471575 defaultOpen={defaultOpen}
472− sourceId={sourceId}
473− prefetch={prefetch}
474576 />
475577 ))}
476578 </div>
@@ -498,12 +600,9 @@ function ToolListItem(props: { · 21 unmodified lines
498600 search: string;
499601 depth: number;
500602 className?: string;
501− sourceId: string;
502− prefetch: (sourceId: string, toolPath: string) => () => void;
503603 }) {
504604 const ref = useRef<HTMLButtonElement>(null);
505605 const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
506− const prefetchedRef = useRef(false);
507606
508607 useEffect(() => {
509608 if (props.active && ref.current) {
@@ -511,24 +610,6 @@ function ToolListItem(props: { · 1 unmodified line
511610 }
512611 }, [props.active]);
513612
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−
532613 const label = props.depth >= 0
533614 ? props.tool.path.split(".").pop() ?? props.tool.path
534615 : props.tool.path;
@@ -560,9 +641,14 @@ function ToolListItem(props: { · 25 unmodified lines
560641 // ToolDetailPanel
561642 // ---------------------------------------------------------------------------
562643
563−function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) {
644+function ToolDetailPanel(props: { sourceId: string; detail: SourceInspectionToolDetail }) {
564645 const { detail } = props;
565646 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+ );
566652
567653 const copy = useCallback((text: string, field: string) => {
568654 void navigator.clipboard.writeText(text).then(() => {
@@ -571,6 +657,43 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 2 unmodified lines
571657 });
572658 }, []);
573659
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+
574697 return (
575698 <div className="flex h-full flex-col overflow-hidden">
576699 {/* Header */}
@@ -613,9 +736,30 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 36 unmodified lines
613736 </div>
614737
615738 {/* 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+ )}
616760 <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 />
618− <DocumentPanel title="Output schema" body={detail.outputSchemaJson} empty="No output schema." compact />
761+ <DocumentPanel title="Input schema" body={resolvedSchemas.inputSchemaJson} empty="No input schema." compact />
762+ <DocumentPanel title="Output schema" body={resolvedSchemas.outputSchemaJson} empty="No output schema." compact />
619763 {detail.exampleInputJson && (
620764 <DocumentPanel title="Example request" body={detail.exampleInputJson} empty="" compact />
621765 )}
apps/web/src/views/source-editor.tsx+15−0
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 useRefreshSecrets,
1616 useRemoveSource,
1717 useSecrets,
18+ useSources,
1819 useStartSourceOAuth,
1920 useSource,
2021 useUpdateSource,
@@ -22,6 +23,7 @@ import { · 1 unmodified line
2223 import { Badge } from "../components/ui/badge";
2324 import { Button } from "../components/ui/button";
2425 import { LoadableBlock } from "../components/loadable";
26+import { SourceNotFoundState } from "../components/source-not-found-state";
2527 import { SourceFavicon } from "../components/source-favicon";
2628 import {
2729 IconArrowLeft,
@@ -56,6 +58,10 @@ type SourceOAuthPopupMessage = · 28 unmodified lines
5658 const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
5759 const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
5860
61+const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean =>
62+ loadable.status === "error"
63+ && loadable.error.message.toLowerCase().includes("source not found");
64+
5965 type TransportValue = "" | NonNullable<Source["transport"]>;
6066
6167 type SourceFormState = {
@@ -495,7 +501,16 @@ export function NewSourcePage() { · 433 unmodified lines
495501 }
496502
497503 export function EditSourcePage(props: { sourceId: string }) {
504+ const sources = useSources();
498505 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+ }
499514
500515 return (
501516 <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
111111 })
112112 : null,
113113 ),
114+ getSchemaBundle: () => Effect.succeed(null),
114115 searchTools: ({ query, namespace, limit }) => {
115116 const queryTokens = tokenize(query);
116117
@@ -184,6 +185,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 67 unmodified lines
184185 const describe: DescribePrimitive = {
185186 tool: ({ path, includeSchemas = false }) =>
186187 catalog.getToolByPath({ path, includeSchemas }),
188+ schemaBundle: ({ id }) => catalog.getSchemaBundle({ id }),
187189 };
188190
189191 const discover: DiscoverPrimitive = ({
@@ -234,6 +236,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 44 unmodified lines
234236 ? {
235237 inputSchemaJson: descriptor.inputSchemaJson,
236238 outputSchemaJson: descriptor.outputSchemaJson,
239+ schemaBundleId: descriptor.schemaBundleId,
237240 }
238241 : {}),
239242 };
@@ -296,7 +299,8 @@ export function buildDynamicExecuteDescription(input: { · 56 unmodified lines
296299 "Workflow:",
297300 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
298301 "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).",
300304 "Do not use fetch; use tools.* only.",
301305 ].join("\n");
302306 });
packages/codemode-core/src/index.test.ts+1−0
@@ -331,6 +331,7 @@ describe("codemode-core", () => { · 330 unmodified lines
331331 ),
332332 getToolByPath: ({ path }) =>
333333 Effect.succeed(descriptors[path] ?? null),
334+ getSchemaBundle: () => Effect.succeed(null),
334335 searchTools: () =>
335336 Effect.succeed([
336337 { 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
5151 outputType: Schema.optional(Schema.String),
5252 inputSchemaJson: Schema.optional(Schema.String),
5353 outputSchemaJson: Schema.optional(Schema.String),
54+ schemaBundleId: Schema.optional(Schema.String),
5455 exampleInputJson: Schema.optional(Schema.String),
5556 exampleOutputJson: Schema.optional(Schema.String),
5657 providerKind: Schema.optional(Schema.String),
@@ -71,6 +72,23 @@ const describeToolOutputSchema = Schema.standardSchemaV1( · 14 unmodified lines
7172 Schema.NullOr(Schema.Unknown),
7273 );
7374
75+const describeSchemaBundleInputSchema = Schema.standardSchemaV1(
76+ Schema.Struct({
77+ id: Schema.String,
78+ }),
79+);
80+
81+const 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+
7492 const discoverInputSchema = Schema.standardSchemaV1(
7593 Schema.Struct({
7694 query: Schema.String,
@@ -88,6 +106,7 @@ const discoverResultItemSchema = Schema.Struct({ · 11 unmodified lines
88106 outputType: Schema.optional(Schema.String),
89107 inputSchemaJson: Schema.optional(Schema.String),
90108 outputSchemaJson: Schema.optional(Schema.String),
109+ schemaBundleId: Schema.optional(Schema.String),
91110 });
92111
93112 const discoverOutputSchema = Schema.standardSchemaV1(
@@ -182,6 +201,24 @@ export const createSystemToolMap = ( · 88 unmodified lines
182201 interaction: "auto",
183202 },
184203 });
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+ });
185222 }
186223
187224 if (primitives.discover) {
packages/codemode-core/src/tool-map.ts+1−0
@@ -438,6 +438,7 @@ export function toolDescriptorsFromTools(input: { · 437 unmodified lines
438438 metadata?.outputType ?? inferTypeFromSchemaJson(outputSchemaJson, "unknown"),
439439 inputSchemaJson,
440440 outputSchemaJson,
441+ ...(metadata?.schemaBundleId ? { schemaBundleId: metadata.schemaBundleId } : {}),
441442 ...(metadata?.exampleInputJson
442443 ? { exampleInputJson: metadata.exampleInputJson }
443444 : {}),
packages/codemode-core/src/types.ts+16−0
@@ -81,6 +81,7 @@ export type ToolMetadata = { · 80 unmodified lines
8181 outputType?: string;
8282 inputSchemaJson?: string;
8383 outputSchemaJson?: string;
84+ schemaBundleId?: string;
8485 exampleInputJson?: string;
8586 exampleOutputJson?: string;
8687 sourceKey?: string;
@@ -134,12 +135,20 @@ export type ToolDescriptor = { · 47 unmodified lines
134135 outputType?: string;
135136 inputSchemaJson?: string;
136137 outputSchemaJson?: string;
138+ schemaBundleId?: string;
137139 exampleInputJson?: string;
138140 exampleOutputJson?: string;
139141 providerKind?: string;
140142 providerDataJson?: string;
141143 };
142144
145+export type ToolSchemaBundle = {
146+ id: string;
147+ kind: string;
148+ hash: string;
149+ refsJson: string;
150+};
151+
143152 export type ToolNamespace = {
144153 namespace: string;
145154 displayName?: string;
@@ -168,6 +177,10 @@ export interface ToolCatalog { · 22 unmodified lines
168177 includeSchemas: boolean;
169178 }): Effect.Effect<ToolDescriptor | null, unknown>;
170179
180+ getSchemaBundle(input: {
181+ id: string;
182+ }): Effect.Effect<ToolSchemaBundle | null, unknown>;
183+
171184 searchTools(input: {
172185 query: string;
173186 namespace?: string;
@@ -196,6 +209,9 @@ export type DescribePrimitive = { · 22 unmodified lines
196209 path: ToolPath;
197210 includeSchemas?: boolean;
198211 }): Effect.Effect<ToolDescriptor | null, unknown>;
212+ schemaBundle(input: {
213+ id: string;
214+ }): Effect.Effect<ToolSchemaBundle | null, unknown>;
199215 };
200216
201217 export type DiscoverPrimitive = (input: {
packages/codemode-openapi/src/index.ts+7−0
@@ -19,6 +19,7 @@ export { · 18 unmodified lines
1919 } from "./openapi-tool-presentation";
2020 export {
2121 OpenApiToolInvocationError,
22+ createOpenApiToolFromDefinition,
2223 createOpenApiToolsFromManifest,
2324 createOpenApiToolsFromSpec,
2425 } from "./openapi-tools";
@@ -39,6 +40,9 @@ export { · 14 unmodified lines
3940 OpenApiRequestBodyDocumentationSchema,
4041 OpenApiResponseDocumentationSchema,
4142 OpenApiToolDocumentationSchema,
43+ OpenApiToolProviderDataSchema,
44+ OpenApiRefHintTableSchema,
45+ OpenApiRefHintValueSchema,
4246 type DiscoveryTypingPayload,
4347 type OpenApiExtractedTool,
4448 type OpenApiHttpMethod,
@@ -49,6 +53,9 @@ export { · 4 unmodified lines
4953 type OpenApiParameterLocation,
5054 type OpenApiSpecInput,
5155 type OpenApiToolManifest,
56+ type OpenApiToolProviderData,
57+ type OpenApiRefHintTable,
58+ type OpenApiRefHintValue,
5259 type OpenApiToolParameter,
5360 type OpenApiToolRequestBody,
5461 } from "./openapi-types";
packages/codemode-openapi/src/openapi-definitions.ts+3−1
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 OpenApiHttpMethodSchema,
66 OpenApiInvocationPayloadSchema,
77 OpenApiToolDocumentationSchema,
8+ OpenApiToolProviderDataSchema,
89 type OpenApiExtractedTool,
910 type OpenApiToolManifest,
1011 } from "./openapi-types";
@@ -250,5 +251,6 @@ export const openApiProviderDataJsonFromDefinition = ( · 239 unmodified lines
250251 method: definition.method,
251252 path: definition.path,
252253 operationHash: definition.operationHash,
254+ invocation: definition.invocation,
253255 documentation: definition.documentation,
254− });
256+ } satisfies typeof OpenApiToolProviderDataSchema.Type);
packages/codemode-openapi/src/openapi-schema-refs.ts+10−4
@@ -1,4 +1,5 @@
11 type JsonObject = Record<string, unknown>;
2+type RefHintValue = string | JsonObject;
23
34 type DiscoveryTypingLike = {
45 inputSchemaJson?: string;
@@ -18,7 +19,7 @@ const parseJson = (value: string): unknown | null => { · 13 unmodified lines
1819
1920 const resolveSchemaNode = (
2021 value: unknown,
21− refHintTable: Readonly<Record<string, string>>,
22+ refHintTable: Readonly<Record<string, RefHintValue>>,
2223 parsedHintCache: Map<string, unknown>,
2324 activeRefs: ReadonlySet<string>,
2425 ): unknown => {
@@ -38,7 +39,12 @@ const resolveSchemaNode = ( · 13 unmodified lines
3839
3940 if (resolvedRefValue === undefined) {
4041 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;
4248 parsedHintCache.set(ref, resolvedRefValue);
4349 }
4450
@@ -88,7 +94,7 @@ const resolveSchemaNode = ( · 43 unmodified lines
8894
8995 export const resolveSchemaJsonWithRefHints = (
9096 schemaJson: string | undefined,
91− refHintTable: Readonly<Record<string, string>> | undefined,
97+ refHintTable: Readonly<Record<string, RefHintValue>> | undefined,
9298 ): string | null => {
9399 if (!schemaJson) {
94100 return null;
@@ -119,7 +125,7 @@ export const resolveSchemaJsonWithRefHints = ( · 24 unmodified lines
119125
120126 export const resolveTypingSchemasWithRefHints = (
121127 typing: DiscoveryTypingLike | undefined,
122− refHintTable: Readonly<Record<string, string>> | undefined,
128+ refHintTable: Readonly<Record<string, RefHintValue>> | undefined,
123129 ): {
124130 inputSchemaJson: string | null;
125131 outputSchemaJson: string | null;
packages/codemode-openapi/src/openapi-tool-presentation.ts+7−26
@@ -1,14 +1,13 @@
11 import { typeSignatureFromSchemaJson } from "@executor/codemode-core";
22
3−import {
4− resolveTypingSchemasWithRefHints,
5−} from "./openapi-schema-refs";
63 import type {
74 OpenApiExample,
85 OpenApiToolDocumentation,
9− OpenApiToolManifest,
106 } from "./openapi-types";
11−import type { OpenApiToolDefinition } from "./openapi-definitions";
7+import {
8+ openApiProviderDataJsonFromDefinition,
9+ type OpenApiToolDefinition,
10+} from "./openapi-definitions";
1211
1312 const parseJson = (value: string): unknown | undefined => {
1413 try {
@@ -104,15 +103,10 @@ export type OpenApiToolPresentation = { · 89 unmodified lines
104103 };
105104
106105 export const buildOpenApiToolPresentation = (input: {
107− manifest: OpenApiToolManifest;
108106 definition: OpenApiToolDefinition;
109107 }): OpenApiToolPresentation => {
110− const resolvedSchemas = resolveTypingSchemasWithRefHints(
111− input.definition.typing,
112− input.manifest.refHintTable,
113− );
114− const inputSchemaJson = resolvedSchemas.inputSchemaJson ?? undefined;
115− const outputSchemaJson = resolvedSchemas.outputSchemaJson ?? undefined;
108+ const inputSchemaJson = input.definition.typing?.inputSchemaJson;
109+ const outputSchemaJson = input.definition.typing?.outputSchemaJson;
116110 const exampleInputJson = buildExampleInputJson(input.definition.documentation);
117111 const exampleOutputJson = buildExampleOutputJson(input.definition.documentation);
118112
@@ -123,19 +117,6 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines
123117 ...(outputSchemaJson ? { outputSchemaJson } : {}),
124118 ...(exampleInputJson ? { exampleInputJson } : {}),
125119 ...(exampleOutputJson ? { exampleOutputJson } : {}),
126− providerDataJson: JSON.stringify({
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− }),
120+ providerDataJson: openApiProviderDataJsonFromDefinition(input.definition),
140121 };
141122 };
packages/codemode-openapi/src/openapi-tools.ts+112−73
@@ -19,11 +19,13 @@ import { · 18 unmodified lines
1919 type OpenApiToolDefinition,
2020 } from "./openapi-definitions";
2121 import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
22+import { resolveSchemaJsonWithRefHints } from "./openapi-schema-refs";
2223 import {
2324 extractOpenApiManifest,
2425 type OpenApiExtractionError,
2526 } from "./openapi-extraction";
2627 import {
28+ type OpenApiRefHintTable,
2729 type OpenApiInvocationPayload,
2830 type OpenApiSpecInput,
2931 type OpenApiToolManifest,
@@ -256,13 +258,21 @@ const summarizeHttpResponseBody = (body: unknown): string | null => { · 226 unmodified lines
256258 }
257259 };
258260
259−const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => {
260− if (!inputSchemaJson) {
261+const inputSchemaFromTypingJson = (input: {
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) {
261271 return unknownInputSchema;
262272 }
263273
264274 try {
265− return standardSchemaFromJsonSchema(JSON.parse(inputSchemaJson), {
275+ return standardSchemaFromJsonSchema(JSON.parse(resolvedSchemaJson), {
266276 vendor: "openapi",
267277 fallback: unknownInputSchema,
268278 });
@@ -396,6 +406,97 @@ const buildFetchRequest = (input: { · 127 unmodified lines
396406 const createToolPath = (namespace: string | undefined, definition: OpenApiToolDefinition): string =>
397407 namespace ? `${namespace}.${definition.toolId}` : definition.toolId;
398408
409+export 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+
420+export 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+
399500 export const createOpenApiToolsFromManifest = (
400501 input: CreateOpenApiToolsFromManifestInput,
401502 ): ToolMap => {
@@ -410,77 +511,15 @@ export const createOpenApiToolsFromManifest = ( · 8 unmodified lines
410511
411512 for (const definition of definitions) {
412513 const toolPath = createToolPath(input.namespace, definition);
413− const presentation = buildOpenApiToolPresentation({
414− manifest: input.manifest,
514+ result[toolPath] = createOpenApiToolFromDefinition({
415515 definition,
416− });
417−
418− result[toolPath] = toTool({
419− tool: {
420− description: definition.description,
421− inputSchema: inputSchemaFromTypingJson(
422− presentation.inputSchemaJson ?? definition.typing?.inputSchemaJson,
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− },
516+ path: toolPath,
517+ sourceKey,
518+ baseUrl,
519+ defaultHeaders,
520+ credentialHeaders,
521+ refHintTable: input.manifest.refHintTable,
522+ httpClientLayer,
484523 });
485524 }
486525
packages/codemode-openapi/src/openapi-types.ts+32−0
@@ -63,6 +63,19 @@ export const DiscoveryTypingPayloadSchema = Schema.Struct({ · 62 unmodified lines
6363 refHintKeys: Schema.optional(Schema.Array(Schema.String)),
6464 });
6565
66+export const OpenApiRefHintValueSchema = Schema.Union(
67+ Schema.String,
68+ Schema.Record({
69+ key: Schema.String,
70+ value: Schema.Unknown,
71+ }),
72+);
73+
74+export const OpenApiRefHintTableSchema = Schema.Record({
75+ key: Schema.String,
76+ value: OpenApiRefHintValueSchema,
77+});
78+
6679 export const OpenApiExampleSchema = Schema.Struct({
6780 valueJson: Schema.String,
6881 mediaType: Schema.optional(Schema.String),
@@ -97,6 +110,22 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 28 unmodified lines
97110 response: Schema.optional(OpenApiResponseDocumentationSchema),
98111 });
99112
113+export 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+
100129 export const OpenApiExtractedToolSchema = Schema.Struct({
101130 toolId: Schema.String,
102131 operationId: Schema.optional(Schema.String),
@@ -129,10 +158,13 @@ export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; · 26 unmodified lines
129158 export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
130159 export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
131160 export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;
161+export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;
162+export type OpenApiRefHintTable = typeof OpenApiRefHintTableSchema.Type;
132163 export type OpenApiExample = typeof OpenApiExampleSchema.Type;
133164 export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;
134165 export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;
135166 export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;
136167 export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;
168+export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;
137169 export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;
138170 export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;
packages/control-plane/drizzle/20260310183000_source_recipe_schema_bundles/migration.sql+14−0
@@ -0,0 +1,14 @@
1+CREATE 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
12+CREATE UNIQUE INDEX "source_recipe_schema_bundles_revision_kind_idx" ON "source_recipe_schema_bundles" USING btree ("recipe_revision_id","bundle_kind");
13+--> statement-breakpoint
14+CREATE 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 @@
1+ALTER TABLE "sources" DROP CONSTRAINT IF EXISTS "sources_kind_check";
2+--> statement-breakpoint
3+ALTER TABLE "source_recipes" DROP CONSTRAINT IF EXISTS "source_recipes_importer_kind_check";
4+--> statement-breakpoint
5+ALTER TABLE "source_recipe_documents" DROP CONSTRAINT IF EXISTS "source_recipe_documents_kind_check";
6+--> statement-breakpoint
7+ALTER TABLE "source_recipe_schema_bundles" DROP CONSTRAINT IF EXISTS "source_recipe_schema_bundles_kind_check";
8+--> statement-breakpoint
9+ALTER 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 @@
1+ALTER TABLE "source_recipes"
2+DROP CONSTRAINT IF EXISTS "source_recipes_kind_check";--> statement-breakpoint
3+
4+UPDATE "source_recipes"
5+SET "kind" = 'http_api'
6+WHERE "kind" IN ('http_recipe', 'graphql_recipe');--> statement-breakpoint
7+
8+UPDATE "source_recipes"
9+SET "kind" = 'mcp'
10+WHERE "kind" = 'mcp_recipe';--> statement-breakpoint
11+
12+UPDATE "source_recipes"
13+SET "kind" = 'internal'
14+WHERE "kind" = 'internal_recipe';--> statement-breakpoint
15+
16+ALTER TABLE "source_recipes"
17+ADD CONSTRAINT "source_recipes_kind_check"
18+CHECK ("kind" in ('http_api', 'mcp', 'internal'));--> statement-breakpoint
19+
20+CREATE 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 @@
1+UPDATE "sources"
2+SET "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
38+END;--> statement-breakpoint
39+
40+ALTER TABLE "sources"
41+ALTER COLUMN "binding_config_json" SET NOT NULL;--> statement-breakpoint
42+
43+ALTER TABLE "sources"
44+DROP CONSTRAINT IF EXISTS "sources_transport_check";--> statement-breakpoint
45+
46+ALTER TABLE "sources"
47+DROP COLUMN IF EXISTS "transport";--> statement-breakpoint
48+
49+ALTER TABLE "sources"
50+DROP COLUMN IF EXISTS "query_params_json";--> statement-breakpoint
51+
52+ALTER TABLE "sources"
53+DROP COLUMN IF EXISTS "headers_json";--> statement-breakpoint
54+
55+ALTER TABLE "sources"
56+DROP COLUMN IF EXISTS "spec_url";--> statement-breakpoint
57+
58+ALTER TABLE "sources"
59+DROP COLUMN IF EXISTS "default_headers_json";--> statement-breakpoint
60+
61+ALTER TABLE "sources"
62+DROP 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 @@
1+ALTER TABLE "sources" ADD COLUMN "import_auth_policy" text;--> statement-breakpoint
2+
3+UPDATE "sources"
4+SET "import_auth_policy" = CASE
5+ WHEN "kind" = 'internal' THEN 'none'
6+ ELSE 'reuse_runtime'
7+END
8+WHERE "import_auth_policy" IS NULL;--> statement-breakpoint
9+
10+ALTER TABLE "sources"
11+ALTER COLUMN "import_auth_policy" SET NOT NULL;--> statement-breakpoint
12+
13+ALTER TABLE "sources"
14+DROP CONSTRAINT IF EXISTS "sources_import_auth_policy_check";--> statement-breakpoint
15+
16+ALTER TABLE "sources"
17+ADD CONSTRAINT "sources_import_auth_policy_check"
18+CHECK ("import_auth_policy" in ('none', 'reuse_runtime', 'separate'));--> statement-breakpoint
19+
20+ALTER TABLE "source_recipe_revisions" ADD COLUMN "materialization_hash" text;--> statement-breakpoint
21+
22+UPDATE "source_recipe_revisions"
23+SET "materialization_hash" = "manifest_hash"
24+WHERE "materialization_hash" IS NULL;--> statement-breakpoint
25+
26+DROP INDEX IF EXISTS "source_recipe_revisions_recipe_manifest_idx";--> statement-breakpoint
27+
28+CREATE UNIQUE INDEX "source_recipe_revisions_recipe_materialization_idx"
29+ON "source_recipe_revisions" ("recipe_id","materialization_hash");--> statement-breakpoint
30+
31+CREATE INDEX "source_recipe_revisions_recipe_manifest_idx"
32+ON "source_recipe_revisions" ("recipe_id","manifest_hash");--> statement-breakpoint
33+
34+ALTER TABLE "workspace_source_credentials" ADD COLUMN "slot" text;--> statement-breakpoint
35+
36+UPDATE "workspace_source_credentials"
37+SET "slot" = 'runtime'
38+WHERE "slot" IS NULL;--> statement-breakpoint
39+
40+ALTER TABLE "workspace_source_credentials"
41+ALTER COLUMN "slot" SET NOT NULL;--> statement-breakpoint
42+
43+DROP INDEX IF EXISTS "credentials_workspace_source_actor_idx";--> statement-breakpoint
44+
45+ALTER TABLE "workspace_source_credentials"
46+DROP CONSTRAINT IF EXISTS "credentials_slot_check";--> statement-breakpoint
47+
48+ALTER TABLE "workspace_source_credentials"
49+ADD CONSTRAINT "credentials_slot_check"
50+CHECK ("slot" in ('runtime', 'import'));--> statement-breakpoint
51+
52+CREATE UNIQUE INDEX "credentials_workspace_source_actor_idx"
53+ON "workspace_source_credentials" ("workspace_id","source_id","actor_account_id","slot");--> statement-breakpoint
54+
55+ALTER TABLE "source_auth_sessions" ADD COLUMN "credential_slot" text;--> statement-breakpoint
56+
57+UPDATE "source_auth_sessions"
58+SET "credential_slot" = 'runtime'
59+WHERE "credential_slot" IS NULL;--> statement-breakpoint
60+
61+ALTER TABLE "source_auth_sessions"
62+ALTER COLUMN "credential_slot" SET NOT NULL;--> statement-breakpoint
63+
64+DROP INDEX IF EXISTS "source_auth_sessions_pending_idx";--> statement-breakpoint
65+
66+ALTER TABLE "source_auth_sessions"
67+DROP CONSTRAINT IF EXISTS "source_auth_sessions_credential_slot_check";--> statement-breakpoint
68+
69+ALTER TABLE "source_auth_sessions"
70+ADD CONSTRAINT "source_auth_sessions_credential_slot_check"
71+CHECK ("credential_slot" in ('runtime', 'import'));--> statement-breakpoint
72+
73+CREATE INDEX "source_auth_sessions_pending_idx"
74+ON "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 @@
1+UPDATE "source_recipe_operations"
2+SET "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"
61+END
62+WHERE "provider_data_json" IS NULL;--> statement-breakpoint
63+
64+ALTER TABLE "source_recipe_operations" DROP COLUMN "mcp_tool_name";--> statement-breakpoint
65+ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_method";--> statement-breakpoint
66+ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_path_template";--> statement-breakpoint
67+ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_operation_hash";--> statement-breakpoint
68+ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_raw_tool_id";--> statement-breakpoint
69+ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_operation_id";--> statement-breakpoint
70+ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_tags_json";--> statement-breakpoint
71+ALTER TABLE "source_recipe_operations" DROP COLUMN "openapi_request_body_required";--> statement-breakpoint
72+ALTER TABLE "source_recipe_operations" DROP COLUMN "graphql_operation_type";--> statement-breakpoint
73+ALTER 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 @@
1+DROP TABLE IF EXISTS "tool_artifact_parameters";
2+--> statement-breakpoint
3+DROP TABLE IF EXISTS "tool_artifact_request_body_content_types";
4+--> statement-breakpoint
5+DROP TABLE IF EXISTS "tool_artifact_ref_hint_keys";
6+--> statement-breakpoint
7+DROP TABLE IF EXISTS "tool_artifacts";
packages/control-plane/src/api/payload-schemas.test.ts+20−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 } from "./organizations/api";
1212 import { CreatePolicyPayloadSchema } from "./policies/api";
1313 import {
14+ ConnectSourcePayloadSchema,
1415 CreateSourcePayloadSchema,
1516 UpdateSourcePayloadSchema,
1617 } from "./sources/api";
@@ -56,6 +57,18 @@ describe("control-plane payload schemas", () => { · 39 unmodified lines
5657 code: "console.log('ok')",
5758 });
5859
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+
5972 expect(
6073 Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({
6174 resourcePattern: " source.github.* ",
@@ -83,5 +96,12 @@ describe("control-plane payload schemas", () => { · 21 unmodified lines
8396 endpoint: " ",
8497 })
8598 );
99+
100+ throws(() =>
101+ Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({
102+ kind: "graphql",
103+ endpoint: " ",
104+ })
105+ );
86106 });
87107 });
packages/control-plane/src/api/sources/api.ts+23−54
@@ -1,15 +1,16 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import {
33 ExecutionInteractionIdSchema,
4− SecretRefSchema,
54 SourceAuthSchema,
65 SourceAuthSessionIdSchema,
76 SourceDiscoveryResultSchema,
87 SourceIdSchema,
98 SourceInspectionDiscoverPayloadSchema,
109 SourceInspectionDiscoverResultSchema,
10+ SourceInspectionSchemaBundleSchema,
1111 SourceInspectionSchema,
1212 SourceInspectionToolDetailSchema,
13+ SourceImportAuthPolicySchema,
1314 SourceKindSchema,
1415 SourceProbeAuthSchema,
1516 SourceSchema,
@@ -31,6 +32,10 @@ import { · 15 unmodified lines
3132 OptionalTrimmedNonEmptyStringSchema,
3233 TrimmedNonEmptyStringSchema,
3334 } from "../string-schemas";
35+import {
36+ ConnectSourcePayloadSchema,
37+ type ConnectSourcePayload,
38+} from "../../runtime/source-adapters";
3439
3540 const createSourcePayloadRequiredSchema = Schema.Struct({
3641 name: TrimmedNonEmptyStringSchema,
@@ -47,6 +52,8 @@ const createSourcePayloadOptionalSchema = Schema.Struct({ · 10 unmodified lines
4752 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
4853 specUrl: Schema.optional(Schema.NullOr(Schema.String)),
4954 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
55+ importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema),
56+ importAuth: Schema.optional(SourceAuthSchema),
5057 auth: Schema.optional(SourceAuthSchema),
5158 sourceHash: Schema.optional(Schema.NullOr(Schema.String)),
5259 lastError: Schema.optional(Schema.NullOr(Schema.String)),
@@ -71,6 +78,8 @@ export const UpdateSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines
7178 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
7279 specUrl: Schema.optional(Schema.NullOr(Schema.String)),
7380 defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)),
81+ importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema),
82+ importAuth: Schema.optional(SourceAuthSchema),
7483 auth: Schema.optional(SourceAuthSchema),
7584 sourceHash: Schema.optional(Schema.NullOr(Schema.String)),
7685 lastError: Schema.optional(Schema.NullOr(Schema.String)),
@@ -81,6 +90,7 @@ export type UpdateSourcePayload = typeof UpdateSourcePayloadSchema.Type; · 4 unmodified lines
8190 const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
8291 const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
8392 const toolPathParam = HttpApiSchema.param("toolPath", Schema.String);
93+const schemaBundleIdParam = HttpApiSchema.param("schemaBundleId", Schema.String);
8494
8595 const CredentialPageUrlParamsSchema = Schema.Struct({
8696 interactionId: ExecutionInteractionIdSchema,
@@ -105,59 +115,6 @@ const DiscoverSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines
105115
106116 export type DiscoverSourcePayload = typeof DiscoverSourcePayloadSchema.Type;
107117
108−const 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−
116−const 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−
126−const ConnectHttpAuthSchema = Schema.Union(
127− Schema.Struct({ kind: Schema.Literal("none") }),
128− ConnectBearerAuthSchema,
129− ConnectOAuth2AuthSchema,
130−);
131−
132−const 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−
159−export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type;
160−
161118 const ConnectSourceResultSchema = Schema.Union(
162119 Schema.Struct({
163120 kind: Schema.Literal("connected"),
@@ -166,6 +123,7 @@ const ConnectSourceResultSchema = Schema.Union( · 2 unmodified lines
166123 Schema.Struct({
167124 kind: Schema.Literal("credential_required"),
168125 source: SourceSchema,
126+ credentialSlot: Schema.Literal("runtime", "import"),
169127 }),
170128 Schema.Struct({
171129 kind: Schema.Literal("oauth_required"),
@@ -183,6 +141,8 @@ export { · 11 unmodified lines
183141 DiscoverSourcePayloadSchema,
184142 };
185143
144+export type { ConnectSourcePayload };
145+
186146 const HtmlSchema = HttpApiSchema.Text({
187147 contentType: "text/html",
188148 });
@@ -292,6 +252,15 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 103 unmodified lines
292252 .addError(ControlPlaneNotFoundError)
293253 .addError(ControlPlaneStorageError),
294254 )
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+ )
295264 .add(
296265 HttpApiEndpoint.post("inspectionDiscover")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/discover`
297266 .setPayload(SourceInspectionDiscoverPayloadSchema)
packages/control-plane/src/api/sources/http.ts+15−24
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 import {
2222 discoverSourceInspectionTools,
2323 getSourceInspection,
24+ getSourceInspectionSchemaBundle,
2425 getSourceInspectionToolDetail,
2526 } from "../../runtime/source-inspection";
2627 import {
@@ -29,6 +30,7 @@ import { · 2 unmodified lines
2930 submitSourceCredentialInteraction,
3031 } from "../../runtime/local-operations";
3132 import { discoverSource } from "../../runtime/source-discovery";
33+import { hasSourceAdapterFamily } from "../../runtime/source-adapters";
3234 import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service";
3335
3436 import {
@@ -682,7 +684,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 647 unmodified lines
682684 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
683685 const baseUrl = resolveRequestOrigin(request);
684686
685− if (payload.kind === "mcp") {
687+ if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) {
686688 return yield* sourceAuthService.connectMcpSource({
687689 workspaceId: path.workspaceId,
688690 actorAccountId: actor.principal.accountId,
@@ -696,35 +698,13 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 7 unmodified lines
696698 });
697699 }
698700
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−
717701 return yield* sourceAuthService.addExecutorSource(
718702 {
719703 workspaceId: path.workspaceId,
720704 actorAccountId: actor.principal.accountId,
721705 executionId: null,
722706 interactionId: null,
723− kind: "graphql",
724− endpoint: payload.endpoint,
725− name: payload.name,
726− namespace: payload.namespace,
727− auth: payload.auth,
707+ ...payload,
728708 },
729709 { baseUrl },
730710 );
@@ -781,6 +761,17 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 50 unmodified lines
781761 ),
782762 ),
783763 )
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+ )
784775 .handle("inspectionDiscover", ({ path, payload }) =>
785776 withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () =>
786777 withPolicy(requireReadSources(path.workspaceId))(
packages/control-plane/src/persistence/control-plane-rows.test.ts+436−14
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 SourceAuthSessionIdSchema,
1212 SourceIdSchema,
1313 SourceRecipeIdSchema,
14+ SourceRecipeSchemaBundleIdSchema,
1415 SourceRecipeRevisionIdSchema,
1516 WorkspaceIdSchema,
1617 } from "#schema";
@@ -39,6 +40,35 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 22 unmodified lines
3940
4041 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
4142
43+const openApiBindingConfigJson = (specUrl: string): string =>
44+ JSON.stringify({
45+ adapterKey: "openapi",
46+ specUrl,
47+ defaultHeaders: null,
48+ });
49+
50+const 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+
4272 const seedWorkspaceSourceState = (input: {
4373 persistence: SqlControlPlanePersistence;
4474 accountId: ReturnType<typeof AccountIdSchema.make>;
@@ -68,6 +98,33 @@ const seedWorkspaceSourceState = (input: { · 23 unmodified lines
6898 createdAt: now,
6999 updatedAt: now,
70100 });
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+ }));
71128 yield* input.persistence.rows.sources.insert({
72129 id: input.sourceId,
73130 workspaceId: input.workspaceId,
@@ -79,14 +136,9 @@ const seedWorkspaceSourceState = (input: { · 5 unmodified lines
79136 status: "connected",
80137 enabled: true,
81138 namespace: "github",
82− transport: null,
83− bindingConfigJson: null,
84− queryParamsJson: null,
85− headersJson: null,
86− specUrl: "https://api.github.com/openapi.json",
87− defaultHeadersJson: null,
139+ importAuthPolicy: "reuse_runtime",
140+ bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
88141 sourceHash: null,
89− sourceDocumentText: null,
90142 lastError: null,
91143 createdAt: now,
92144 updatedAt: now,
@@ -131,6 +183,7 @@ const seedWorkspaceCredentialState = (input: { · 38 unmodified lines
131183 workspaceId: input.workspaceId,
132184 sourceId: input.sourceId,
133185 actorAccountId: input.accountId,
186+ slot: "runtime",
134187 authKind: "oauth2",
135188 authHeaderName: "Authorization",
136189 authPrefix: "Bearer ",
@@ -149,6 +202,7 @@ const seedWorkspaceCredentialState = (input: { · 12 unmodified lines
149202 executionId: null,
150203 interactionId: null,
151204 providerKind: "mcp_oauth",
205+ credentialSlot: "runtime",
152206 status: "pending",
153207 state: `state_${input.workspaceId}`,
154208 sessionDataJson: encodeSessionDataJson({
@@ -212,14 +266,9 @@ describe("control-plane-persistence-drizzle", () => { · 57 unmodified lines
212266 status: "draft",
213267 enabled: true,
214268 namespace: "github",
215− transport: null,
216− bindingConfigJson: null,
217− queryParamsJson: null,
218− headersJson: null,
219− specUrl: "https://api.github.com/openapi.json",
220− defaultHeadersJson: null,
269+ importAuthPolicy: "reuse_runtime",
270+ bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
221271 sourceHash: null,
222− sourceDocumentText: null,
223272 lastError: null,
224273 createdAt: now,
225274 updatedAt: now,
@@ -302,6 +351,94 @@ describe("control-plane-persistence-drizzle", () => { · 76 unmodified lines
302351 }),
303352 );
304353
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+
305442 it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () =>
306443 Effect.gen(function* () {
307444 const persistence = yield* makePersistence;
@@ -329,6 +466,7 @@ describe("control-plane-persistence-drizzle", () => { · 21 unmodified lines
329466 workspaceId,
330467 sourceId,
331468 actorAccountId: null,
469+ slot: "runtime",
332470 authKind: "bearer",
333471 authHeaderName: "Authorization",
334472 authPrefix: "Bearer ",
@@ -344,6 +482,7 @@ describe("control-plane-persistence-drizzle", () => { · 9 unmodified lines
344482 workspaceId,
345483 sourceId,
346484 actorAccountId: null,
485+ slot: "runtime",
347486 authKind: "bearer",
348487 authHeaderName: "Authorization",
349488 authPrefix: "Bearer ",
@@ -362,6 +501,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
362501 workspaceId,
363502 sourceId,
364503 actorAccountId: null,
504+ slot: "runtime",
365505 authKind: "oauth2",
366506 authHeaderName: "X-Auth",
367507 authPrefix: "Token ",
@@ -378,6 +518,7 @@ describe("control-plane-persistence-drizzle", () => { · 10 unmodified lines
378518 workspaceId,
379519 sourceId,
380520 actorAccountId: accountId,
521+ slot: "runtime",
381522 authKind: "bearer",
382523 authHeaderName: "Authorization",
383524 authPrefix: "Bearer ",
@@ -418,6 +559,7 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines
418559 workspaceId,
419560 sourceId,
420561 actorAccountId: null,
562+ slot: "runtime",
421563 });
422564 assertTrue(Option.isSome(nullActorOnly));
423565 if (Option.isSome(nullActorOnly)) {
@@ -428,6 +570,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines
428570 workspaceId,
429571 sourceId,
430572 actorAccountId: AccountIdSchema.make("acc_missing_credentials"),
573+ slot: "runtime",
431574 });
432575 expect(Option.isNone(missingActor)).toBe(true);
433576 }),
@@ -440,6 +583,7 @@ describe("control-plane-persistence-drizzle", () => { · 6 unmodified lines
440583 const organizationId = OrganizationIdSchema.make("org_ws_cleanup");
441584 const workspaceId = WorkspaceIdSchema.make("ws_cleanup");
442585 const sourceId = SourceIdSchema.make("src_ws_cleanup");
586+ const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
443587
444588 const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({
445589 persistence,
@@ -448,17 +592,295 @@ describe("control-plane-persistence-drizzle", () => { · 2 unmodified lines
448592 workspaceId,
449593 sourceId,
450594 });
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+ });
451666
452667 const removed = yield* persistence.rows.workspaces.removeById(workspaceId);
453668 expect(removed).toBe(true);
454669 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
455670 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);
456671 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);
457681 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
458682 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
459683 }),
460684 );
461685
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+
462884 it.scoped("deleting an organization removes workspace credential state and postgres secrets", () =>
463885 Effect.gen(function* () {
464886 const persistence = yield* makePersistence;
packages/control-plane/src/persistence/data-migrations.ts+234−0
@@ -0,0 +1,234 @@
1+import {
2+ type McpSourceAuthSessionData,
3+ type SourceAuthSession,
4+ type StoredSourceRecipeDocumentRecord,
5+ type StoredSourceRecord,
6+} from "#schema";
7+import {
8+ JsonObjectSchema,
9+ McpSourceAuthSessionDataJsonSchema,
10+} from "#schema";
11+import * as Either from "effect/Either";
12+import * as Effect from "effect/Effect";
13+import * as ParseResult from "effect/ParseResult";
14+import * as Schema from "effect/Schema";
15+
16+import { rebuildGraphqlRecipeRevisionContent } from "../runtime/source-adapters/graphql";
17+import { rebuildOpenApiRecipeRevisionContent } from "../runtime/source-adapters/openapi";
18+import type { SqlControlPlaneRows } from "./index";
19+
20+type DataMigration = {
21+ id: string;
22+ run: (rows: SqlControlPlaneRows) => Effect.Effect<void, Error, never>;
23+};
24+
25+const 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+
39+const decodeLegacyMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
40+ Schema.parseJson(LegacyMcpSourceAuthSessionDataSchema),
41+);
42+
43+const decodeMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
44+ McpSourceAuthSessionDataJsonSchema,
45+);
46+
47+const encodeMcpSourceAuthSessionDataJson = Schema.encodeSync(
48+ McpSourceAuthSessionDataJsonSchema,
49+);
50+
51+const decodeJsonObjectFromJson = Schema.decodeUnknownEither(
52+ Schema.parseJson(JsonObjectSchema),
53+);
54+
55+const 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+
64+const 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+
115+const 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+
132+const 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+
210+const dataMigrations = [
211+ rebuildPersistedSourceRecipes,
212+ migrateLegacySourceAuthSessions,
213+] as const satisfies readonly DataMigration[];
214+
215+export 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
77 import {
88 createAccountsRepo,
99 createCredentialsRepo,
10+ createDataMigrationsRepo,
1011 createExecutionInteractionsRepo,
1112 createExecutionsRepo,
1213 createLocalInstallationsRepo,
@@ -17,11 +18,11 @@ import { · 4 unmodified lines
1718 createSourceAuthSessionsRepo,
1819 createSourceOauthClientsRepo,
1920 createSourceRecipeDocumentsRepo,
21+ createSourceRecipeSchemaBundlesRepo,
2022 createSourceRecipeOperationsRepo,
2123 createSourceRecipeRevisionsRepo,
2224 createSourceRecipesRepo,
2325 createSourcesRepo,
24− createToolArtifactsRepo,
2526 createWorkspacesRepo,
2627 } from "./repos";
2728 import { drizzleSchema, tableNames, type DrizzleTables } from "./schema";
@@ -33,7 +34,7 @@ import { · 5 unmodified lines
3334 type SqlBackend,
3435 type SqlRuntime,
3536 } from "./sql-runtime";
36−import { runPostMigrationRepairs } from "./post-migrations";
37+import { runDataMigrations } from "./data-migrations";
3738
3839 export { tableNames, type DrizzleTables } from "./schema";
3940 export {
@@ -60,10 +61,11 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 20 unmodified lines
6061 sourceRecipes: createSourceRecipesRepo(client, tables),
6162 sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables),
6263 sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables),
64+ sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables),
6365 sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),
66+ dataMigrations: createDataMigrationsRepo(client, tables),
6467 credentials: createCredentialsRepo(client, tables),
6568 sourceOauthClients: createSourceOauthClientsRepo(client, tables),
66− toolArtifacts: createToolArtifactsRepo(client, tables),
6769 secretMaterials: createSecretMaterialsRepo(client, tables),
6870 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),
6971 policies: createPoliciesRepo(client, tables),
@@ -126,8 +128,14 @@ const closeRuntimeEffect = (runtime: SqlRuntime) => · 56 unmodified lines
126128 cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")),
127129 }).pipe(Effect.orDie);
128130
131+export type CreateSqlControlPlanePersistenceOptions =
132+ & CreateSqlRuntimeOptions
133+ & {
134+ runDataMigrations?: boolean;
135+ };
136+
129137 export const createSqlControlPlanePersistence = (
130− options: CreateSqlRuntimeOptions,
138+ options: CreateSqlControlPlanePersistenceOptions,
131139 ): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> =>
132140 Effect.flatMap(createRuntimeEffect(options), (runtime) =>
133141 runMigrationsEffect(runtime, options.migrationsFolder).pipe(
@@ -146,10 +154,12 @@ export const createSqlControlPlanePersistence = ( · 12 unmodified lines
146154 } satisfies SqlControlPlanePersistence;
147155 }),
148156 Effect.flatMap((persistence) =>
149− runPostMigrationRepairs(persistence.rows).pipe(
150− Effect.mapError(toBootstrapError),
151− Effect.map(() => persistence),
152− )),
157+ options.runDataMigrations === false
158+ ? Effect.succeed(persistence)
159+ : runDataMigrations(persistence.rows).pipe(
160+ Effect.mapError(toBootstrapError),
161+ Effect.map(() => persistence),
162+ )),
153163 Effect.catchAll((error) =>
154164 closeRuntimeEffect(runtime).pipe(
155165 Effect.zipRight(Effect.fail(error)),
@@ -157,7 +167,7 @@ export const createSqlControlPlanePersistence = ( · 1 unmodified line
157167 ));
158168
159169 export const SqlControlPlanePersistenceLive = (
160− options: CreateSqlRuntimeOptions,
170+ options: CreateSqlControlPlanePersistenceOptions,
161171 ) =>
162172 Layer.scoped(
163173 SqlControlPlanePersistenceService,
packages/control-plane/src/persistence/post-migrations.test.ts+464−73
@@ -1,3 +1,8 @@
1+import { cpSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import path from "node:path";
4+import { fileURLToPath } from "node:url";
5+
16 import { describe, expect, it } from "@effect/vitest";
27 import {
38 AccountIdSchema,
@@ -13,29 +18,88 @@ import * as Effect from "effect/Effect"; · 9 unmodified lines
1318 import * as Option from "effect/Option";
1419 import * as Schema from "effect/Schema";
1520 import * as Scope from "effect/Scope";
16−import {
17− buildSchema,
18− getIntrospectionQuery,
19− graphqlSync,
20−} from "graphql";
21+import { buildSchema, getIntrospectionQuery, graphqlSync } from "graphql";
22+import { sql } from "drizzle-orm";
2123
2224 import {
2325 createSqlControlPlanePersistence,
2426 type SqlControlPlanePersistence,
2527 } from "./index";
26−import { runPostMigrationRepairs } from "./post-migrations";
28+import { runDataMigrations } from "./data-migrations";
29+import { loadSourceById } from "../runtime/source-store";
2730
28−const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
29− Effect.acquireRelease(
30− createSqlControlPlanePersistence({
31− localDataDir: ":memory:",
32− }),
33− (persistence) =>
34− Effect.tryPromise({
35− try: () => persistence.close(),
36− catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
37− }).pipe(Effect.orDie),
38− );
31+const openApiBindingConfigJson = (specUrl: string): string =>
32+ JSON.stringify({
33+ adapterKey: "openapi",
34+ specUrl,
35+ defaultHeaders: null,
36+ });
37+
38+const graphqlBindingConfigJson = (): string =>
39+ JSON.stringify({
40+ adapterKey: "graphql",
41+ defaultHeaders: null,
42+ });
43+
44+const 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+
66+const drizzleDir = path.resolve(
67+ path.dirname(fileURLToPath(import.meta.url)),
68+ "../../drizzle",
69+);
70+
71+const 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+
88+const 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+);
39103
40104 const seedMigratedSourceRecipe = (input: {
41105 persistence: SqlControlPlanePersistence;
@@ -43,15 +107,21 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line
43107 workspaceId: ReturnType<typeof WorkspaceIdSchema.make>;
44108 sourceId: ReturnType<typeof SourceIdSchema.make>;
45109 documentText: string;
46−}): Effect.Effect<{
47− recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;
48−}, unknown, never> =>
110+}): Effect.Effect<
111+ {
112+ recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>;
113+ },
114+ unknown,
115+ never
116+> =>
49117 Effect.gen(function* () {
50118 const now = Date.now();
51119 const accountId = AccountIdSchema.make(`acc_${input.sourceId}`);
52120 const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`);
53121 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+ );
55125
56126 yield* input.persistence.rows.organizations.insert({
57127 id: organizationId,
@@ -72,9 +142,11 @@ const seedMigratedSourceRecipe = (input: { · 14 unmodified lines
72142 });
73143 yield* input.persistence.rows.sourceRecipes.upsert({
74144 id: recipeId,
75− kind: input.kind === "openapi" ? "http_recipe" : "graphql_recipe",
76− importerKind: input.kind === "openapi" ? "openapi" : "graphql_introspection",
77− providerKey: input.kind === "openapi" ? "generic_http" : "generic_graphql",
145+ kind: "http_api",
146+ importerKind:
147+ input.kind === "openapi" ? "openapi" : "graphql_introspection",
148+ providerKey:
149+ input.kind === "openapi" ? "generic_http" : "generic_graphql",
78150 name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo",
79151 summary: null,
80152 visibility: "workspace",
@@ -82,7 +154,7 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line
82154 createdAt: now,
83155 updatedAt: now,
84156 });
85− yield* input.persistence.rows.sourceRecipeRevisions.upsert({
157+ yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
86158 id: recipeRevisionId,
87159 recipeId,
88160 revisionNumber: 1,
@@ -102,9 +174,10 @@ const seedMigratedSourceRecipe = (input: { · 13 unmodified lines
102174 ),
103175 manifestJson: null,
104176 manifestHash: null,
177+ materializationHash: null,
105178 createdAt: now,
106179 updatedAt: now,
107− });
180+ }));
108181 yield* input.persistence.rows.sources.insert({
109182 id: input.sourceId,
110183 workspaceId: input.workspaceId,
@@ -119,45 +192,47 @@ const seedMigratedSourceRecipe = (input: { · 8 unmodified lines
119192 status: "connected",
120193 enabled: true,
121194 namespace: input.kind === "openapi" ? "github" : "graphql",
122− transport: null,
123− bindingConfigJson: null,
124− queryParamsJson: null,
125− headersJson: null,
126− specUrl: input.kind === "openapi" ? "https://api.example.com/openapi.json" : null,
127− defaultHeadersJson: null,
195+ importAuthPolicy: "reuse_runtime",
196+ bindingConfigJson:
197+ input.kind === "openapi"
198+ ? openApiBindingConfigJson("https://api.example.com/openapi.json")
199+ : graphqlBindingConfigJson(),
128200 sourceHash: null,
129− sourceDocumentText: null,
130201 lastError: null,
131202 createdAt: now,
132203 updatedAt: now,
133204 });
134205 yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({
135206 recipeRevisionId,
136− documents: [{
137− id: `src_recipe_doc_${input.sourceId}`,
138− recipeRevisionId,
139− documentKind: input.kind === "openapi" ? "openapi" : "graphql_introspection",
140− documentKey:
141− input.kind === "openapi"
142− ? "https://api.example.com/openapi.json"
143− : "https://api.example.com/graphql",
144− contentText: input.documentText,
145− contentHash: `hash_${input.sourceId}`,
146− fetchedAt: now,
147− createdAt: now,
148− updatedAt: now,
149− }],
207+ documents: [
208+ {
209+ id: `src_recipe_doc_${input.sourceId}`,
210+ recipeRevisionId,
211+ documentKind:
212+ input.kind === "openapi" ? "openapi" : "graphql_introspection",
213+ documentKey:
214+ input.kind === "openapi"
215+ ? "https://api.example.com/openapi.json"
216+ : "https://api.example.com/graphql",
217+ contentText: input.documentText,
218+ contentHash: `hash_${input.sourceId}`,
219+ fetchedAt: now,
220+ createdAt: now,
221+ updatedAt: now,
222+ },
223+ ],
150224 });
151225
152226 return { recipeRevisionId };
153227 });
154228
155−describe("post-migrations", () => {
229+describe("data-migrations", () => {
156230 it.scoped("repairs migrated OpenAPI recipes from stored documents", () =>
157231 Effect.gen(function* () {
158232 const persistence = yield* makePersistence;
159233 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi");
160234 const sourceId = SourceIdSchema.make("src_post_migration_openapi");
235+ yield* persistence.rows.dataMigrations.clearAll();
161236
162237 const openApiDocument = JSON.stringify({
163238 openapi: "3.0.3",
@@ -189,11 +264,7 @@ describe("post-migrations", () => { · 25 unmodified lines
189264 content: {
190265 "application/json": {
191266 schema: {
192− type: "object",
193− properties: {
194− full_name: { type: "string" },
195− },
196− required: ["full_name"],
267+ $ref: "#/components/schemas/Repository",
197268 },
198269 },
199270 },
@@ -202,6 +273,17 @@ describe("post-migrations", () => { · 2 unmodified lines
202273 },
203274 },
204275 },
276+ components: {
277+ schemas: {
278+ Repository: {
279+ type: "object",
280+ properties: {
281+ full_name: { type: "string" },
282+ },
283+ required: ["full_name"],
284+ },
285+ },
286+ },
205287 });
206288
207289 const { recipeRevisionId } = yield* seedMigratedSourceRecipe({
@@ -212,13 +294,22 @@ describe("post-migrations", () => { · 4 unmodified lines
212294 documentText: openApiDocument,
213295 });
214296
215− yield* runPostMigrationRepairs(persistence.rows);
297+ yield* runDataMigrations(persistence.rows);
216298
217− const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
299+ const revision =
300+ yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
218301 expect(Option.isSome(revision)).toBe(true);
219302 expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();
220− expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length)
221− .toBeGreaterThan(0);
303+ expect(
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);
222313 }),
223314 );
224315
@@ -227,6 +318,7 @@ describe("post-migrations", () => { · 2 unmodified lines
227318 const persistence = yield* makePersistence;
228319 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql");
229320 const sourceId = SourceIdSchema.make("src_post_migration_graphql");
321+ yield* persistence.rows.dataMigrations.clearAll();
230322 const schema = buildSchema(`
231323 type Query {
232324 viewer: User!
@@ -260,13 +352,22 @@ describe("post-migrations", () => { · 27 unmodified lines
260352 documentText: graphqlDocument,
261353 });
262354
263− yield* runPostMigrationRepairs(persistence.rows);
355+ yield* runDataMigrations(persistence.rows);
264356
265− const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
357+ const revision =
358+ yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId);
266359 expect(Option.isSome(revision)).toBe(true);
267360 expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull();
268− expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length)
269− .toBeGreaterThan(0);
361+ expect(
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);
270371 }),
271372 );
272373
@@ -275,7 +376,10 @@ describe("post-migrations", () => { · 2 unmodified lines
275376 const persistence = yield* makePersistence;
276377 const workspaceId = WorkspaceIdSchema.make("ws_post_migration_session");
277378 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+ );
279383 const now = Date.now();
280384
281385 yield* persistence.rows.sourceAuthSessions.insert({
@@ -283,20 +387,23 @@ describe("post-migrations", () => { · 1 unmodified line
283387 workspaceId,
284388 sourceId,
285389 actorAccountId: null,
286− executionId: null,
287− interactionId: null,
288− providerKind: "mcp_oauth",
289− status: "pending",
390+ executionId: null,
391+ interactionId: null,
392+ providerKind: "mcp_oauth",
393+ credentialSlot: "runtime",
394+ status: "pending",
290395 state: "state_post_migration_session",
291396 sessionDataJson: JSON.stringify({
292397 kind: "mcp_oauth",
293398 endpoint: "https://api.github.com",
294399 redirectUri: "http://127.0.0.1/callback",
295400 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",
297403 authorizationServerUrl: "https://github.com/login/oauth",
298404 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"}',
300407 clientInformationJson: '{"client_id":"abc123"}',
301408 codeVerifier: "verifier",
302409 authorizationUrl: "https://github.com/login/oauth/authorize",
@@ -307,15 +414,16 @@ describe("post-migrations", () => { · 4 unmodified lines
307414 updatedAt: now,
308415 });
309416
310− yield* runPostMigrationRepairs(persistence.rows);
417+ yield* runDataMigrations(persistence.rows);
311418
312− const session = yield* persistence.rows.sourceAuthSessions.getById(sessionId);
419+ const session =
420+ yield* persistence.rows.sourceAuthSessions.getById(sessionId);
313421 expect(Option.isSome(session)).toBe(true);
314422 const repairedSession = session.pipe(Option.getOrNull);
315423 expect(repairedSession).not.toBeNull();
316− const repaired = Schema.decodeUnknownSync(McpSourceAuthSessionDataJsonSchema)(
317− repairedSession!.sessionDataJson,
318− );
424+ const repaired = Schema.decodeUnknownSync(
425+ McpSourceAuthSessionDataJsonSchema,
426+ )(repairedSession!.sessionDataJson);
319427 expect(repaired.resourceMetadata).toEqual({
320428 issuer: "https://api.github.com",
321429 });
@@ -327,4 +435,287 @@ describe("post-migrations", () => { · 5 unmodified lines
327435 });
328436 }),
329437 );
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+ });
330721 });
packages/control-plane/src/persistence/post-migrations.ts+0−415
@@ -1,415 +0,0 @@
1−import { randomUUID } from "node:crypto";
2−
3−import {
4− buildOpenApiToolPresentation,
5− compileOpenApiToolDefinitions,
6− extractOpenApiManifest,
7−} from "@executor/codemode-openapi";
8−import type {
9− McpSourceAuthSessionData,
10− StoredSourceRecipeDocumentRecord,
11− StoredSourceRecipeOperationRecord,
12− StoredSourceRecipeRevisionRecord,
13− StoredSourceRecord,
14− SourceAuthSession,
15−} from "#schema";
16−import {
17− JsonObjectSchema,
18− McpSourceAuthSessionDataJsonSchema,
19−} from "#schema";
20−import * as Either from "effect/Either";
21−import * as Effect from "effect/Effect";
22−import * as ParseResult from "effect/ParseResult";
23−import * as Schema from "effect/Schema";
24−
25−import {
26− buildGraphqlToolPresentation,
27− compileGraphqlToolDefinitions,
28− extractGraphqlManifest,
29−} from "../runtime/graphql-tools";
30−import type { SqlControlPlaneRows } from "./index";
31−
32−const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
33− parts
34− .flatMap((part) => (part ? [part.trim()] : []))
35− .filter(Boolean)
36− .join(" ")
37− .toLowerCase();
38−
39−const 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−
53−const decodeLegacyMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
54− Schema.parseJson(LegacyMcpSourceAuthSessionDataSchema),
55−);
56−
57−const decodeMcpSourceAuthSessionDataJson = Schema.decodeUnknownEither(
58− McpSourceAuthSessionDataJsonSchema,
59−);
60−
61−const encodeMcpSourceAuthSessionDataJson = Schema.encodeSync(
62− McpSourceAuthSessionDataJsonSchema,
63−);
64−
65−const decodeJsonObjectFromJson = Schema.decodeUnknownEither(
66− Schema.parseJson(JsonObjectSchema),
67−);
68−
69−const 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−
78−const 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−
119−const 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−
176−const 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−
232−const 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−
250−const 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−
293−const 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.
338−export 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
8888 ),
8989 )
9090 .orderBy(
91+ asc(tables.credentialsTable.slot),
9192 asc(tables.credentialsTable.actorAccountId),
9293 asc(tables.credentialsTable.updatedAt),
9394 asc(tables.credentialsTable.id),
@@ -100,6 +101,7 @@ export const createCredentialsRepo = ( · 6 unmodified lines
100101 workspaceId: Credential["workspaceId"];
101102 sourceId: Credential["sourceId"];
102103 actorAccountId: Credential["actorAccountId"];
104+ slot: Credential["slot"];
103105 }) =>
104106 client.use("rows.credentials.get_by_workspace_source_actor", async (db) => {
105107 const rows = await db
@@ -109,6 +111,7 @@ export const createCredentialsRepo = ( · 3 unmodified lines
109111 and(
110112 eq(tables.credentialsTable.workspaceId, input.workspaceId),
111113 eq(tables.credentialsTable.sourceId, input.sourceId),
114+ eq(tables.credentialsTable.slot, input.slot),
112115 input.actorAccountId === null
113116 ? isNull(tables.credentialsTable.actorAccountId)
114117 : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),
@@ -134,6 +137,7 @@ export const createCredentialsRepo = ( · 19 unmodified lines
134137 and(
135138 eq(tables.credentialsTable.workspaceId, credential.workspaceId),
136139 eq(tables.credentialsTable.sourceId, credential.sourceId),
140+ eq(tables.credentialsTable.slot, credential.slot),
137141 isNull(tables.credentialsTable.actorAccountId),
138142 ),
139143 )
@@ -170,6 +174,7 @@ export const createCredentialsRepo = ( · 30 unmodified lines
170174 tables.credentialsTable.workspaceId,
171175 tables.credentialsTable.sourceId,
172176 tables.credentialsTable.actorAccountId,
177+ tables.credentialsTable.slot,
173178 ],
174179 set: {
175180 ...credentialUpdateSet(credential),
@@ -181,6 +186,7 @@ export const createCredentialsRepo = ( · 5 unmodified lines
181186 workspaceId: Credential["workspaceId"];
182187 sourceId: Credential["sourceId"];
183188 actorAccountId: Credential["actorAccountId"];
189+ slot?: Credential["slot"];
184190 }) =>
185191 client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => {
186192 const deleted = await db
@@ -189,6 +195,9 @@ export const createCredentialsRepo = ( · 2 unmodified lines
189195 and(
190196 eq(tables.credentialsTable.workspaceId, input.workspaceId),
191197 eq(tables.credentialsTable.sourceId, input.sourceId),
198+ input.slot === undefined
199+ ? undefined
200+ : eq(tables.credentialsTable.slot, input.slot),
192201 input.actorAccountId === null
193202 ? isNull(tables.credentialsTable.actorAccountId)
194203 : eq(tables.credentialsTable.actorAccountId, input.actorAccountId),
packages/control-plane/src/persistence/repos/data-migrations-repo.ts+41−0
@@ -0,0 +1,41 @@
1+import type {
2+ StoredDataMigrationRecord,
3+} from "#schema";
4+import { StoredDataMigrationRecordSchema } from "#schema";
5+import { Schema } from "effect";
6+import { asc } from "drizzle-orm";
7+
8+import type { DrizzleClient } from "../client";
9+import type { DrizzleTables } from "../schema";
10+
11+const decodeStoredDataMigrationRecord = Schema.decodeUnknownSync(
12+ StoredDataMigrationRecordSchema,
13+);
14+
15+export 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
66 export { createSourceRecipesRepo } from "./source-recipes-repo";
77 export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo";
88 export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo";
9+export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo";
910 export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";
11+export { createDataMigrationsRepo } from "./data-migrations-repo";
1012 export { createCredentialsRepo } from "./credentials-repo";
1113 export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
12−export { createToolArtifactsRepo } from "./tool-artifacts-repo";
1314 export { createSecretMaterialsRepo } from "./secret-materials-repo";
1415 export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";
1516 export { createPoliciesRepo } from "./policies-repo";
packages/control-plane/src/persistence/repos/organizations-repo.ts+10−79
@@ -6,11 +6,12 @@ import { · 5 unmodified lines
66 } from "#schema";
77 import * as Option from "effect/Option";
88 import { Schema } from "effect";
9−import { and, asc, eq, inArray, or } from "drizzle-orm";
9+import { asc, eq, inArray } from "drizzle-orm";
1010
1111 import type { DrizzleClient } from "../client";
1212 import type { DrizzleTables } from "../schema";
1313 import {
14+ cleanupOrphanedSourceRecipes,
1415 firstOption,
1516 postgresSecretHandlesFromCredentials,
1617 withoutCreatedAt,
@@ -138,8 +139,8 @@ export const createOrganizationsRepo = ( · 121 unmodified lines
138139 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
139140 const sourceRows = await tx
140141 .select({
141− sourceId: tables.sourcesTable.sourceId,
142142 recipeId: tables.sourcesTable.recipeId,
143+ recipeRevisionId: tables.sourcesTable.recipeRevisionId,
143144 })
144145 .from(tables.sourcesTable)
145146 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
@@ -154,28 +155,8 @@ export const createOrganizationsRepo = ( · 8 unmodified lines
154155 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
155156
156157 const executionIds = executionRows.map((execution) => execution.id);
157− const sourceIds = sourceRows.map((source) => source.sourceId);
158158 const recipeIds = sourceRows.map((source) => source.recipeId);
159− const recipeRevisionRows = recipeIds.length > 0
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− : [];
159+ const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
179160 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
180161
181162 if (executionIds.length > 0) {
@@ -186,39 +167,6 @@ export const createOrganizationsRepo = ( · 4 unmodified lines
186167 );
187168 }
188169
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−
222170 await tx
223171 .delete(tables.executionsTable)
224172 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
@@ -235,33 +183,16 @@ export const createOrganizationsRepo = ( · 10 unmodified lines
235183 .delete(tables.credentialsTable)
236184 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
237185
238− await tx
239− .delete(tables.toolArtifactsTable)
240− .where(inArray(tables.toolArtifactsTable.workspaceId, workspaceIds));
241−
242186 await tx
243187 .delete(tables.sourcesTable)
244188 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
245189
246− if (recipeRevisionIds.length > 0) {
247− await tx
248− .delete(tables.sourceRecipeDocumentsTable)
249− .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds));
250−
251− await tx
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− }
190+ await cleanupOrphanedSourceRecipes({
191+ tx,
192+ tables,
193+ candidateRecipeIds: recipeIds,
194+ candidateRecipeRevisionIds: recipeRevisionIds,
195+ });
265196
266197 await tx
267198 .delete(tables.policiesTable)
packages/control-plane/src/persistence/repos/shared.ts+96−0
@@ -1,5 +1,9 @@
1+import { inArray } from "drizzle-orm";
12 import * as Option from "effect/Option";
23
4+import type { DrizzleClient } from "../client";
5+import type { DrizzleTables } from "../schema";
6+
37 export const firstOption = <A>(rows: ReadonlyArray<A>): Option.Option<A> =>
48 rows.length > 0 ? Option.some(rows[0] as A) : Option.none<A>();
59
@@ -37,3 +41,95 @@ export const postgresSecretHandlesFromCredentials = ( · 31 unmodified lines
3741
3842 return [...handles];
3943 };
44+
45+export 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+
61+export 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
7474 workspaceId: SourceAuthSession["workspaceId"];
7575 sourceId: SourceAuthSession["sourceId"];
7676 actorAccountId: SourceAuthSession["actorAccountId"];
77+ credentialSlot?: SourceAuthSession["credentialSlot"];
7778 }) =>
7879 client.use(
7980 "rows.source_auth_sessions.get_pending_by_workspace_source_actor",
@@ -85,6 +86,9 @@ export const createSourceAuthSessionsRepo = ( · 5 unmodified lines
8586 and(
8687 eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId),
8788 eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId),
89+ input.credentialSlot === undefined
90+ ? undefined
91+ : eq(tables.sourceAuthSessionsTable.credentialSlot, input.credentialSlot),
8892 input.actorAccountId === null
8993 ? isNull(tables.sourceAuthSessionsTable.actorAccountId)
9094 : 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
77
88 import type { DrizzleClient } from "../client";
99 import type { DrizzleTables } from "../schema";
10+import { chunkArray } from "./shared";
1011
1112 const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync(
1213 StoredSourceRecipeDocumentRecordSchema,
1314 );
1415
16+const RECIPE_DOCUMENT_INSERT_BATCH_SIZE = 500;
17+
1518 export const createSourceRecipeDocumentsRepo = (
1619 client: DrizzleClient,
1720 tables: DrizzleTables,
@@ -61,7 +64,12 @@ export const createSourceRecipeDocumentsRepo = ( · 43 unmodified lines
6164 .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId));
6265
6366 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+ }
6573 }
6674 }),
6775
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
77
88 import type { DrizzleClient } from "../client";
99 import type { DrizzleTables } from "../schema";
10+import { chunkArray } from "./shared";
1011
1112 const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync(
1213 StoredSourceRecipeOperationRecordSchema,
1314 );
1415
16+// Keep wide recipe-operation inserts well below Postgres/PGlite parameter limits.
17+const RECIPE_OPERATION_INSERT_BATCH_SIZE = 200;
18+
1519 export const createSourceRecipeOperationsRepo = (
1620 client: DrizzleClient,
1721 tables: DrizzleTables,
@@ -61,7 +65,12 @@ export const createSourceRecipeOperationsRepo = ( · 43 unmodified lines
6165 .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId));
6266
6367 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+ }
6574 }
6675 }),
6776
packages/control-plane/src/persistence/repos/source-recipe-revisions-repo.ts+47−1
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
44 } from "#schema";
55 import * as Option from "effect/Option";
66 import { Schema } from "effect";
7−import { asc, eq, inArray } from "drizzle-orm";
7+import { asc, desc, eq, inArray, and } from "drizzle-orm";
88
99 import type { DrizzleClient } from "../client";
1010 import type { DrizzleTables } from "../schema";
@@ -69,6 +69,42 @@ export const createSourceRecipeRevisionsRepo = ( · 58 unmodified lines
6969 : Option.none<StoredSourceRecipeRevisionRecord>();
7070 }),
7171
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+
72108 upsert: (revision: StoredSourceRecipeRevisionRecord) =>
73109 client.use("rows.source_recipe_revisions.upsert", async (db) => {
74110 await db
@@ -108,4 +144,14 @@ export const createSourceRecipeRevisionsRepo = ( · 33 unmodified lines
108144
109145 return deleted.length;
110146 }),
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+ }),
111157 });
packages/control-plane/src/persistence/repos/source-recipe-schema-bundles-repo.ts+106−0
@@ -0,0 +1,106 @@
1+import {
2+ type StoredSourceRecipeSchemaBundleRecord,
3+ StoredSourceRecipeSchemaBundleRecordSchema,
4+} from "#schema";
5+import * as Option from "effect/Option";
6+import { Schema } from "effect";
7+import { asc, eq, inArray } from "drizzle-orm";
8+
9+import type { DrizzleClient } from "../client";
10+import type { DrizzleTables } from "../schema";
11+import { firstOption, withoutCreatedAt } from "./shared";
12+
13+const decodeStoredSourceRecipeSchemaBundleRecord = Schema.decodeUnknownSync(
14+ StoredSourceRecipeSchemaBundleRecordSchema,
15+);
16+
17+export 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
44 } from "#schema";
55 import * as Option from "effect/Option";
66 import { Schema } from "effect";
7−import { and, asc, count, eq, or } from "drizzle-orm";
7+import { and, asc, count, eq } from "drizzle-orm";
88
99 import type { DrizzleClient } from "../client";
1010 import type { DrizzleTables } from "../schema";
@@ -26,16 +26,9 @@ const toSourceUpdateSet = ( · 15 unmodified lines
2626 if (patch.status !== undefined) set.status = patch.status;
2727 if (patch.enabled !== undefined) set.enabled = patch.enabled;
2828 if (patch.namespace !== undefined) set.namespace = patch.namespace;
29+ if (patch.importAuthPolicy !== undefined) set.importAuthPolicy = patch.importAuthPolicy;
2930 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− }
3731 if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash;
38− if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText;
3932 if (patch.lastError !== undefined) set.lastError = patch.lastError;
4033 if (patch.updatedAt !== undefined) set.updatedAt = patch.updatedAt;
4134
@@ -146,66 +139,6 @@ export const createSourcesRepo = ( · 104 unmodified lines
146139 sourceId: StoredSourceRecord["id"],
147140 ) =>
148141 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−
209142 const deleted = await tx
210143 .delete(tables.sourcesTable)
211144 .where(
packages/control-plane/src/persistence/repos/tool-artifacts-repo.ts+0−343
@@ -1,343 +0,0 @@
1−import {
2− type StoredToolArtifactParameterRecord,
3− StoredToolArtifactParameterRecordSchema,
4− type StoredToolArtifactRecord,
5− StoredToolArtifactRecordSchema,
6− type StoredToolArtifactRefHintKeyRecord,
7− StoredToolArtifactRefHintKeyRecordSchema,
8− type StoredToolArtifactRequestBodyContentTypeRecord,
9− StoredToolArtifactRequestBodyContentTypeRecordSchema,
10−} from "#schema";
11−import * as Option from "effect/Option";
12−import { Schema } from "effect";
13−import {
14− and,
15− asc,
16− count,
17− eq,
18− or,
19− sql,
20−} from "drizzle-orm";
21−
22−import type { DrizzleClient } from "../client";
23−import type { DrizzleTables } from "../schema";
24−import { firstOption } from "./shared";
25−
26−const decodeStoredToolArtifactRecord = Schema.decodeUnknownSync(
27− StoredToolArtifactRecordSchema,
28−);
29−const decodeStoredToolArtifactParameterRecord = Schema.decodeUnknownSync(
30− StoredToolArtifactParameterRecordSchema,
31−);
32−const decodeStoredToolArtifactRequestBodyContentTypeRecord = Schema.decodeUnknownSync(
33− StoredToolArtifactRequestBodyContentTypeRecordSchema,
34−);
35−const decodeStoredToolArtifactRefHintKeyRecord = Schema.decodeUnknownSync(
36− StoredToolArtifactRefHintKeyRecordSchema,
37−);
38−
39−const 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−
48−const 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−
60−const 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−
71−const 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−
88−const 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−
111−export 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 @@
11 import { type Workspace, WorkspaceSchema } from "#schema";
22 import * as Option from "effect/Option";
33 import { Schema } from "effect";
4−import { and, asc, eq, inArray, or } from "drizzle-orm";
4+import { and, asc, eq, inArray } from "drizzle-orm";
55
66 import type { DrizzleClient } from "../client";
77 import type { DrizzleTables } from "../schema";
8−import { firstOption, postgresSecretHandlesFromCredentials } from "./shared";
8+import {
9+ cleanupOrphanedSourceRecipes,
10+ firstOption,
11+ postgresSecretHandlesFromCredentials,
12+} from "./shared";
913
1014 const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
1115
@@ -71,14 +75,14 @@ export const createWorkspacesRepo = ( · 59 unmodified lines
7175 .where(eq(tables.executionsTable.workspaceId, workspaceId));
7276 const sourceRows = await tx
7377 .select({
74− sourceId: tables.sourcesTable.sourceId,
7578 recipeId: tables.sourcesTable.recipeId,
79+ recipeRevisionId: tables.sourcesTable.recipeRevisionId,
7680 })
7781 .from(tables.sourcesTable)
7882 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
7983 const executionIds = executionRows.map((execution) => execution.id);
80− const sourceIds = sourceRows.map((source) => source.sourceId);
8184 const recipeIds = sourceRows.map((source) => source.recipeId);
85+ const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
8286 const credentials = await tx
8387 .select({
8488 tokenProviderId: tables.credentialsTable.tokenProviderId,
@@ -88,26 +92,6 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
8892 })
8993 .from(tables.credentialsTable)
9094 .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− : [];
11195 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
11296
11397 if (executionIds.length > 0) {
@@ -116,39 +100,6 @@ export const createWorkspacesRepo = ( · 2 unmodified lines
116100 .where(inArray(tables.executionInteractionsTable.executionId, executionIds));
117101 }
118102
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−
152103 await tx
153104 .delete(tables.executionsTable)
154105 .where(eq(tables.executionsTable.workspaceId, workspaceId));
@@ -165,33 +116,16 @@ export const createWorkspacesRepo = ( · 10 unmodified lines
165116 .delete(tables.credentialsTable)
166117 .where(eq(tables.credentialsTable.workspaceId, workspaceId));
167118
168− await tx
169− .delete(tables.toolArtifactsTable)
170− .where(eq(tables.toolArtifactsTable.workspaceId, workspaceId));
171−
172119 await tx
173120 .delete(tables.sourcesTable)
174121 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
175122
176− if (recipeRevisionIds.length > 0) {
177− await tx
178− .delete(tables.sourceRecipeDocumentsTable)
179− .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds));
180−
181− await tx
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− }
123+ await cleanupOrphanedSourceRecipes({
124+ tx,
125+ tables,
126+ candidateRecipeIds: recipeIds,
127+ candidateRecipeRevisionIds: recipeRevisionIds,
128+ });
195129
196130 await tx
197131 .delete(tables.policiesTable)
packages/control-plane/src/persistence/schema.ts+58−187
@@ -19,11 +19,9 @@ export const tableNames = { · 18 unmodified lines
1919 sourceRecipes: "source_recipes",
2020 sourceRecipeRevisions: "source_recipe_revisions",
2121 sourceRecipeDocuments: "source_recipe_documents",
22+ sourceRecipeSchemaBundles: "source_recipe_schema_bundles",
2223 sourceRecipeOperations: "source_recipe_operations",
23− toolArtifacts: "tool_artifacts",
24− toolArtifactParameters: "tool_artifact_parameters",
25− toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",
26− toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",
24+ dataMigrations: "control_plane_data_migrations",
2725 credentials: "workspace_source_credentials",
2826 workspaceSourceOauthClients: "workspace_source_oauth_clients",
2927 secretMaterials: "secret_materials",
@@ -145,14 +143,9 @@ export const sourcesTable = pgTable( · 115 unmodified lines
145143 status: text("status").notNull(),
146144 enabled: boolean("enabled").notNull(),
147145 namespace: text("namespace"),
148− bindingConfigJson: text("binding_config_json"),
149− transport: text("transport"),
150− queryParamsJson: text("query_params_json"),
151− headersJson: text("headers_json"),
152− specUrl: text("spec_url"),
153− defaultHeadersJson: text("default_headers_json"),
146+ importAuthPolicy: text("import_auth_policy").notNull(),
147+ bindingConfigJson: text("binding_config_json").notNull(),
154148 sourceHash: text("source_hash"),
155− sourceDocumentText: text("source_document_text"),
156149 lastError: text("last_error"),
157150 createdAt: bigint("created_at", { mode: "number" }).notNull(),
158151 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
@@ -173,17 +166,13 @@ export const sourcesTable = pgTable( · 14 unmodified lines
173166 table.sourceId,
174167 ),
175168 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− ),
180169 check(
181170 "sources_status_check",
182171 sql`${table.status} in ('draft', 'probing', 'auth_required', 'connected', 'error')`,
183172 ),
184173 check(
185− "sources_transport_check",
186− sql`${table.transport} is null or ${table.transport} in ('auto', 'streamable-http', 'sse')`,
174+ "sources_import_auth_policy_check",
175+ sql`${table.importAuthPolicy} in ('none', 'reuse_runtime', 'separate')`,
187176 ),
188177 ],
189178 );
@@ -215,19 +204,7 @@ export const sourceRecipesTable = pgTable( · 25 unmodified lines
215204 ),
216205 check(
217206 "source_recipes_kind_check",
218− sql`${table.kind} in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')`,
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− )`,
207+ sql`${table.kind} in ('http_api', 'mcp', 'internal')`,
231208 ),
232209 check(
233210 "source_recipes_visibility_check",
@@ -245,6 +222,7 @@ export const sourceRecipeRevisionsTable = pgTable( · 11 unmodified lines
245222 sourceConfigJson: text("source_config_json").notNull(),
246223 manifestJson: text("manifest_json"),
247224 manifestHash: text("manifest_hash"),
225+ materializationHash: text("materialization_hash"),
248226 createdAt: bigint("created_at", { mode: "number" }).notNull(),
249227 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
250228 },
@@ -253,7 +231,11 @@ export const sourceRecipeRevisionsTable = pgTable( · 2 unmodified lines
253231 table.recipeId,
254232 table.revisionNumber,
255233 ),
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(
257239 table.recipeId,
258240 table.manifestHash,
259241 ),
@@ -289,16 +271,29 @@ export const sourceRecipeDocumentsTable = pgTable( · 29 unmodified lines
289271 table.createdAt,
290272 table.id,
291273 ),
292− check(
293− "source_recipe_documents_kind_check",
294− sql`${table.documentKind} in (
295− 'google_discovery',
296− 'openapi',
297− 'postman_collection',
298− 'postman_environment',
299− 'graphql_introspection',
300− 'mcp_manifest'
301− )`,
274+ ],
275+);
276+
277+export const sourceRecipeSchemaBundlesTable = pgTable(
278+ tableNames.sourceRecipeSchemaBundles,
279+ {
280+ id: text("id").notNull().primaryKey(),
281+ recipeRevisionId: text("recipe_revision_id").notNull(),
282+ bundleKind: text("bundle_kind").notNull(),
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,
302297 ),
303298 ],
304299 );
@@ -319,16 +314,6 @@ export const sourceRecipeOperationsTable = pgTable( · 14 unmodified lines
319314 outputSchemaJson: text("output_schema_json"),
320315 providerKind: text("provider_kind").notNull(),
321316 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"),
332317 createdAt: bigint("created_at", { mode: "number" }).notNull(),
333318 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
334319 },
@@ -355,13 +340,17 @@ export const sourceRecipeOperationsTable = pgTable( · 20 unmodified lines
355340 "source_recipe_operations_kind_check",
356341 sql`${table.operationKind} in ('read', 'write', 'delete', 'unknown')`,
357342 ),
358− check(
359− "source_recipe_operations_provider_kind_check",
360− sql`${table.providerKind} in ('mcp', 'openapi', 'graphql', 'internal')`,
361− ),
362343 ],
363344 );
364345
346+export const dataMigrationsTable = pgTable(
347+ tableNames.dataMigrations,
348+ {
349+ id: text("id").notNull().primaryKey(),
350+ appliedAt: bigint("applied_at", { mode: "number" }).notNull(),
351+ },
352+);
353+
365354 export const credentialsTable = pgTable(
366355 tableNames.credentials,
367356 {
@@ -369,6 +358,7 @@ export const credentialsTable = pgTable( · 1 unmodified line
369358 workspaceId: text("workspace_id").notNull(),
370359 sourceId: text("source_id").notNull(),
371360 actorAccountId: text("actor_account_id"),
361+ slot: text("slot").notNull(),
372362 authKind: text("auth_kind").notNull(),
373363 authHeaderName: text("auth_header_name").notNull(),
374364 authPrefix: text("auth_prefix").notNull(),
@@ -389,6 +379,7 @@ export const credentialsTable = pgTable( · 14 unmodified lines
389379 table.workspaceId,
390380 table.sourceId,
391381 table.actorAccountId,
382+ table.slot,
392383 ),
393384 index("credentials_workspace_source_idx").on(
394385 table.workspaceId,
@@ -400,6 +391,10 @@ export const credentialsTable = pgTable( · 5 unmodified lines
400391 "credentials_auth_kind_check",
401392 sql`${table.authKind} in ('bearer', 'oauth2')`,
402393 ),
394+ check(
395+ "credentials_slot_check",
396+ sql`${table.slot} in ('runtime', 'import')`,
397+ ),
403398 ],
404399 );
405400
@@ -431,134 +426,6 @@ export const workspaceSourceOauthClientsTable = pgTable( · 25 unmodified lines
431426 ],
432427 );
433428
434−export 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−
511−export 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−
532−export 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−
547−export 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−
562429 export const secretMaterialsTable = pgTable(
563430 tableNames.secretMaterials,
564431 {
@@ -585,6 +452,7 @@ export const sourceAuthSessionsTable = pgTable( · 20 unmodified lines
585452 workspaceId: text("workspace_id").notNull(),
586453 sourceId: text("source_id").notNull(),
587454 actorAccountId: text("actor_account_id"),
455+ credentialSlot: text("credential_slot").notNull(),
588456 executionId: text("execution_id"),
589457 interactionId: text("interaction_id"),
590458 providerKind: text("provider_kind").notNull(),
@@ -606,6 +474,7 @@ export const sourceAuthSessionsTable = pgTable( · 15 unmodified lines
606474 table.workspaceId,
607475 table.sourceId,
608476 table.actorAccountId,
477+ table.credentialSlot,
609478 table.status,
610479 table.updatedAt,
611480 table.id,
@@ -619,6 +488,10 @@ export const sourceAuthSessionsTable = pgTable( · 7 unmodified lines
619488 "source_auth_sessions_status_check",
620489 sql`${table.status} in ('pending', 'completed', 'failed', 'cancelled')`,
621490 ),
491+ check(
492+ "source_auth_sessions_credential_slot_check",
493+ sql`${table.credentialSlot} in ('runtime', 'import')`,
494+ ),
622495 ],
623496 );
624497
@@ -762,13 +635,11 @@ export const drizzleSchema = { · 137 unmodified lines
762635 sourceRecipesTable,
763636 sourceRecipeRevisionsTable,
764637 sourceRecipeDocumentsTable,
638+ sourceRecipeSchemaBundlesTable,
765639 sourceRecipeOperationsTable,
640+ dataMigrationsTable,
766641 credentialsTable,
767642 workspaceSourceOauthClientsTable,
768− toolArtifactsTable,
769− toolArtifactParametersTable,
770− toolArtifactRequestBodyContentTypesTable,
771− toolArtifactRefHintKeysTable,
772643 secretMaterialsTable,
773644 sourceAuthSessionsTable,
774645 policiesTable,
packages/control-plane/src/runtime/control-plane-runtime.test.ts+11−14
@@ -29,6 +29,13 @@ const makeRuntime = Effect.acquireRelease( · 28 unmodified lines
2929 (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie),
3030 );
3131
32+const openApiBindingConfigJson = (specUrl: string): string =>
33+ JSON.stringify({
34+ adapterKey: "openapi",
35+ specUrl,
36+ defaultHeaders: null,
37+ });
38+
3239 type OpenApiSpecServer = {
3340 baseUrl: string;
3441 specUrl: string;
@@ -288,14 +295,9 @@ describe("control-plane-runtime", () => { · 253 unmodified lines
288295 status: "auth_required",
289296 enabled: true,
290297 namespace: "github",
291− bindingConfigJson: null,
292− transport: null,
293− queryParamsJson: null,
294− headersJson: null,
295− specUrl: "https://example.com/github-openapi.yaml",
296− defaultHeadersJson: null,
298+ importAuthPolicy: "reuse_runtime",
299+ bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
297300 sourceHash: null,
298− sourceDocumentText: null,
299301 lastError: null,
300302 createdAt: now,
301303 updatedAt: now,
@@ -448,14 +450,9 @@ describe("control-plane-runtime", () => { · 146 unmodified lines
448450 status: "auth_required",
449451 enabled: true,
450452 namespace: "github",
451− bindingConfigJson: null,
452− transport: null,
453− queryParamsJson: null,
454− headersJson: null,
455− specUrl: "https://example.com/github-openapi.yaml",
456− defaultHeadersJson: null,
453+ importAuthPolicy: "reuse_runtime",
454+ bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"),
457455 sourceHash: null,
458− sourceDocumentText: null,
459456 lastError: null,
460457 createdAt: now,
461458 updatedAt: now,
packages/control-plane/src/runtime/executor-tools.ts+101−97
@@ -36,59 +36,20 @@ import { · 35 unmodified lines
3636 deriveSchemaJson,
3737 deriveSchemaTypeSignature,
3838 } from "./schema-type-signature";
39+import {
40+ ExecutorAddSourceInputSchema,
41+ executorAddableSourceAdapters,
42+} from "./source-adapters";
3943 import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
4044
41−const 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−
48−const 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−
56−const 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−
63−const ExecutorSourcesAddSchema = Schema.Union(
64− ExecutorMcpSourceAddInputSchema,
65− ExecutorOpenApiSourceAddInputSchema,
66− ExecutorGraphqlSourceAddInputSchema,
67−);
68−
6945 const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1(
70− ExecutorSourcesAddSchema,
46+ ExecutorAddSourceInputSchema,
7147 );
7248
7349 const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(SourceSchema);
7450
75−export const EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE = deriveSchemaTypeSignature(
76− ExecutorMcpSourceAddInputSchema,
77− 240,
78−);
79−
80−export const EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE = deriveSchemaTypeSignature(
81− ExecutorOpenApiSourceAddInputSchema,
82− 420,
83−);
84−
85−export const EXECUTOR_SOURCES_ADD_GRAPHQL_INPUT_SIGNATURE = deriveSchemaTypeSignature(
86− ExecutorGraphqlSourceAddInputSchema,
87− 320,
88−);
89−
9051 export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature(
91− ExecutorSourcesAddInputSchema,
52+ ExecutorAddSourceInputSchema,
9253 320,
9354 );
9455
@@ -98,7 +59,7 @@ export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature( · 3 unmodified lines
9859 );
9960
10061 export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify(
101− deriveSchemaJson(ExecutorSourcesAddSchema) ?? {},
62+ deriveSchemaJson(ExecutorAddSourceInputSchema) ?? {},
10263 );
10364
10465 export 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
10768
10869 export const EXECUTOR_SOURCES_ADD_HELP_LINES = [
10970 "Source add input shapes:",
110− `- MCP: ${EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE}`,
111− ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.',
112− `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`,
113− " endpoint is the base API URL. specUrl is the OpenAPI document URL.",
114− `- GraphQL: ${EXECUTOR_SOURCES_ADD_GRAPHQL_INPUT_SIGNATURE}`,
115− " endpoint is the GraphQL HTTP endpoint.",
71+ ...executorAddableSourceAdapters.flatMap((adapter) =>
72+ adapter.executorAddInputSchema
73+ && adapter.executorAddInputSignatureWidth !== null
74+ && adapter.executorAddHelpText
75+ ? [
76+ `- ${adapter.displayName}: ${deriveSchemaTypeSignature(adapter.executorAddInputSchema, adapter.executorAddInputSignatureWidth)}`,
77+ ...adapter.executorAddHelpText.map((line) => ` ${line}`),
78+ ]
79+ : [],
80+ ),
11681 " executor handles the credential setup for you.",
11782 ] as const;
11883
@@ -168,6 +133,7 @@ const promptForSourceCredentialSelection = (input: { · 49 unmodified lines
168133 name?: string | null;
169134 namespace?: string | null;
170135 };
136+ credentialSlot: "runtime" | "import";
171137 source: Source;
172138 executionId: string;
173139 interactionId: string;
@@ -200,7 +166,10 @@ const promptForSourceCredentialSelection = (input: { · 26 unmodified lines
200166 context: input.invocation,
201167 elicitation: {
202168 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}`,
204173 url: resolveLocalCredentialUrl({
205174 baseUrl: input.localServerBaseUrl,
206175 workspaceId: input.args.workspaceId,
@@ -272,7 +241,7 @@ export const createExecutorToolMap = (input: { · 65 unmodified lines
272241 `executor.sources.add:${crypto.randomUUID()}`,
273242 );
274243 const preparedArgs: ExecutorAddSourceInput =
275− args.kind === "openapi" || args.kind === "graphql"
244+ args.kind === "openapi"
276245 ? {
277246 ...args,
278247 workspaceId: input.workspaceId,
@@ -280,6 +249,14 @@ export const createExecutorToolMap = (input: { · 1 unmodified line
280249 executionId,
281250 interactionId,
282251 }
252+ : args.kind === "graphql"
253+ ? {
254+ ...args,
255+ workspaceId: input.workspaceId,
256+ actorAccountId: input.accountId,
257+ executionId,
258+ interactionId,
259+ }
283260 : {
284261 kind: args.kind,
285262 endpoint: args.endpoint,
@@ -290,7 +267,7 @@ export const createExecutorToolMap = (input: { · 4 unmodified lines
290267 executionId,
291268 interactionId,
292269 };
293− const result = await runEffect(
270+ let result = await runEffect(
294271 input.sourceAuthService.addExecutorSource(
295272 preparedArgs,
296273 context?.onElicitation
@@ -313,61 +290,88 @@ export const createExecutorToolMap = (input: { · 16 unmodified lines
313290 }
314291
315292 if (result.kind === "credential_required") {
316− const preparedHttpArgs = preparedArgs as Extract<
293+ let pendingResult = result;
294+ let pendingArgs = preparedArgs as Extract<
317295 ExecutorAddSourceInput,
318296 { kind: "openapi" | "graphql" }
319297 >;
320− const selectedAuth = await runEffect(
321− promptForSourceCredentialSelection({
322− args: {
323− ...preparedHttpArgs,
324− workspaceId: input.workspaceId,
325− sourceId: result.source.id,
326− },
327− source: result.source,
328− executionId,
329− interactionId,
330− path: context?.path ?? asToolPath("executor.sources.add"),
331− sourceKey: context?.sourceKey ?? "executor",
332− localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(),
333− metadata: context?.metadata,
334− invocation: context?.invocation,
335− onElicitation: context?.onElicitation,
336− }),
337− );
338−
339− const completed = await runEffect(
340− input.sourceAuthService.addExecutorSource(
341− {
342− ...preparedHttpArgs,
343− auth: selectedAuth,
344− },
345− context?.onElicitation
346− ? {
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− },
298+
299+ while (pendingResult.kind === "credential_required") {
300+ const selectedAuth = await runEffect(
301+ promptForSourceCredentialSelection({
302+ args: {
303+ ...pendingArgs,
304+ workspaceId: input.workspaceId,
305+ sourceId: pendingResult.source.id,
306+ },
307+ credentialSlot: pendingResult.credentialSlot,
308+ source: pendingResult.source,
309+ executionId,
310+ interactionId,
311+ path: context?.path ?? asToolPath("executor.sources.add"),
312+ sourceKey: context?.sourceKey ?? "executor",
313+ localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(),
314+ metadata: context?.metadata,
315+ invocation: context?.invocation,
316+ onElicitation: context?.onElicitation,
317+ }),
318+ );
319+
320+ pendingArgs = pendingResult.credentialSlot === "import"
321+ && pendingArgs.importAuthPolicy === "separate"
322+ ? {
323+ ...pendingArgs,
324+ importAuth: selectedAuth,
355325 }
356− : undefined,
357− ),
358− );
326+ : {
327+ ...pendingArgs,
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+ }
359357
360− if (completed.kind === "connected") {
361− return completed.source;
358+ result = completed;
359+ break;
362360 }
363361
364− throw new Error(`Source add was not completed for ${result.source.id}`);
362+ if (pendingResult.kind === "credential_required") {
363+ result = pendingResult;
364+ }
365365 }
366366
367367 if (!context?.onElicitation) {
368368 throw new Error("executor.sources.add requires an elicitation-capable host");
369369 }
370370
371+ if (result.kind !== "oauth_required") {
372+ throw new Error(`Source add did not reach OAuth continuation for ${result.source.id}`);
373+ }
374+
371375 const response: ElicitationResponse = await runEffect(
372376 context.onElicitation({
373377 interactionId,
packages/control-plane/src/runtime/graphql-tools.ts+615−326
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 typeSignatureFromSchemaJson,
1111 } from "@executor/codemode-core";
1212 import * as Effect from "effect/Effect";
13+import * as Schema from "effect/Schema";
1314 import {
1415 buildClientSchema,
1516 getIntrospectionQuery,
@@ -25,13 +26,18 @@ import { · 9 unmodified lines
2526 isUnionType,
2627 type GraphQLArgument,
2728 type GraphQLField,
29+ type GraphQLInputObjectType,
2830 type GraphQLInputType,
31+ type GraphQLInterfaceType,
32+ type GraphQLNamedType,
33+ type GraphQLObjectType,
2934 type GraphQLOutputType,
3035 type GraphQLSchema,
3136 type IntrospectionQuery,
3237 } from "graphql";
3338
3439 type JsonSchema = Record<string, unknown>;
40+type GraphqlSchemaRefTable = Record<string, string>;
3541
3642 type GraphqlToolKind = "request" | "field";
3743
@@ -72,6 +78,7 @@ export type GraphqlToolManifest = { · 34 unmodified lines
7278 queryTypeName: string | null;
7379 mutationTypeName: string | null;
7480 subscriptionTypeName: string | null;
81+ schemaRefTable?: GraphqlSchemaRefTable;
7582 tools: readonly GraphqlToolManifestEntry[];
7683 };
7784
@@ -98,6 +105,28 @@ export type GraphqlToolPresentation = { · 20 unmodified lines
98105 providerDataJson: string;
99106 };
100107
108+const GraphqlToolKindSchema = Schema.Literal("request", "field");
109+
110+const GraphqlOperationTypeSchema = Schema.Literal("query", "mutation");
111+
112+export 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+
128+export type GraphqlToolProviderData = typeof GraphqlToolProviderDataSchema.Type;
129+
101130 type SelectedGraphqlOutput = {
102131 selectionSet: string;
103132 schema: JsonSchema;
@@ -218,11 +247,11 @@ const normalizeHttpUrl = (value: string): string => { · 114 unmodified lines
218247 };
219248
220249 const graphqlToolError = (message: string, cause?: unknown): Error =>
221− new Error(
222− cause instanceof Error ? `${message}: ${cause.message}` : message,
223− );
250+ new Error(cause instanceof Error ? `${message}: ${cause.message}` : message);
224251
225−const parseGraphqlResponseBody = async (response: Response): Promise<unknown> => {
252+const parseGraphqlResponseBody = async (
253+ response: Response,
254+): Promise<unknown> => {
226255 const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
227256 if (contentType.includes("application/json")) {
228257 return await response.json();
@@ -271,10 +300,8 @@ const withSchemaDescription = ( · 42 unmodified lines
271300 return trimmed ? { ...schema, description: trimmed } : schema;
272301 };
273302
274−const withSchemaDefault = (
275− schema: JsonSchema,
276− value: unknown,
277−): JsonSchema => (value !== undefined ? { ...schema, default: value } : schema);
303+const withSchemaDefault = (schema: JsonSchema, value: unknown): JsonSchema =>
304+ value !== undefined ? { ...schema, default: value } : schema;
278305
279306 const withSchemaDeprecation = (
280307 schema: JsonSchema,
@@ -286,7 +313,9 @@ const withSchemaDeprecation = ( · 5 unmodified lines
286313 : schema;
287314 };
288315
289−const introspectionQueryFromDocument = (documentText: string): IntrospectionQuery => {
316+const introspectionQueryFromDocument = (
317+ documentText: string,
318+): IntrospectionQuery => {
290319 let parsed: unknown;
291320 try {
292321 parsed = JSON.parse(documentText) as unknown;
@@ -416,68 +445,286 @@ const scalarExampleValue = (name: string): unknown => { · 123 unmodified lines
416445 }
417446 };
418447
419−const schemaForGraphqlInputType = (
420− type: GraphQLInputType,
421− depth = 0,
422−): JsonSchema => {
423− if (isNonNullType(type)) {
424− return schemaForGraphqlInputType(type.ofType, depth);
425− }
448+const GRAPHQL_SCHEMA_REF_PREFIX = "#/$defs/graphql";
426449
427− if (isListType(type)) {
428− return {
429− type: "array",
430− items: schemaForGraphqlInputType(type.ofType, depth + 1),
431− };
432− }
450+const graphqlScalarRef = (name: string): string =>
451+ `${GRAPHQL_SCHEMA_REF_PREFIX}/scalars/${name}`;
433452
434− const namedType = getNamedType(type);
435− if (isScalarType(namedType)) {
436− return scalarInputSchema(namedType.name);
437− }
453+const graphqlEnumRef = (name: string): string =>
454+ `${GRAPHQL_SCHEMA_REF_PREFIX}/enums/${name}`;
438455
439− if (isEnumType(namedType)) {
440− return {
441− type: "string",
442− enum: namedType.getValues().map((value) => value.name),
443− };
444− }
456+const graphqlInputObjectRef = (name: string): string =>
457+ `${GRAPHQL_SCHEMA_REF_PREFIX}/input/${name}`;
445458
446− if (isInputObjectType(namedType)) {
447− if (depth >= 2) {
448− return {
459+const graphqlOutputTypeRef = (name: string, depth: number): string =>
460+ `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`;
461+
462+const schemaRef = (ref: string): JsonSchema => ({
463+ $ref: ref,
464+});
465+
466+const typenameOnlyObjectSchema = (): JsonSchema => ({
467+ type: "object",
468+ properties: {
469+ __typename: { type: "string" },
470+ },
471+ required: ["__typename"],
472+ additionalProperties: false,
473+});
474+
475+const 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, {
449536 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),
451555 };
452556 }
453557
454− const fields = Object.values(namedType.getFields());
455− const properties = Object.fromEntries(
456− fields.map((field) => {
457− const schema = schemaForGraphqlInputType(field.type, depth + 1);
458− const enriched = withSchemaDeprecation(
459− withSchemaDefault(
460− withSchemaDescription(schema, field.description),
461− field.defaultValue,
462− ),
463− field.deprecationReason,
464− );
465− return [field.name, enriched];
466− }),
558+ const namedType = getNamedType(type);
559+ if (isScalarType(namedType)) {
560+ return ensureScalarRef(namedType.name);
561+ }
562+
563+ if (isEnumType(namedType)) {
564+ return ensureEnumRef(
565+ namedType.name,
566+ namedType.getValues().map((value) => value.name),
567+ );
568+ }
569+
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)),
467640 );
468− const required = fields
469− .filter((field) => isNonNullType(field.type) && field.defaultValue === undefined)
470− .map((field) => field.name);
471641
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, {
473687 type: "object",
474688 properties,
475− ...(required.length > 0 ? { required } : {}),
689+ required,
476690 additionalProperties: false,
477− };
478− }
691+ });
479692
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+ };
481728 };
482729
483730 const exampleValueForGraphqlInputType = (
@@ -510,14 +757,14 @@ const exampleValueForGraphqlInputType = ( · 26 unmodified lines
510757 const requiredFields = fields.filter(
511758 (field) => isNonNullType(field.type) && field.defaultValue === undefined,
512759 );
513− const fieldsForExample = requiredFields.length > 0
514− ? requiredFields
515− : fields.slice(0, 1);
760+ const fieldsForExample =
761+ requiredFields.length > 0 ? requiredFields : fields.slice(0, 1);
516762
517763 return Object.fromEntries(
518764 fieldsForExample.map((field) => [
519765 field.name,
520− field.defaultValue ?? exampleValueForGraphqlInputType(field.type, depth + 1),
766+ field.defaultValue ??
767+ exampleValueForGraphqlInputType(field.type, depth + 1),
521768 ]),
522769 );
523770 }
@@ -525,35 +772,9 @@ const exampleValueForGraphqlInputType = ( · 1 unmodified line
525772 return {};
526773 };
527774
528−const scalarOutputSchema = (name: string): JsonSchema => scalarInputSchema(name);
529−
530−const selectedLeafOutputForType = (type: GraphQLOutputType): SelectedGraphqlOutput => {
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−
556−const dedupeFields = <T extends { name: string }>(fields: readonly T[]): T[] => {
775+const dedupeFields = <T extends { name: string }>(
776+ fields: readonly T[],
777+): T[] => {
557778 const seen = new Set<string>();
558779 const deduped: T[] = [];
559780
@@ -585,155 +806,6 @@ const pickPreferredFields = <T extends { name: string }>(input: { · 25 unmodified lines
585806 return dedupeFields([...matches, ...input.fields]).slice(0, input.limit);
586807 };
587808
588−const 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−
695−const 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−
737809 const printGraphqlType = (type: GraphQLInputType): string => {
738810 if (isNonNullType(type)) {
739811 return `${printGraphqlType(type.ofType)}!`;
@@ -746,10 +818,13 @@ const printGraphqlType = (type: GraphQLInputType): string => { · 6 unmodified lines
746818 return type.name;
747819 };
748820
749−const inputSchemaForFieldArguments = (args: readonly GraphQLArgument[]): JsonSchema => {
821+const inputSchemaForFieldArguments = (
822+ args: readonly GraphQLArgument[],
823+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>,
824+): JsonSchema => {
750825 const properties = Object.fromEntries(
751826 args.map((arg) => {
752− const schema = schemaForGraphqlInputType(arg.type);
827+ const schema = bundleBuilder.inputSchemaForType(arg.type);
753828 const enriched = withSchemaDeprecation(
754829 withSchemaDefault(
755830 withSchemaDescription(schema, arg.description),
@@ -781,8 +856,11 @@ const inputSchemaForFieldArguments = (args: readonly GraphQLArgument[]): JsonSch · 25 unmodified lines
781856 };
782857 };
783858
784−const outputEnvelopeSchemaForField = (fieldType: GraphQLOutputType): JsonSchema => {
785− const selectedOutput = selectedOutputForType(fieldType);
859+const outputEnvelopeSchemaForField = (
860+ fieldType: GraphQLOutputType,
861+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>,
862+): JsonSchema => {
863+ const selectedOutput = bundleBuilder.selectedOutputForType(fieldType);
786864
787865 return {
788866 type: "object",
@@ -801,7 +879,9 @@ const outputEnvelopeSchemaForField = (fieldType: GraphQLOutputType): JsonSchema · 12 unmodified lines
801879 };
802880 };
803881
804−const exampleInputJsonForField = (args: readonly GraphQLArgument[]): string | undefined => {
882+const exampleInputJsonForField = (
883+ args: readonly GraphQLArgument[],
884+): string | undefined => {
805885 if (args.length === 0) {
806886 return JSON.stringify({});
807887 }
@@ -809,7 +889,8 @@ const exampleInputJsonForField = (args: readonly GraphQLArgument[]): string | un · 1 unmodified line
809889 const requiredArgs = args.filter(
810890 (arg) => isNonNullType(arg.type) && arg.defaultValue === undefined,
811891 );
812− const argsForExample = requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1);
892+ const argsForExample =
893+ requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1);
813894 const example = Object.fromEntries(
814895 argsForExample.map((arg) => [
815896 arg.name,
@@ -825,8 +906,11 @@ const buildGraphqlFieldOperationDocument = (input: { · 9 unmodified lines
825906 fieldName: string;
826907 args: readonly GraphQLArgument[];
827908 fieldType: GraphQLOutputType;
909+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>;
828910 }): { operationName: string; operationDocument: string } => {
829− const selectedOutput = selectedOutputForType(input.fieldType);
911+ const selectedOutput = input.bundleBuilder.selectedOutputForType(
912+ input.fieldType,
913+ );
830914 const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`;
831915 const variableDefinitions = input.args
832916 .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`)
@@ -834,12 +918,14 @@ const buildGraphqlFieldOperationDocument = (input: { · 1 unmodified line
834918 const fieldArguments = input.args
835919 .map((arg) => `${arg.name}: $${arg.name}`)
836920 .join(", ");
837− const fieldCall = fieldArguments.length > 0
838− ? `${input.fieldName}(${fieldArguments})`
839− : input.fieldName;
840− const selectionSuffix = selectedOutput.selectionSet.length > 0
841− ? ` ${selectedOutput.selectionSet}`
842− : "";
921+ const fieldCall =
922+ fieldArguments.length > 0
923+ ? `${input.fieldName}(${fieldArguments})`
924+ : input.fieldName;
925+ const selectionSuffix =
926+ selectedOutput.selectionSet.length > 0
927+ ? ` ${selectedOutput.selectionSet}`
928+ : "";
843929
844930 return {
845931 operationName,
@@ -907,23 +993,30 @@ const resolveGraphqlFieldToolIds = ( · 61 unmodified lines
907993 }
908994 };
909995
910− applyDuplicates((draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`);
911− applyDuplicates((draft) =>
912− `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`,
996+ applyDuplicates(
997+ (draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`,
998+ );
999+ applyDuplicates(
1000+ (draft) =>
1001+ `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`,
9131002 );
9141003
9151004 return staged
916− .sort((left, right) =>
917− left.toolId.localeCompare(right.toolId)
918− || left.fieldName.localeCompare(right.fieldName)
919− || left.operationType.localeCompare(right.operationType),
1005+ .sort(
1006+ (left, right) =>
1007+ left.toolId.localeCompare(right.toolId) ||
1008+ left.fieldName.localeCompare(right.fieldName) ||
1009+ left.operationType.localeCompare(right.operationType),
9201010 )
9211011 .map((draft) => ({ ...draft }));
9221012 };
9231013
9241014 const fieldToolDraftsFromRootType = (input: {
925− rootType: ReturnType<GraphQLSchema["getQueryType"]> | ReturnType<GraphQLSchema["getMutationType"]>;
1015+ rootType:
1016+ | ReturnType<GraphQLSchema["getQueryType"]>
1017+ | ReturnType<GraphQLSchema["getMutationType"]>;
9261018 operationType: GraphqlOperationType;
1019+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>;
9271020 }): GraphqlFieldToolDraft[] => {
9281021 if (!input.rootType) {
9291022 return [];
@@ -933,14 +1026,20 @@ const fieldToolDraftsFromRootType = (input: { · 3 unmodified lines
9331026 .filter((field) => !field.name.startsWith("__"))
9341027 .map((field) => {
9351028 const leaf = toCamelCase(field.name);
936− const outputSchemaJson = JSON.stringify(outputEnvelopeSchemaForField(field.type));
937− const inputSchemaJson = JSON.stringify(inputSchemaForFieldArguments(field.args));
938− const { operationName, operationDocument } = buildGraphqlFieldOperationDocument({
939− operationType: input.operationType,
940− fieldName: field.name,
941− args: field.args,
942− fieldType: field.type,
943− });
1029+ const outputSchemaJson = JSON.stringify(
1030+ outputEnvelopeSchemaForField(field.type, input.bundleBuilder),
1031+ );
1032+ const inputSchemaJson = JSON.stringify(
1033+ inputSchemaForFieldArguments(field.args, input.bundleBuilder),
1034+ );
1035+ const { operationName, operationDocument } =
1036+ buildGraphqlFieldOperationDocument({
1037+ operationType: input.operationType,
1038+ fieldName: field.name,
1039+ args: field.args,
1040+ fieldType: field.type,
1041+ bundleBuilder: input.bundleBuilder,
1042+ });
9441043
9451044 return {
9461045 kind: "field",
@@ -983,14 +1082,17 @@ export const extractGraphqlManifest = ( · 36 unmodified lines
9831082 const introspection = introspectionQueryFromDocument(documentText);
9841083 const schema = buildClientSchema(introspection);
9851084 const sourceHash = createGraphqlSourceHash(documentText);
1085+ const bundleBuilder = createGraphqlSchemaRefTableBuilder();
9861086 const fieldTools = resolveGraphqlFieldToolIds([
9871087 ...fieldToolDraftsFromRootType({
9881088 rootType: schema.getQueryType(),
9891089 operationType: "query",
1090+ bundleBuilder,
9901091 }),
9911092 ...fieldToolDraftsFromRootType({
9921093 rootType: schema.getMutationType(),
9931094 operationType: "mutation",
1095+ bundleBuilder,
9941096 }),
9951097 ]);
9961098
@@ -1000,10 +1102,10 @@ export const extractGraphqlManifest = ( · 3 unmodified lines
10001102 queryTypeName: schema.getQueryType()?.name ?? null,
10011103 mutationTypeName: schema.getMutationType()?.name ?? null,
10021104 subscriptionTypeName: schema.getSubscriptionType()?.name ?? null,
1003− tools: [
1004− ...fieldTools,
1005− requestToolManifestEntry(sourceName),
1006− ],
1105+ ...(Object.keys(bundleBuilder.refTable).length > 0
1106+ ? { schemaRefTable: bundleBuilder.refTable }
1107+ : {}),
1108+ tools: [...fieldTools, requestToolManifestEntry(sourceName)],
10071109 } satisfies GraphqlToolManifest;
10081110 },
10091111 catch: (cause) =>
@@ -1024,23 +1126,38 @@ export const compileGraphqlToolDefinitions = ( · 14 unmodified lines
10241126 operationType: tool.kind === "field" ? tool.operationType : null,
10251127 operationName: tool.kind === "field" ? tool.operationName : null,
10261128 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"],
10281133 }));
10291134
10301135 export const buildGraphqlToolPresentation = (input: {
10311136 manifest: GraphqlToolManifest;
10321137 definition: GraphqlToolDefinition;
10331138 }): 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+ );
10351142 const inputSchemaJson = entry?.inputSchemaJson;
10361143 const outputSchemaJson = entry?.outputSchemaJson;
10371144
10381145 return {
1039− inputType: typeSignatureFromSchemaJson(inputSchemaJson, "unknown", Infinity),
1040− outputType: typeSignatureFromSchemaJson(outputSchemaJson, "unknown", Infinity),
1146+ inputType: typeSignatureFromSchemaJson(
1147+ inputSchemaJson,
1148+ "unknown",
1149+ Infinity,
1150+ ),
1151+ outputType: typeSignatureFromSchemaJson(
1152+ outputSchemaJson,
1153+ "unknown",
1154+ Infinity,
1155+ ),
10411156 ...(inputSchemaJson ? { inputSchemaJson } : {}),
10421157 ...(outputSchemaJson ? { outputSchemaJson } : {}),
1043− ...(entry?.exampleInputJson ? { exampleInputJson: entry.exampleInputJson } : {}),
1158+ ...(entry?.exampleInputJson
1159+ ? { exampleInputJson: entry.exampleInputJson }
1160+ : {}),
10441161 providerDataJson: JSON.stringify({
10451162 kind: "graphql",
10461163 toolKind: entry?.kind ?? "request",
@@ -1051,11 +1168,196 @@ export const buildGraphqlToolPresentation = (input: { · 4 unmodified lines
10511168 fieldName: input.definition.fieldName,
10521169 operationType: input.definition.operationType,
10531170 operationName: input.definition.operationName,
1171+ operationDocument: input.definition.operationDocument,
10541172 queryTypeName: input.manifest.queryTypeName,
10551173 mutationTypeName: input.manifest.mutationTypeName,
10561174 subscriptionTypeName: input.manifest.subscriptionTypeName,
1175+ } satisfies GraphqlToolProviderData),
1176+ };
1177+};
1178+
1179+const decodeGraphqlToolProviderDataJson = Schema.decodeUnknownEither(
1180+ Schema.parseJson(GraphqlToolProviderDataSchema),
1181+);
1182+
1183+export const decodeGraphqlSchemaRefTableJson = Schema.decodeUnknownEither(
1184+ Schema.parseJson(
1185+ Schema.Record({
1186+ key: Schema.String,
1187+ value: Schema.Unknown,
10571188 }),
1189+ ),
1190+);
1191+
1192+const 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+
1216+const 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+
1263+export 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,
10581318 };
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+ });
10591361 };
10601362
10611363 export const graphqlToolDescriptorFromDefinition = (input: {
@@ -1074,7 +1376,8 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 12 unmodified lines
10741376 path: asToolPath(input.path),
10751377 sourceKey: input.sourceKey,
10761378 description: input.definition.description,
1077− interaction: input.definition.operationType === "query" ? "auto" : "required",
1379+ interaction:
1380+ input.definition.operationType === "query" ? "auto" : "required",
10781381 inputType: presentation.inputType,
10791382 outputType: presentation.outputType,
10801383 ...(input.includeSchemas && presentation.inputSchemaJson
@@ -1105,15 +1408,24 @@ const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => · 24 unmodified lines
11051408 }),
11061409 body: JSON.stringify({
11071410 query: input.query,
1108− ...(input.variables !== undefined ? { variables: input.variables } : {}),
1109− ...(input.operationName ? { operationName: input.operationName } : {}),
1411+ ...(input.variables !== undefined
1412+ ? { variables: input.variables }
1413+ : {}),
1414+ ...(input.operationName
1415+ ? { operationName: input.operationName }
1416+ : {}),
11101417 }),
11111418 }),
1112− catch: (cause) => graphqlToolError(`GraphQL request failed for ${input.path}`, cause),
1419+ catch: (cause) =>
1420+ graphqlToolError(`GraphQL request failed for ${input.path}`, cause),
11131421 });
11141422 const body = yield* Effect.tryPromise({
11151423 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+ ),
11171429 });
11181430
11191431 const responseHeaders: Record<string, string> = {};
@@ -1151,7 +1463,8 @@ const invokeRawGraphqlTool = (input: { · 31 unmodified lines
11511463 defaultHeaders: input.defaultHeaders,
11521464 credentialHeaders: input.credentialHeaders,
11531465 query,
1154− variables: record.variables !== undefined ? asRecord(record.variables) : undefined,
1466+ variables:
1467+ record.variables !== undefined ? asRecord(record.variables) : undefined,
11551468 operationName: asString(record.operationName) ?? undefined,
11561469 requestHeaders: asStringRecord(record.headers),
11571470 });
@@ -1165,7 +1478,10 @@ const withoutUndefinedEntries = ( · 7 unmodified lines
11651478 );
11661479
11671480 const invokeGraphqlFieldTool = (input: {
1168− entry: GraphqlFieldToolManifestEntry;
1481+ entry: Pick<
1482+ GraphqlFieldToolManifestEntry,
1483+ "fieldName" | "operationName" | "operationDocument"
1484+ >;
11691485 endpoint: string;
11701486 path: string;
11711487 defaultHeaders?: Readonly<Record<string, string>>;
@@ -1218,51 +1534,24 @@ export const createGraphqlToolsFromManifest = (input: { · 46 unmodified lines
12181534 manifest: input.manifest,
12191535 definition,
12201536 });
1221− const entry = input.manifest.tools.find((tool) => tool.toolId === definition.toolId);
1222− const path = input.namespace ? `${input.namespace}.${definition.toolId}` : definition.toolId;
1223− const inputSchema = presentation.inputSchemaJson
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− };
1537+ const path = input.namespace
1538+ ? `${input.namespace}.${definition.toolId}`
1539+ : definition.toolId;
12371540
12381541 return [
12391542 path,
1240− toTool({
1241− tool: {
1242− description: definition.description,
1243− inputSchema: standardSchemaFromJsonSchema(inputSchema),
1244− execute: (args: unknown) => {
1245− if (entry?.kind === "field") {
1246− return Effect.runPromise(invokeGraphqlFieldTool({
1247− entry,
1248− endpoint,
1249− path,
1250− defaultHeaders: input.defaultHeaders,
1251− 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,
1543+ createGraphqlToolFromPersistedOperation({
1544+ path,
1545+ sourceKey: input.sourceKey,
1546+ endpoint,
1547+ description: definition.description,
1548+ inputSchemaJson: presentation.inputSchemaJson,
1549+ outputSchemaJson: presentation.outputSchemaJson,
1550+ exampleInputJson: presentation.exampleInputJson,
1551+ providerDataJson: presentation.providerDataJson,
1552+ schemaRefTable: input.manifest.schemaRefTable,
1553+ defaultHeaders: input.defaultHeaders,
1554+ credentialHeaders: input.credentialHeaders,
12661555 }),
12671556 ] as const;
12681557 }),
packages/control-plane/src/runtime/invocation-policy-engine.test.ts+4−5
@@ -27,9 +27,8 @@ const baseDescriptor: InvocationDescriptor = { · 26 unmodified lines
2727 sourceKind: "openapi",
2828 sourceNamespace: "vercel.api.dns",
2929 operationKind: "write",
30− httpMethod: "POST",
31− httpPathTemplate: "/v10/domains/{domain}/records",
32− graphqlOperationType: null,
30+ interaction: "required",
31+ approvalLabel: "POST /v10/domains/{domain}/records",
3332 };
3433
3534 const basePolicy = (
@@ -61,8 +60,8 @@ describe("invocation-policy-engine", () => { · 25 unmodified lines
6160 ...baseDescriptor,
6261 toolPath: "vercel.api.dns.getRecords",
6362 operationKind: "read",
64− httpMethod: "GET",
65− httpPathTemplate: "/v4/domains/{domain}/records",
63+ interaction: "auto",
64+ approvalLabel: "GET /v4/domains/{domain}/records",
6665 },
6766 args: {},
6867 policies: [],
packages/control-plane/src/runtime/invocation-policy-engine.ts+7−33
@@ -18,9 +18,8 @@ export type InvocationDescriptor = { · 17 unmodified lines
1818 sourceKind: Source["kind"];
1919 sourceNamespace: string | null;
2020 operationKind: InvocationOperationKind;
21− httpMethod: string | null;
22− httpPathTemplate: string | null;
23− graphqlOperationType: "query" | "mutation" | "subscription" | null;
21+ interaction: "auto" | "required";
22+ approvalLabel: string | null;
2423 };
2524
2625 export type InvocationPolicyContext = {
@@ -223,42 +222,17 @@ const policySpecificity = ( · 196 unmodified lines
223222 const defaultDecisionForInvocation = (
224223 descriptor: InvocationDescriptor,
225224 ): InvocationAuthorizationDecision => {
226− if (descriptor.sourceKind === "openapi") {
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−
225+ if (descriptor.interaction === "auto") {
252226 return {
253− kind: "require_interaction",
254− reason: `${descriptor.graphqlOperationType ?? "Unknown GraphQL operation"} defaults to approval`,
227+ kind: "allow",
228+ reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to allow`,
255229 matchedPolicyId: null,
256230 };
257231 }
258232
259233 return {
260− kind: "allow",
261− reason: "No invocation-specific approval required by default",
234+ kind: "require_interaction",
235+ reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to approval`,
262236 matchedPolicyId: null,
263237 };
264238 };
packages/control-plane/src/runtime/source-adapters/graphql.ts+526−0
@@ -0,0 +1,526 @@
1+import {
2+ makeToolInvokerFromTools,
3+ typeSignatureFromSchemaJson,
4+} from "@executor/codemode-core";
5+import type { SqlControlPlaneRows } from "#persistence";
6+import type {
7+ GraphqlToolManifest,
8+} from "../graphql-tools";
9+import {
10+ buildGraphqlToolPresentation,
11+ compileGraphqlToolDefinitions,
12+ createGraphqlToolFromPersistedOperation,
13+ decodeGraphqlSchemaRefTableJson,
14+ extractGraphqlManifest,
15+ GraphqlToolProviderDataSchema,
16+ GRAPHQL_INTROSPECTION_QUERY,
17+} from "../graphql-tools";
18+import type {
19+ Source,
20+ SourceRecipeRevisionId,
21+ StoredSourceRecipeDocumentRecord,
22+ StoredSourceRecipeOperationRecord,
23+ StoredSourceRecipeSchemaBundleRecord,
24+} from "#schema";
25+import {
26+ StringMapSchema,
27+ SourceRecipeSchemaBundleIdSchema,
28+} from "#schema";
29+import * as Either from "effect/Either";
30+import * as Effect from "effect/Effect";
31+import * as Schema from "effect/Schema";
32+
33+import { namespaceFromSourceName } from "../source-names";
34+import {
35+ contentHash,
36+ normalizeSearchText,
37+} from "../source-recipe-support";
38+import type {
39+ SourceAdapter,
40+ SourceAdapterMaterialization,
41+ SourceAdapterRepairRevisionInput,
42+} from "./types";
43+import {
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+
57+const 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+
68+const 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+
79+const GraphqlBindingConfigSchema = Schema.Struct({
80+ adapterKey: Schema.Literal("graphql"),
81+ defaultHeaders: Schema.NullOr(StringMapSchema),
82+});
83+
84+const GRAPHQL_MATERIALIZATION_REVISION_ID = "src_recipe_rev_materialization" as SourceRecipeRevisionId;
85+
86+const decodeGraphqlToolProviderDataJson = Schema.decodeUnknownEither(
87+ Schema.parseJson(GraphqlToolProviderDataSchema),
88+);
89+
90+const 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+
136+const 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+
170+const 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+
216+const primaryDocument = (
217+ documents: readonly StoredSourceRecipeDocumentRecord[],
218+): StoredSourceRecipeDocumentRecord | null =>
219+ documents.find((document) => document.documentKind === "graphql_introspection")
220+ ?? null;
221+
222+export 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+
291+export 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 @@
1+import type { Source, StoredSourceRecipeOperationRecord } from "#schema";
2+import * as Schema from "effect/Schema";
3+
4+import { graphqlSourceAdapter } from "./graphql";
5+import { internalSourceAdapter } from "./internal";
6+import { mcpSourceAdapter } from "./mcp";
7+import { openApiSourceAdapter } from "./openapi";
8+import type { SourceAdapter } from "./types";
9+
10+export const builtInSourceAdapters = [
11+ openApiSourceAdapter,
12+ graphqlSourceAdapter,
13+ mcpSourceAdapter,
14+ internalSourceAdapter,
15+] as const satisfies readonly SourceAdapter[];
16+
17+export const connectableSourceAdapters = [
18+ mcpSourceAdapter,
19+ openApiSourceAdapter,
20+ graphqlSourceAdapter,
21+] as const;
22+
23+export const ConnectSourcePayloadSchema = Schema.Union(
24+ mcpSourceAdapter.connectPayloadSchema!,
25+ openApiSourceAdapter.connectPayloadSchema!,
26+ graphqlSourceAdapter.connectPayloadSchema!,
27+);
28+
29+export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type;
30+
31+export const executorAddableSourceAdapters = [
32+ mcpSourceAdapter,
33+ openApiSourceAdapter,
34+ graphqlSourceAdapter,
35+] as const;
36+
37+export const ExecutorAddSourceInputSchema = Schema.Union(
38+ mcpSourceAdapter.executorAddInputSchema!,
39+ openApiSourceAdapter.executorAddInputSchema!,
40+ graphqlSourceAdapter.executorAddInputSchema!,
41+);
42+
43+export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type;
44+
45+const adaptersByKey = new Map<string, SourceAdapter>(
46+ builtInSourceAdapters.map((adapter) => [adapter.key, adapter]),
47+);
48+
49+export 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+
58+export const getSourceAdapterForSource = (source: Pick<Source, "kind">): SourceAdapter =>
59+ getSourceAdapter(source.kind);
60+
61+export const getSourceAdapterForOperation = (
62+ operation: Pick<StoredSourceRecipeOperationRecord, "providerKind">,
63+): SourceAdapter => getSourceAdapter(operation.providerKind);
64+
65+export 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 @@
1+import * as Effect from "effect/Effect";
2+import * as Schema from "effect/Schema";
3+
4+import type { SourceAdapter } from "./types";
5+import {
6+ createStandardToolDescriptor,
7+ decodeBindingConfig,
8+ emptySourceBindingState,
9+ encodeBindingConfig,
10+} from "./shared";
11+
12+const InternalBindingConfigSchema = Schema.Struct({
13+ adapterKey: Schema.String,
14+});
15+
16+export 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 @@
1+import {
2+ makeToolInvokerFromTools,
3+} from "@executor/codemode-core";
4+import {
5+ createMcpToolsFromManifest,
6+ createSdkMcpConnector,
7+ discoverMcpToolsFromConnector,
8+ type McpToolManifest,
9+ type McpToolManifestEntry,
10+} from "@executor/codemode-mcp";
11+import type { SqlControlPlaneRows } from "#persistence";
12+import type {
13+ Source,
14+ SourceRecipeRevisionId,
15+ StoredSourceRecipeOperationRecord,
16+} from "#schema";
17+import {
18+ SourceTransportSchema,
19+ StringMapSchema,
20+} from "#schema";
21+import * as Effect from "effect/Effect";
22+import * as Schema from "effect/Schema";
23+
24+import {
25+ contentHash,
26+ normalizeSearchText,
27+ persistRecipeMaterialization,
28+ type SourceRecipeMaterialization,
29+} from "../source-recipe-support";
30+import { namespaceFromSourceName } from "../source-names";
31+import type { SourceAdapter, SourceAdapterMaterialization } from "./types";
32+import {
33+ createStandardToolDescriptor,
34+ decodeBindingConfig,
35+ emptySourceBindingState,
36+ encodeBindingConfig,
37+ McpConnectFieldsSchema,
38+ OptionalNullableStringSchema,
39+ parseJsonValue,
40+ SourceConnectCommonFieldsSchema,
41+} from "./shared";
42+
43+const McpConnectPayloadSchema = Schema.extend(
44+ SourceConnectCommonFieldsSchema,
45+ Schema.extend(
46+ McpConnectFieldsSchema,
47+ Schema.Struct({
48+ kind: Schema.Literal("mcp"),
49+ }),
50+ ),
51+);
52+
53+const McpExecutorAddInputSchema = Schema.Struct({
54+ kind: Schema.optional(Schema.Literal("mcp")),
55+ endpoint: Schema.String,
56+ name: OptionalNullableStringSchema,
57+ namespace: OptionalNullableStringSchema,
58+});
59+
60+const 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+
67+const McpToolProviderDataSchema = Schema.Struct({
68+ kind: Schema.Literal("mcp"),
69+ toolId: Schema.String,
70+ toolName: Schema.String,
71+ description: Schema.NullOr(Schema.String),
72+});
73+
74+const decodeMcpToolProviderDataJson = Schema.decodeUnknownEither(
75+ Schema.parseJson(McpToolProviderDataSchema),
76+);
77+
78+const 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+
110+export 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+
127+const 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+
168+export 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 @@
1+import {
2+ FetchHttpClient,
3+ HttpClient,
4+ HttpClientRequest,
5+} from "@effect/platform";
6+import type { SqlControlPlaneRows } from "#persistence";
7+import {
8+ makeToolInvokerFromTools,
9+} from "@executor/codemode-core";
10+import {
11+ buildOpenApiToolPresentation,
12+ compileOpenApiToolDefinitions,
13+ createOpenApiToolFromDefinition,
14+ extractOpenApiManifest,
15+ openApiOutputTypeSignatureFromSchemaJson,
16+ OpenApiRefHintTableSchema,
17+ OpenApiToolProviderDataSchema,
18+ type OpenApiToolManifest,
19+} from "@executor/codemode-openapi";
20+import type {
21+ Source,
22+ SourceRecipeRevisionId,
23+ StoredSourceRecipeDocumentRecord,
24+ StoredSourceRecipeOperationRecord,
25+ StoredSourceRecipeSchemaBundleRecord,
26+} from "#schema";
27+import {
28+ StringMapSchema,
29+ SourceRecipeSchemaBundleIdSchema,
30+} from "#schema";
31+import * as Either from "effect/Either";
32+import * as Effect from "effect/Effect";
33+import * as Schema from "effect/Schema";
34+
35+import {
36+ contentHash,
37+ normalizeSearchText,
38+} from "../source-recipe-support";
39+import type {
40+ SourceAdapter,
41+ SourceAdapterMaterialization,
42+ SourceAdapterRepairRevisionInput,
43+} from "./types";
44+import {
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+
58+const 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+
70+const 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+
82+const OpenApiBindingConfigSchema = Schema.Struct({
83+ adapterKey: Schema.Literal("openapi"),
84+ specUrl: Schema.String,
85+ defaultHeaders: Schema.NullOr(StringMapSchema),
86+});
87+
88+const OPENAPI_MATERIALIZATION_REVISION_ID = "src_recipe_rev_materialization" as SourceRecipeRevisionId;
89+
90+const decodeOpenApiProviderDataJson = Schema.decodeUnknownEither(
91+ Schema.parseJson(OpenApiToolProviderDataSchema),
92+);
93+
94+const decodeOpenApiRefHintTableJson = Schema.decodeUnknownEither(
95+ Schema.parseJson(OpenApiRefHintTableSchema),
96+);
97+
98+const 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+
134+const 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+
179+const 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+
213+const 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+
260+const primaryDocument = (
261+ documents: readonly StoredSourceRecipeDocumentRecord[],
262+): StoredSourceRecipeDocumentRecord | null =>
263+ documents.find((document) => document.documentKind === "openapi") ?? null;
264+
265+export 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+
333+export 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 @@
1+import {
2+ type ToolDescriptor,
3+ type ToolPath,
4+ type ToolSchemaBundle,
5+ typeSignatureFromSchemaJson,
6+} from "@executor/codemode-core";
7+import {
8+ type CredentialSlot,
9+ SecretRefSchema,
10+ SourceImportAuthPolicySchema,
11+ SourceTransportSchema,
12+ StringMapSchema,
13+} from "#schema";
14+import type {
15+ SecretRef,
16+ Source,
17+ SourceTransport,
18+ StoredSourceRecipeOperationRecord,
19+ StringMap,
20+} from "#schema";
21+import * as Effect from "effect/Effect";
22+import * as Schema from "effect/Schema";
23+
24+import {
25+ TrimmedNonEmptyStringSchema,
26+} from "../../api/string-schemas";
27+
28+const asToolPath = (value: string): ToolPath => value as ToolPath;
29+
30+export const OptionalNullableStringSchema = Schema.optional(
31+ Schema.NullOr(Schema.String),
32+);
33+
34+export 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+
44+export 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+
58+export const ConnectHttpAuthSchema = Schema.Union(
59+ Schema.Struct({ kind: Schema.Literal("none") }),
60+ ConnectBearerAuthSchema,
61+ ConnectOAuth2AuthSchema,
62+);
63+
64+export const ConnectHttpImportAuthSchema = Schema.Struct({
65+ importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema),
66+ importAuth: Schema.optional(ConnectHttpAuthSchema),
67+});
68+
69+export const SourceConnectCommonFieldsSchema = Schema.Struct({
70+ endpoint: TrimmedNonEmptyStringSchema,
71+ name: OptionalNullableStringSchema,
72+ namespace: OptionalNullableStringSchema,
73+});
74+
75+export 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+
81+export 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+
95+export 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+
106+export const isSourceCredentialRequiredError = (
107+ error: unknown,
108+): error is SourceCredentialRequiredError =>
109+ error instanceof SourceCredentialRequiredError;
110+
111+export 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+
125+export const encodeBindingConfig = (schema: Schema.Schema.AnyNoContext, value: unknown): string =>
126+ Schema.encodeSync(Schema.parseJson(schema))(value);
127+
128+export 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+
146+export 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+
156+export 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 @@
1+import type {
2+ OnElicitation,
3+ ToolDescriptor,
4+ ToolSchemaBundle,
5+} from "@executor/codemode-core";
6+import type { SqlControlPlaneRows } from "#persistence";
7+import 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";
20+import * as Effect from "effect/Effect";
21+import * as Schema from "effect/Schema";
22+
23+import type { ResolvedSourceAuthMaterial } from "../source-auth-material";
24+import type { ResolveSecretMaterial } from "../secret-material-providers";
25+
26+export type SourceAdapterFamily = "http_api" | "mcp" | "internal";
27+export type SourceAdapterInputSchema = Schema.Schema<any, any, never>;
28+export type SourceBindingState = {
29+ transport: SourceTransport | null;
30+ queryParams: StringMap | null;
31+ headers: StringMap | null;
32+ specUrl: string | null;
33+ defaultHeaders: StringMap | null;
34+};
35+
36+export type SourceAdapterSyncInput = {
37+ source: Source;
38+ resolveSecretMaterial: ResolveSecretMaterial;
39+ resolveAuthMaterialForSlot: (slot: CredentialSlot) => Effect.Effect<
40+ ResolvedSourceAuthMaterial,
41+ Error,
42+ never
43+ >;
44+};
45+
46+export 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+
55+export 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+
68+export 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+
77+export 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+
92+export 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
77 type Source,
88 } from "#schema";
99
10−import { resolveSourceAuthMaterial } from "./tool-artifacts";
10+import { resolveSourceAuthMaterial } from "./source-auth-material";
1111
1212 const makeSource = (overrides: Partial<Source> = {}): Source => ({
1313 id: SourceIdSchema.make("src_tool_artifacts"),
@@ -23,6 +23,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines
2323 headers: null,
2424 specUrl: "https://api.github.com/openapi.json",
2525 defaultHeaders: null,
26+ importAuthPolicy: "reuse_runtime",
27+ importAuth: { kind: "none" },
2628 auth: { kind: "none" },
2729 sourceHash: null,
2830 lastError: null,
@@ -31,7 +33,7 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 2 unmodified lines
3133 ...overrides,
3234 });
3335
34−describe("tool-artifacts", () => {
36+describe("source-auth-material", () => {
3537 describe("resolveSourceAuthMaterial", () => {
3638 it("returns empty headers for auth.kind none", async () => {
3739 const auth = await Effect.runPromise(resolveSourceAuthMaterial({
packages/control-plane/src/runtime/source-auth-material.ts+72−0
@@ -0,0 +1,72 @@
1+import type { CredentialSlot, Source, SourceAuth } from "#schema";
2+import * as Effect from "effect/Effect";
3+
4+import type {
5+ ResolveSecretMaterial,
6+ SecretMaterialResolveContext,
7+} from "./secret-material-providers";
8+
9+export type ResolvedSourceAuthMaterial = {
10+ headers: Readonly<Record<string, string>>;
11+};
12+
13+export 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+
40+const 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+
59+export 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
88
99 import {
1010 createTerminalSourceAuthSessionPatch,
11− shouldPromptForHttpCredentialSetup,
1211 } from "./source-auth-service";
1312
1413 const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({
@@ -25,6 +24,8 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 10 unmodified lines
2524 headers: null,
2625 specUrl: "https://example.com/openapi.json",
2726 defaultHeaders: null,
27+ importAuthPolicy: "reuse_runtime",
28+ importAuth: { kind: "none" },
2829 auth,
2930 sourceHash: null,
3031 lastError: null,
@@ -88,64 +89,4 @@ describe("source-auth-service", () => { · 57 unmodified lines
8889 updatedAt: 456,
8990 });
9091 });
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− });
15192 });
packages/control-plane/src/runtime/source-auth-service.ts+152−89
@@ -9,11 +9,13 @@ import { · 8 unmodified lines
99 } from "#persistence";
1010 import {
1111 AccountId,
12+ type CredentialSlot,
1213 ExecutionIdSchema,
1314 McpSourceAuthSessionDataJsonSchema,
1415 type McpSourceAuthSessionData,
1516 type SecretMaterialPurpose,
1617 Source,
18+ type SourceImportAuthPolicy,
1719 SourceAuthSession,
1820 SourceAuthSessionIdSchema,
1921 SourceIdSchema,
@@ -41,6 +43,8 @@ import { · 21 unmodified lines
4143 createSourceFromPayload,
4244 updateSourceFromPayload,
4345 } from "./source-definitions";
46+import { hasSourceAdapterFamily } from "./source-adapters";
47+import { isSourceCredentialRequiredError } from "./source-adapters/shared";
4448 import {
4549 createDefaultSecretMaterialResolver,
4650 createDefaultSecretMaterialStorer,
@@ -48,9 +52,9 @@ import { · 1 unmodified line
4852 type StoreSecretMaterial,
4953 } from "./secret-material-providers";
5054 import {
51− persistMcpToolArtifactsFromManifest,
52− syncSourceToolArtifacts,
53−} from "./tool-artifacts";
55+ persistMcpRecipeMaterializationFromManifest,
56+ syncSourceMaterialization,
57+} from "./source-materialization";
5458 import {
5559 loadSourceById,
5660 loadSourcesInWorkspace,
@@ -255,6 +259,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 198 unmodified lines
255259 status: Source["status"];
256260 lastError?: string | null;
257261 auth?: Source["auth"];
262+ importAuth?: Source["importAuth"];
258263 }) =>
259264 Effect.gen(function* () {
260265 const latest = yield* loadSourceById(rows, {
@@ -268,6 +273,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 7 unmodified lines
268273 status: input.status,
269274 lastError: input.lastError ?? null,
270275 auth: input.auth ?? latest.auth,
276+ importAuth: input.importAuth ?? latest.importAuth,
271277 updatedAt: Date.now(),
272278 }, {
273279 actorAccountId: input.actorAccountId,
@@ -282,6 +288,7 @@ export type ExecutorSourceAddResult = · 8 unmodified lines
282288 | {
283289 kind: "credential_required";
284290 source: Source;
291+ credentialSlot: CredentialSlot;
285292 }
286293 | {
287294 kind: "oauth_required";
@@ -332,6 +339,8 @@ export type ExecutorAddSourceInput = · 44 unmodified lines
332339 specUrl: string;
333340 name?: string | null;
334341 namespace?: string | null;
342+ importAuthPolicy?: SourceImportAuthPolicy | null;
343+ importAuth?: ExecutorHttpSourceAuthInput | null;
335344 auth?: ExecutorHttpSourceAuthInput | null;
336345 }
337346 | {
@@ -343,6 +352,8 @@ export type ExecutorAddSourceInput = · 5 unmodified lines
343352 endpoint: string;
344353 name?: string | null;
345354 namespace?: string | null;
355+ importAuthPolicy?: SourceImportAuthPolicy | null;
356+ importAuth?: ExecutorHttpSourceAuthInput | null;
346357 auth?: ExecutorHttpSourceAuthInput | null;
347358 };
348359
@@ -390,20 +401,6 @@ export type CompleteSourceOAuthSessionResult = { · 41 unmodified lines
390401 auth: Extract<Source["auth"], { kind: "oauth2" }>;
391402 };
392403
393−export 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−
407404 const materializeSecretRefInput = (input: {
408405 rawValue?: string | null;
409406 ref?: SecretRef | null;
@@ -436,11 +433,10 @@ const materializeExecutorHttpAuth = (input: { · 26 unmodified lines
436433 storeSecretMaterial: StoreSecretMaterial;
437434 }): Effect.Effect<Source["auth"], Error, never> =>
438435 Effect.gen(function* () {
439− if (
440− input.auth === undefined
441− && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql")
442− ) {
443− return input.existing.auth;
436+ const existing = input.existing;
437+
438+ if (input.auth === undefined && existing && hasSourceAdapterFamily(existing.kind, "http_api")) {
439+ return existing.auth;
444440 }
445441
446442 const auth = input.auth ?? { kind: "none" } satisfies ExecutorHttpSourceAuthInput;
@@ -458,10 +454,11 @@ const materializeExecutorHttpAuth = (input: { · 11 unmodified lines
458454 if (
459455 token === null
460456 && tokenRefInput === null
461− && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql")
462− && input.existing.auth.kind === "bearer"
457+ && existing
458+ && hasSourceAdapterFamily(existing.kind, "http_api")
459+ && existing.auth.kind === "bearer"
463460 ) {
464− return input.existing.auth;
461+ return existing.auth;
465462 }
466463
467464 const tokenRef = yield* materializeSecretRefInput({
@@ -488,10 +485,11 @@ const materializeExecutorHttpAuth = (input: { · 20 unmodified lines
488485 && auth.accessTokenRef == null
489486 && trimOrNull(auth.refreshToken) === null
490487 && auth.refreshTokenRef == null
491− && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql")
492− && input.existing.auth.kind === "oauth2"
488+ && existing
489+ && hasSourceAdapterFamily(existing.kind, "http_api")
490+ && existing.auth.kind === "oauth2"
493491 ) {
494− return input.existing.auth;
492+ return existing.auth;
495493 }
496494
497495 const accessTokenRef = yield* materializeSecretRefInput({
@@ -520,6 +518,61 @@ const materializeExecutorHttpAuth = (input: { · 22 unmodified lines
520518 } satisfies Source["auth"];
521519 });
522520
521+const 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+
567+const 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+
523576 const connectMcpSourceInternal = (input: {
524577 rows: SqlControlPlaneRows;
525578 getLocalServerBaseUrl?: () => string | undefined;
@@ -549,7 +602,7 @@ const connectMcpSourceInternal = (input: { · 23 unmodified lines
549602 actorAccountId: input.actorAccountId,
550603 }).pipe(
551604 Effect.flatMap((source) =>
552− source.kind === "mcp"
605+ hasSourceAdapterFamily(source.kind, "mcp")
553606 ? Effect.succeed(source)
554607 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),
555608 ),
@@ -560,7 +613,8 @@ const connectMcpSourceInternal = (input: { · 4 unmodified lines
560613 Effect.map((sources) =>
561614 sources.find(
562615 (source) =>
563− source.kind === "mcp" && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
616+ hasSourceAdapterFamily(source.kind, "mcp")
617+ && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
564618 ),
565619 ),
566620 )
@@ -593,6 +647,8 @@ const connectMcpSourceInternal = (input: { · 26 unmodified lines
593647 transport: chosenTransport,
594648 queryParams: chosenQueryParams,
595649 headers: chosenHeaders,
650+ importAuthPolicy: "reuse_runtime",
651+ importAuth: { kind: "none" },
596652 auth: { kind: "none" },
597653 lastError: null,
598654 },
@@ -611,6 +667,8 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines
611667 transport: chosenTransport,
612668 queryParams: chosenQueryParams,
613669 headers: chosenHeaders,
670+ importAuthPolicy: "reuse_runtime",
671+ importAuth: { kind: "none" },
614672 auth: { kind: "none" },
615673 },
616674 now,
@@ -619,7 +677,7 @@ const connectMcpSourceInternal = (input: { · 2 unmodified lines
619677 const persistedDraft = yield* persistSource(input.rows, draftSource, {
620678 actorAccountId: input.actorAccountId,
621679 });
622− yield* syncSourceToolArtifacts({
680+ yield* syncSourceMaterialization({
623681 rows: input.rows,
624682 source: persistedDraft,
625683 resolveSecretMaterial: input.resolveSecretMaterial,
@@ -643,7 +701,7 @@ const connectMcpSourceInternal = (input: { · 17 unmodified lines
643701 auth: { kind: "none" },
644702 });
645703 const indexed = yield* Effect.either(
646− persistMcpToolArtifactsFromManifest({
704+ persistMcpRecipeMaterializationFromManifest({
647705 rows: input.rows,
648706 source: connected,
649707 manifestEntries: result.manifest.tools,
@@ -704,6 +762,7 @@ const connectMcpSourceInternal = (input: { · 54 unmodified lines
704762 workspaceId: input.workspaceId,
705763 sourceId: authRequiredSource.id,
706764 actorAccountId: input.actorAccountId ?? null,
765+ credentialSlot: "runtime",
707766 executionId: input.executionId ?? null,
708767 interactionId: input.interactionId ?? null,
709768 providerKind: "mcp_oauth",
@@ -780,56 +839,18 @@ const addExecutorHttpSource = (input: { · 70 unmodified lines
780839 ?? defaultNamespaceFromName(chosenName);
781840 const now = Date.now();
782841
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−
828842 const auth = yield* materializeExecutorHttpAuth({
829843 existing,
830844 auth: input.sourceInput.auth,
831845 storeSecretMaterial: input.storeSecretMaterial,
832846 });
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+ });
833854
834855 const draftSource = existing
835856 ? yield* updateSourceFromPayload({
@@ -842,6 +863,8 @@ const addExecutorHttpSource = (input: { · 6 unmodified lines
842863 status: "probing",
843864 enabled: true,
844865 specUrl: normalizedSpecUrl,
866+ importAuthPolicy: importAuth.importAuthPolicy,
867+ importAuth: importAuth.importAuth,
845868 auth,
846869 lastError: null,
847870 },
@@ -858,6 +881,8 @@ const addExecutorHttpSource = (input: { · 10 unmodified lines
858881 status: "probing",
859882 enabled: true,
860883 specUrl: normalizedSpecUrl,
884+ importAuthPolicy: importAuth.importAuthPolicy,
885+ importAuth: importAuth.importAuth,
861886 auth,
862887 },
863888 now,
@@ -866,8 +891,27 @@ const addExecutorHttpSource = (input: { · 2 unmodified lines
866891 const persistedDraft = yield* persistSource(input.rows, draftSource, {
867892 actorAccountId: input.sourceInput.actorAccountId,
868893 });
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+
869913 const synced = yield* Effect.either(
870− syncSourceToolArtifacts({
914+ syncSourceMaterialization({
871915 rows: input.rows,
872916 source: {
873917 ...persistedDraft,
@@ -879,13 +923,27 @@ const addExecutorHttpSource = (input: { · 5 unmodified lines
879923
880924 return yield* Either.match(synced, {
881925 onLeft: (error) =>
882− updateSourceStatus(input.rows, persistedDraft, {
883− actorAccountId: input.sourceInput.actorAccountId,
884− status: "error",
885− lastError: error.message,
886− }).pipe(
887− Effect.zipRight(Effect.fail(error)),
888− ),
926+ isSourceCredentialRequiredError(error)
927+ ? updateSourceStatus(input.rows, persistedDraft, {
928+ actorAccountId: input.sourceInput.actorAccountId,
929+ status: "auth_required",
930+ lastError: null,
931+ }).pipe(
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+ ),
889947 onRight: () =>
890948 updateSourceStatus(input.rows, persistedDraft, {
891949 actorAccountId: input.sourceInput.actorAccountId,
@@ -972,10 +1030,13 @@ export const createRuntimeSourceAuthService = (input: { · 80 unmodified lines
9721030 }),
9731031
9741032 addExecutorSource: (sourceInput, options) =>
975− sourceInput.kind === "openapi" || sourceInput.kind === "graphql"
1033+ hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
9761034 ? addExecutorHttpSource({
9771035 rows: input.rows,
978− sourceInput,
1036+ sourceInput: sourceInput as Extract<
1037+ ExecutorAddSourceInput,
1038+ { kind: "openapi" | "graphql" }
1039+ >,
9791040 storeSecretMaterial,
9801041 resolveSecretMaterial,
9811042 })
@@ -1043,6 +1104,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines
10431104 workspaceId: oauthInput.workspaceId,
10441105 sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`),
10451106 actorAccountId: oauthInput.actorAccountId ?? null,
1107+ credentialSlot: "runtime",
10461108 executionId: null,
10471109 interactionId: null,
10481110 providerKind: "mcp_oauth",
@@ -1257,7 +1319,7 @@ export const createRuntimeSourceAuthService = (input: { · 208 unmodified lines
12571319 status: "error",
12581320 lastError: reason,
12591321 });
1260− yield* syncSourceToolArtifacts({
1322+ yield* syncSourceMaterialization({
12611323 rows: input.rows,
12621324 source: failedSource,
12631325 resolveSecretMaterial,
@@ -1328,7 +1390,7 @@ export const createRuntimeSourceAuthService = (input: { · 64 unmodified lines
13281390 },
13291391 });
13301392 const indexed = yield* Effect.either(
1331− syncSourceToolArtifacts({
1393+ syncSourceMaterialization({
13321394 rows: input.rows,
13331395 source: connectedSource,
13341396 resolveSecretMaterial,
@@ -1410,6 +1472,7 @@ export const ExecutorAddSourceResultSchema = Schema.Union( · 75 unmodified lines
14101472 Schema.Struct({
14111473 kind: Schema.Literal("credential_required"),
14121474 source: SourceSchema,
1475+ credentialSlot: Schema.Literal("runtime", "import"),
14131476 }),
14141477 Schema.Struct({
14151478 kind: Schema.Literal("oauth_required"),
packages/control-plane/src/runtime/source-definitions.test.ts+26−15
@@ -16,7 +16,7 @@ import { · 15 unmodified lines
1616 stableSourceRecipeRevisionId,
1717 updateSourceFromPayload,
1818 } from "./source-definitions";
19−import { namespaceFromSourceName } from "./tool-artifacts";
19+import { namespaceFromSourceName } from "./source-names";
2020
2121 const makeSource = (overrides: Partial<Source> = {}): Source => ({
2222 id: SourceIdSchema.make("src_source_definitions"),
@@ -32,6 +32,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines
3232 headers: null,
3333 specUrl: "https://api.github.com/openapi.json",
3434 defaultHeaders: null,
35+ importAuthPolicy: "reuse_runtime",
36+ importAuth: { kind: "none" },
3537 auth: { kind: "none" },
3638 sourceHash: null,
3739 lastError: null,
@@ -276,7 +278,7 @@ describe("source-definitions", () => { · 238 unmodified lines
276278 },
277279 } as never,
278280 now: 1234,
279− }))).rejects.toThrow("internal sources cannot define OpenAPI settings");
281+ }))).rejects.toThrow("internal sources cannot define HTTP source settings");
280282 });
281283
282284 it("normalizes new auth during updates", async () => {
@@ -313,9 +315,12 @@ describe("source-definitions", () => { · 30 unmodified lines
313315 describe("storage roundtrip", () => {
314316 it("roundtrips bearer auth and serialized maps", async () => {
315317 const source = makeSource({
318+ kind: "mcp",
319+ specUrl: null,
320+ defaultHeaders: null,
321+ transport: "auto",
316322 queryParams: { page: "1" },
317323 headers: { "x-api-key": "secret" },
318− defaultHeaders: { accept: "application/json" },
319324 auth: {
320325 kind: "bearer",
321326 headerName: "Authorization",
@@ -330,21 +335,25 @@ describe("source-definitions", () => { · 8 unmodified lines
330335 const recipeRevisionId = stableSourceRecipeRevisionId(source);
331336 const existingCredentialId = CredentialIdSchema.make("cred_existing");
332337
333− const { sourceRecord, credential } = splitSourceForStorage({
338+ const { sourceRecord, runtimeCredential } = splitSourceForStorage({
334339 source,
335340 recipeId,
336341 recipeRevisionId,
337− existingCredentialId,
342+ existingRuntimeCredentialId: existingCredentialId,
338343 });
339344
340− expect(credential?.id).toBe(existingCredentialId);
341− expect(sourceRecord.queryParamsJson).toBe(JSON.stringify(source.queryParams));
342− expect(sourceRecord.headersJson).toBe(JSON.stringify(source.headers));
343− expect(sourceRecord.defaultHeadersJson).toBe(JSON.stringify(source.defaultHeaders));
345+ expect(runtimeCredential?.id).toBe(existingCredentialId);
346+ expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({
347+ adapterKey: "mcp",
348+ transport: source.transport,
349+ queryParams: source.queryParams,
350+ headers: source.headers,
351+ });
344352
345353 const projected = await Effect.runPromise(projectSourceFromStorage({
346354 sourceRecord,
347− credential: credential ?? null,
355+ runtimeCredential: runtimeCredential ?? null,
356+ importCredential: null,
348357 }));
349358
350359 expect(projected).toEqual(source);
@@ -389,14 +398,15 @@ describe("source-definitions", () => { · 38 unmodified lines
389398 });
390399
391400 for (const source of [withRefresh, withoutRefresh]) {
392− const { sourceRecord, credential } = splitSourceForStorage({
401+ const { sourceRecord, runtimeCredential } = splitSourceForStorage({
393402 source,
394403 recipeId: stableSourceRecipeId(source),
395404 recipeRevisionId: stableSourceRecipeRevisionId(source),
396405 });
397406 const projected = await Effect.runPromise(projectSourceFromStorage({
398407 sourceRecord,
399− credential: credential ?? null,
408+ runtimeCredential: runtimeCredential ?? null,
409+ importCredential: null,
400410 }));
401411
402412 expect(projected).toEqual(source);
@@ -407,17 +417,18 @@ describe("source-definitions", () => { · 4 unmodified lines
407417 const source = makeSource({
408418 auth: { kind: "none" },
409419 });
410− const { sourceRecord, credential } = splitSourceForStorage({
420+ const { sourceRecord, runtimeCredential } = splitSourceForStorage({
411421 source,
412422 recipeId: stableSourceRecipeId(source),
413423 recipeRevisionId: stableSourceRecipeRevisionId(source),
414424 });
415425
416− expect(credential).toBeNull();
426+ expect(runtimeCredential).toBeNull();
417427
418428 const projected = await Effect.runPromise(projectSourceFromStorage({
419429 sourceRecord,
420− credential: null,
430+ runtimeCredential: null,
431+ importCredential: null,
421432 }));
422433
423434 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
77 import type {
88 AccountId,
99 Credential,
10+ CredentialSlot,
1011 Source,
1112 SourceAuth,
13+ SourceImportAuthPolicy,
1214 SourceRecipeId,
1315 SourceRecipeImporterKind,
1416 SourceRecipeKind,
@@ -25,16 +27,8 @@ import { · 10 unmodified lines
2527 SourceRecipeRevisionIdSchema,
2628 } from "#schema";
2729 import * as Effect from "effect/Effect";
28−import * as Schema from "effect/Schema";
29−
30−const decodeStringMap = Schema.decodeUnknown(
31− Schema.NullOr(
32− Schema.Record({
33− key: Schema.String,
34− value: Schema.String,
35− }),
36− ),
37−);
30+
31+import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters";
3832
3933 const trimOrNull = (value: string | null | undefined): string | null => {
4034 if (value === null || value === undefined) {
@@ -45,134 +39,22 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines
4539 return trimmed.length > 0 ? trimmed : null;
4640 };
4741
48−const serializeStringMap = (value: StringMap | null): string | null =>
49− value === null ? null : JSON.stringify(value);
50−
51−const 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− );
42+type SourceRecipeSourceConfig = Record<string, unknown>;
7143
72−type SourceRecipeSourceConfig =
73− | {
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−
96−const 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−};
44+const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig =>
45+ getSourceAdapterForSource(source).sourceConfigFromSource(source);
12946
13047 const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => {
131− if (source.kind === "mcp") {
132− return "mcp_recipe";
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";
48+ const adapter = getSourceAdapterForSource(source);
49+ return adapter.family;
14450 };
14551
14652 const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => {
147− if (source.kind === "mcp") {
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";
53+ return getSourceAdapterForSource(source).key;
16054 };
16155
16256 const sourceRecipeProviderKeyFromSource = (source: Source): string => {
163− if (source.kind === "mcp") {
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";
57+ return getSourceAdapterForSource(source).providerKey;
17658 };
17759
17860 const stableHash = (value: string): string =>
@@ -260,58 +142,108 @@ const normalizeAuth = ( · 81 unmodified lines
260142 } satisfies SourceAuth;
261143 });
262144
263−const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, never> =>
264− Effect.gen(function* () {
265− if (source.kind === "mcp") {
266− if (source.specUrl !== null) {
267− return yield* Effect.fail(new Error("MCP sources cannot define specUrl"));
268− }
269− return source;
270− }
145+const normalizeImportAuthPolicy = (
146+ sourceKind: Source["kind"],
147+ policy: SourceImportAuthPolicy | undefined,
148+): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy;
271149
272− if (source.kind === "openapi") {
273− if (trimOrNull(source.specUrl) === null) {
274− return yield* Effect.fail(new Error("OpenAPI sources require specUrl"));
275− }
276−
277− if (source.transport !== null || source.queryParams !== null || source.headers !== null) {
278− return yield* Effect.fail(
279− new Error("OpenAPI sources cannot define MCP transport settings"),
280− );
281− }
150+const credentialFromAuth = (input: {
151+ source: Source;
152+ auth: SourceAuth;
153+ slot: CredentialSlot;
154+ actorAccountId?: AccountId | null;
155+ existingCredentialId?: Credential["id"] | null;
156+}): Credential | null => {
157+ if (input.auth.kind === "none") {
158+ return null;
159+ }
282160
283− return source;
284− }
161+ const credentialId = input.existingCredentialId
162+ ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);
285163
286− if (source.kind === "graphql") {
287− if (source.transport !== null || source.queryParams !== null || source.headers !== null) {
288− return yield* Effect.fail(
289− new Error("GraphQL sources cannot define MCP transport settings"),
290− );
291− }
164+ return {
165+ id: credentialId,
166+ workspaceId: input.source.workspaceId,
167+ sourceId: input.source.id,
168+ actorAccountId: input.actorAccountId ?? null,
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+};
292193
293− if (source.specUrl !== null) {
294− return yield* Effect.fail(new Error("GraphQL sources cannot define specUrl"));
295− }
194+const authFromCredential = (credential: Credential | null): SourceAuth => {
195+ if (credential === null) {
196+ return { kind: "none" };
197+ }
296198
297− return source;
298− }
199+ if (credential.authKind === "bearer") {
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+ }
299210
300− if (source.transport !== null || source.queryParams !== null || source.headers !== null) {
301− return yield* Effect.fail(
302− new Error(`${source.kind} sources cannot define MCP transport settings`),
303− );
304− }
211+ return {
212+ kind: "oauth2",
213+ headerName: credential.authHeaderName,
214+ prefix: credential.authPrefix,
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+};
305229
306− if (source.specUrl !== null || source.defaultHeaders !== null) {
230+const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> =>
231+ Effect.gen(function* () {
232+ if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") {
307233 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"),
309235 );
310236 }
311237
312238 return source;
313239 });
314240
241+const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, never> =>
242+ Effect.flatMap(
243+ validateSourceImportAuth(source),
244+ (validated) => getSourceAdapterForSource(validated).validateSource(validated),
245+ );
246+
315247 export const createSourceFromPayload = (input: {
316248 workspaceId: WorkspaceId;
317249 sourceId: Source["id"];
@@ -320,6 +252,11 @@ export const createSourceFromPayload = (input: { · 2 unmodified lines
320252 }): Effect.Effect<Source, Error, never> =>
321253 Effect.gen(function* () {
322254 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+ );
323260
324261 return yield* validateSourceByKind({
325262 id: input.sourceId,
@@ -335,6 +272,8 @@ export const createSourceFromPayload = (input: { · 9 unmodified lines
335272 headers: input.payload.headers ?? null,
336273 specUrl: trimOrNull(input.payload.specUrl),
337274 defaultHeaders: input.payload.defaultHeaders ?? null,
275+ importAuthPolicy,
276+ importAuth,
338277 auth,
339278 sourceHash: trimOrNull(input.payload.sourceHash),
340279 lastError: trimOrNull(input.payload.lastError),
@@ -352,6 +291,13 @@ export const updateSourceFromPayload = (input: { · 11 unmodified lines
352291 const nextAuth = input.payload.auth === undefined
353292 ? input.source.auth
354293 : 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+ );
355301
356302 return yield* validateSourceByKind({
357303 ...input.source,
@@ -381,6 +327,8 @@ export const updateSourceFromPayload = (input: { · 23 unmodified lines
381327 defaultHeaders: input.payload.defaultHeaders !== undefined
382328 ? input.payload.defaultHeaders
383329 : input.source.defaultHeaders,
330+ importAuthPolicy: nextImportAuthPolicy,
331+ importAuth: nextImportAuth,
384332 auth: nextAuth,
385333 sourceHash: input.payload.sourceHash !== undefined
386334 ? trimOrNull(input.payload.sourceHash)
@@ -416,6 +364,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 29 unmodified lines
416364 revisionNumber: number;
417365 manifestJson?: string | null;
418366 manifestHash?: string | null;
367+ materializationHash?: string | null;
419368 }): StoredSourceRecipeRevisionRecord => ({
420369 id:
421370 input.recipeRevisionId
@@ -425,6 +374,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 3 unmodified lines
425374 sourceConfigJson: sourceConfigSignature(input.source),
426375 manifestJson: input.manifestJson ?? null,
427376 manifestHash: input.manifestHash ?? null,
377+ materializationHash: input.materializationHash ?? null,
428378 createdAt: input.source.createdAt,
429379 updatedAt: input.source.updatedAt,
430380 });
@@ -434,10 +384,12 @@ export const splitSourceForStorage = (input: { · 3 unmodified lines
434384 recipeId: SourceRecipeId;
435385 recipeRevisionId: SourceRecipeRevisionId;
436386 actorAccountId?: AccountId | null;
437− existingCredentialId?: Credential["id"] | null;
387+ existingRuntimeCredentialId?: Credential["id"] | null;
388+ existingImportCredentialId?: Credential["id"] | null;
438389 }): {
439390 sourceRecord: StoredSourceRecord;
440− credential: Credential | null;
391+ runtimeCredential: Credential | null;
392+ importCredential: Credential | null;
441393 } => {
442394 const sourceRecord: StoredSourceRecord = {
443395 id: input.source.id,
@@ -450,113 +402,46 @@ export const splitSourceForStorage = (input: { · 6 unmodified lines
450402 status: input.source.status,
451403 enabled: input.source.enabled,
452404 namespace: input.source.namespace,
453− bindingConfigJson: null,
454− transport: input.source.transport,
455− queryParamsJson: serializeStringMap(input.source.queryParams),
456− headersJson: serializeStringMap(input.source.headers),
457− specUrl: input.source.specUrl,
458− defaultHeadersJson: serializeStringMap(input.source.defaultHeaders),
405+ importAuthPolicy: input.source.importAuthPolicy,
406+ bindingConfigJson: getSourceAdapterForSource(input.source).serializeBindingConfig(input.source),
459407 sourceHash: input.source.sourceHash,
460− sourceDocumentText: null,
461408 lastError: input.source.lastError,
462409 createdAt: input.source.createdAt,
463410 updatedAt: input.source.updatedAt,
464411 };
465412
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−
504413 return {
505414 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,
507431 };
508432 };
509433
510434 export const projectSourceFromStorage = (input: {
511435 sourceRecord: StoredSourceRecord;
512− credential: Credential | null;
436+ runtimeCredential: Credential | null;
437+ importCredential: Credential | null;
513438 }): Effect.Effect<Source, Error, never> =>
514439 Effect.gen(function* () {
515− const queryParams = yield* parseStringMapJson(
516− `queryParamsJson for ${input.sourceRecord.id}`,
517− input.sourceRecord.queryParamsJson,
518− );
519− const headers = yield* parseStringMapJson(
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− }
440+ const adapter = getSourceAdapter(input.sourceRecord.kind);
441+ const bindingState = yield* adapter.deserializeBindingConfig({
442+ id: input.sourceRecord.id,
443+ bindingConfigJson: input.sourceRecord.bindingConfigJson,
444+ });
560445
561446 return {
562447 id: input.sourceRecord.id,
@@ -567,12 +452,17 @@ export const projectSourceFromStorage = (input: { · 4 unmodified lines
567452 status: input.sourceRecord.status,
568453 enabled: input.sourceRecord.enabled,
569454 namespace: input.sourceRecord.namespace,
570− transport: input.sourceRecord.transport,
571− queryParams,
572− headers,
573− specUrl: input.sourceRecord.specUrl,
574− defaultHeaders,
575− auth,
455+ transport: bindingState.transport,
456+ queryParams: bindingState.queryParams,
457+ headers: bindingState.headers,
458+ specUrl: bindingState.specUrl,
459+ defaultHeaders: bindingState.defaultHeaders,
460+ importAuthPolicy: input.sourceRecord.importAuthPolicy,
461+ importAuth:
462+ input.sourceRecord.importAuthPolicy === "separate"
463+ ? authFromCredential(input.importCredential)
464+ : { kind: "none" },
465+ auth: authFromCredential(input.runtimeCredential),
576466 sourceHash: input.sourceRecord.sourceHash,
577467 lastError: input.sourceRecord.lastError,
578468 createdAt: input.sourceRecord.createdAt,
@@ -588,18 +478,29 @@ export const projectSourcesFromStorage = (input: { · 9 unmodified lines
588478 sourceRecords: ReadonlyArray<StoredSourceRecord>;
589479 credentials: ReadonlyArray<Credential>;
590480 }): 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+ }>();
592485
593486 for (const credential of input.credentials) {
594− const existing = credentialsBySourceId.get(credential.sourceId);
595− if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) {
596− credentialsBySourceId.set(credential.sourceId, credential);
487+ const existing = credentialsBySourceId.get(credential.sourceId) ?? {
488+ runtime: null,
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+ });
597497 }
598498 }
599499
600500 return Effect.forEach(input.sourceRecords, (sourceRecord) =>
601501 projectSourceFromStorage({
602502 sourceRecord,
603− credential: credentialsBySourceId.get(sourceRecord.id) ?? null,
503+ runtimeCredential: credentialsBySourceId.get(sourceRecord.id)?.runtime ?? null,
504+ importCredential: credentialsBySourceId.get(sourceRecord.id)?.import ?? null,
604505 }));
605506 };
packages/control-plane/src/runtime/source-discovery.ts+1−1
@@ -19,7 +19,7 @@ import { · 18 unmodified lines
1919 import * as Effect from "effect/Effect";
2020 import { getIntrospectionQuery } from "graphql";
2121 import { startMcpOAuthAuthorization } from "./mcp-oauth";
22−import { namespaceFromSourceName } from "./tool-artifacts";
22+import { namespaceFromSourceName } from "./source-names";
2323
2424 const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery();
2525
packages/control-plane/src/runtime/source-inspection.test.ts+309−0
@@ -0,0 +1,309 @@
1+import { describe, expect, it } from "vitest";
2+import * as Effect from "effect/Effect";
3+
4+import {
5+ AccountIdSchema,
6+ OrganizationIdSchema,
7+ SourceIdSchema,
8+ SourceRecipeIdSchema,
9+ SourceRecipeRevisionIdSchema,
10+ SourceRecipeSchemaBundleIdSchema,
11+ WorkspaceIdSchema,
12+ type Source,
13+ type StoredSourceRecipeOperationRecord,
14+} from "#schema";
15+import {
16+ createSqlControlPlanePersistence,
17+ type SqlControlPlanePersistence,
18+} from "#persistence";
19+
20+import {
21+ getSourceInspection,
22+ getSourceInspectionSchemaBundle,
23+ getSourceInspectionToolDetail,
24+} from "./source-inspection";
25+import { ControlPlaneStore } from "./store";
26+
27+const makePersistence = () =>
28+ Effect.runPromise(
29+ createSqlControlPlanePersistence({
30+ localDataDir: ":memory:",
31+ }),
32+ );
33+
34+const 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+
59+const 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+
85+const 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+
136+describe("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 @@
1−import { typeSignatureFromSchemaJson } from "@executor/codemode-core";
2−import {
3− buildOpenApiToolPresentation,
4− compileOpenApiToolDefinitions,
5− openApiOutputTypeSignatureFromSchemaJson,
6− type OpenApiToolDefinition,
7− type OpenApiToolManifest,
8−} from "@executor/codemode-openapi";
91 import type {
102 Source,
113 SourceId,
@@ -13,10 +5,14 @@ import type { · 1 unmodified line
135 SourceInspectionDiscoverPayload,
146 SourceInspectionDiscoverResult,
157 SourceInspectionDiscoverResultItem,
8+ SourceInspectionSchemaBundle,
9+ SourceInspectionToolListItem,
1610 SourceInspectionToolDetail,
1711 SourceInspectionToolSummary,
12+ StoredSourceRecipeOperationRecord,
1813 WorkspaceId,
1914 } from "#schema";
15+import { SourceRecipeSchemaBundleIdSchema } from "#schema";
2016 import {
2117 ControlPlaneNotFoundError,
2218 ControlPlaneStorageError,
@@ -25,21 +21,18 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
2521 import * as Option from "effect/Option";
2622
2723 import { operationErrors } from "./operation-errors";
28−import { formatJsonIfNeeded, formatWithPrettier } from "./prettier-format";
24+import { formatWithPrettier } from "./prettier-format";
2925 import {
30− buildGraphqlToolPresentation,
31− compileGraphqlToolDefinitions,
32− type GraphqlToolDefinition,
33− type GraphqlToolManifest,
34−} from "./graphql-tools";
26+ getSourceAdapterForOperation,
27+} from "./source-adapters";
3528 import {
36− loadSourceRecipe,
37− recipePrimaryDocumentText,
29+ recipeToolMetadata,
3830 recipeToolPath,
39− type LoadedSourceRecipe,
4031 } from "./source-recipes-runtime";
32+import type { SourceAdapterPersistedOperationMetadata } from "./source-adapters/types";
33+import { namespaceFromSourceName } from "./source-names";
34+import { loadSourceById } from "./source-store";
4135 import { ControlPlaneStore } from "./store";
42−import { namespaceFromSourceName } from "./tool-artifacts";
4336
4437 const sourceInspectOps = {
4538 bundle: operationErrors("sources.inspect.bundle"),
@@ -47,8 +40,6 @@ const sourceInspectOps = { · 1 unmodified line
4740 discover: operationErrors("sources.inspect.discover"),
4841 } as const;
4942
50−const asPrettyJson = (value: unknown): string => JSON.stringify(value, null, 2);
51−
5243 const tokenize = (value: string): Array<string> =>
5344 value
5445 .trim()
@@ -56,26 +47,10 @@ const tokenize = (value: string): Array<string> => · 1 unmodified line
5647 .split(/[^a-z0-9]+/)
5748 .filter(Boolean);
5849
59−const 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−
7650 type InspectionToolRecord = {
77− summary: SourceInspectionToolSummary;
78− detail: SourceInspectionToolDetail;
51+ operation: StoredSourceRecipeOperationRecord;
52+ metadata: SourceAdapterPersistedOperationMetadata;
53+ listItem: SourceInspectionToolListItem;
7954 searchText: string;
8055 };
8156
@@ -83,9 +58,7 @@ type ResolvedSourceInspection = { · 1 unmodified line
8358 source: Source;
8459 namespace: string;
8560 pipelineKind: SourceInspection["pipelineKind"];
86− rawDocumentText: string | null;
87− manifestJson: string | null;
88− definitionsJson: string | null;
61+ schemaBundleId: string | null;
8962 tools: ReadonlyArray<InspectionToolRecord>;
9063 };
9164
@@ -111,41 +84,26 @@ const formatToolSummary = (summary: SourceInspectionToolSummary) => · 19 unmodified lines
11184 } satisfies SourceInspectionToolSummary)),
11285 );
11386
114−const formatInspectionToolRecord = (record: InspectionToolRecord) =>
87+const formatInspectionToolDetail = (detail: SourceInspectionToolDetail) =>
11588 Effect.gen(function* () {
116− const summary = yield* formatToolSummary(record.summary);
89+ const summary = yield* formatToolSummary(detail.summary);
11790 const detailFields = yield* Effect.all({
118− definitionJson: formatOptionalJson(record.detail.definitionJson),
119− documentationJson: formatOptionalJson(record.detail.documentationJson),
120− providerDataJson: formatOptionalJson(record.detail.providerDataJson),
121− inputSchemaJson: formatOptionalJson(record.detail.inputSchemaJson),
122− outputSchemaJson: formatOptionalJson(record.detail.outputSchemaJson),
123− exampleInputJson: formatOptionalJson(record.detail.exampleInputJson),
124− exampleOutputJson: formatOptionalJson(record.detail.exampleOutputJson),
91+ definitionJson: formatOptionalJson(detail.definitionJson),
92+ documentationJson: formatOptionalJson(detail.documentationJson),
93+ providerDataJson: formatOptionalJson(detail.providerDataJson),
94+ inputSchemaJson: formatOptionalJson(detail.inputSchemaJson),
95+ outputSchemaJson: formatOptionalJson(detail.outputSchemaJson),
96+ exampleInputJson: formatOptionalJson(detail.exampleInputJson),
97+ exampleOutputJson: formatOptionalJson(detail.exampleOutputJson),
12598 });
12699
127100 return {
101+ ...detail,
102+ ...detailFields,
128103 summary,
129− detail: {
130− ...record.detail,
131− ...detailFields,
132− summary,
133− },
134− searchText: record.searchText,
135− } satisfies InspectionToolRecord;
136− });
137−
138−const loadStoredDocumentText = (recipe: LoadedSourceRecipe) => {
139− const rawDocumentText = recipePrimaryDocumentText({
140− source: recipe.source,
141− documents: recipe.documents,
104+ } satisfies SourceInspectionToolDetail;
142105 });
143106
144− return rawDocumentText === null
145− ? Effect.succeed<string | null>(null)
146− : Effect.promise(() => formatJsonIfNeeded(rawDocumentText));
147−};
148−
149107 const loadSourceRecipeRecord = (input: {
150108 workspaceId: WorkspaceId;
151109 sourceId: SourceId;
@@ -164,316 +122,146 @@ const loadSourceRecipeRecord = (input: { · 12 unmodified lines
164122 );
165123 }
166124
167− const recipe = yield* loadSourceRecipe({
168− rows: store,
125+ const source = yield* loadSourceById(store, {
169126 workspaceId: input.workspaceId,
170127 sourceId: input.sourceId,
171128 }).pipe(
172129 Effect.mapError((cause) =>
173130 sourceInspectOps.bundle.unknownStorage(
174131 cause,
175− "Failed loading source recipe",
132+ "Failed loading source",
176133 ),
177134 ),
178135 );
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+ );
179142
180143 return {
181− store,
182− sourceRecord: sourceRecord.value,
183− recipe,
144+ source,
145+ operations,
146+ schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id
147+ ?? schemaBundles[0]?.id
148+ ?? null,
184149 };
185150 }),
186151 );
187152
188−const persistedToolSummaryFromRecipeOperation = (input: {
153+const inspectionToolListItemFromOperation = (input: {
189154 source: Source;
190− operation: LoadedSourceRecipe["operations"][number];
191−}): SourceInspectionToolSummary => ({
155+ operation: StoredSourceRecipeOperationRecord;
156+ metadata: SourceAdapterPersistedOperationMetadata;
157+}): SourceInspectionToolListItem => ({
192158 path: recipeToolPath({
193159 source: input.source,
194160 operation: input.operation,
195161 }),
196− sourceKey: input.source.id,
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− : {}),
162+ method: input.metadata.method,
235163 });
236164
237−const openApiToolRecord = (input: {
165+const persistedToolSummaryFromRecipeOperation = (input: {
238166 source: Source;
239− namespace: string;
240− manifest: OpenApiToolManifest;
241− definition: OpenApiToolDefinition;
242−}): InspectionToolRecord => {
243− const presentation = buildOpenApiToolPresentation({
244− manifest: input.manifest,
245− definition: input.definition,
246− });
247− const path = `${input.namespace}.${input.definition.toolId}`;
248− const summary: SourceInspectionToolSummary = {
249− path,
250− sourceKey: input.source.id,
251− title: input.definition.name,
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− };
167+ operation: StoredSourceRecipeOperationRecord;
168+ metadata: SourceAdapterPersistedOperationMetadata;
169+ includeTypes: boolean;
170+}): SourceInspectionToolSummary => {
171+ const path = inspectionToolListItemFromOperation(input).path;
172+ const descriptor = input.includeTypes
173+ ? getSourceAdapterForOperation(input.operation).createToolDescriptor({
174+ source: input.source,
175+ operation: input.operation,
176+ path,
177+ includeSchemas: false,
178+ })
179+ : null;
265180
266181 return {
267− summary,
268− detail: {
269− summary,
270− definitionJson: asPrettyJson(input.definition),
271− documentationJson: input.definition.documentation
272− ? asPrettyJson(input.definition.documentation)
273− : null,
274− providerDataJson: presentation.providerDataJson,
275− inputSchemaJson: presentation.inputSchemaJson ?? null,
276− outputSchemaJson: presentation.outputSchemaJson ?? null,
277− exampleInputJson: presentation.exampleInputJson ?? null,
278− exampleOutputJson: presentation.exampleOutputJson ?? null,
279− },
280− searchText: searchTextFromSummary(summary),
182+ path,
183+ sourceKey: input.source.id,
184+ ...(input.operation.title ? { title: input.operation.title } : {}),
185+ ...(input.operation.description ? { description: input.operation.description } : {}),
186+ providerKind: input.operation.providerKind,
187+ toolId: input.operation.toolId,
188+ rawToolId: input.metadata.rawToolId,
189+ operationId: input.metadata.operationId,
190+ group: input.metadata.group,
191+ leaf: input.metadata.leaf,
192+ tags: [...input.metadata.tags],
193+ method: input.metadata.method,
194+ pathTemplate: input.metadata.pathTemplate,
195+ ...(descriptor?.inputType ? { inputType: descriptor.inputType } : {}),
196+ ...(descriptor?.outputType ? { outputType: descriptor.outputType } : {}),
281197 };
282198 };
283199
284−const graphqlToolRecord = (input: {
200+const inspectionToolDetailFromOperation = (input: {
285201 source: Source;
286− namespace: string;
287− manifest: GraphqlToolManifest;
288− definition: GraphqlToolDefinition;
289−}): InspectionToolRecord => {
290− const presentation = buildGraphqlToolPresentation({
291− manifest: input.manifest,
292− definition: input.definition,
202+ operation: StoredSourceRecipeOperationRecord;
203+ metadata: SourceAdapterPersistedOperationMetadata;
204+ schemaBundleId: string | null;
205+}): SourceInspectionToolDetail => {
206+ const summary = persistedToolSummaryFromRecipeOperation({
207+ source: input.source,
208+ operation: input.operation,
209+ metadata: input.metadata,
210+ includeTypes: true,
293211 });
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− };
312212
313213 return {
314214 summary,
315− detail: {
316− summary,
317− definitionJson: asPrettyJson(input.definition),
318− documentationJson: null,
319− providerDataJson: presentation.providerDataJson,
320− inputSchemaJson: presentation.inputSchemaJson ?? null,
321− outputSchemaJson: presentation.outputSchemaJson ?? null,
322− exampleInputJson: presentation.exampleInputJson ?? null,
323− exampleOutputJson: null,
324− },
325− searchText: searchTextFromSummary(summary),
326− };
215+ definitionJson: null,
216+ documentationJson: null,
217+ providerDataJson: input.operation.providerDataJson,
218+ inputSchemaJson: input.operation.inputSchemaJson,
219+ outputSchemaJson: input.operation.outputSchemaJson,
220+ schemaBundleId: input.schemaBundleId,
221+ exampleInputJson: null,
222+ exampleOutputJson: null,
223+ } satisfies SourceInspectionToolDetail;
327224 };
328225
329−const 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−
369−const 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−
406−const 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−
443226 const resolveSourceInspection = (input: {
444227 workspaceId: WorkspaceId;
445228 sourceId: SourceId;
446229 }) =>
447230 Effect.gen(function* () {
448− const { recipe } = yield* loadSourceRecipeRecord(input);
449−
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− }
231+ const { source, operations, schemaBundleId } = yield* loadSourceRecipeRecord(input);
232+ const namespace = source.namespace ?? namespaceFromSourceName(source.name);
473233
474− return yield* loadPersistedInspection({
475− recipe,
476− });
234+ return {
235+ source,
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;
477265 });
478266
479267 const scoreTool = (input: {
@@ -482,15 +270,12 @@ const scoreTool = (input: { · 2 unmodified lines
482270 }): SourceInspectionDiscoverResultItem | null => {
483271 let score = 0;
484272 const reasons: Array<string> = [];
485− const pathTokens = tokenize(input.tool.summary.path);
486− const titleTokens = tokenize(input.tool.summary.title ?? "");
487− const descriptionTokens = tokenize(input.tool.summary.description ?? "");
488− const tagTokens = input.tool.summary.tags.flatMap(tokenize);
489− const typeTokens = tokenize(
490− `${input.tool.summary.inputType ?? ""} ${input.tool.summary.outputType ?? ""}`,
491− );
273+ const pathTokens = tokenize(input.tool.listItem.path);
274+ const titleTokens = tokenize(input.tool.operation.title ?? "");
275+ const descriptionTokens = tokenize(input.tool.operation.description ?? "");
276+ const tagTokens = input.tool.metadata.tags.flatMap(tokenize);
492277 const methodPathTokens = tokenize(
493− `${input.tool.summary.method ?? ""} ${input.tool.summary.pathTemplate ?? ""}`,
278+ `${input.tool.listItem.method ?? ""} ${input.tool.metadata.pathTemplate ?? ""}`,
494279 );
495280
496281 for (const token of input.queryTokens) {
@@ -514,11 +299,6 @@ const scoreTool = (input: { · 17 unmodified lines
514299 reasons.push(`method/path matches ${token} (+6)`);
515300 continue;
516301 }
517− if (typeTokens.includes(token)) {
518− score += 4;
519− reasons.push(`type signature matches ${token} (+4)`);
520− continue;
521− }
522302 if (descriptionTokens.includes(token) || input.tool.searchText.includes(token)) {
523303 score += 2;
524304 reasons.push(`description/text matches ${token} (+2)`);
@@ -530,16 +310,10 @@ const scoreTool = (input: { · 5 unmodified lines
530310 }
531311
532312 return {
533− path: input.tool.summary.path,
313+ path: input.tool.listItem.path,
534314 score,
535− ...(input.tool.summary.description
536− ? { description: input.tool.summary.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 }
315+ ...(input.tool.operation.description
316+ ? { description: input.tool.operation.description }
543317 : {}),
544318 reasons,
545319 } satisfies SourceInspectionDiscoverResultItem;
@@ -575,10 +349,7 @@ export const getSourceInspection = (input: { · 29 unmodified lines
575349 namespace: inspection.namespace,
576350 pipelineKind: inspection.pipelineKind,
577351 toolCount: inspection.tools.length,
578− rawDocumentText: inspection.rawDocumentText,
579− manifestJson: inspection.manifestJson,
580− definitionsJson: inspection.definitionsJson,
581− tools: inspection.tools.map((tool) => tool.summary),
352+ tools: inspection.tools.map((tool) => tool.listItem),
582353 } satisfies SourceInspection;
583354 }).pipe(
584355 Effect.mapError((cause) =>
@@ -599,7 +370,7 @@ export const getSourceInspectionToolDetail = (input: { · 14 unmodified lines
599370 workspaceId: input.workspaceId,
600371 sourceId: input.sourceId,
601372 });
602− const tool = inspection.tools.find((candidate) => candidate.summary.path === input.toolPath);
373+ const tool = inspection.tools.find((candidate) => candidate.listItem.path === input.toolPath);
603374
604375 if (!tool) {
605376 return yield* Effect.fail(
@@ -610,7 +381,12 @@ export const getSourceInspectionToolDetail = (input: { · 4 unmodified lines
610381 );
611382 }
612383
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+ }));
614390 }).pipe(
615391 Effect.mapError((cause) =>
616392 mapInspectionError(
@@ -620,6 +396,58 @@ export const getSourceInspectionToolDetail = (input: { · 3 unmodified lines
620396 )),
621397 );
622398
399+export 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+
623451 export const discoverSourceInspectionTools = (input: {
624452 workspaceId: WorkspaceId;
625453 sourceId: SourceId;
packages/control-plane/src/runtime/source-materialization.ts+57−0
@@ -0,0 +1,57 @@
1+import type { SqlControlPlaneRows } from "#persistence";
2+import type {
3+ Source,
4+} from "#schema";
5+import * as Effect from "effect/Effect";
6+
7+import { resolveSourceAuthMaterial } from "./source-auth-material";
8+import {
9+ getSourceAdapterForSource,
10+} from "./source-adapters";
11+import { persistMcpRecipeRevisionFromManifestEntries } from "./source-adapters/mcp";
12+import type {
13+ ResolveSecretMaterial as ResolveSourceSecretMaterial,
14+} from "./secret-material-providers";
15+import { persistRecipeMaterialization } from "./source-recipe-support";
16+
17+const shouldIndexSource = (source: Source): boolean =>
18+ source.enabled
19+ && source.status === "connected"
20+ && getSourceAdapterForSource(source).family !== "internal";
21+
22+export 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+
50+export 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 @@
1+export 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 @@
1+import { createHash } from "node:crypto";
2+
3+import type {
4+ Source,
5+ StoredSourceRecipeDocumentRecord,
6+ StoredSourceRecipeOperationRecord,
7+ StoredSourceRecipeSchemaBundleRecord,
8+ StoredSourceRecipeRevisionRecord,
9+} from "#schema";
10+import type { SqlControlPlaneRows } from "#persistence";
11+import * as Effect from "effect/Effect";
12+import * as Option from "effect/Option";
13+
14+type SourceBindingRecord =
15+ Awaited<
16+ ReturnType<SqlControlPlaneRows["sources"]["getByWorkspaceAndId"]>
17+ > extends Effect.Effect<Option.Option<infer T>, unknown, never>
18+ ? T
19+ : never;
20+
21+export 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+
30+export const contentHash = (value: string): string =>
31+ createHash("sha256").update(value).digest("hex");
32+
33+export 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+
57+export 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+
66+const 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+
113+const 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+
181+const 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+
197+const 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+
218+export 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+
354+const 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
3838 }),
3939 );
4040
41+const openApiBindingConfigJson = (specUrl: string): string =>
42+ JSON.stringify({
43+ adapterKey: "openapi",
44+ specUrl,
45+ defaultHeaders: null,
46+ });
47+
48+const graphqlBindingConfigJson = (): string =>
49+ JSON.stringify({
50+ adapterKey: "graphql",
51+ defaultHeaders: null,
52+ });
53+
4154 const makeSource = (overrides: Partial<Source> = {}): Source => ({
4255 id: SourceIdSchema.make("src_runtime_recipe"),
4356 workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"),
@@ -52,6 +65,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 8 unmodified lines
5265 headers: null,
5366 specUrl: "https://api.github.com/openapi.json",
5467 defaultHeaders: null,
68+ importAuthPolicy: "reuse_runtime",
69+ importAuth: { kind: "none" },
5570 auth: { kind: "none" },
5671 sourceHash: null,
5772 lastError: null,
@@ -61,8 +76,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 3 unmodified lines
6176 });
6277
6378 const makeOperation = (
64− overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }>> = {},
65−): Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }> => ({
79+ overrides: Partial<StoredSourceRecipeOperationRecord> = {},
80+): StoredSourceRecipeOperationRecord => ({
6681 id: "src_recipe_op_runtime",
6782 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
6883 operationKey: "getRepo",
@@ -85,25 +100,30 @@ const makeOperation = ( · 16 unmodified lines
85100 providerKind: "openapi",
86101 providerDataJson: JSON.stringify({
87102 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+ },
88118 }),
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,
99119 createdAt: 1000,
100120 updatedAt: 1000,
101121 ...overrides,
102122 });
103123
104124 const makeGraphqlOperation = (
105− overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }>> = {},
106−): Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }> => ({
125+ overrides: Partial<StoredSourceRecipeOperationRecord> = {},
126+): StoredSourceRecipeOperationRecord => ({
107127 id: "src_recipe_op_graphql_runtime",
108128 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
109129 operationKey: "viewer",
@@ -126,25 +146,27 @@ const makeGraphqlOperation = ( · 16 unmodified lines
126146 providerKind: "graphql",
127147 providerDataJson: JSON.stringify({
128148 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,
129161 }),
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",
140162 createdAt: 1000,
141163 updatedAt: 1000,
142164 ...overrides,
143165 });
144166
145167 const makeMcpOperation = (
146− overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }>> = {},
147−): Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }> => ({
168+ overrides: Partial<StoredSourceRecipeOperationRecord> = {},
169+): StoredSourceRecipeOperationRecord => ({
148170 id: "src_recipe_op_mcp_runtime",
149171 recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"),
150172 operationKey: "echo",
@@ -159,17 +181,10 @@ const makeMcpOperation = ( · 8 unmodified lines
159181 providerKind: "mcp",
160182 providerDataJson: JSON.stringify({
161183 kind: "mcp",
184+ toolId: "echo",
185+ toolName: "echo",
186+ description: "Echo a value",
162187 }),
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,
173188 createdAt: 1000,
174189 updatedAt: 1000,
175190 ...overrides,
@@ -248,19 +263,14 @@ describe("source-recipes-runtime", () => { · 72 unmodified lines
248263 expect(recipeToolSearchNamespace({
249264 source: graphqlSource,
250265 path: "ignored.path",
251− operation: makeGraphqlOperation({
252− graphqlOperationType: "query",
253− graphqlOperationName: "viewer",
254− }),
266+ operation: makeGraphqlOperation(),
255267 })).toBe("issues");
256268 });
257269
258270 it("computes interaction modes and descriptor fields correctly", () => {
259271 expect(recipeToolDescriptor({
260272 source: makeSource(),
261− operation: makeOperation({
262− openApiMethod: "get",
263− }),
273+ operation: makeOperation(),
264274 path: "github.getRepo",
265275 includeSchemas: true,
266276 }).interaction).toBe("auto");
@@ -268,7 +278,7 @@ describe("source-recipes-runtime", () => { · 1 unmodified line
268278 expect(recipeToolDescriptor({
269279 source: makeSource(),
270280 operation: makeOperation({
271− openApiMethod: "delete",
281+ operationKind: "delete",
272282 }),
273283 path: "github.deleteRepo",
274284 includeSchemas: true,
@@ -280,10 +290,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
280290 endpoint: "https://example.com/graphql",
281291 specUrl: null,
282292 }),
283− operation: makeGraphqlOperation({
284− graphqlOperationType: "query",
285− graphqlOperationName: "viewer",
286− }),
293+ operation: makeGraphqlOperation(),
287294 path: "graphql.viewer",
288295 includeSchemas: true,
289296 }).interaction).toBe("auto");
@@ -295,8 +302,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
295302 specUrl: null,
296303 }),
297304 operation: makeGraphqlOperation({
298− graphqlOperationType: "mutation",
299− graphqlOperationName: "createIssue",
305+ operationKind: "write",
300306 }),
301307 path: "graphql.createIssue",
302308 includeSchemas: true,
@@ -309,9 +315,7 @@ describe("source-recipes-runtime", () => { · 6 unmodified lines
309315 specUrl: null,
310316 transport: "streamable-http",
311317 }),
312− operation: makeMcpOperation({
313− mcpToolName: "echo",
314− }),
318+ operation: makeMcpOperation(),
315319 path: "mcp.echo",
316320 includeSchemas: true,
317321 }).interaction).toBe("auto");
@@ -349,14 +353,9 @@ describe("source-recipes-runtime", () => { · 31 unmodified lines
349353 status: "connected",
350354 enabled: true,
351355 namespace: "github",
352− bindingConfigJson: null,
353− transport: null,
354− queryParamsJson: null,
355− headersJson: null,
356− specUrl: "https://api.github.com/openapi.json",
357− defaultHeadersJson: null,
356+ importAuthPolicy: "reuse_runtime",
357+ bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
358358 sourceHash: null,
359− sourceDocumentText: null,
360359 lastError: null,
361360 createdAt: 1000,
362361 updatedAt: 1000,
@@ -368,30 +367,36 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
368367 sourceConfigJson: "{}",
369368 manifestJson: null,
370369 manifestHash: null,
370+ materializationHash: null,
371371 createdAt: 1000,
372372 updatedAt: 1000,
373373 },
374374 documents: [],
375+ schemaBundles: [],
375376 operations: [makeOperation({
376377 searchText: "",
377378 })],
378379 manifest: null,
379380 };
380381
381− const expanded = expandRecipeTools({
382− recipes: [recipe],
383− includeSchemas: false,
384− });
382+ const expanded = Effect.runSync(
383+ expandRecipeTools({
384+ recipes: [recipe],
385+ includeSchemas: false,
386+ }),
387+ );
385388 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+ );
387392
388− expect(expandRecipeTools({
393+ expect(Effect.runSync(expandRecipeTools({
389394 recipes: [{
390395 ...recipe,
391396 operations: [],
392397 }],
393398 includeSchemas: false,
394− })).toEqual([]);
399+ }))).toEqual([]);
395400 });
396401 });
397402
@@ -440,7 +445,6 @@ describe("source-recipes-runtime", () => { · 42 unmodified lines
440445 const manifest = await Effect.runPromise(extractOpenApiManifest("GitHub", openApiDocument));
441446 const definition = compileOpenApiToolDefinitions(manifest)[0]!;
442447 const presentation = buildOpenApiToolPresentation({
443− manifest,
444448 definition,
445449 });
446450
@@ -452,7 +456,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines
452456 });
453457 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
454458 id: recipeId,
455− kind: "http_recipe",
459+ kind: "http_api",
456460 importerKind: "openapi",
457461 providerKey: "generic_http",
458462 name: "GitHub",
@@ -474,6 +478,7 @@ describe("source-recipes-runtime", () => { · 15 unmodified lines
474478 }),
475479 manifestJson: JSON.stringify(manifest),
476480 manifestHash: manifest.sourceHash,
481+ materializationHash: manifest.sourceHash,
477482 createdAt: 1000,
478483 updatedAt: 1000,
479484 }));
@@ -504,13 +509,6 @@ describe("source-recipes-runtime", () => { · 24 unmodified lines
504509 inputSchemaJson: presentation.inputSchemaJson ?? null,
505510 outputSchemaJson: presentation.outputSchemaJson ?? null,
506511 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,
514512 })],
515513 }));
516514
@@ -526,14 +524,9 @@ describe("source-recipes-runtime", () => { · 9 unmodified lines
526524 status: "connected",
527525 enabled: true,
528526 namespace: "github",
529− bindingConfigJson: null,
530− transport: null,
531− queryParamsJson: null,
532− headersJson: null,
533− specUrl: "https://api.github.com/openapi.json",
534− defaultHeadersJson: null,
527+ importAuthPolicy: "reuse_runtime",
528+ bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
535529 sourceHash: manifest.sourceHash,
536− sourceDocumentText: null,
537530 lastError: null,
538531 createdAt: 1000 + index,
539532 updatedAt: 1000 + index,
@@ -573,7 +566,7 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines
573566 });
574567 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
575568 id: recipeId,
576− kind: "graphql_recipe",
569+ kind: "http_api",
577570 importerKind: "graphql_introspection",
578571 providerKey: "generic_graphql",
579572 name: "GraphQL Demo",
@@ -594,6 +587,7 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines
594587 }),
595588 manifestJson: null,
596589 manifestHash: null,
590+ materializationHash: null,
597591 createdAt: 1000,
598592 updatedAt: 1000,
599593 }));
@@ -608,14 +602,9 @@ describe("source-recipes-runtime", () => { · 8 unmodified lines
608602 status: "connected",
609603 enabled: true,
610604 namespace: "graphql",
611− bindingConfigJson: null,
612− transport: null,
613− queryParamsJson: null,
614− headersJson: null,
615− specUrl: null,
616− defaultHeadersJson: null,
605+ importAuthPolicy: "reuse_runtime",
606+ bindingConfigJson: graphqlBindingConfigJson(),
617607 sourceHash: null,
618− sourceDocumentText: null,
619608 lastError: null,
620609 createdAt: 1000,
621610 updatedAt: 1000,
@@ -659,7 +648,7 @@ describe("source-recipes-runtime", () => { · 37 unmodified lines
659648 });
660649 await Effect.runPromise(persistence.rows.sourceRecipes.upsert({
661650 id: recipeId,
662− kind: "http_recipe",
651+ kind: "http_api",
663652 importerKind: "openapi",
664653 providerKey: "generic_http",
665654 name: "Broken GitHub",
@@ -680,14 +669,9 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines
680669 status: "connected",
681670 enabled: true,
682671 namespace: "github",
683− bindingConfigJson: null,
684− transport: null,
685− queryParamsJson: null,
686− headersJson: null,
687− specUrl: "https://api.github.com/openapi.json",
688− defaultHeadersJson: null,
672+ importAuthPolicy: "reuse_runtime",
673+ bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"),
689674 sourceHash: null,
690− sourceDocumentText: null,
691675 lastError: null,
692676 createdAt: 1000,
693677 updatedAt: 1000,
@@ -711,6 +695,7 @@ describe("source-recipes-runtime", () => { · 17 unmodified lines
711695 }),
712696 manifestJson: "{bad-json",
713697 manifestHash: null,
698+ materializationHash: null,
714699 createdAt: 1000,
715700 updatedAt: 1000,
716701 }));
packages/control-plane/src/runtime/source-recipes-runtime.ts+318−134
@@ -1,15 +1,6 @@
11 import {
22 type ToolDescriptor,
3− type ToolPath,
4− typeSignatureFromSchemaJson,
53 } from "@executor/codemode-core";
6−import type {
7− McpToolManifest,
8−} from "@executor/codemode-mcp";
9−import {
10− openApiOutputTypeSignatureFromSchemaJson,
11− type OpenApiToolManifest,
12−} from "@executor/codemode-openapi";
134 import type { SqlControlPlaneRows } from "#persistence";
145 import type {
156 AccountId,
@@ -17,29 +8,30 @@ import type { · 1 unmodified line
178 StoredSourceRecord,
189 StoredSourceRecipeDocumentRecord,
1910 StoredSourceRecipeOperationRecord,
11+ StoredSourceRecipeSchemaBundleRecord,
2012 StoredSourceRecipeRevisionRecord,
2113 WorkspaceId,
2214 } from "#schema";
2315 import * as Effect from "effect/Effect";
2416 import * as Option from "effect/Option";
2517
26−import type { GraphqlToolManifest } from "./graphql-tools";
18+import { namespaceFromSourceName } from "./source-names";
19+import {
20+ getSourceAdapterForOperation,
21+ getSourceAdapterForSource,
22+} from "./source-adapters";
23+import type { SourceAdapterPersistedOperationMetadata } from "./source-adapters/types";
24+import { firstSchemaBundle } from "./source-adapters/shared";
2725 import { loadSourceById, loadSourcesInWorkspace } from "./source-store";
28−import { namespaceFromSourceName } from "./tool-artifacts";
29−
30−type RecipeManifest =
31− | OpenApiToolManifest
32− | GraphqlToolManifest
33− | McpToolManifest
34− | null;
3526
36−const asToolPath = (value: string): ToolPath => value as ToolPath;
27+type RecipeManifest = unknown | null;
3728
3829 export type LoadedSourceRecipe = {
3930 source: Source;
4031 sourceRecord: StoredSourceRecord;
4132 revision: StoredSourceRecipeRevisionRecord;
4233 documents: readonly StoredSourceRecipeDocumentRecord[];
34+ schemaBundles: readonly StoredSourceRecipeSchemaBundleRecord[];
4335 operations: readonly StoredSourceRecipeOperationRecord[];
4436 manifest: RecipeManifest;
4537 };
@@ -52,58 +44,31 @@ export type LoadedSourceRecipeTool = { · 6 unmodified lines
5244 sourceRecord: StoredSourceRecord;
5345 revision: StoredSourceRecipeRevisionRecord;
5446 operation: StoredSourceRecipeOperationRecord;
47+ metadata: SourceAdapterPersistedOperationMetadata;
48+ schemaBundleId: string | null;
5549 manifest: RecipeManifest;
5650 descriptor: ToolDescriptor;
5751 };
5852
59−const parseJson = <T>(input: {
60− label: string;
61− value: string | null;
62−}): Effect.Effect<T | null, Error, never> =>
63− input.value === null
64− ? Effect.succeed<T | null>(null)
65− : Effect.try({
66− try: () => {
67− const value = input.value!;
68− return JSON.parse(value) as T;
69− },
70− catch: (cause) =>
71− cause instanceof Error
72− ? new Error(`Invalid ${input.label}: ${cause.message}`)
73− : new Error(`Invalid ${input.label}: ${String(cause)}`),
74− });
53+export type LoadedSourceRecipeToolIndexEntry = {
54+ path: string;
55+ searchNamespace: string;
56+ searchText: string;
57+ source: Source;
58+ sourceRecord: StoredSourceRecord;
59+ operation: StoredSourceRecipeOperationRecord;
60+ metadata: SourceAdapterPersistedOperationMetadata;
61+ schemaBundleId: string | null;
62+ descriptor: ToolDescriptor;
63+};
7564
7665 const parseManifestForRecipe = (input: {
7766 source: Source;
7867 revision: StoredSourceRecipeRevisionRecord;
7968 }): Effect.Effect<RecipeManifest, Error, never> =>
80− Effect.gen(function* () {
81− if (input.revision.manifestJson === null) {
82− return null;
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;
69+ getSourceAdapterForSource(input.source).parseManifest({
70+ source: input.source,
71+ manifestJson: input.revision.manifestJson,
10772 });
10873
10974 const catalogNamespaceFromPath = (path: string): string => {
@@ -124,74 +89,34 @@ export const recipeToolSearchNamespace = (input: { · 14 unmodified lines
12489 path: string;
12590 operation: StoredSourceRecipeOperationRecord;
12691 }): string =>
127− input.operation.providerKind === "graphql"
128− ? (input.source.namespace ?? namespaceFromSourceName(input.source.name))
129− : catalogNamespaceFromPath(input.path);
92+ getSourceAdapterForOperation(input.operation).searchNamespace?.({
93+ source: input.source,
94+ path: input.path,
95+ operation: input.operation,
96+ })
97+ ?? catalogNamespaceFromPath(input.path);
13098
13199 export const recipeToolDescriptor = (input: {
132100 source: Source;
133101 operation: StoredSourceRecipeOperationRecord;
134102 path: string;
103+ schemaBundleId?: string | null;
135104 includeSchemas: boolean;
136−}): ToolDescriptor => {
137− const interaction =
138− input.operation.providerKind === "openapi"
139− ? (
140− input.operation.openApiMethod?.toUpperCase() === "GET"
141− || input.operation.openApiMethod?.toUpperCase() === "HEAD"
142− ? "auto"
143− : "required"
144− )
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−};
105+}): ToolDescriptor =>
106+ getSourceAdapterForOperation(input.operation).createToolDescriptor(input);
107+
108+export const recipeToolMetadata = (input: {
109+ source: Source;
110+ operation: StoredSourceRecipeOperationRecord;
111+ path: string;
112+}): Effect.Effect<SourceAdapterPersistedOperationMetadata, Error, never> =>
113+ getSourceAdapterForOperation(input.operation).describePersistedOperation(input);
182114
183115 const sourceRecipeDocumentForSource = (input: {
184116 source: Source;
185117 documents: readonly StoredSourceRecipeDocumentRecord[];
186118 }): StoredSourceRecipeDocumentRecord | null => {
187− const preferredKind =
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;
119+ const preferredKind = getSourceAdapterForSource(input.source).primaryDocumentKind;
195120
196121 if (preferredKind === null) {
197122 return null;
@@ -206,6 +131,25 @@ export const recipePrimaryDocumentText = (input: { · 8 unmodified lines
206131 }): string | null =>
207132 sourceRecipeDocumentForSource(input)?.contentText ?? null;
208133
134+const 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+
209153 export const loadWorkspaceSourceRecipes = (input: {
210154 rows: SqlControlPlaneRows;
211155 workspaceId: WorkspaceId;
@@ -224,12 +168,19 @@ export const loadWorkspaceSourceRecipes = (input: { · 12 unmodified lines
224168 const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds);
225169 const revisionById = new Map(revisions.map((revision) => [revision.id, revision]));
226170 const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds);
171+ const schemaBundles = yield* input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds);
227172 const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>();
228173 for (const document of documents) {
229174 const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? [];
230175 existing.push(document);
231176 documentsByRevisionId.set(document.recipeRevisionId, existing);
232177 }
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+ }
233184 const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds);
234185 const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>();
235186 for (const operation of operations) {
@@ -264,6 +215,7 @@ export const loadWorkspaceSourceRecipes = (input: { · 28 unmodified lines
264215 sourceRecord,
265216 revision,
266217 documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
218+ schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
267219 operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [],
268220 manifest,
269221 } satisfies LoadedSourceRecipe;
@@ -299,8 +251,9 @@ export const loadSourceRecipe = (input: { · 29 unmodified lines
299251 new Error(`Recipe revision missing for source ${input.sourceId}`),
300252 );
301253 }
302− const [documents, operations, manifest] = yield* Effect.all([
254+ const [documents, schemaBundles, operations, manifest] = yield* Effect.all([
303255 input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId),
256+ input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId),
304257 input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId),
305258 parseManifestForRecipe({
306259 source,
@@ -313,6 +266,7 @@ export const loadSourceRecipe = (input: { · 6 unmodified lines
313266 sourceRecord: sourceRecord.value,
314267 revision: revision.value,
315268 documents,
269+ schemaBundles,
316270 operations,
317271 manifest,
318272 } satisfies LoadedSourceRecipe;
@@ -321,18 +275,245 @@ export const loadSourceRecipe = (input: { · 2 unmodified lines
321275 export const expandRecipeTools = (input: {
322276 recipes: readonly LoadedSourceRecipe[];
323277 includeSchemas: boolean;
324−}): readonly LoadedSourceRecipeTool[] =>
325− input.recipes.flatMap((recipe) =>
326− recipe.operations.map((operation) => {
278+}): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> =>
279+ Effect.map(
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+
335+export 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+
432+export 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+
327498 const path = recipeToolPath({
328− source: recipe.source,
499+ source: candidate.source,
329500 operation,
330501 });
331502 const searchNamespace = recipeToolSearchNamespace({
332− source: recipe.source,
503+ source: candidate.source,
333504 path,
334505 operation,
335506 });
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+ });
336517
337518 return {
338519 path,
@@ -340,23 +521,26 @@ export const expandRecipeTools = (input: { · 1 unmodified line
340521 searchText: [
341522 path,
342523 searchNamespace,
343− recipe.source.name,
344− operation.searchText,
524+ candidate.source.name,
525+ metadata.searchText,
345526 ]
346527 .filter((part) => part.length > 0)
347528 .join(" ")
348529 .toLowerCase(),
349− source: recipe.source,
350− sourceRecord: recipe.sourceRecord,
351− revision: recipe.revision,
530+ source: candidate.source,
531+ sourceRecord: candidate.sourceRecord,
352532 operation,
353− manifest: recipe.manifest,
533+ metadata,
534+ schemaBundleId,
354535 descriptor: recipeToolDescriptor({
355− source: recipe.source,
536+ source: candidate.source,
356537 operation,
357538 path,
539+ schemaBundleId,
358540 includeSchemas: input.includeSchemas,
359541 }),
360− } satisfies LoadedSourceRecipeTool;
361− })
362− );
542+ } satisfies LoadedSourceRecipeToolIndexEntry;
543+ }
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
6262 headers: null,
6363 specUrl: input.specUrl ?? "https://example.com/openapi.json",
6464 defaultHeaders: null,
65+ importAuthPolicy: "reuse_runtime",
66+ importAuth: { kind: "none" },
6567 auth: input.auth,
6668 sourceHash: null,
6769 lastError: null,
@@ -142,6 +144,7 @@ describe("source-store", () => { · 74 unmodified lines
142144 executionId: null,
143145 interactionId: null,
144146 providerKind: "mcp_oauth",
147+ credentialSlot: "runtime",
145148 status: "pending",
146149 state: "state_source_store",
147150 sessionDataJson: encodeSessionDataJson({
@@ -341,17 +344,24 @@ describe("source-store", () => { · 193 unmodified lines
341344 inputSchemaJson: null,
342345 outputSchemaJson: null,
343346 providerKind: "openapi",
344− providerDataJson: null,
345− mcpToolName: null,
346− openApiMethod: "get",
347− openApiPathTemplate: "/repos/{owner}/{repo}",
348− openApiOperationHash: "hash_recipe_rewrite",
349− openApiRawToolId: "repos_getRepo",
350− openApiOperationId: "repos.getRepo",
351− openApiTagsJson: JSON.stringify(["repos"]),
352− openApiRequestBodyRequired: null,
353− graphqlOperationType: null,
354− graphqlOperationName: null,
347+ providerDataJson: JSON.stringify({
348+ kind: "openapi",
349+ toolId: "getRepo",
350+ rawToolId: "repos_getRepo",
351+ operationId: "repos.getRepo",
352+ group: "repos",
353+ leaf: "getRepo",
354+ tags: ["repos"],
355+ method: "get",
356+ path: "/repos/{owner}/{repo}",
357+ operationHash: "hash_recipe_rewrite",
358+ invocation: {
359+ method: "get",
360+ pathTemplate: "/repos/{owner}/{repo}",
361+ parameters: [],
362+ requestBody: null,
363+ },
364+ }),
355365 createdAt: now,
356366 updatedAt: now,
357367 }],
@@ -465,17 +475,24 @@ describe("source-store", () => { · 107 unmodified lines
465475 inputSchemaJson: null,
466476 outputSchemaJson: null,
467477 providerKind: "openapi",
468− providerDataJson: null,
469− mcpToolName: null,
470− openApiMethod: "get",
471− openApiPathTemplate: "/repos/{owner}/{repo}",
472− openApiOperationHash: "hash_shared_recipe",
473− openApiRawToolId: "repos_getRepo",
474− openApiOperationId: "repos.getRepo",
475− openApiTagsJson: JSON.stringify(["repos"]),
476− openApiRequestBodyRequired: null,
477− graphqlOperationType: null,
478− graphqlOperationName: null,
478+ providerDataJson: JSON.stringify({
479+ kind: "openapi",
480+ toolId: "getRepo",
481+ rawToolId: "repos_getRepo",
482+ operationId: "repos.getRepo",
483+ group: "repos",
484+ leaf: "getRepo",
485+ tags: ["repos"],
486+ method: "get",
487+ path: "/repos/{owner}/{repo}",
488+ operationHash: "hash_shared_recipe",
489+ invocation: {
490+ method: "get",
491+ pathTemplate: "/repos/{owner}/{repo}",
492+ parameters: [],
493+ requestBody: null,
494+ },
495+ }),
479496 createdAt: now,
480497 updatedAt: now,
481498 }],
packages/control-plane/src/runtime/source-store.ts+64−11
@@ -1,6 +1,7 @@
11 import type {
22 AccountId,
33 Credential,
4+ CredentialSlot,
45 SecretRef,
56 Source,
67 SourceRecipeId,
@@ -64,23 +65,29 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 57 unmodified lines
6465 const selectPreferredCredential = (input: {
6566 credentials: ReadonlyArray<Credential>;
6667 actorAccountId?: AccountId | null;
68+ slot: CredentialSlot;
6769 }): Credential | null => {
70+ const matchingSlot = input.credentials.filter((credential) => credential.slot === input.slot);
71+
6872 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);
7074 if (exact) {
7175 return exact;
7276 }
7377 }
7478
75− return input.credentials.find((credential) => credential.actorAccountId === null) ?? null;
79+ return matchingSlot.find((credential) => credential.actorAccountId === null) ?? null;
7680 };
7781
7882 const selectExactCredential = (input: {
7983 credentials: ReadonlyArray<Credential>;
8084 actorAccountId?: AccountId | null;
85+ slot: CredentialSlot;
8186 }): Credential | null =>
8287 input.credentials.find(
83− (credential) => credential.actorAccountId === (input.actorAccountId ?? null),
88+ (credential) =>
89+ credential.slot === input.slot
90+ && credential.actorAccountId === (input.actorAccountId ?? null),
8491 ) ?? null;
8592
8693 export const loadSourcesInWorkspace = (
@@ -98,8 +105,16 @@ export const loadSourcesInWorkspace = ( · 11 unmodified lines
98105 const preferred = selectPreferredCredential({
99106 credentials: matches,
100107 actorAccountId: options.actorAccountId,
108+ slot: "runtime",
109+ });
110+ const preferredImport = selectPreferredCredential({
111+ credentials: matches,
112+ actorAccountId: options.actorAccountId,
113+ slot: "import",
101114 });
102− return preferred ? [preferred] : [];
115+ return [preferred, preferredImport].filter(
116+ (credential): credential is Credential => credential !== null,
117+ );
103118 });
104119
105120 return yield* projectSourcesFromStorage({
@@ -132,11 +147,18 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 26 unmodified lines
132147 const credential = selectPreferredCredential({
133148 credentials,
134149 actorAccountId: input.actorAccountId,
150+ slot: "runtime",
151+ });
152+ const importCredential = selectPreferredCredential({
153+ credentials,
154+ actorAccountId: input.actorAccountId,
155+ slot: "import",
135156 });
136157
137158 return yield* projectSourceFromStorage({
138159 sourceRecord: sourceRecord.value,
139− credential,
160+ runtimeCredential: credential,
161+ importCredential,
140162 });
141163 });
142164
@@ -178,6 +200,7 @@ const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { · 35 unmodified lines
178200 );
179201 if (revisionReferenceCount === 0) {
180202 yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId);
203+ yield* rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId);
181204 yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId);
182205 }
183206
@@ -192,6 +215,7 @@ const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { · 8 unmodified lines
192215 (recipeRevision) =>
193216 Effect.all([
194217 rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id),
218+ rows.sourceRecipeSchemaBundles.removeByRevisionId(recipeRevision.id),
195219 rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id),
196220 ]),
197221 { discard: true },
@@ -248,10 +272,18 @@ export const persistSource = ( · 50 unmodified lines
248272 const existingCredential = selectExactCredential({
249273 credentials: existingCredentials,
250274 actorAccountId: options.actorAccountId,
275+ slot: "runtime",
276+ });
277+ const existingImportCredential = selectExactCredential({
278+ credentials: existingCredentials,
279+ actorAccountId: options.actorAccountId,
280+ slot: "import",
251281 });
252282
253283 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);
255287 const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId);
256288 const nextRevision = createSourceRecipeRevisionRecord({
257289 source,
@@ -266,6 +298,9 @@ export const persistSource = ( · 8 unmodified lines
266298 manifestHash: Option.isSome(existingTargetRevision)
267299 ? existingTargetRevision.value.manifestHash
268300 : null,
301+ materializationHash: Option.isSome(existingTargetRevision)
302+ ? existingTargetRevision.value.materializationHash
303+ : null,
269304 });
270305
271306 const nextRecipe = createSourceRecipeRecord({
@@ -274,12 +309,13 @@ export const persistSource = ( · 2 unmodified lines
274309 latestRevisionId: nextRevision.id,
275310 });
276311
277− const { sourceRecord, credential } = splitSourceForStorage({
312+ const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({
278313 source,
279314 recipeId: nextRecipe.id,
280315 recipeRevisionId: nextRevision.id,
281316 actorAccountId: options.actorAccountId,
282− existingCredentialId: existingCredential?.id ?? null,
317+ existingRuntimeCredentialId: existingCredential?.id ?? null,
318+ existingImportCredentialId: existingImportCredential?.id ?? null,
283319 });
284320
285321 if (Option.isNone(existing)) {
@@ -310,19 +346,36 @@ export const persistSource = ( · 24 unmodified lines
310346 });
311347 }
312348
313− if (credential === null) {
349+ if (runtimeCredential === null) {
314350 yield* rows.credentials.removeByWorkspaceSourceAndActor({
315351 workspaceId: source.workspaceId,
316352 sourceId: source.id,
317353 actorAccountId: options.actorAccountId ?? null,
354+ slot: "runtime",
318355 });
319356 } else {
320− yield* rows.credentials.upsert(credential);
357+ yield* rows.credentials.upsert(runtimeCredential);
321358 }
322359
323360 yield* cleanupCredentialSecretRefs(rows, {
324361 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,
326379 });
327380
328381 return source;
packages/control-plane/src/runtime/sources-operations.ts+4−8
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 createSourceFromPayload,
1919 updateSourceFromPayload,
2020 } from "./source-definitions";
21+import { getSourceAdapterForSource } from "./source-adapters";
2122 import {
2223 mapPersistenceError,
2324 } from "./operations-shared";
@@ -26,7 +27,7 @@ import { · 2 unmodified lines
2627 } from "./operation-errors";
2728 import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
2829 import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
29−import { syncSourceToolArtifacts } from "./tool-artifacts";
30+import { syncSourceMaterialization } from "./source-materialization";
3031 import {
3132 loadSourceById,
3233 loadSourcesInWorkspace,
@@ -43,12 +44,7 @@ const sourceOps = { · 10 unmodified lines
4344 } as const;
4445
4546 const shouldAutoProbeSource = (source: Source): boolean =>
46− source.enabled
47− && (
48− (source.kind === "openapi" && !!source.specUrl)
49− || source.kind === "graphql"
50− )
51− && (source.status === "draft" || source.status === "probing");
47+ getSourceAdapterForSource(source).shouldAutoProbe(source);
5248
5349 const syncArtifactsForSource = (input: {
5450 store: ControlPlaneStoreShape;
@@ -73,7 +69,7 @@ const syncArtifactsForSource = (input: { · 18 unmodified lines
7369 : input.source;
7470
7571 const synced = yield* Effect.either(
76− syncSourceToolArtifacts({
72+ syncSourceMaterialization({
7773 rows: input.store,
7874 source: sourceForSync,
7975 resolveSecretMaterial,
packages/control-plane/src/runtime/tool-artifacts.ts+0−736
@@ -1,736 +0,0 @@
1−import { createHash } from "node:crypto";
2−
3−import {
4− FetchHttpClient,
5− HttpClient,
6− HttpClientRequest,
7−} from "@effect/platform";
8−import {
9− type McpToolManifest,
10− createSdkMcpConnector,
11− discoverMcpToolsFromConnector,
12− type McpToolManifestEntry,
13−} from "@executor/codemode-mcp";
14−import {
15− buildOpenApiToolPresentation,
16− compileOpenApiToolDefinitions,
17− extractOpenApiManifest,
18−} from "@executor/codemode-openapi";
19−import type { SqlControlPlaneRows } from "#persistence";
20−import {
21− type SecretRef,
22− type Source,
23− type StoredSourceRecipeDocumentRecord,
24− type StoredSourceRecipeOperationRecord,
25−} from "#schema";
26−import * as Effect from "effect/Effect";
27−import * as Option from "effect/Option";
28−
29−import {
30− buildGraphqlToolPresentation,
31− compileGraphqlToolDefinitions,
32− extractGraphqlManifest,
33− GRAPHQL_INTROSPECTION_QUERY,
34−} from "./graphql-tools";
35−import type {
36− ResolveSecretMaterial as ResolveSourceSecretMaterial,
37− SecretMaterialResolveContext,
38−} from "./secret-material-providers";
39−
40−export type ResolvedSourceAuthMaterial = {
41− headers: Readonly<Record<string, string>>;
42−};
43−
44−const trim = (value: string | undefined): string | undefined => {
45− const candidate = value?.trim();
46− return candidate && candidate.length > 0 ? candidate : undefined;
47−};
48−
49−const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
50− parts
51− .flatMap((part) => (part ? [part.trim()] : []))
52− .filter(Boolean)
53− .join(" ")
54− .toLowerCase();
55−
56−const contentHash = (value: string): string =>
57− createHash("sha256").update(value).digest("hex");
58−
59−type SourceBindingRecord = Awaited<
60− ReturnType<SqlControlPlaneRows["sources"]["getByWorkspaceAndId"]>
61−> extends Effect.Effect<Option.Option<infer T>, unknown, never>
62− ? T
63− : never;
64−
65−const 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−
88−const 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−
154−const 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−
211−const 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−
267−const 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−
309−const 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−
357−const shouldIndexSource = (source: Source): boolean =>
358− source.enabled
359− && source.status === "connected"
360− && (source.kind === "mcp" || source.kind === "openapi" || source.kind === "graphql");
361−
362−export 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−
372−export 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−
399−const 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−
425−const 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−
465−const 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−
482−const 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−
523−const 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−
606−const 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−
681−export 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−
731−export 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
3636 ExecutionIdSchema,
3737 SecretMaterialIdSchema,
3838 SourceIdSchema,
39+ SourceRecipeSchemaBundleIdSchema,
3940 WorkspaceIdSchema,
4041 type Source,
4142 } from "#schema";
@@ -60,7 +61,7 @@ import { · 18 unmodified lines
6061 } from "./source-auth-service";
6162 import { createLiveExecutionManager } from "./live-execution";
6263 import { persistSource } from "./source-store";
63−import { persistMcpToolArtifactsFromManifest } from "./tool-artifacts";
64+import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization";
6465 import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment";
6566
6667 type CountedMcpServer = {
@@ -85,7 +86,9 @@ type GraphqlServer = { · 18 unmodified lines
8586 close: () => Promise<void>;
8687 };
8788
88−const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string =>
89+const normalizeSearchText = (
90+ ...parts: ReadonlyArray<string | null | undefined>
91+): string =>
8992 parts
9093 .flatMap((part) => (part ? [part.trim()] : []))
9194 .filter(Boolean)
@@ -263,7 +266,10 @@ const GraphqlMutationType = new GraphQLObjectType({ · 171 unmodified lines
263266 type: new GraphQLNonNull(GraphqlCreateIssueInputType),
264267 },
265268 },
266− resolve: (_source, args: { input: { title: string; teamId?: string | null } }) => ({
269+ resolve: (
270+ _source,
271+ args: { input: { title: string; teamId?: string | null } },
272+ ) => ({
267273 id: "iss_created",
268274 identifier: "ENG-999",
269275 title: args.input.title,
@@ -285,17 +291,21 @@ const testGraphqlSchema = new GraphQLSchema({ · 15 unmodified lines
285291 const closeScope = (scope: Scope.CloseableScope) =>
286292 Scope.close(scope, Exit.void).pipe(Effect.orDie);
287293
288−const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> =
289− Effect.acquireRelease(
294+const makePersistence: Effect.Effect<
295+ SqlControlPlanePersistence,
296+ unknown,
297+ Scope.Scope
298+> = Effect.acquireRelease(
290299 createSqlControlPlanePersistence({
291300 localDataDir: ":memory:",
292301 }),
293302 (persistence) =>
294303 Effect.tryPromise({
295304 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)),
297307 }).pipe(Effect.orDie),
298− );
308+);
299309
300310 const makeCountedMcpServer = Effect.acquireRelease(
301311 Effect.promise<CountedMcpServer>(
@@ -425,37 +435,38 @@ const makeCountedMcpServer = Effect.acquireRelease( · 123 unmodified lines
425435 const ownerParam = HttpApiSchema.param("owner", Schema.String);
426436 const repoParam = HttpApiSchema.param("repo", Schema.String);
427437
428−class GeneratedReposApi extends HttpApiGroup.make("repos")
429− .add(
430− HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`
431− .addSuccess(
432− Schema.Struct({
433− ok: Schema.Boolean,
434− full_name: Schema.String,
435− }),
436− ),
437− ) {}
438+class GeneratedReposApi extends HttpApiGroup.make("repos").add(
439+ HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`.addSuccess(
440+ Schema.Struct({
441+ ok: Schema.Boolean,
442+ full_name: Schema.String,
443+ }),
444+ ),
445+) {}
438446
439447 class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {}
440448
441449 const makeOpenApiSpecServer = Effect.acquireRelease(
442450 Effect.gen(function* () {
443451 const seenAuthHeaders: OpenApiSpecServer["seenAuthHeaders"] = [];
444− const handlersLayer = HttpApiBuilder.group(GeneratedApi, "repos", (handlers) =>
445− handlers.handle("getRepo", ({ path, request }) =>
446− Effect.sync(() => {
447− seenAuthHeaders.push(
448− typeof request.headers.authorization === "string"
449− ? request.headers.authorization
450− : null,
451− );
452+ const handlersLayer = HttpApiBuilder.group(
453+ GeneratedApi,
454+ "repos",
455+ (handlers) =>
456+ handlers.handle("getRepo", ({ path, request }) =>
457+ Effect.sync(() => {
458+ seenAuthHeaders.push(
459+ typeof request.headers.authorization === "string"
460+ ? request.headers.authorization
461+ : null,
462+ );
452463
453− return {
454− ok: true,
455− full_name: `${path.owner}/${path.repo}`,
456− };
457− })
458− )
464+ return {
465+ ok: true,
466+ full_name: `${path.owner}/${path.repo}`,
467+ };
468+ }),
469+ ),
459470 );
460471 const apiLayer = HttpApiBuilder.api(GeneratedApi).pipe(
461472 Layer.provide(handlersLayer),
@@ -468,9 +479,8 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 6 unmodified lines
468479 const scope = yield* Scope.make();
469480 const context = yield* Layer.buildWithScope(serverLayer, scope).pipe(
470481 Effect.catchAll((error) =>
471− closeScope(scope).pipe(
472− Effect.zipRight(Effect.fail(error)),
473− )),
482+ closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
483+ ),
474484 );
475485 const server = Context.get(context, HttpServer.HttpServer);
476486 const baseUrl = HttpServer.formatAddress(server.address);
@@ -485,8 +495,9 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 8 unmodified lines
485495 (server) =>
486496 Effect.tryPromise({
487497 try: () => server.close(),
488− catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
489− }).pipe(Effect.orDie),
498+ catch: (cause) =>
499+ cause instanceof Error ? cause : new Error(String(cause)),
500+ }).pipe(Effect.orDie),
490501 );
491502
492503 const makeGraphqlServer = Effect.acquireRelease(
@@ -518,7 +529,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 25 unmodified lines
518529
519530 const result = await graphql({
520531 schema: testGraphqlSchema,
521− source: typeof parsedBody.query === "string" ? parsedBody.query : "",
532+ source:
533+ typeof parsedBody.query === "string" ? parsedBody.query : "",
522534 variableValues:
523535 parsedBody.variables && typeof parsedBody.variables === "object"
524536 ? (parsedBody.variables as Record<string, unknown>)
@@ -561,7 +573,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 36 unmodified lines
561573 (server) =>
562574 Effect.tryPromise({
563575 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)),
565578 }).pipe(Effect.orDie),
566579 );
567580
@@ -587,6 +600,8 @@ const persistConnectedEchoTool = (input: { · 19 unmodified lines
587600 headers: null,
588601 specUrl: null,
589602 defaultHeaders: null,
603+ importAuthPolicy: "reuse_runtime",
604+ importAuth: { kind: "none" },
590605 auth: { kind: "none" },
591606 sourceHash: null,
592607 lastError: null,
@@ -595,24 +610,26 @@ const persistConnectedEchoTool = (input: { · 2 unmodified lines
595610 };
596611
597612 yield* persistSource(input.persistence.rows, source);
598− yield* persistMcpToolArtifactsFromManifest({
613+ yield* persistMcpRecipeMaterializationFromManifest({
599614 rows: input.persistence.rows,
600615 source,
601− manifestEntries: [{
602− toolId: "echo",
603− toolName: "echo",
604− description: "Echo the provided string",
605− inputSchemaJson: JSON.stringify({
606− type: "object",
607− properties: {
608− value: {
609− type: "string",
616+ manifestEntries: [
617+ {
618+ toolId: "echo",
619+ toolName: "echo",
620+ description: "Echo the provided string",
621+ inputSchemaJson: JSON.stringify({
622+ type: "object",
623+ properties: {
624+ value: {
625+ type: "string",
626+ },
610627 },
611− },
612− required: ["value"],
613− additionalProperties: false,
614− }),
615− }],
628+ required: ["value"],
629+ additionalProperties: false,
630+ }),
631+ },
632+ ],
616633 });
617634 });
618635
@@ -635,31 +652,52 @@ const persistConnectedGithubOpenApiSource = (input: { · 16 unmodified lines
635652 version: "1.0.0",
636653 },
637654 paths: {
638− "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}": {
639− get: {
640− operationId: "actions/get-environment-secret",
641− tags: ["actions"],
642− summary: "Get an environment secret",
643− description:
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.",
645− parameters: [
646− { name: "owner", in: "path", required: true, schema: { type: "string" } },
647− { name: "repo", in: "path", required: true, schema: { type: "string" } },
648− { name: "environment_name", in: "path", required: true, schema: { type: "string" } },
649− { name: "secret_name", in: "path", required: true, schema: { type: "string" } },
650− ],
651− responses: {
652− 200: {
653− description: "ok",
654− content: {
655− "application/json": {
656− schema: { type: "object" },
655+ "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}":
656+ {
657+ get: {
658+ operationId: "actions/get-environment-secret",
659+ tags: ["actions"],
660+ summary: "Get an environment secret",
661+ description:
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.",
663+ parameters: [
664+ {
665+ name: "owner",
666+ in: "path",
667+ required: true,
668+ schema: { type: "string" },
669+ },
670+ {
671+ name: "repo",
672+ in: "path",
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+ },
657696 },
658697 },
659698 },
660699 },
661700 },
662− },
663701 "/orgs/{org}/actions/runners/{runner_id}": {
664702 get: {
665703 operationId: "actions/get-self-hosted-runner-for-org",
@@ -668,8 +706,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 2 unmodified lines
668706 description:
669707 "Gets a specific self-hosted runner configured in an organization. Authenticated users must have admin access to the organization to use this endpoint.",
670708 parameters: [
671− { name: "org", in: "path", required: true, schema: { type: "string" } },
672− { name: "runner_id", in: "path", required: true, schema: { type: "integer" } },
709+ {
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+ },
673721 ],
674722 responses: {
675723 200: {
@@ -690,8 +738,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 14 unmodified lines
690738 summary: "Get a repository",
691739 description: "Gets a repository by owner and name.",
692740 parameters: [
693− { name: "owner", in: "path", required: true, schema: { type: "string" } },
694− { name: "repo", in: "path", required: true, schema: { type: "string" } },
741+ {
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+ },
695753 ],
696754 responses: {
697755 200: {
@@ -725,6 +783,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 27 unmodified lines
725783 },
726784 },
727785 },
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+ },
728798 });
729799
730800 const source: Source = {
@@ -741,6 +811,8 @@ const persistConnectedGithubOpenApiSource = (input: { · 10 unmodified lines
741811 headers: null,
742812 specUrl: "https://example.com/github-openapi.json",
743813 defaultHeaders: null,
814+ importAuthPolicy: "reuse_runtime",
815+ importAuth: { kind: "none" },
744816 auth: input.auth
745817 ? {
746818 kind: "bearer",
@@ -769,12 +841,15 @@ const persistConnectedGithubOpenApiSource = (input: { · 22 unmodified lines
769841 ),
770842 );
771843 const definitions = compileOpenApiToolDefinitions(manifest);
772− const sourceRecord = yield* input.persistence.rows.sources.getByWorkspaceAndId(
773− input.workspaceId,
774− input.sourceId,
775− );
844+ const sourceRecord =
845+ yield* input.persistence.rows.sources.getByWorkspaceAndId(
846+ input.workspaceId,
847+ input.sourceId,
848+ );
776849 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+ );
778853 }
779854
780855 yield* input.persistence.rows.sourceRecipeRevisions.update(
@@ -787,23 +862,54 @@ const persistConnectedGithubOpenApiSource = (input: { · 6 unmodified lines
787862 );
788863 yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({
789864 recipeRevisionId: sourceRecord.value.recipeRevisionId,
790− documents: [{
791− id: `src_recipe_doc_${randomUUID()}`,
792− recipeRevisionId: sourceRecord.value.recipeRevisionId,
793− documentKind: "openapi",
794− documentKey: source.specUrl ?? source.endpoint,
795− contentText: openApiDocumentText,
796− contentHash: manifest.sourceHash,
797− fetchedAt: now,
798− createdAt: now,
799− updatedAt: now,
800− }],
865+ documents: [
866+ {
867+ id: `src_recipe_doc_${randomUUID()}`,
868+ recipeRevisionId: sourceRecord.value.recipeRevisionId,
869+ documentKind: "openapi",
870+ documentKey: source.specUrl ?? source.endpoint,
871+ contentText: openApiDocumentText,
872+ contentHash: manifest.sourceHash,
873+ fetchedAt: now,
874+ createdAt: now,
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+ : [],
801908 });
802909 yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({
803910 recipeRevisionId: sourceRecord.value.recipeRevisionId,
804911 operations: definitions.map((definition) => {
805912 const presentation = buildOpenApiToolPresentation({
806− manifest,
807913 definition,
808914 });
809915 const method = definition.method.toUpperCase();
@@ -838,25 +944,19 @@ const persistConnectedGithubOpenApiSource = (input: { · 28 unmodified lines
838944 outputSchemaJson: presentation.outputSchemaJson ?? null,
839945 providerKind: "openapi",
840946 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,
851947 createdAt: now,
852948 updatedAt: now,
853949 };
854950 }),
855951 });
856− yield* input.persistence.rows.sources.update(input.workspaceId, input.sourceId, {
857− sourceHash: manifest.sourceHash,
858− updatedAt: now,
859− });
952+ yield* input.persistence.rows.sources.update(
953+ input.workspaceId,
954+ input.sourceId,
955+ {
956+ sourceHash: manifest.sourceHash,
957+ updatedAt: now,
958+ },
959+ );
860960
861961 return;
862962 });
@@ -866,17 +966,24 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 3 unmodified lines
866966 rows: persistence.rows,
867967 sourceAuthService: {
868968 getLocalServerBaseUrl: () => null,
869− storeSecretMaterial: () => Effect.fail(new Error("not implemented in test")),
969+ storeSecretMaterial: () =>
970+ Effect.fail(new Error("not implemented in test")),
870971 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")),
872974 connectMcpSource: () => Effect.fail(new Error("not implemented in test")),
873− startSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")),
874− completeSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")),
875− completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")),
975+ startSourceOAuthSession: () =>
976+ 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")),
876981 } as RuntimeSourceAuthService,
877982 });
878983
879−const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) =>
984+const makeResolverWithLiveSourceAuth = (
985+ persistence: SqlControlPlanePersistence,
986+) =>
880987 createWorkspaceExecutionEnvironmentResolver({
881988 rows: persistence.rows,
882989 sourceAuthService: createRuntimeSourceAuthService({
@@ -902,7 +1009,8 @@ describe("workspace-execution-environment", () => { · 19 unmodified lines
9021009
9031010 yield* Effect.tryPromise({
9041011 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)),
9061014 }).pipe(Effect.orDie);
9071015
9081016 const resolveEnvironment = makeResolver(persistence);
@@ -931,52 +1039,58 @@ describe("workspace-execution-environment", () => { · 22 unmodified lines
9311039 }),
9321040 );
9331041
934− it.scoped("prefers namespace and path matches over noisy GitHub boilerplate", () =>
935− Effect.gen(function* () {
936− const persistence = yield* makePersistence;
937− const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");
938− const sourceId = SourceIdSchema.make("src_github_discovery");
939− yield* persistConnectedGithubOpenApiSource({
940− persistence,
941− workspaceId,
942− sourceId,
943− });
1042+ it.scoped(
1043+ "prefers namespace and path matches over noisy GitHub boilerplate",
1044+ () =>
1045+ Effect.gen(function* () {
1046+ const persistence = yield* makePersistence;
1047+ const workspaceId = WorkspaceIdSchema.make("ws_github_discovery");
1048+ const sourceId = SourceIdSchema.make("src_github_discovery");
1049+ yield* persistConnectedGithubOpenApiSource({
1050+ persistence,
1051+ workspaceId,
1052+ sourceId,
1053+ });
9441054
945− const resolveEnvironment = makeResolver(persistence);
946− const environment = yield* resolveEnvironment({
947− workspaceId,
948− accountId: AccountIdSchema.make("acc_github_discovery"),
949− executionId: ExecutionIdSchema.make("exec_github_discovery"),
950− });
1055+ const resolveEnvironment = makeResolver(persistence);
1056+ const environment = yield* resolveEnvironment({
1057+ workspaceId,
1058+ accountId: AccountIdSchema.make("acc_github_discovery"),
1059+ executionId: ExecutionIdSchema.make("exec_github_discovery"),
1060+ });
9511061
952− const firstQuery = (yield* environment.toolInvoker.invoke({
953− path: "discover",
954− args: {
955− query: "current authenticated user profile",
956− limit: 5,
957− },
958− })) as {
959− bestPath: string | null;
960− results: Array<{ path: string }>;
961− };
1062+ const firstQuery = (yield* environment.toolInvoker.invoke({
1063+ path: "discover",
1064+ args: {
1065+ query: "current authenticated user profile",
1066+ limit: 5,
1067+ },
1068+ })) as {
1069+ bestPath: string | null;
1070+ results: Array<{ path: string }>;
1071+ };
9621072
963− expect(firstQuery.bestPath).toBe("github.users.getAuthenticated");
964− expect(firstQuery.results[0]?.path).toBe("github.users.getAuthenticated");
1073+ expect(firstQuery.bestPath).toBe("github.users.getAuthenticated");
1074+ expect(firstQuery.results[0]?.path).toBe(
1075+ "github.users.getAuthenticated",
1076+ );
9651077
966− const secondQuery = (yield* environment.toolInvoker.invoke({
967− path: "discover",
968− args: {
969− query: "users get myself current user profile",
970− limit: 5,
971− },
972− })) as {
973− bestPath: string | null;
974− results: Array<{ path: string }>;
975− };
1078+ const secondQuery = (yield* environment.toolInvoker.invoke({
1079+ path: "discover",
1080+ args: {
1081+ query: "users get myself current user profile",
1082+ limit: 5,
1083+ },
1084+ })) as {
1085+ bestPath: string | null;
1086+ results: Array<{ path: string }>;
1087+ };
9761088
977− expect(secondQuery.bestPath).toBe("github.users.getAuthenticated");
978− expect(secondQuery.results[0]?.path).toBe("github.users.getAuthenticated");
979− }),
1089+ expect(secondQuery.bestPath).toBe("github.users.getAuthenticated");
1090+ expect(secondQuery.results[0]?.path).toBe(
1091+ "github.users.getAuthenticated",
1092+ );
1093+ }),
9801094 );
9811095
9821096 it.scoped("invokes persisted MCP tools on demand without re-listing", () =>
@@ -1014,338 +1128,381 @@ describe("workspace-execution-environment", () => { · 31 unmodified lines
10141128 }),
10151129 );
10161130
1017− it.scoped("adds an OpenAPI source through executor.sources.add with elicited credentials", () =>
1018− Effect.gen(function* () {
1019− const specServer = yield* makeOpenApiSpecServer;
1020− const persistence = yield* makePersistence;
1021− const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
1022− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1023− const now = Date.now();
1024− const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1025− "sec_test_openapi_bearer",
1026− );
1027− yield* persistence.rows.secretMaterials.upsert({
1028− id: tokenSecretMaterialId,
1029− name: null,
1030− purpose: "auth_material",
1031− value: "ghp_test_token",
1032− createdAt: now,
1033− updatedAt: now,
1034− });
1035− let capturedElicitation:
1036− | {
1037− mode?: string;
1038− url?: string;
1039− elicitationId?: string;
1040− }
1041− | null = null;
1042−
1043− const environment = yield* resolveEnvironment({
1044− workspaceId,
1045− accountId: AccountIdSchema.make("acc_add_openapi"),
1046− executionId: ExecutionIdSchema.make("exec_add_openapi"),
1047− onElicitation: (request) =>
1048− Effect.sync(() => {
1049− capturedElicitation = request.elicitation as {
1050− mode?: string;
1051− url?: string;
1052− elicitationId?: string;
1053− };
1054−
1055− return {
1056− action: "accept" as const,
1057− content: {
1058− authKind: "bearer",
1059− tokenRef: {
1060− providerId: "postgres",
1061− handle: tokenSecretMaterialId,
1131+ it.scoped(
1132+ "adds an OpenAPI source through executor.sources.add with elicited credentials",
1133+ () =>
1134+ Effect.gen(function* () {
1135+ const specServer = yield* makeOpenApiSpecServer;
1136+ const persistence = yield* makePersistence;
1137+ const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
1138+ const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1139+ const now = Date.now();
1140+ const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1141+ "sec_test_openapi_bearer",
1142+ );
1143+ yield* persistence.rows.secretMaterials.upsert({
1144+ id: tokenSecretMaterialId,
1145+ name: null,
1146+ purpose: "auth_material",
1147+ value: "ghp_test_token",
1148+ createdAt: now,
1149+ updatedAt: now,
1150+ });
1151+ let capturedElicitation: {
1152+ mode?: string;
1153+ url?: string;
1154+ elicitationId?: string;
1155+ } | null = null;
1156+
1157+ const environment = yield* resolveEnvironment({
1158+ workspaceId,
1159+ accountId: AccountIdSchema.make("acc_add_openapi"),
1160+ executionId: ExecutionIdSchema.make("exec_add_openapi"),
1161+ onElicitation: (request) =>
1162+ Effect.sync(() => {
1163+ capturedElicitation = request.elicitation as {
1164+ mode?: string;
1165+ url?: string;
1166+ elicitationId?: string;
1167+ };
1168+
1169+ return {
1170+ action: "accept" as const,
1171+ content: {
1172+ authKind: "bearer",
1173+ tokenRef: {
1174+ providerId: "postgres",
1175+ handle: tokenSecretMaterialId,
1176+ },
10621177 },
1063− },
1064− };
1065− }),
1066− });
1178+ };
1179+ }),
1180+ });
10671181
1068− const added = (yield* environment.toolInvoker.invoke({
1069− path: "executor.sources.add",
1070− args: {
1071− kind: "openapi",
1072− endpoint: specServer.baseUrl,
1073− specUrl: specServer.specUrl,
1074− name: "GitHub",
1075− namespace: "github",
1076− },
1077− context: {
1078− runId: "exec_add_openapi",
1079− },
1080− })) as {
1081− id: SourceId;
1082− kind: string;
1083− status: string;
1084− auth: {
1182+ const added = (yield* environment.toolInvoker.invoke({
1183+ path: "executor.sources.add",
1184+ args: {
1185+ kind: "openapi",
1186+ endpoint: specServer.baseUrl,
1187+ specUrl: specServer.specUrl,
1188+ name: "GitHub",
1189+ namespace: "github",
1190+ },
1191+ context: {
1192+ runId: "exec_add_openapi",
1193+ },
1194+ })) as {
1195+ id: SourceId;
10851196 kind: string;
1086− token?: {
1087− providerId: string;
1197+ status: string;
1198+ auth: {
1199+ kind: string;
1200+ token?: {
1201+ providerId: string;
1202+ };
10881203 };
10891204 };
1090− };
10911205
1092− expect(added.kind).toBe("openapi");
1093− expect(added.status).toBe("connected");
1094− expect(added.auth.kind).toBe("bearer");
1095− expect(added.auth.token?.providerId).toBe("postgres");
1096− expect(capturedElicitation).toMatchObject({
1097− mode: "url",
1098− url: expect.stringContaining("/v1/workspaces/"),
1099− elicitationId: expect.stringContaining("executor.sources.add:"),
1100− });
1101− expect((capturedElicitation as { url?: string } | null)?.url).toContain(
1102− 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,
1206+ expect(added.kind).toBe("openapi");
1207+ expect(added.status).toBe("connected");
1208+ expect(added.auth.kind).toBe("bearer");
1209+ expect(added.auth.token?.providerId).toBe("postgres");
1210+ expect(capturedElicitation).toMatchObject({
1211+ mode: "url",
1212+ url: expect.stringContaining("/v1/workspaces/"),
1213+ elicitationId: expect.stringContaining("executor.sources.add:"),
1214+ });
1215+ expect((capturedElicitation as { url?: string } | null)?.url).toContain(
1216+ encodeURIComponent("exec_add_openapi:executor.sources.add:"),
11131217 );
1114− expect(documents[0]?.contentText).toContain('"operationId":"repos.getRepo"');
1115− }
11161218
1117− const freshEnvironment = yield* resolveEnvironment({
1118− workspaceId,
1119− accountId: AccountIdSchema.make("acc_add_openapi"),
1120− executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"),
1121− });
1219+ const storedSource =
1220+ yield* persistence.rows.sources.getByWorkspaceAndId(
1221+ workspaceId,
1222+ added.id,
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+ }
11221234
1123− const discovered = (yield* freshEnvironment.toolInvoker.invoke({
1124− path: "discover",
1125− args: {
1126− query: "github repo",
1127− limit: 5,
1128− },
1129− })) as {
1130− bestPath: string | null;
1131− };
1235+ const freshEnvironment = yield* resolveEnvironment({
1236+ workspaceId,
1237+ accountId: AccountIdSchema.make("acc_add_openapi"),
1238+ executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"),
1239+ });
11321240
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+ };
11341250
1135− const invoked = (yield* freshEnvironment.toolInvoker.invoke({
1136− path: "github.repos.getRepo",
1137− args: {
1138− owner: "vercel",
1139− repo: "ai",
1140− },
1141− })) as {
1142− ok: boolean;
1143− full_name: string;
1144− };
1251+ expect(discovered.bestPath).toBe("github.repos.getRepo");
11451252
1146− expect(invoked).toEqual({
1147− ok: true,
1148− full_name: "vercel/ai",
1149− });
1150− expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]);
1151− }),
1253+ const invoked = (yield* freshEnvironment.toolInvoker.invoke({
1254+ path: "github.repos.getRepo",
1255+ args: {
1256+ owner: "vercel",
1257+ repo: "ai",
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+ }),
11521270 );
11531271
1154− it.scoped("adds a GraphQL source through executor.sources.add with elicited credentials", () =>
1155− Effect.gen(function* () {
1156− const graphqlServer = yield* makeGraphqlServer;
1157− const persistence = yield* makePersistence;
1158− const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");
1159− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1160− const now = Date.now();
1161− const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1162− "sec_test_graphql_bearer",
1163− );
1164− yield* persistence.rows.secretMaterials.upsert({
1165− id: tokenSecretMaterialId,
1166− name: null,
1167− purpose: "auth_material",
1168− value: "linear_test_token",
1169− createdAt: now,
1170− updatedAt: now,
1171− });
1272+ it.scoped(
1273+ "adds a GraphQL source through executor.sources.add with elicited credentials",
1274+ () =>
1275+ Effect.gen(function* () {
1276+ const graphqlServer = yield* makeGraphqlServer;
1277+ const persistence = yield* makePersistence;
1278+ const workspaceId = WorkspaceIdSchema.make("ws_add_graphql");
1279+ const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1280+ const now = Date.now();
1281+ const tokenSecretMaterialId = SecretMaterialIdSchema.make(
1282+ "sec_test_graphql_bearer",
1283+ );
1284+ yield* persistence.rows.secretMaterials.upsert({
1285+ id: tokenSecretMaterialId,
1286+ name: null,
1287+ purpose: "auth_material",
1288+ value: "linear_test_token",
1289+ createdAt: now,
1290+ updatedAt: now,
1291+ });
11721292
1173− const environment = yield* resolveEnvironment({
1174− workspaceId,
1175− accountId: AccountIdSchema.make("acc_add_graphql"),
1176− executionId: ExecutionIdSchema.make("exec_add_graphql"),
1177− onElicitation: () =>
1178− Effect.succeed({
1179− action: "accept" as const,
1180− content: {
1181− authKind: "bearer",
1182− tokenRef: {
1183− providerId: "postgres",
1184− handle: tokenSecretMaterialId,
1293+ const environment = yield* resolveEnvironment({
1294+ workspaceId,
1295+ accountId: AccountIdSchema.make("acc_add_graphql"),
1296+ executionId: ExecutionIdSchema.make("exec_add_graphql"),
1297+ onElicitation: () =>
1298+ Effect.succeed({
1299+ action: "accept" as const,
1300+ content: {
1301+ authKind: "bearer",
1302+ tokenRef: {
1303+ providerId: "postgres",
1304+ handle: tokenSecretMaterialId,
1305+ },
11851306 },
1186− },
1187− }),
1188− });
1307+ }),
1308+ });
11891309
1190− const added = (yield* environment.toolInvoker.invoke({
1191− path: "executor.sources.add",
1192− args: {
1193− kind: "graphql",
1194− endpoint: graphqlServer.endpoint,
1195− name: "Linear",
1196− namespace: "linear",
1197− },
1198− context: {
1199− runId: "exec_add_graphql",
1200− },
1201− })) as {
1202− id: SourceId;
1203− kind: string;
1204− status: string;
1205− auth: {
1310+ const added = (yield* environment.toolInvoker.invoke({
1311+ path: "executor.sources.add",
1312+ args: {
1313+ kind: "graphql",
1314+ endpoint: graphqlServer.endpoint,
1315+ name: "Linear",
1316+ namespace: "linear",
1317+ },
1318+ context: {
1319+ runId: "exec_add_graphql",
1320+ },
1321+ })) as {
1322+ id: SourceId;
12061323 kind: string;
1207− token?: {
1208− providerId: string;
1324+ status: string;
1325+ auth: {
1326+ kind: string;
1327+ token?: {
1328+ providerId: string;
1329+ };
12091330 };
12101331 };
1211− };
12121332
1213− expect(added.kind).toBe("graphql");
1214− expect(added.status).toBe("connected");
1215− expect(added.auth.kind).toBe("bearer");
1216− expect(added.auth.token?.providerId).toBe("postgres");
1333+ expect(added.kind).toBe("graphql");
1334+ expect(added.status).toBe("connected");
1335+ expect(added.auth.kind).toBe("bearer");
1336+ expect(added.auth.token?.providerId).toBe("postgres");
12171337
1218− const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId(
1219− workspaceId,
1220− added.id,
1221− );
1222− expect(Option.isSome(storedSource)).toBe(true);
1223− if (Option.isSome(storedSource)) {
1224− const documents = yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(
1225− storedSource.value.recipeRevisionId,
1338+ const storedSource =
1339+ yield* persistence.rows.sources.getByWorkspaceAndId(
1340+ workspaceId,
1341+ added.id,
1342+ );
1343+ expect(Option.isSome(storedSource)).toBe(true);
1344+ if (Option.isSome(storedSource)) {
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+ },
12261364 );
1227− expect(documents[0]?.contentText).toContain('"__schema"');
1228− }
12291365
1230− const freshEnvironment = yield* resolveEnvironment({
1231− workspaceId,
1232− accountId: AccountIdSchema.make("acc_add_graphql"),
1233− executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"),
1234− onElicitation: () =>
1235− Effect.succeed({
1236− action: "accept" as const,
1237− }),
1238− });
1366+ const freshEnvironment = yield* resolveEnvironment({
1367+ workspaceId,
1368+ accountId: AccountIdSchema.make("acc_add_graphql"),
1369+ executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"),
1370+ onElicitation: () =>
1371+ Effect.succeed({
1372+ action: "accept" as const,
1373+ }),
1374+ });
12391375
1240− const discovered = (yield* freshEnvironment.toolInvoker.invoke({
1241− path: "discover",
1242− args: {
1243− query: "linear current user viewer",
1244− limit: 5,
1245− },
1246− })) as {
1247− bestPath: string | null;
1248− };
1376+ const discovered = (yield* freshEnvironment.toolInvoker.invoke({
1377+ path: "discover",
1378+ args: {
1379+ query: "linear current user viewer",
1380+ limit: 5,
1381+ },
1382+ })) as {
1383+ bestPath: string | null;
1384+ };
12491385
1250− expect(discovered.bestPath).toBe("linear.viewer");
1386+ expect(discovered.bestPath).toBe("linear.viewer");
12511387
1252− const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke({
1253− path: "describe.tool",
1254− args: {
1255− path: "linear.createIssue",
1256− includeSchemas: true,
1257− },
1258− })) as {
1259− path: string;
1260− inputType?: string;
1261− inputSchemaJson?: string;
1262− } | null;
1263−
1264− expect(describeCreateIssue?.path).toBe("linear.createIssue");
1265− expect(describeCreateIssue?.inputType).toContain("input");
1266− expect(describeCreateIssue?.inputType).toContain("title");
1267−
1268− const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({
1269− path: "linear.viewer",
1270− args: {},
1271− })) as {
1272− data: {
1273− id: string;
1274− name: string;
1275− __typename: string;
1388+ const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke(
1389+ {
1390+ path: "describe.tool",
1391+ args: {
1392+ path: "linear.createIssue",
1393+ includeSchemas: true,
1394+ },
1395+ },
1396+ )) as {
1397+ path: string;
1398+ inputType?: string;
1399+ inputSchemaJson?: string;
1400+ schemaBundleId?: string;
1401+ } | null;
1402+
1403+ expect(describeCreateIssue?.path).toBe("linear.createIssue");
1404+ expect(describeCreateIssue?.inputType).toContain("input");
1405+ expect(describeCreateIssue?.inputType).toContain("CreateIssueInput");
1406+ expect(describeCreateIssue?.inputSchemaJson).toContain("$ref");
1407+ expect(describeCreateIssue?.schemaBundleId).toBeDefined();
1408+
1409+ const graphqlSchemaBundle = (yield* freshEnvironment.toolInvoker.invoke(
1410+ {
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[];
12761435 };
1277− errors: unknown[];
1278− };
12791436
1280− expect(viewerResult).toMatchObject({
1281− data: {
1282− id: "usr_123",
1283− name: "Rhys",
1284− __typename: "User",
1285− },
1286− errors: [],
1287− });
1437+ expect(viewerResult).toMatchObject({
1438+ data: {
1439+ id: "usr_123",
1440+ name: "Rhys",
1441+ __typename: "User",
1442+ },
1443+ errors: [],
1444+ });
12881445
1289− const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({
1290− path: "linear.createIssue",
1291− args: {
1292− input: {
1293− title: "Ship GraphQL field tools",
1446+ const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({
1447+ path: "linear.createIssue",
1448+ args: {
1449+ input: {
1450+ title: "Ship GraphQL field tools",
1451+ },
12941452 },
1295− },
1296− })) as {
1297− data: {
1298− id: string;
1299− identifier: string;
1300− title: string;
1301− __typename: string;
1453+ })) as {
1454+ data: {
1455+ id: string;
1456+ identifier: string;
1457+ title: string;
1458+ __typename: string;
1459+ };
1460+ errors: unknown[];
13021461 };
1303− errors: unknown[];
1304− };
13051462
1306− 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: {
1463+ expect(createIssueResult).toMatchObject({
13241464 data: {
1325− viewer: {
1326− id: string;
1327− name: string;
1465+ id: "iss_created",
1466+ identifier: "ENG-999",
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+ };
13281486 };
13291487 };
13301488 };
1331− };
13321489
1333− expect(rawRequestResult.status).toBe(200);
1334− expect(rawRequestResult.body).toEqual({
1335− data: {
1336− viewer: {
1337− id: "usr_123",
1338− name: "Rhys",
1490+ expect(rawRequestResult.status).toBe(200);
1491+ expect(rawRequestResult.body).toEqual({
1492+ data: {
1493+ viewer: {
1494+ id: "usr_123",
1495+ name: "Rhys",
1496+ },
13391497 },
1340− },
1341− });
1342− expect(graphqlServer.seenAuthHeaders).toEqual([
1343− "Bearer linear_test_token",
1344− "Bearer linear_test_token",
1345− "Bearer linear_test_token",
1346− "Bearer linear_test_token",
1347− ]);
1348− }),
1498+ });
1499+ expect(graphqlServer.seenAuthHeaders).toEqual([
1500+ "Bearer linear_test_token",
1501+ "Bearer linear_test_token",
1502+ "Bearer linear_test_token",
1503+ "Bearer linear_test_token",
1504+ ]);
1505+ }),
13491506 );
13501507
13511508 it.scoped("resolves params-backed source auth from invocation context", () =>
@@ -1397,44 +1554,105 @@ describe("workspace-execution-environment", () => { · 45 unmodified lines
13971554 }),
13981555 );
13991556
1400− it.scoped("describes executor.sources.add with derived type info and schemas", () =>
1401− Effect.gen(function* () {
1402− const persistence = yield* makePersistence;
1403− const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);
1557+ it.scoped(
1558+ "describes persisted OpenAPI tools with compact schemas and shared bundle ids",
1559+ () =>
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");
14041566
1405− const environment = yield* resolveEnvironment({
1406− workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"),
1407− accountId: AccountIdSchema.make("acc_describe_add_source"),
1408− executionId: ExecutionIdSchema.make("exec_describe_add_source"),
1409− });
1567+ yield* persistConnectedGithubOpenApiSource({
1568+ persistence,
1569+ workspaceId,
1570+ sourceId,
1571+ });
14101572
1411− const described = (yield* environment.toolInvoker.invoke({
1412− path: "describe.tool",
1413− args: {
1414− path: "executor.sources.add",
1415− includeSchemas: true,
1416− },
1417− })) as {
1418− path: string;
1419− description?: string;
1420− inputType?: string;
1421− inputSchemaJson?: string;
1422− } | null;
1423−
1424− expect(described?.path).toBe("executor.sources.add");
1425− expect(described?.description).toContain("Source add input shapes:");
1426− expect(described?.description).toContain("specUrl");
1427− expect(described?.description).toContain("credential setup");
1428− expect(described?.inputType).toContain("endpoint");
1429− expect(described?.inputType).not.toContain("auth");
1430−
1431− const inputSchema = described?.inputSchemaJson
1432− ? JSON.parse(described.inputSchemaJson) as Record<string, unknown>
1433− : null;
1434− expect(inputSchema).not.toBeNull();
1435− expect(JSON.stringify(inputSchema)).toContain("specUrl");
1436− expect(JSON.stringify(inputSchema)).toContain("endpoint");
1437− expect(JSON.stringify(inputSchema)).not.toContain("\"auth\"");
1438− }),
1573+ const resolveEnvironment = makeResolver(persistence);
1574+ const environment = yield* resolveEnvironment({
1575+ workspaceId,
1576+ accountId: AccountIdSchema.make("acc_openapi_describe_bundle"),
1577+ executionId: ExecutionIdSchema.make("exec_openapi_describe_bundle"),
1578+ });
1579+
1580+ const described = (yield* environment.toolInvoker.invoke({
1581+ path: "describe.tool",
1582+ args: {
1583+ path: "github.repos.getRepo",
1584+ includeSchemas: true,
1585+ },
1586+ })) as {
1587+ path: string;
1588+ inputSchemaJson?: string;
1589+ outputSchemaJson?: string;
1590+ schemaBundleId?: string;
1591+ } | null;
1592+
1593+ expect(described?.path).toBe("github.repos.getRepo");
1594+ expect(described?.inputSchemaJson).toContain('"owner"');
1595+ expect(described?.outputSchemaJson).toContain('"type"');
1596+ expect(described?.schemaBundleId).toBeDefined();
1597+
1598+ const schemaBundle = (yield* environment.toolInvoker.invoke({
1599+ path: "describe.schemaBundle",
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+ }),
14391657 );
14401658 });
packages/control-plane/src/runtime/workspace-execution-environment.ts+249−234
@@ -7,27 +7,19 @@ import { · 6 unmodified lines
77 type ToolInvoker,
88 type ToolNamespace,
99 type ToolPath,
10+ type ToolSchemaBundle,
1011 } from "@executor/codemode-core";
1112 import {
12− createSdkMcpConnector,
13− createMcpToolsFromManifest,
14− type McpToolManifest,
15−} from "@executor/codemode-mcp";
16−import {
17− createOpenApiToolsFromManifest,
18− type OpenApiToolManifest,
19−} from "@executor/codemode-openapi";
20−import { isDenoAvailable,
21− makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess";
13+ isDenoAvailable,
14+ makeDenoSubprocessExecutor,
15+} from "@executor/runtime-deno-subprocess";
2216 import { makeSesExecutor } from "@executor/runtime-ses";
2317 import {
2418 SqlControlPlaneRowsService,
2519 type SqlControlPlaneRows,
2620 } from "#persistence";
27−import type {
28− AccountId,
29− Source,
30−} from "#schema";
21+import type { AccountId, Source, SourceRecipeSchemaBundleId } from "#schema";
22+import { SourceRecipeSchemaBundleIdSchema } from "#schema";
3123 import * as Context from "effect/Context";
3224 import * as Either from "effect/Either";
3325 import * as Effect from "effect/Effect";
@@ -40,28 +32,23 @@ import type { · 6 unmodified lines
4032 ResolveExecutionEnvironment,
4133 } from "./execution-state";
4234 import { createExecutorToolMap } from "./executor-tools";
43−import {
44− createGraphqlToolsFromManifest,
45− type GraphqlToolManifest,
46−} from "./graphql-tools";
4735 import {
4836 RuntimeSourceAuthServiceTag,
4937 type RuntimeSourceAuthService,
5038 } from "./source-auth-service";
5139 import {
52− expandRecipeTools,
53− loadWorkspaceSourceRecipes,
54− type LoadedSourceRecipeTool,
40+ loadWorkspaceSourceRecipeToolByPath,
41+ loadWorkspaceSourceRecipeToolIndex,
42+ type LoadedSourceRecipeToolIndexEntry,
5543 } from "./source-recipes-runtime";
44+import { resolveSourceAuthMaterial } from "./source-auth-material";
45+import { namespaceFromSourceName } from "./source-names";
46+import { getSourceAdapterForOperation } from "./source-adapters";
5647 import {
5748 createDefaultSecretMaterialResolver,
5849 type ResolveSecretMaterial,
5950 type SecretMaterialResolveContext,
6051 } from "./secret-material-providers";
61−import {
62− namespaceFromSourceName,
63− resolveSourceAuthMaterial,
64−} from "./tool-artifacts";
6552 import {
6653 evaluateInvocationPolicy,
6754 type InvocationDescriptor,
@@ -76,7 +63,6 @@ const tokenize = (value: string): string[] => · 8 unmodified lines
7663 .split(/[^a-z0-9]+/)
7764 .filter(Boolean);
7865
79−
8066 const LOW_SIGNAL_QUERY_TOKENS = new Set([
8167 "a",
8268 "an",
@@ -101,15 +87,15 @@ const LOW_SIGNAL_QUERY_TOKENS = new Set([ · 18 unmodified lines
10187 ]);
10288
10389 const singularizeToken = (value: string): string =>
104− value.length > 3 && value.endsWith("s")
105− ? value.slice(0, -1)
106− : value;
90+ value.length > 3 && value.endsWith("s") ? value.slice(0, -1) : value;
10791
10892 const tokenEquals = (left: string, right: string): boolean =>
10993 left === right || singularizeToken(left) === singularizeToken(right);
11094
111−const hasTokenMatch = (tokens: readonly string[], queryToken: string): boolean =>
112− tokens.some((token) => tokenEquals(token, queryToken));
95+const hasTokenMatch = (
96+ tokens: readonly string[],
97+ queryToken: string,
98+): boolean => tokens.some((token) => tokenEquals(token, queryToken));
11399
114100 const hasSubstringMatch = (value: string, queryToken: string): boolean => {
115101 if (value.includes(queryToken)) {
@@ -132,7 +118,6 @@ const SecretResolutionContextEnvelopeSchema = Schema.Struct({ · 16 unmodified lines
132118 const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither(
133119 SecretResolutionContextEnvelopeSchema,
134120 );
135−
136121 const toSecretResolutionContext = (
137122 value: unknown,
138123 ): SecretMaterialResolveContext | undefined => {
@@ -154,37 +139,89 @@ const loadWorkspaceRecipeTools = (input: { · 15 unmodified lines
154139 workspaceId: Source["workspaceId"];
155140 accountId: AccountId;
156141 includeSchemas: boolean;
157−}): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> =>
158− Effect.gen(function* () {
159− const recipes = yield* loadWorkspaceSourceRecipes({
142+}): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> =>
143+ Effect.map(
144+ loadWorkspaceSourceRecipeToolIndex({
160145 rows: input.rows,
161146 workspaceId: input.workspaceId,
162147 actorAccountId: input.accountId,
163− });
164−
165− return expandRecipeTools({
166− recipes: recipes.filter((recipe) =>
167− recipe.source.enabled && recipe.source.status === "connected"
168− ),
169148 includeSchemas: input.includeSchemas,
170− });
149+ }),
150+ (tools) =>
151+ tools.filter(
152+ (tool) => tool.source.enabled && tool.source.status === "connected",
153+ ),
154+ );
155+
156+const 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+
177+const 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+ };
171208 });
172209
173210 const scoreRecipeTool = (
174211 queryTokens: readonly string[],
175− tool: LoadedSourceRecipeTool,
212+ tool: LoadedSourceRecipeToolIndexEntry,
176213 ): number => {
177214 const pathText = tool.path.toLowerCase();
178215 const namespaceText = tool.searchNamespace.toLowerCase();
179216 const toolIdText = tool.operation.toolId.toLowerCase();
180217 const titleText = tool.operation.title?.toLowerCase() ?? "";
181218 const descriptionText = tool.operation.description?.toLowerCase() ?? "";
182− const templateText = tool.operation.openApiPathTemplate?.toLowerCase() ?? "";
219+ const templateText = tool.metadata.pathTemplate?.toLowerCase() ?? "";
183220
184221 const pathTokens = tokenize(`${tool.path} ${tool.operation.toolId}`);
185222 const namespaceTokens = tokenize(tool.searchNamespace);
186223 const titleTokens = tokenize(tool.operation.title ?? "");
187− const templateTokens = tokenize(tool.operation.openApiPathTemplate ?? "");
224+ const templateTokens = tokenize(tool.metadata.pathTemplate ?? "");
188225
189226 let score = 0;
190227 let structuralHits = 0;
@@ -220,7 +257,10 @@ const scoreRecipeTool = ( · 29 unmodified lines
220257 continue;
221258 }
222259
223− if (hasSubstringMatch(pathText, token) || hasSubstringMatch(toolIdText, token)) {
260+ if (
261+ hasSubstringMatch(pathText, token) ||
262+ hasSubstringMatch(toolIdText, token)
263+ ) {
224264 score += 6 * weight;
225265 structuralHits += 1;
226266 pathHits += 1;
@@ -234,7 +274,10 @@ const scoreRecipeTool = ( · 7 unmodified lines
234274 continue;
235275 }
236276
237− if (hasSubstringMatch(titleText, token) || hasSubstringMatch(templateText, token)) {
277+ if (
278+ hasSubstringMatch(titleText, token) ||
279+ hasSubstringMatch(templateText, token)
280+ ) {
238281 score += 4 * weight;
239282 structuralHits += 1;
240283 continue;
@@ -245,7 +288,9 @@ const scoreRecipeTool = ( · 4 unmodified lines
245288 }
246289 }
247290
248− const strongTokens = queryTokens.filter((token) => queryTokenWeight(token) >= 1);
291+ const strongTokens = queryTokens.filter(
292+ (token) => queryTokenWeight(token) >= 1,
293+ );
249294 if (strongTokens.length >= 2) {
250295 for (let index = 0; index < strongTokens.length - 1; index += 1) {
251296 const current = strongTokens[index]!;
@@ -256,7 +301,12 @@ const scoreRecipeTool = ( · 4 unmodified lines
256301 `${current}/${next}`,
257302 ];
258303
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+ ) {
260310 score += 10;
261311 }
262312 }
@@ -285,39 +335,29 @@ const approvalSchema = { · 22 unmodified lines
285335 additionalProperties: false,
286336 } satisfies Record<string, unknown>;
287337
288−const approvalMessageForInvocation = (descriptor: InvocationDescriptor): string => {
289− if (descriptor.httpMethod && descriptor.httpPathTemplate) {
290− return `Allow ${descriptor.httpMethod.toUpperCase()} ${descriptor.httpPathTemplate}?`;
291− }
292−
293− if (descriptor.graphqlOperationType) {
294− return `Allow GraphQL ${descriptor.graphqlOperationType} ${descriptor.toolPath}?`;
338+const approvalMessageForInvocation = (
339+ descriptor: InvocationDescriptor,
340+): string => {
341+ if (descriptor.approvalLabel) {
342+ return `Allow ${descriptor.approvalLabel}?`;
295343 }
296344
297345 return `Allow tool call: ${descriptor.toolPath}?`;
298346 };
299347
300−const toGraphqlInvocationOperationType = (
301− value: string | null,
302−): InvocationDescriptor["graphqlOperationType"] =>
303− value === "query" || value === "mutation" || value === "subscription"
304− ? value
305− : null;
306−
307348 const toInvocationDescriptorFromRecipeTool = (input: {
308− tool: LoadedSourceRecipeTool;
349+ tool: LoadedSourceRecipeToolIndexEntry;
309350 }): InvocationDescriptor => ({
310351 toolPath: input.tool.path,
311352 sourceId: input.tool.source.id,
312353 sourceName: input.tool.source.name,
313354 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),
315358 operationKind: input.tool.operation.operationKind,
316− httpMethod: input.tool.operation.openApiMethod?.toUpperCase() ?? null,
317− httpPathTemplate: input.tool.operation.openApiPathTemplate,
318− graphqlOperationType: toGraphqlInvocationOperationType(
319− input.tool.operation.graphqlOperationType,
320− ),
359+ interaction: input.tool.metadata.interaction,
360+ approvalLabel: input.tool.metadata.approvalLabel,
321361 });
322362
323363 const authorizePersistedToolInvocation = (input: {
@@ -328,23 +368,29 @@ const authorizePersistedToolInvocation = (input: { · 4 unmodified lines
328368 args: unknown;
329369 source: Source;
330370 context?: Record<string, unknown>;
331− onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"];
371+ onElicitation?: Parameters<
372+ typeof makeToolInvokerFromTools
373+ >[0]["onElicitation"];
332374 }): Effect.Effect<void, Error, never> =>
333375 Effect.gen(function* () {
334− const workspace = yield* input.rows.workspaces.getById(input.workspaceId).pipe(
335− Effect.mapError((cause) =>
336− cause instanceof Error ? cause : new Error(String(cause)),
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(
376+ const workspace = yield* input.rows.workspaces
377+ .getById(input.workspaceId)
378+ .pipe(
344379 Effect.mapError((cause) =>
345380 cause instanceof Error ? cause : new Error(String(cause)),
346381 ),
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+ )
348394 : [];
349395
350396 const decision = evaluateInvocationPolicy({
@@ -358,8 +404,8 @@ const authorizePersistedToolInvocation = (input: { · 7 unmodified lines
358404 : ("org_unknown" as never),
359405 accountId: input.accountId,
360406 clientId:
361− typeof input.context?.clientId === "string"
362− && input.context.clientId.length > 0
407+ typeof input.context?.clientId === "string" &&
408+ input.context.clientId.length > 0
363409 ? input.context.clientId
364410 : null,
365411 },
@@ -375,45 +421,52 @@ const authorizePersistedToolInvocation = (input: { · 9 unmodified lines
375421
376422 if (!input.onElicitation) {
377423 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+ ),
379427 );
380428 }
381429
382− const interactionId = typeof input.context?.callId === "string" && input.context.callId.length > 0
383− ? `tool_execution_gate:${input.context.callId}`
384− : `tool_execution_gate:${crypto.randomUUID()}`;
385− const response = yield* input.onElicitation({
386− interactionId,
387− path: asToolPath(input.descriptor.toolPath),
388− sourceKey: input.source.id,
389− args: input.args,
390− context: {
391− ...(input.context ?? {}),
392− interactionPurpose: "tool_execution_gate",
393− interactionReason: decision.reason,
394− invocationDescriptor: {
395− operationKind: input.descriptor.operationKind,
396− httpMethod: input.descriptor.httpMethod,
397− httpPathTemplate: input.descriptor.httpPathTemplate,
398− graphqlOperationType: input.descriptor.graphqlOperationType,
399− sourceId: input.source.id,
400− sourceName: input.source.name,
430+ const interactionId =
431+ typeof input.context?.callId === "string" &&
432+ input.context.callId.length > 0
433+ ? `tool_execution_gate:${input.context.callId}`
434+ : `tool_execution_gate:${crypto.randomUUID()}`;
435+ const response = yield* input
436+ .onElicitation({
437+ interactionId,
438+ path: asToolPath(input.descriptor.toolPath),
439+ sourceKey: input.source.id,
440+ args: input.args,
441+ context: {
442+ ...(input.context ?? {}),
443+ interactionPurpose: "tool_execution_gate",
444+ interactionReason: decision.reason,
445+ invocationDescriptor: {
446+ operationKind: input.descriptor.operationKind,
447+ interaction: input.descriptor.interaction,
448+ approvalLabel: input.descriptor.approvalLabel,
449+ sourceId: input.source.id,
450+ sourceName: input.source.name,
451+ },
401452 },
402− },
403− elicitation: {
404− mode: "form",
405− message: approvalMessageForInvocation(input.descriptor),
406− requestedSchema: approvalSchema,
407− },
408− }).pipe(
409− Effect.mapError((cause) =>
410− cause instanceof Error ? cause : new Error(String(cause)),
411− ),
412− );
453+ elicitation: {
454+ mode: "form",
455+ message: approvalMessageForInvocation(input.descriptor),
456+ requestedSchema: approvalSchema,
457+ },
458+ })
459+ .pipe(
460+ Effect.mapError((cause) =>
461+ cause instanceof Error ? cause : new Error(String(cause)),
462+ ),
463+ );
413464
414465 if (response.action !== "accept") {
415466 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+ ),
417470 );
418471 }
419472 });
@@ -450,7 +503,8 @@ const createWorkspaceToolCatalog = (input: { · 30 unmodified lines
450503 namespace: namespace.namespace,
451504 displayName: namespace.displayName ?? existing?.displayName,
452505 toolCount:
453− namespace.toolCount !== undefined || existing?.toolCount === undefined
506+ namespace.toolCount !== undefined ||
507+ existing?.toolCount === undefined
454508 ? namespace.toolCount
455509 : existing.toolCount,
456510 });
@@ -478,7 +532,9 @@ const createWorkspaceToolCatalog = (input: { · 21 unmodified lines
478532 if (!query) {
479533 return true;
480534 }
481− return tokenize(query).every((token) => tool.searchText.includes(token));
535+ return tokenize(query).every((token) =>
536+ tool.searchText.includes(token),
537+ );
482538 }),
483539 ),
484540 ),
@@ -490,10 +546,7 @@ const createWorkspaceToolCatalog = (input: { · 5 unmodified lines
490546 }),
491547 ]);
492548
493− return [
494− ...recipeTools.map((tool) => tool.descriptor),
495− ...executor,
496− ]
549+ return [...recipeTools.map((tool) => tool.descriptor), ...executor]
497550 .sort((left, right) => left.path.localeCompare(right.path))
498551 .slice(0, limit);
499552 }),
@@ -508,13 +561,21 @@ const createWorkspaceToolCatalog = (input: { · 8 unmodified lines
508561 return executor;
509562 }
510563
511− const recipeTools = yield* loadWorkspaceRecipeTools({
564+ const recipeTool = yield* loadWorkspaceRecipeToolByPath({
512565 rows: input.rows,
513566 workspaceId: input.workspaceId,
514567 accountId: input.accountId,
568+ path,
515569 includeSchemas,
516570 });
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),
518579 }),
519580
520581 searchTools: ({ query, namespace, limit }) =>
@@ -528,7 +589,9 @@ const createWorkspaceToolCatalog = (input: { · 7 unmodified lines
528589 includeSchemas: false,
529590 }).pipe(
530591 Effect.map((tools) =>
531− tools.filter((tool) => !namespace || tool.searchNamespace === namespace),
592+ tools.filter(
593+ (tool) => !namespace || tool.searchNamespace === namespace,
594+ ),
532595 ),
533596 ),
534597 input.executorCatalog.searchTools({
@@ -546,8 +609,9 @@ const createWorkspaceToolCatalog = (input: { · 11 unmodified lines
546609 .filter((hit) => hit.score > 0);
547610
548611 return [...recipeHits, ...executor]
549− .sort((left, right) =>
550− right.score - left.score || left.path.localeCompare(right.path),
612+ .sort(
613+ (left, right) =>
614+ right.score - left.score || left.path.localeCompare(right.path),
551615 )
552616 .slice(0, limit);
553617 }),
@@ -559,11 +623,13 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines
559623 rows: SqlControlPlaneRows;
560624 resolveSecretMaterial: ResolveSecretMaterial;
561625 sourceAuthService: RuntimeSourceAuthService;
562− onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"];
626+ onElicitation?: Parameters<
627+ typeof makeToolInvokerFromTools
628+ >[0]["onElicitation"];
563629 }): {
564630 catalog: ToolCatalog;
565631 toolInvoker: ToolInvoker;
566− } => {
632+} => {
567633 const executorTools = createExecutorToolMap({
568634 workspaceId: input.workspaceId,
569635 accountId: input.accountId,
@@ -596,15 +662,17 @@ const createWorkspaceToolInvoker = (input: { · 26 unmodified lines
596662 context?: Record<string, unknown>;
597663 }) =>
598664 Effect.gen(function* () {
599− const recipeTools = yield* loadWorkspaceRecipeTools({
665+ const recipeTool = yield* loadWorkspaceRecipeToolByPath({
600666 rows: input.rows,
601667 workspaceId: input.workspaceId,
602668 accountId: input.accountId,
669+ path: invocation.path,
603670 includeSchemas: false,
604671 });
605− const recipeTool = recipeTools.find((tool) => tool.path === invocation.path);
606672 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+ );
608676 }
609677
610678 yield* authorizePersistedToolInvocation({
@@ -623,94 +691,40 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines
623691 resolveSecretMaterial: input.resolveSecretMaterial,
624692 context: toSecretResolutionContext(invocation.context),
625693 });
626−
627− if (recipeTool.operation.providerKind === "openapi") {
628− if (recipeTool.manifest === null) {
629− return yield* Effect.fail(
630− new Error(`Missing OpenAPI manifest for ${recipeTool.source.id}`),
631− );
632− }
633−
634− const tools = createOpenApiToolsFromManifest({
635− manifest: recipeTool.manifest as OpenApiToolManifest,
636− baseUrl: recipeTool.source.endpoint,
637− namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name),
638− sourceKey: recipeTool.source.id,
639− defaultHeaders: recipeTool.source.defaultHeaders ?? {},
640− credentialHeaders: auth.headers,
641− });
642−
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− });
694+ const schemaBundle = recipeTool.schemaBundleId
695+ ? yield* loadWorkspaceSchemaBundle({
696+ rows: input.rows,
697+ workspaceId: input.workspaceId,
698+ id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId),
699+ })
700+ : null;
701+ const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(
702+ input.workspaceId,
703+ recipeTool.source.id,
704+ );
705+ let manifestJson: string | null = null;
706+ if (Option.isSome(sourceRecord)) {
707+ const revision = yield* input.rows.sourceRecipeRevisions.getById(
708+ sourceRecord.value.recipeRevisionId,
709+ );
710+ manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null;
651711 }
652712
653− if (recipeTool.operation.providerKind === "graphql") {
654− if (recipeTool.manifest === null) {
655− return yield* Effect.fail(
656− new Error(`Missing GraphQL manifest for ${recipeTool.source.id}`),
657− );
658− }
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({
713+ return yield* getSourceAdapterForOperation(recipeTool.operation)
714+ .invokePersistedTool({
715+ rows: input.rows,
716+ workspaceId: input.workspaceId,
717+ accountId: input.accountId,
718+ source: recipeTool.source,
673719 path: invocation.path,
720+ operation: recipeTool.operation,
721+ schemaBundle,
722+ manifestJson,
723+ auth,
674724 args: invocation.args,
675725 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,
703726 onElicitation: input.onElicitation,
704− }).invoke({
705− path: invocation.path,
706− args: invocation.args,
707− context: invocation.context,
708727 });
709− }
710−
711− return yield* Effect.fail(
712− new Error(`Unsupported stored tool provider for ${invocation.path}`),
713− );
714728 });
715729
716730 return {
@@ -732,8 +746,8 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 15 unmodified lines
732746 sourceAuthService: RuntimeSourceAuthService;
733747 }): ResolveExecutionEnvironment => {
734748 const resolveSecretMaterial =
735− input.resolveSecretMaterial
736− ?? createDefaultSecretMaterialResolver({
749+ input.resolveSecretMaterial ??
750+ createDefaultSecretMaterialResolver({
737751 rows: input.rows,
738752 });
739753
@@ -765,25 +779,26 @@ export class RuntimeExecutionResolverService extends Context.Tag( · 25 unmodified lines
765779 )<
766780 RuntimeExecutionResolverService,
767781 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
768−>() {
769−}
770−
771−export const RuntimeExecutionResolverLive = (input: {
772− executionResolver?: ResolveExecutionEnvironment;
773− resolveSecretMaterial?: ResolveSecretMaterial;
774−} = {}) =>
782+>() {}
783+
784+export const RuntimeExecutionResolverLive = (
785+ input: {
786+ executionResolver?: ResolveExecutionEnvironment;
787+ resolveSecretMaterial?: ResolveSecretMaterial;
788+ } = {},
789+) =>
775790 input.executionResolver
776791 ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver)
777792 : Layer.effect(
778− RuntimeExecutionResolverService,
779− Effect.gen(function* () {
780− const rows = yield* SqlControlPlaneRowsService;
781− const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
782−
783− return createWorkspaceExecutionEnvironmentResolver({
784− rows,
785− sourceAuthService,
786− resolveSecretMaterial: input.resolveSecretMaterial,
787− });
788− }),
789− );
793+ RuntimeExecutionResolverService,
794+ Effect.gen(function* () {
795+ const rows = yield* SqlControlPlaneRowsService;
796+ const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
797+
798+ return createWorkspaceExecutionEnvironmentResolver({
799+ rows,
800+ sourceAuthService,
801+ resolveSecretMaterial: input.resolveSecretMaterial,
802+ });
803+ }),
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
1111 export const SourceRecipeRevisionIdSchema = Schema.String.pipe(
1212 Schema.brand("SourceRecipeRevisionId"),
1313 );
14+export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe(
15+ Schema.brand("SourceRecipeSchemaBundleId"),
16+);
1417 export const SourceAuthSessionIdSchema = Schema.String.pipe(
1518 Schema.brand("SourceAuthSessionId"),
1619 );
@@ -37,6 +40,7 @@ export type WorkspaceId = typeof WorkspaceIdSchema.Type; · 20 unmodified lines
3740 export type SourceId = typeof SourceIdSchema.Type;
3841 export type SourceRecipeId = typeof SourceRecipeIdSchema.Type;
3942 export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;
43+export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type;
4044 export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
4145 export type CredentialId = typeof CredentialIdSchema.Type;
4246 export 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
88 export * from "./models/source-recipe";
99 export * from "./models/credential";
1010 export * from "./models/source-oauth-client";
11−export * from "./models/tool-artifact";
1211 export * from "./models/secret-material";
1312 export * from "./models/source-auth-session";
1413 export * from "./models/policy";
@@ -16,3 +15,4 @@ export * from "./models/local-installation"; · 1 unmodified line
1615 export * from "./models/execution";
1716 export * from "./models/source-inspection";
1817 export * from "./models/source-discovery";
18+export * from "./models/data-migration";
packages/control-plane/src/schema/models/credential.ts+3−0
@@ -11,12 +11,14 @@ import { · 10 unmodified lines
1111 } from "../ids";
1212
1313 export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2");
14+export const CredentialSlotSchema = Schema.Literal("runtime", "import");
1415
1516 const credentialSchemaOverrides = {
1617 id: CredentialIdSchema,
1718 workspaceId: WorkspaceIdSchema,
1819 sourceId: SourceIdSchema,
1920 actorAccountId: Schema.NullOr(AccountIdSchema),
21+ slot: CredentialSlotSchema,
2022 authKind: CredentialAuthKindSchema,
2123 authHeaderName: Schema.String,
2224 authPrefix: Schema.String,
@@ -34,4 +36,5 @@ export const CredentialSchema = createSelectSchema( · 11 unmodified lines
3436 );
3537
3638 export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;
39+export type CredentialSlot = typeof CredentialSlotSchema.Type;
3740 export type Credential = typeof CredentialSchema.Type;
packages/control-plane/src/schema/models/data-migration.ts+14−0
@@ -0,0 +1,14 @@
1+import { createSelectSchema } from "drizzle-orm/effect-schema";
2+
3+import { dataMigrationsTable } from "../../persistence/schema";
4+import { TimestampMsSchema } from "../common";
5+
6+export const StoredDataMigrationRecordSchema = createSelectSchema(
7+ dataMigrationsTable,
8+ {
9+ appliedAt: TimestampMsSchema,
10+ },
11+);
12+
13+export 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
1111 SourceIdSchema,
1212 WorkspaceIdSchema,
1313 } from "../ids";
14+import { CredentialSlotSchema } from "./credential";
1415
1516 export const SourceAuthSessionProviderKindSchema = Schema.Literal(
1617 "mcp_oauth",
@@ -78,6 +79,7 @@ const sourceAuthSessionSchemaOverrides = { · 61 unmodified lines
7879 workspaceId: WorkspaceIdSchema,
7980 sourceId: SourceIdSchema,
8081 actorAccountId: Schema.NullOr(AccountIdSchema),
82+ credentialSlot: CredentialSlotSchema,
8183 executionId: Schema.NullOr(ExecutionIdSchema),
8284 interactionId: Schema.NullOr(ExecutionInteractionIdSchema),
8385 providerKind: SourceAuthSessionProviderKindSchema,
packages/control-plane/src/schema/models/source-inspection.ts+19−6
@@ -1,4 +1,3 @@
1−import { OpenApiHttpMethodSchema } from "@executor/codemode-openapi";
21 import { Schema } from "effect";
32
43 import { SourceSchema } from "./source";
@@ -21,21 +20,23 @@ export const SourceInspectionToolSummarySchema = Schema.Struct({ · 16 unmodified lines
2120 group: Schema.NullOr(Schema.String),
2221 leaf: Schema.NullOr(Schema.String),
2322 tags: Schema.Array(Schema.String),
24− method: Schema.NullOr(OpenApiHttpMethodSchema),
23+ method: Schema.NullOr(Schema.String),
2524 pathTemplate: Schema.NullOr(Schema.String),
2625 inputType: Schema.optional(Schema.String),
2726 outputType: Schema.optional(Schema.String),
2827 });
2928
29+export const SourceInspectionToolListItemSchema = Schema.Struct({
30+ path: Schema.String,
31+ method: Schema.NullOr(Schema.String),
32+});
33+
3034 export const SourceInspectionSchema = Schema.Struct({
3135 source: SourceSchema,
3236 namespace: Schema.String,
3337 pipelineKind: SourceInspectionPipelineKindSchema,
3438 toolCount: Schema.Number,
35− rawDocumentText: Schema.NullOr(Schema.String),
36− manifestJson: Schema.NullOr(Schema.String),
37− definitionsJson: Schema.NullOr(Schema.String),
38− tools: Schema.Array(SourceInspectionToolSummarySchema),
39+ tools: Schema.Array(SourceInspectionToolListItemSchema),
3940 });
4041
4142 export const SourceInspectionToolDetailSchema = Schema.Struct({
@@ -45,10 +46,18 @@ export const SourceInspectionToolDetailSchema = Schema.Struct({ · 3 unmodified lines
4546 providerDataJson: Schema.NullOr(Schema.String),
4647 inputSchemaJson: Schema.NullOr(Schema.String),
4748 outputSchemaJson: Schema.NullOr(Schema.String),
49+ schemaBundleId: Schema.NullOr(Schema.String),
4850 exampleInputJson: Schema.NullOr(Schema.String),
4951 exampleOutputJson: Schema.NullOr(Schema.String),
5052 });
5153
54+export const SourceInspectionSchemaBundleSchema = Schema.Struct({
55+ id: Schema.String,
56+ kind: Schema.String,
57+ hash: Schema.String,
58+ refsJson: Schema.String,
59+});
60+
5261 export const SourceInspectionDiscoverPayloadSchema = Schema.Struct({
5362 query: Schema.String,
5463 limit: Schema.optional(Schema.Number),
@@ -75,9 +84,13 @@ export type SourceInspectionPipelineKind = · 20 unmodified lines
7584 typeof SourceInspectionPipelineKindSchema.Type;
7685 export type SourceInspectionToolSummary =
7786 typeof SourceInspectionToolSummarySchema.Type;
87+export type SourceInspectionToolListItem =
88+ typeof SourceInspectionToolListItemSchema.Type;
7889 export type SourceInspection = typeof SourceInspectionSchema.Type;
7990 export type SourceInspectionToolDetail =
8091 typeof SourceInspectionToolDetailSchema.Type;
92+export type SourceInspectionSchemaBundle =
93+ typeof SourceInspectionSchemaBundleSchema.Type;
8194 export type SourceInspectionDiscoverPayload =
8295 typeof SourceInspectionDiscoverPayloadSchema.Type;
8396 export type SourceInspectionDiscoverResultItem =
packages/control-plane/src/schema/models/source-recipe.ts+29−144
@@ -4,6 +4,7 @@ import { Schema } from "effect"; · 3 unmodified lines
44 import {
55 sourceRecipeDocumentsTable,
66 sourceRecipeOperationsTable,
7+ sourceRecipeSchemaBundlesTable,
78 sourceRecipeRevisionsTable,
89 sourceRecipesTable,
910 } from "../../persistence/schema";
@@ -11,24 +12,16 @@ import { TimestampMsSchema } from "../common"; · 1 unmodified line
1112 import {
1213 SourceRecipeIdSchema,
1314 SourceRecipeRevisionIdSchema,
15+ SourceRecipeSchemaBundleIdSchema,
1416 } from "../ids";
1517
1618 export const SourceRecipeKindSchema = Schema.Literal(
17− "http_recipe",
18− "graphql_recipe",
19− "mcp_recipe",
20− "internal_recipe",
19+ "http_api",
20+ "mcp",
21+ "internal",
2122 );
2223
23−export const SourceRecipeImporterKindSchema = Schema.Literal(
24− "openapi",
25− "google_discovery",
26− "postman_collection",
27− "snippet_bundle",
28− "graphql_introspection",
29− "mcp_manifest",
30− "internal_manifest",
31−);
24+export const SourceRecipeImporterKindSchema = Schema.String;
3225
3326 export const SourceRecipeVisibilitySchema = Schema.Literal(
3427 "private",
@@ -37,14 +30,9 @@ export const SourceRecipeVisibilitySchema = Schema.Literal( · 2 unmodified lines
3730 "public",
3831 );
3932
40−export const SourceRecipeDocumentKindSchema = Schema.Literal(
41− "google_discovery",
42− "openapi",
43− "postman_collection",
44− "postman_environment",
45− "graphql_introspection",
46− "mcp_manifest",
47−);
33+export const SourceRecipeDocumentKindSchema = Schema.String;
34+
35+export const SourceRecipeSchemaBundleKindSchema = Schema.String;
4836
4937 export const SourceRecipeTransportKindSchema = Schema.Literal(
5038 "http",
@@ -60,29 +48,7 @@ export const SourceRecipeOperationKindSchema = Schema.Literal( · 9 unmodified lines
6048 "unknown",
6149 );
6250
63−export const SourceRecipeOperationProviderKindSchema = Schema.Literal(
64− "mcp",
65− "openapi",
66− "graphql",
67− "internal",
68−);
69−
70−export const SourceRecipeOpenApiMethodSchema = Schema.Literal(
71− "get",
72− "put",
73− "post",
74− "delete",
75− "patch",
76− "head",
77− "options",
78− "trace",
79−);
80−
81−export const SourceRecipeGraphqlOperationTypeSchema = Schema.Literal(
82− "query",
83− "mutation",
84− "subscription",
85−);
51+export const SourceRecipeOperationProviderKindSchema = Schema.String;
8652
8753 const recipeRowSchemaOverrides = {
8854 id: SourceRecipeIdSchema,
@@ -98,6 +64,7 @@ const recipeRevisionRowSchemaOverrides = { · 9 unmodified lines
9864 id: SourceRecipeRevisionIdSchema,
9965 recipeId: SourceRecipeIdSchema,
10066 revisionNumber: Schema.Number,
67+ materializationHash: Schema.NullOr(Schema.String),
10168 createdAt: TimestampMsSchema,
10269 updatedAt: TimestampMsSchema,
10370 } as const;
@@ -110,13 +77,19 @@ const recipeDocumentRowSchemaOverrides = { · 6 unmodified lines
11077 updatedAt: TimestampMsSchema,
11178 } as const;
11279
80+const recipeSchemaBundleRowSchemaOverrides = {
81+ id: SourceRecipeSchemaBundleIdSchema,
82+ recipeRevisionId: SourceRecipeRevisionIdSchema,
83+ bundleKind: SourceRecipeSchemaBundleKindSchema,
84+ createdAt: TimestampMsSchema,
85+ updatedAt: TimestampMsSchema,
86+} as const;
87+
11388 const sourceRecipeOperationRowSchemaOverrides = {
11489 recipeRevisionId: SourceRecipeRevisionIdSchema,
11590 transportKind: SourceRecipeTransportKindSchema,
11691 operationKind: SourceRecipeOperationKindSchema,
11792 providerKind: SourceRecipeOperationProviderKindSchema,
118− openApiMethod: Schema.NullOr(SourceRecipeOpenApiMethodSchema),
119− graphqlOperationType: Schema.NullOr(SourceRecipeGraphqlOperationTypeSchema),
12093 createdAt: TimestampMsSchema,
12194 updatedAt: TimestampMsSchema,
12295 } as const;
@@ -136,97 +109,16 @@ export const StoredSourceRecipeDocumentRecordSchema = createSelectSchema( · 13 unmodified lines
136109 recipeDocumentRowSchemaOverrides,
137110 );
138111
112+export const StoredSourceRecipeSchemaBundleRecordSchema = createSelectSchema(
113+ sourceRecipeSchemaBundlesTable,
114+ recipeSchemaBundleRowSchemaOverrides,
115+);
116+
139117 export const StoredSourceRecipeOperationRowSchema = createSelectSchema(
140118 sourceRecipeOperationsTable,
141119 sourceRecipeOperationRowSchemaOverrides,
142120 );
143−
144−const 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−
160−export 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−
176−export 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−
192−export 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−
208−export 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−
224−export const StoredSourceRecipeOperationRecordSchema = Schema.Union(
225− StoredOpenApiSourceRecipeOperationRecordSchema,
226− StoredGraphqlSourceRecipeOperationRecordSchema,
227− StoredMcpSourceRecipeOperationRecordSchema,
228− StoredInternalSourceRecipeOperationRecordSchema,
229−).annotations({
121+export const StoredSourceRecipeOperationRecordSchema = StoredSourceRecipeOperationRowSchema.annotations({
230122 identifier: "StoredSourceRecipeOperationRecord",
231123 });
232124
@@ -234,24 +126,17 @@ export type SourceRecipeKind = typeof SourceRecipeKindSchema.Type; · 1 unmodified line
234126 export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type;
235127 export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type;
236128 export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type;
129+export type SourceRecipeSchemaBundleKind =
130+ typeof SourceRecipeSchemaBundleKindSchema.Type;
237131 export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type;
238132 export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type;
239133 export type SourceRecipeOperationProviderKind =
240134 typeof SourceRecipeOperationProviderKindSchema.Type;
241−export type SourceRecipeOpenApiMethod = typeof SourceRecipeOpenApiMethodSchema.Type;
242−export type SourceRecipeGraphqlOperationType =
243− typeof SourceRecipeGraphqlOperationTypeSchema.Type;
244135 export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type;
245136 export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type;
246137 export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type;
138+export type StoredSourceRecipeSchemaBundleRecord =
139+ typeof StoredSourceRecipeSchemaBundleRecordSchema.Type;
247140 export type StoredSourceRecipeOperationRow = typeof StoredSourceRecipeOperationRowSchema.Type;
248−export type StoredOpenApiSourceRecipeOperationRecord =
249− typeof StoredOpenApiSourceRecipeOperationRecordSchema.Type;
250−export type StoredGraphqlSourceRecipeOperationRecord =
251− typeof StoredGraphqlSourceRecipeOperationRecordSchema.Type;
252−export type StoredMcpSourceRecipeOperationRecord =
253− typeof StoredMcpSourceRecipeOperationRecordSchema.Type;
254−export type StoredInternalSourceRecipeOperationRecord =
255− typeof StoredInternalSourceRecipeOperationRecordSchema.Type;
256141 export type StoredSourceRecipeOperationRecord =
257142 typeof StoredSourceRecipeOperationRecordSchema.Type;
packages/control-plane/src/schema/models/source.ts+15−26
@@ -12,12 +12,7 @@ import { · 11 unmodified lines
1212 WorkspaceIdSchema,
1313 } from "../ids";
1414
15−export const SourceKindSchema = Schema.Literal(
16− "mcp",
17− "openapi",
18− "graphql",
19− "internal",
20−);
15+export const SourceKindSchema = Schema.String;
2116
2217 export const SourceStatusSchema = Schema.Literal(
2318 "draft",
@@ -33,6 +28,12 @@ export const SourceTransportSchema = Schema.Literal( · 9 unmodified lines
3328 "sse",
3429 );
3530
31+export const SourceImportAuthPolicySchema = Schema.Literal(
32+ "none",
33+ "reuse_runtime",
34+ "separate",
35+);
36+
3637 export const SecretRefSchema = Schema.Struct({
3738 providerId: Schema.String,
3839 handle: Schema.String,
@@ -69,7 +70,7 @@ const sourceRowSchemaOverrides = { · 30 unmodified lines
6970 recipeRevisionId: SourceRecipeRevisionIdSchema,
7071 kind: SourceKindSchema,
7172 status: SourceStatusSchema,
72− transport: Schema.NullOr(SourceTransportSchema),
73+ importAuthPolicy: SourceImportAuthPolicySchema,
7374 createdAt: TimestampMsSchema,
7475 updatedAt: TimestampMsSchema,
7576 } as const;
@@ -89,14 +90,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 13 unmodified lines
8990 status: SourceStatusSchema,
9091 enabled: Schema.Boolean,
9192 namespace: Schema.NullOr(Schema.String),
92− bindingConfigJson: Schema.NullOr(Schema.String),
93− transport: Schema.NullOr(SourceTransportSchema),
94− queryParamsJson: Schema.NullOr(Schema.String),
95− headersJson: Schema.NullOr(Schema.String),
96− specUrl: Schema.NullOr(Schema.String),
97− defaultHeadersJson: Schema.NullOr(Schema.String),
93+ importAuthPolicy: SourceImportAuthPolicySchema,
94+ bindingConfigJson: Schema.String,
9895 sourceHash: Schema.NullOr(Schema.String),
99− sourceDocumentText: Schema.NullOr(Schema.String),
10096 lastError: Schema.NullOr(Schema.String),
10197 createdAt: TimestampMsSchema,
10298 updatedAt: TimestampMsSchema,
@@ -114,14 +110,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 11 unmodified lines
114110 status: row.status,
115111 enabled: row.enabled,
116112 namespace: row.namespace,
113+ importAuthPolicy: row.importAuthPolicy,
117114 bindingConfigJson: row.bindingConfigJson,
118− transport: row.transport,
119− queryParamsJson: row.queryParamsJson,
120− headersJson: row.headersJson,
121− specUrl: row.specUrl,
122− defaultHeadersJson: row.defaultHeadersJson,
123115 sourceHash: row.sourceHash,
124− sourceDocumentText: row.sourceDocumentText,
125116 lastError: row.lastError,
126117 createdAt: row.createdAt,
127118 updatedAt: row.updatedAt,
@@ -137,14 +128,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 9 unmodified lines
137128 status: source.status,
138129 enabled: source.enabled,
139130 namespace: source.namespace,
131+ importAuthPolicy: source.importAuthPolicy,
140132 bindingConfigJson: source.bindingConfigJson,
141− transport: source.transport,
142− queryParamsJson: source.queryParamsJson,
143− headersJson: source.headersJson,
144− specUrl: source.specUrl,
145− defaultHeadersJson: source.defaultHeadersJson,
146133 sourceHash: source.sourceHash,
147− sourceDocumentText: source.sourceDocumentText,
148134 lastError: source.lastError,
149135 createdAt: source.createdAt,
150136 updatedAt: source.updatedAt,
@@ -166,6 +152,8 @@ export const SourceSchema = Schema.Struct({ · 15 unmodified lines
166152 headers: Schema.NullOr(StringMapSchema),
167153 specUrl: Schema.NullOr(Schema.String),
168154 defaultHeaders: Schema.NullOr(StringMapSchema),
155+ importAuthPolicy: SourceImportAuthPolicySchema,
156+ importAuth: SourceAuthSchema,
169157 auth: SourceAuthSchema,
170158 sourceHash: Schema.NullOr(Schema.String),
171159 lastError: Schema.NullOr(Schema.String),
@@ -176,6 +164,7 @@ export const SourceSchema = Schema.Struct({ · 4 unmodified lines
176164 export type SourceKind = typeof SourceKindSchema.Type;
177165 export type SourceStatus = typeof SourceStatusSchema.Type;
178166 export type SourceTransport = typeof SourceTransportSchema.Type;
167+export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;
179168 export type SecretRef = typeof SecretRefSchema.Type;
180169 export type SourceAuth = typeof SourceAuthSchema.Type;
181170 export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;
packages/control-plane/src/schema/models/tool-artifact.ts+0−168
@@ -1,168 +0,0 @@
1−import { createSelectSchema } from "drizzle-orm/effect-schema";
2−import { Schema } from "effect";
3−
4−import {
5− toolArtifactParametersTable,
6− toolArtifactRefHintKeysTable,
7− toolArtifactRequestBodyContentTypesTable,
8− toolArtifactsTable,
9−} from "../../persistence/schema";
10−import { TimestampMsSchema } from "../common";
11−import { SourceIdSchema, WorkspaceIdSchema } from "../ids";
12−
13−export const ToolArtifactProviderKindSchema = Schema.Literal(
14− "mcp",
15− "openapi",
16−);
17−
18−export const ToolArtifactParameterLocationSchema = Schema.Literal(
19− "path",
20− "query",
21− "header",
22− "cookie",
23−);
24−
25−const 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−
36−const ToolArtifactStorageRowSchema = createSelectSchema(
37− toolArtifactsTable,
38− toolArtifactRowSchemaOverrides,
39−);
40−
41−export 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−
69−export 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−
123−const toolArtifactParameterRowSchemaOverrides = {
124− workspaceId: WorkspaceIdSchema,
125− location: ToolArtifactParameterLocationSchema,
126− position: Schema.Number,
127−} as const;
128−
129−export const StoredToolArtifactParameterRecordSchema = createSelectSchema(
130− toolArtifactParametersTable,
131− toolArtifactParameterRowSchemaOverrides,
132−).annotations({
133− identifier: "StoredToolArtifactParameterRecord",
134−});
135−
136−const toolArtifactRequestBodyContentTypeRowSchemaOverrides = {
137− workspaceId: WorkspaceIdSchema,
138− position: Schema.Number,
139−} as const;
140−
141−export const StoredToolArtifactRequestBodyContentTypeRecordSchema = createSelectSchema(
142− toolArtifactRequestBodyContentTypesTable,
143− toolArtifactRequestBodyContentTypeRowSchemaOverrides,
144−).annotations({
145− identifier: "StoredToolArtifactRequestBodyContentTypeRecord",
146−});
147−
148−const toolArtifactRefHintKeyRowSchemaOverrides = {
149− workspaceId: WorkspaceIdSchema,
150− position: Schema.Number,
151−} as const;
152−
153−export const StoredToolArtifactRefHintKeyRecordSchema = createSelectSchema(
154− toolArtifactRefHintKeysTable,
155− toolArtifactRefHintKeyRowSchemaOverrides,
156−).annotations({
157− identifier: "StoredToolArtifactRefHintKeyRecord",
158−});
159−
160−export type ToolArtifactProviderKind = typeof ToolArtifactProviderKindSchema.Type;
161−export type ToolArtifactParameterLocation = typeof ToolArtifactParameterLocationSchema.Type;
162−export type StoredToolArtifactRecord = typeof StoredToolArtifactRecordSchema.Type;
163−export type StoredToolArtifactParameterRecord =
164− typeof StoredToolArtifactParameterRecordSchema.Type;
165−export type StoredToolArtifactRequestBodyContentTypeRecord =
166− typeof StoredToolArtifactRequestBodyContentTypeRecordSchema.Type;
167−export 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
1616 expect(RolePermissions.owner).toContain("policies:manage");
1717 });
1818
19− it("enforces provider-specific source recipe operation shapes", () => {
19+ it("accepts canonical source recipe operation rows and rejects invalid transport kinds", () => {
2020 const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema);
2121
22− const openApiRecord = decode({
22+ const operationRecord = decode({
2323 id: "src_recipe_op_1",
2424 recipeRevisionId: "src_recipe_rev_1",
2525 operationKey: "getRepo",
@@ -32,28 +32,20 @@ describe("control-plane-schema", () => { · 6 unmodified lines
3232 inputSchemaJson: null,
3333 outputSchemaJson: null,
3434 providerKind: "openapi",
35− providerDataJson: null,
36− mcpToolName: null,
37− openApiMethod: "get",
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,
35+ providerDataJson: JSON.stringify({
36+ kind: "openapi",
37+ }),
4638 createdAt: 1,
4739 updatedAt: 1,
4840 });
4941
50− expect(Either.isRight(openApiRecord)).toBe(true);
42+ expect(Either.isRight(operationRecord)).toBe(true);
5143
52− const invalidGraphqlRecord = decode({
44+ const invalidRecord = decode({
5345 id: "src_recipe_op_2",
5446 recipeRevisionId: "src_recipe_rev_1",
5547 operationKey: "viewer",
56− transportKind: "graphql",
48+ transportKind: "ftp",
5749 toolId: "viewer",
5850 title: "Viewer",
5951 description: null,
@@ -62,21 +54,13 @@ describe("control-plane-schema", () => { · 2 unmodified lines
6254 inputSchemaJson: null,
6355 outputSchemaJson: null,
6456 providerKind: "graphql",
65− providerDataJson: null,
66− mcpToolName: null,
67− openApiMethod: "get",
68− openApiPathTemplate: null,
69− openApiOperationHash: null,
70− openApiRawToolId: null,
71− openApiOperationId: null,
72− openApiTagsJson: null,
73− openApiRequestBodyRequired: null,
74− graphqlOperationType: "query",
75− graphqlOperationName: "viewer",
57+ providerDataJson: JSON.stringify({
58+ kind: "graphql",
59+ }),
7660 createdAt: 1,
7761 updatedAt: 1,
7862 });
7963
80− expect(Either.isLeft(invalidGraphqlRecord)).toBe(true);
64+ expect(Either.isLeft(invalidRecord)).toBe(true);
8165 });
8266 });
packages/kitchen-sink/src/source-runtime.test.ts+35−13
@@ -16,7 +16,6 @@ import { · 15 unmodified lines
1616 type SecretRef,
1717 type Source,
1818 type SourceId,
19− type StoredToolArtifactRecord,
2019 type WorkspaceId,
2120 } from "@executor/control-plane";
2221 import {
@@ -59,6 +58,29 @@ const catalogNamespaceFromPath = (path: string): string => { · 36 unmodified lines
5958 return second ? `${first}.${second}` : first;
6059 };
6160
61+type 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+
6284 const toDescriptor = (
6385 artifact: StoredToolArtifactRecord,
6486 includeSchemas: boolean,
@@ -282,15 +304,11 @@ const resolveSourceCallContext = (input: { · 217 unmodified lines
282304
283305 const createProviderInvoker = (): ProviderInvoker => ({
284306 async invoke({ source, artifact, args, runtime, context }) {
285− const invocation = artifact.providerKind === "mcp"
286− ? {
287− toolName: artifact.mcpToolName ?? artifact.title ?? artifact.toolId,
288− }
289− : {
290− method: artifact.openApiMethod,
291− pathTemplate: artifact.openApiPathTemplate,
292− operationHash: artifact.openApiOperationHash,
293− };
307+ const invocation = {
308+ method: artifact.openApiMethod,
309+ pathTemplate: artifact.openApiPathTemplate,
310+ operationHash: artifact.openApiOperationHash,
311+ };
294312
295313 return {
296314 sourceId: source.id,
@@ -337,6 +355,7 @@ const createWorkspaceToolCatalog = (input: { · 40 unmodified lines
337355 path,
338356 }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null))
339357 ),
358+ getSchemaBundle: () => Effect.succeed(null),
340359 searchTools: ({ query, namespace, limit }) =>
341360 Effect.promise(() =>
342361 input.toolStore.list({
@@ -480,6 +499,8 @@ const openApiSource = (input: { · 137 unmodified lines
480499 headers: null,
481500 specUrl: input.specUrl ?? null,
482501 defaultHeaders: null,
502+ importAuthPolicy: "reuse_runtime",
503+ importAuth: { kind: "none" },
483504 auth: input.auth,
484505 sourceHash: null,
485506 lastError: null,
@@ -523,7 +544,6 @@ const openApiArtifact = (input: { · 37 unmodified lines
523544 inputSchemaJson: input.inputSchemaJson ?? null,
524545 outputSchemaJson: input.outputSchemaJson ?? null,
525546 providerKind: "openapi",
526− mcpToolName: null,
527547 openApiMethod: input.method,
528548 openApiPathTemplate: input.pathTemplate,
529549 openApiOperationHash: input.operationHash ?? input.toolId,
@@ -759,7 +779,8 @@ describe("source runtime", () => { · 229 unmodified lines
759779 "Workflow:",
760780 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
761781 "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).",
763784 "Do not use fetch; use tools.* only.",
764785 ].join("\n"),
765786 );
@@ -828,7 +849,8 @@ describe("source runtime", () => { · 62 unmodified lines
828849 "Workflow:",
829850 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
830851 "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).",
832854 "Do not use fetch; use tools.* only.",
833855 ].join("\n"),
834856 );
packages/react/src/index.test.tsx+35−0
@@ -702,4 +702,39 @@ describe("executor-react source hooks", () => { · 701 unmodified lines
702702 }
703703 }),
704704 );
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+ );
705740 });
packages/react/src/index.ts+70−0
@@ -19,6 +19,7 @@ import type { · 18 unmodified lines
1919 SourceDiscoveryResult,
2020 SourceInspection,
2121 SourceInspectionDiscoverResult,
22+ SourceInspectionSchemaBundle,
2223 SourceInspectionToolDetail,
2324 StartSourceOAuthPayload,
2425 StartSourceOAuthResult,
@@ -58,6 +59,13 @@ type SourceToolDetailKeyParts = readonly [ · 33 unmodified lines
5859 Source["id"],
5960 string | null,
6061 ];
62+type SourceSchemaBundleKeyParts = readonly [
63+ boolean,
64+ Source["workspaceId"],
65+ string,
66+ Source["id"],
67+ string | null,
68+];
6169 type SourceDiscoveryKeyParts = readonly [
6270 boolean,
6371 Source["workspaceId"],
@@ -78,6 +86,7 @@ type ActiveQueryCollections = { · 14 unmodified lines
7886 sources: Set<string>;
7987 inspections: Set<string>;
8088 toolDetails: Set<string>;
89+ schemaBundles: Set<string>;
8190 discoveries: Set<string>;
8291 };
8392
@@ -153,6 +162,17 @@ const encodeToolDetailKey = ( · 69 unmodified lines
153162 ): string =>
154163 encodeAtomKey([enabled, workspaceId, accountId, sourceId, toolPath] satisfies SourceToolDetailKeyParts);
155164
165+const 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+
156176 const encodeDiscoveryKey = (
157177 enabled: boolean,
158178 workspaceId: Source["workspaceId"],
@@ -335,6 +355,25 @@ const sourceInspectionToolAtom = Atom.family((key: string) => { · 176 unmodified lines
335355 ).pipe(Atom.keepAlive);
336356 });
337357
358+const 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+
338377 const sourceDiscoveryAtom = Atom.family((key: string) => {
339378 const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key);
340379
@@ -496,6 +535,7 @@ const createActiveQueryCollections = (): ActiveQueryCollections => ({ · 155 unmodified lines
496535 sources: new Set(),
497536 inspections: new Set(),
498537 toolDetails: new Set(),
538+ schemaBundles: new Set(),
499539 discoveries: new Set(),
500540 });
501541
@@ -550,6 +590,13 @@ const invalidateTrackedQueries = ( · 48 unmodified lines
550590 }
551591 });
552592
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+
553600 activeQueries.discoveries.forEach((key) => {
554601 const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key);
555602 if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
@@ -625,6 +672,8 @@ const createOptimisticSource = (input: { · 69 unmodified lines
625672 headers: input.payload.headers ?? null,
626673 specUrl: input.payload.specUrl ?? null,
627674 defaultHeaders: input.payload.defaultHeaders ?? null,
675+ importAuthPolicy: input.payload.importAuthPolicy ?? "reuse_runtime",
676+ importAuth: input.payload.importAuth ?? { kind: "none" },
628677 auth: input.payload.auth ?? { kind: "none" },
629678 sourceHash: input.payload.sourceHash ?? null,
630679 lastError: input.payload.lastError ?? null,
@@ -937,6 +986,27 @@ export const useSourceToolDetail = ( · 306 unmodified lines
937986 return workspace.enabled ? detail : pendingLoadable(workspace.workspace);
938987 };
939988
989+export 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+
9401010 export const useSourceDiscovery = (input: {
9411011 sourceId: string;
9421012 query: string;