Unified · Split
| @@ -1372,7 +1372,7 @@ describe("local-executor-server", () => { · 1371 unmodified lines | |||
| 1372 | const manifestJson = JSON.stringify(manifest); | 1372 | const manifestJson = JSON.stringify(manifest); |
| 1373 | yield* server.runtime.persistence.rows.sourceRecipes.upsert({ | 1373 | yield* server.runtime.persistence.rows.sourceRecipes.upsert({ |
| 1374 | id: recipeId, | 1374 | id: recipeId, |
| 1375 | kind: "mcp_recipe", | 1375 | kind: "mcp", |
| 1376 | importerKind: "mcp_manifest", | 1376 | importerKind: "mcp_manifest", |
| 1377 | providerKey: "generic_mcp", | 1377 | providerKey: "generic_mcp", |
| 1378 | name: "Demo", | 1378 | name: "Demo", |
| @@ -1395,6 +1395,7 @@ describe("local-executor-server", () => { · 16 unmodified lines | |||
| 1395 | }), | 1395 | }), |
| 1396 | manifestJson, | 1396 | manifestJson, |
| 1397 | manifestHash: null, | 1397 | manifestHash: null, |
| 1398 | materializationHash: null, | ||
| 1398 | createdAt: now, | 1399 | createdAt: now, |
| 1399 | updatedAt: now, | 1400 | updatedAt: now, |
| 1400 | }); | 1401 | }); |
| @@ -1409,14 +1410,14 @@ describe("local-executor-server", () => { · 8 unmodified lines | |||
| 1409 | status: "connected", | 1410 | status: "connected", |
| 1410 | enabled: true, | 1411 | enabled: true, |
| 1411 | namespace: "demo", | 1412 | namespace: "demo", |
| 1412 | transport: "streamable-http", | 1413 | importAuthPolicy: "reuse_runtime", |
| 1413 | bindingConfigJson: null, | 1414 | bindingConfigJson: JSON.stringify({ |
| 1414 | queryParamsJson: null, | 1415 | adapterKey: "mcp", |
| 1415 | headersJson: null, | 1416 | transport: "streamable-http", |
| 1416 | specUrl: null, | 1417 | queryParams: null, |
| 1417 | defaultHeadersJson: null, | 1418 | headers: null, |
| 1419 | }), | ||
| 1418 | sourceHash: null, | 1420 | sourceHash: null, |
| 1419 | sourceDocumentText: null, | ||
| 1420 | lastError: null, | 1421 | lastError: null, |
| 1421 | createdAt: now, | 1422 | createdAt: now, |
| 1422 | updatedAt: now, | 1423 | updatedAt: now, |
| @@ -1450,17 +1451,12 @@ describe("local-executor-server", () => { · 27 unmodified lines | |||
| 1450 | inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null, | 1451 | inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null, |
| 1451 | outputSchemaJson: null, | 1452 | outputSchemaJson: null, |
| 1452 | providerKind: "mcp", | 1453 | providerKind: "mcp", |
| 1453 | providerDataJson: null, | 1454 | providerDataJson: JSON.stringify({ |
| 1454 | mcpToolName: "gated_echo", | 1455 | kind: "mcp", |
| 1455 | openApiMethod: null, | 1456 | toolId: "gated_echo", |
| 1456 | openApiPathTemplate: null, | 1457 | toolName: "gated_echo", |
| 1457 | openApiOperationHash: null, | 1458 | description: "Asks for approval before echoing a value", |
| 1458 | openApiRawToolId: null, | 1459 | }), |
| 1459 | openApiOperationId: null, | ||
| 1460 | openApiTagsJson: null, | ||
| 1461 | openApiRequestBodyRequired: null, | ||
| 1462 | graphqlOperationType: null, | ||
| 1463 | graphqlOperationName: null, | ||
| 1464 | createdAt: now, | 1460 | createdAt: now, |
| 1465 | updatedAt: now, | 1461 | updatedAt: now, |
| 1466 | }], | 1462 | }], |
| @@ -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 | } | ||
| @@ -22,12 +22,12 @@ import { AddSourcePage } from "./views/add-source"; · 21 unmodified lines | |||
| 22 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| 23 | 23 | ||
| 24 | type SourceRouteSearch = { | 24 | type SourceRouteSearch = { |
| 25 | tab: "model" | "discover" | "manifest" | "definitions" | "raw"; | 25 | tab: "model" | "discover"; |
| 26 | tool?: string; | 26 | tool?: string; |
| 27 | query?: string; | 27 | query?: string; |
| 28 | }; | 28 | }; |
| 29 | 29 | ||
| 30 | const sourceTabs = ["model", "discover", "manifest", "definitions", "raw"] as const; | 30 | const sourceTabs = ["model", "discover"] as const; |
| 31 | 31 | ||
| 32 | // --------------------------------------------------------------------------- | 32 | // --------------------------------------------------------------------------- |
| 33 | // Routes | 33 | // Routes |
| @@ -217,7 +217,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 216 unmodified lines | |||
| 217 | 217 | ||
| 218 | const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({ | 218 | const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({ |
| 219 | ...defaultConnectForm(), | 219 | ...defaultConnectForm(), |
| 220 | kind: template.kind, | 220 | kind: template.kind as ConnectFormState["kind"], |
| 221 | endpoint: template.endpoint, | 221 | endpoint: template.endpoint, |
| 222 | specUrl: "specUrl" in template ? template.specUrl : "", | 222 | specUrl: "specUrl" in template ? template.specUrl : "", |
| 223 | name: template.name, | 223 | name: template.name, |
| @@ -1,11 +1,14 @@ | |||
| 1 | import { useState, useEffect, useCallback, useRef, useMemo } from "react"; | 1 | import { useState, useEffect, useCallback, useRef, useMemo } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { | 3 | import { |
| 4 | useSource, | ||
| 5 | useSources, | ||
| 4 | useSourceInspection, | 6 | useSourceInspection, |
| 7 | useSourceSchemaBundle, | ||
| 5 | useSourceToolDetail, | 8 | useSourceToolDetail, |
| 6 | useSourceDiscovery, | 9 | useSourceDiscovery, |
| 7 | usePrefetchToolDetail, | ||
| 8 | type Loadable, | 10 | type Loadable, |
| 11 | type Source, | ||
| 9 | type SourceInspection, | 12 | type SourceInspection, |
| 10 | type SourceInspectionToolDetail, | 13 | type SourceInspectionToolDetail, |
| 11 | type SourceInspectionDiscoverResult, | 14 | type SourceInspectionDiscoverResult, |
| @@ -15,6 +18,7 @@ import { Badge, MethodBadge } from "../components/ui/badge"; · 3 unmodified lines | |||
| 15 | import { Button } from "../components/ui/button"; | 18 | import { Button } from "../components/ui/button"; |
| 16 | import { LoadableBlock, EmptyState } from "../components/loadable"; | 19 | import { LoadableBlock, EmptyState } from "../components/loadable"; |
| 17 | import { DocumentPanel } from "../components/document-panel"; | 20 | import { DocumentPanel } from "../components/document-panel"; |
| 21 | import { SourceNotFoundState } from "../components/source-not-found-state"; | ||
| 18 | import { | 22 | import { |
| 19 | IconSearch, | 23 | IconSearch, |
| 20 | IconChevron, | 24 | IconChevron, |
| @@ -32,7 +36,7 @@ import { Markdown } from "../components/markdown"; · 11 unmodified lines | |||
| 32 | // --------------------------------------------------------------------------- | 36 | // --------------------------------------------------------------------------- |
| 33 | 37 | ||
| 34 | type SourceRouteSearch = { | 38 | type SourceRouteSearch = { |
| 35 | tab: "model" | "discover" | "manifest" | "definitions" | "raw"; | 39 | tab: "model" | "discover"; |
| 36 | tool?: string; | 40 | tool?: string; |
| 37 | query?: string; | 41 | query?: string; |
| 38 | }; | 42 | }; |
| @@ -42,6 +46,122 @@ const visibleTabs: Array<{ id: SourceRouteSearch["tab"]; label: string }> = [ · 3 unmodified lines | |||
| 42 | { id: "discover", label: "Search" }, | 46 | { id: "discover", label: "Search" }, |
| 43 | ]; | 47 | ]; |
| 44 | 48 | ||
| 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 | |||
| 45 | // --------------------------------------------------------------------------- | 165 | // --------------------------------------------------------------------------- |
| 46 | // SourceDetailPage (main export) | 166 | // SourceDetailPage (main export) |
| 47 | // --------------------------------------------------------------------------- | 167 | // --------------------------------------------------------------------------- |
| @@ -52,7 +172,13 @@ export function SourceDetailPage(props: { · 4 unmodified lines | |||
| 52 | navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void; | 172 | navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void; |
| 53 | }) { | 173 | }) { |
| 54 | const { sourceId, search, navigate } = props; | 174 | const { sourceId, search, navigate } = props; |
| 175 | const sources = useSources(); | ||
| 176 | const source = useSource(sourceId); | ||
| 55 | const inspection = useSourceInspection(sourceId); | 177 | const inspection = useSourceInspection(sourceId); |
| 178 | const missingSource = | ||
| 179 | listExcludesSource(sources, sourceId) | ||
| 180 | || isSourceNotFoundError(source) | ||
| 181 | || isSourceNotFoundError(inspection); | ||
| 56 | 182 | ||
| 57 | const selectedToolPath = | 183 | const selectedToolPath = |
| 58 | search.tool | 184 | search.tool |
| @@ -77,6 +203,10 @@ export function SourceDetailPage(props: { · 18 unmodified lines | |||
| 77 | void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true }); | 203 | void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true }); |
| 78 | }, [inspection, navigate, search.tab, search.tool]); | 204 | }, [inspection, navigate, search.tab, search.tool]); |
| 79 | 205 | ||
| 206 | if (missingSource) { | ||
| 207 | return <SourceNotFoundState />; | ||
| 208 | } | ||
| 209 | |||
| 80 | return ( | 210 | return ( |
| 81 | <LoadableBlock loadable={inspection} loading="Loading source..."> | 211 | <LoadableBlock loadable={inspection} loading="Loading source..."> |
| 82 | {(bundle) => { | 212 | {(bundle) => { |
| @@ -151,21 +281,6 @@ export function SourceDetailPage(props: { · 68 unmodified lines | |||
| 151 | } | 281 | } |
| 152 | /> | 282 | /> |
| 153 | )} | 283 | )} |
| 154 | {search.tab === "manifest" && ( | ||
| 155 | <div className="flex-1 overflow-y-auto p-4"> | ||
| 156 | <DocumentPanel title="Manifest" body={bundle.manifestJson} empty="No manifest available." /> | ||
| 157 | </div> | ||
| 158 | )} | ||
| 159 | {search.tab === "definitions" && ( | ||
| 160 | <div className="flex-1 overflow-y-auto p-4"> | ||
| 161 | <DocumentPanel title="Definitions" body={bundle.definitionsJson} empty="No definitions available." /> | ||
| 162 | </div> | ||
| 163 | )} | ||
| 164 | {search.tab === "raw" && ( | ||
| 165 | <div className="flex-1 overflow-y-auto p-4"> | ||
| 166 | <DocumentPanel title="Raw document" body={bundle.rawDocumentText} empty="No raw document." /> | ||
| 167 | </div> | ||
| 168 | )} | ||
| 169 | </div> | 284 | </div> |
| 170 | </div> | 285 | </div> |
| 171 | ); | 286 | ); |
| @@ -191,7 +306,7 @@ function ModelView(props: { · 19 unmodified lines | |||
| 191 | 306 | ||
| 192 | const filteredTools = props.bundle.tools.filter((tool) => { | 307 | const filteredTools = props.bundle.tools.filter((tool) => { |
| 193 | if (terms.length === 0) return true; | 308 | if (terms.length === 0) return true; |
| 194 | const corpus = [tool.path, tool.description ?? "", tool.title ?? "", tool.method ?? ""] | 309 | const corpus = [tool.path, tool.method ?? ""] |
| 195 | .join(" ") | 310 | .join(" ") |
| 196 | .toLowerCase(); | 311 | .toLowerCase(); |
| 197 | return terms.every((t) => corpus.includes(t)); | 312 | return terms.every((t) => corpus.includes(t)); |
| @@ -258,7 +373,6 @@ function ModelView(props: { · 60 unmodified lines | |||
| 258 | onSelectTool={props.onSelectTool} | 373 | onSelectTool={props.onSelectTool} |
| 259 | search={search} | 374 | search={search} |
| 260 | isFiltered={terms.length > 0} | 375 | isFiltered={terms.length > 0} |
| 261 | sourceId={props.sourceId} | ||
| 262 | /> | 376 | /> |
| 263 | </div> | 377 | </div> |
| 264 | )} | 378 | )} |
| @@ -270,7 +384,7 @@ function ModelView(props: { · 5 unmodified lines | |||
| 270 | <LoadableBlock loadable={props.detail} loading="Loading tool..."> | 384 | <LoadableBlock loadable={props.detail} loading="Loading tool..."> |
| 271 | {(detail) => | 385 | {(detail) => |
| 272 | detail ? ( | 386 | detail ? ( |
| 273 | <ToolDetailPanel detail={detail} /> | 387 | <ToolDetailPanel sourceId={props.sourceId} detail={detail} /> |
| 274 | ) : ( | 388 | ) : ( |
| 275 | <EmptyState | 389 | <EmptyState |
| 276 | title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} | 390 | title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} |
| @@ -316,10 +430,8 @@ function ToolTree(props: { · 39 unmodified lines | |||
| 316 | onSelectTool: (path: string) => void; | 430 | onSelectTool: (path: string) => void; |
| 317 | search: string; | 431 | search: string; |
| 318 | isFiltered: boolean; | 432 | isFiltered: boolean; |
| 319 | sourceId: string; | ||
| 320 | }) { | 433 | }) { |
| 321 | const tree = useMemo(() => buildToolTree(props.tools), [props.tools]); | 434 | const tree = useMemo(() => buildToolTree(props.tools), [props.tools]); |
| 322 | const prefetch = usePrefetchToolDetail(); | ||
| 323 | const entries = [...tree.children.values()].sort((a, b) => | 435 | const entries = [...tree.children.values()].sort((a, b) => |
| 324 | a.segment.localeCompare(b.segment), | 436 | a.segment.localeCompare(b.segment), |
| 325 | ); | 437 | ); |
| @@ -335,8 +447,6 @@ function ToolTree(props: { · 9 unmodified lines | |||
| 335 | onSelectTool={props.onSelectTool} | 447 | onSelectTool={props.onSelectTool} |
| 336 | search={props.search} | 448 | search={props.search} |
| 337 | defaultOpen={props.isFiltered} | 449 | defaultOpen={props.isFiltered} |
| 338 | sourceId={props.sourceId} | ||
| 339 | prefetch={prefetch} | ||
| 340 | /> | 450 | /> |
| 341 | ))} | 451 | ))} |
| 342 | </div> | 452 | </div> |
| @@ -350,10 +460,8 @@ function ToolTreeNodeView(props: { · 7 unmodified lines | |||
| 350 | onSelectTool: (path: string) => void; | 460 | onSelectTool: (path: string) => void; |
| 351 | search: string; | 461 | search: string; |
| 352 | defaultOpen: boolean; | 462 | defaultOpen: boolean; |
| 353 | sourceId: string; | ||
| 354 | prefetch: (sourceId: string, toolPath: string) => () => void; | ||
| 355 | }) { | 463 | }) { |
| 356 | const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen, sourceId, prefetch } = props; | 464 | const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen } = props; |
| 357 | const hasChildren = node.children.size > 0; | 465 | const hasChildren = node.children.size > 0; |
| 358 | const isLeaf = !!node.tool && !hasChildren; | 466 | const isLeaf = !!node.tool && !hasChildren; |
| 359 | 467 | ||
| @@ -383,8 +491,6 @@ function ToolTreeNodeView(props: { · 23 unmodified lines | |||
| 383 | onSelect={() => onSelectTool(node.tool!.path)} | 491 | onSelect={() => onSelectTool(node.tool!.path)} |
| 384 | search={search} | 492 | search={search} |
| 385 | depth={depth} | 493 | depth={depth} |
| 386 | sourceId={sourceId} | ||
| 387 | prefetch={prefetch} | ||
| 388 | /> | 494 | /> |
| 389 | ); | 495 | ); |
| 390 | } | 496 | } |
| @@ -420,8 +526,6 @@ function ToolTreeNodeView(props: { · 29 unmodified lines | |||
| 420 | search={search} | 526 | search={search} |
| 421 | depth={-1} | 527 | depth={-1} |
| 422 | className="flex-1 pl-1" | 528 | className="flex-1 pl-1" |
| 423 | sourceId={sourceId} | ||
| 424 | prefetch={prefetch} | ||
| 425 | /> | 529 | /> |
| 426 | </div> | 530 | </div> |
| 427 | ) : ( | 531 | ) : ( |
| @@ -469,8 +573,6 @@ function ToolTreeNodeView(props: { · 41 unmodified lines | |||
| 469 | onSelectTool={onSelectTool} | 573 | onSelectTool={onSelectTool} |
| 470 | search={search} | 574 | search={search} |
| 471 | defaultOpen={defaultOpen} | 575 | defaultOpen={defaultOpen} |
| 472 | sourceId={sourceId} | ||
| 473 | prefetch={prefetch} | ||
| 474 | /> | 576 | /> |
| 475 | ))} | 577 | ))} |
| 476 | </div> | 578 | </div> |
| @@ -498,12 +600,9 @@ function ToolListItem(props: { · 21 unmodified lines | |||
| 498 | search: string; | 600 | search: string; |
| 499 | depth: number; | 601 | depth: number; |
| 500 | className?: string; | 602 | className?: string; |
| 501 | sourceId: string; | ||
| 502 | prefetch: (sourceId: string, toolPath: string) => () => void; | ||
| 503 | }) { | 603 | }) { |
| 504 | const ref = useRef<HTMLButtonElement>(null); | 604 | const ref = useRef<HTMLButtonElement>(null); |
| 505 | const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined; | 605 | const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined; |
| 506 | const prefetchedRef = useRef(false); | ||
| 507 | 606 | ||
| 508 | useEffect(() => { | 607 | useEffect(() => { |
| 509 | if (props.active && ref.current) { | 608 | if (props.active && ref.current) { |
| @@ -511,24 +610,6 @@ function ToolListItem(props: { · 1 unmodified line | |||
| 511 | } | 610 | } |
| 512 | }, [props.active]); | 611 | }, [props.active]); |
| 513 | 612 | ||
| 514 | useEffect(() => { | ||
| 515 | const el = ref.current; | ||
| 516 | if (!el || prefetchedRef.current) return; | ||
| 517 | |||
| 518 | const observer = new IntersectionObserver( | ||
| 519 | (entries) => { | ||
| 520 | if (entries[0]?.isIntersecting && !prefetchedRef.current) { | ||
| 521 | prefetchedRef.current = true; | ||
| 522 | props.prefetch(props.sourceId, props.tool.path); | ||
| 523 | observer.disconnect(); | ||
| 524 | } | ||
| 525 | }, | ||
| 526 | { threshold: 0 }, | ||
| 527 | ); | ||
| 528 | observer.observe(el); | ||
| 529 | return () => observer.disconnect(); | ||
| 530 | }, [props.prefetch, props.sourceId, props.tool.path]); | ||
| 531 | |||
| 532 | const label = props.depth >= 0 | 613 | const label = props.depth >= 0 |
| 533 | ? props.tool.path.split(".").pop() ?? props.tool.path | 614 | ? props.tool.path.split(".").pop() ?? props.tool.path |
| 534 | : props.tool.path; | 615 | : props.tool.path; |
| @@ -560,9 +641,14 @@ function ToolListItem(props: { · 25 unmodified lines | |||
| 560 | // ToolDetailPanel | 641 | // ToolDetailPanel |
| 561 | // --------------------------------------------------------------------------- | 642 | // --------------------------------------------------------------------------- |
| 562 | 643 | ||
| 563 | function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { | 644 | function ToolDetailPanel(props: { sourceId: string; detail: SourceInspectionToolDetail }) { |
| 564 | const { detail } = props; | 645 | const { detail } = props; |
| 565 | const [copiedField, setCopiedField] = useState<string | null>(null); | 646 | const [copiedField, setCopiedField] = useState<string | null>(null); |
| 647 | const [resolveRefs, setResolveRefs] = useState(false); | ||
| 648 | const schemaBundle = useSourceSchemaBundle( | ||
| 649 | props.sourceId, | ||
| 650 | resolveRefs ? detail.schemaBundleId : null, | ||
| 651 | ); | ||
| 566 | 652 | ||
| 567 | const copy = useCallback((text: string, field: string) => { | 653 | const copy = useCallback((text: string, field: string) => { |
| 568 | void navigator.clipboard.writeText(text).then(() => { | 654 | void navigator.clipboard.writeText(text).then(() => { |
| @@ -571,6 +657,43 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 2 unmodified lines | |||
| 571 | }); | 657 | }); |
| 572 | }, []); | 658 | }, []); |
| 573 | 659 | ||
| 660 | useEffect(() => { | ||
| 661 | setResolveRefs(false); | ||
| 662 | }, [detail.summary.path]); | ||
| 663 | |||
| 664 | const resolvedSchemas = useMemo(() => { | ||
| 665 | if (!resolveRefs || schemaBundle.status !== "ready" || schemaBundle.data === null) { | ||
| 666 | return { | ||
| 667 | inputSchemaJson: detail.inputSchemaJson, | ||
| 668 | outputSchemaJson: detail.outputSchemaJson, | ||
| 669 | }; | ||
| 670 | } | ||
| 671 | |||
| 672 | try { | ||
| 673 | const refHintTable = JSON.parse(schemaBundle.data.refsJson) as Record<string, unknown>; | ||
| 674 | return { | ||
| 675 | inputSchemaJson: resolveSchemaJsonWithRefHints( | ||
| 676 | detail.inputSchemaJson ?? undefined, | ||
| 677 | refHintTable, | ||
| 678 | ) ?? detail.inputSchemaJson, | ||
| 679 | outputSchemaJson: resolveSchemaJsonWithRefHints( | ||
| 680 | detail.outputSchemaJson ?? undefined, | ||
| 681 | refHintTable, | ||
| 682 | ) ?? detail.outputSchemaJson, | ||
| 683 | }; | ||
| 684 | } catch { | ||
| 685 | return { | ||
| 686 | inputSchemaJson: detail.inputSchemaJson, | ||
| 687 | outputSchemaJson: detail.outputSchemaJson, | ||
| 688 | }; | ||
| 689 | } | ||
| 690 | }, [ | ||
| 691 | detail.inputSchemaJson, | ||
| 692 | detail.outputSchemaJson, | ||
| 693 | resolveRefs, | ||
| 694 | schemaBundle, | ||
| 695 | ]); | ||
| 696 | |||
| 574 | return ( | 697 | return ( |
| 575 | <div className="flex h-full flex-col overflow-hidden"> | 698 | <div className="flex h-full flex-col overflow-hidden"> |
| 576 | {/* Header */} | 699 | {/* Header */} |
| @@ -613,9 +736,30 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 36 unmodified lines | |||
| 613 | </div> | 736 | </div> |
| 614 | 737 | ||
| 615 | {/* Schemas */} | 738 | {/* Schemas */} |
| 739 | {detail.schemaBundleId && ( | ||
| 740 | <div className="flex items-center justify-between rounded-md border border-border bg-card/40 px-3 py-2"> | ||
| 741 | <div className="text-[12px] text-muted-foreground"> | ||
| 742 | {resolveRefs | ||
| 743 | ? schemaBundle.status === "loading" | ||
| 744 | ? "Loading shared refs..." | ||
| 745 | : schemaBundle.status === "error" | ||
| 746 | ? "Showing compact root schemas because shared refs failed to load." | ||
| 747 | : "Showing schemas with shared refs resolved client-side." | ||
| 748 | : "Showing compact root schemas. Shared refs are available on demand."} | ||
| 749 | </div> | ||
| 750 | <Button | ||
| 751 | type="button" | ||
| 752 | size="sm" | ||
| 753 | variant="outline" | ||
| 754 | onClick={() => setResolveRefs((value) => !value)} | ||
| 755 | > | ||
| 756 | {resolveRefs ? "Show compact roots" : "Resolve shared refs"} | ||
| 757 | </Button> | ||
| 758 | </div> | ||
| 759 | )} | ||
| 616 | <div className="grid grid-cols-1 gap-2 xl:grid-cols-2"> | 760 | <div className="grid grid-cols-1 gap-2 xl:grid-cols-2"> |
| 617 | <DocumentPanel title="Input schema" body={detail.inputSchemaJson} empty="No input schema." compact /> | 761 | <DocumentPanel title="Input schema" body={resolvedSchemas.inputSchemaJson} empty="No input schema." compact /> |
| 618 | <DocumentPanel title="Output schema" body={detail.outputSchemaJson} empty="No output schema." compact /> | 762 | <DocumentPanel title="Output schema" body={resolvedSchemas.outputSchemaJson} empty="No output schema." compact /> |
| 619 | {detail.exampleInputJson && ( | 763 | {detail.exampleInputJson && ( |
| 620 | <DocumentPanel title="Example request" body={detail.exampleInputJson} empty="" compact /> | 764 | <DocumentPanel title="Example request" body={detail.exampleInputJson} empty="" compact /> |
| 621 | )} | 765 | )} |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | |||
| 15 | useRefreshSecrets, | 15 | useRefreshSecrets, |
| 16 | useRemoveSource, | 16 | useRemoveSource, |
| 17 | useSecrets, | 17 | useSecrets, |
| 18 | useSources, | ||
| 18 | useStartSourceOAuth, | 19 | useStartSourceOAuth, |
| 19 | useSource, | 20 | useSource, |
| 20 | useUpdateSource, | 21 | useUpdateSource, |
| @@ -22,6 +23,7 @@ import { · 1 unmodified line | |||
| 22 | import { Badge } from "../components/ui/badge"; | 23 | import { Badge } from "../components/ui/badge"; |
| 23 | import { Button } from "../components/ui/button"; | 24 | import { Button } from "../components/ui/button"; |
| 24 | import { LoadableBlock } from "../components/loadable"; | 25 | import { LoadableBlock } from "../components/loadable"; |
| 26 | import { SourceNotFoundState } from "../components/source-not-found-state"; | ||
| 25 | import { SourceFavicon } from "../components/source-favicon"; | 27 | import { SourceFavicon } from "../components/source-favicon"; |
| 26 | import { | 28 | import { |
| 27 | IconArrowLeft, | 29 | IconArrowLeft, |
| @@ -56,6 +58,10 @@ type SourceOAuthPopupMessage = · 28 unmodified lines | |||
| 56 | const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; | 58 | const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; |
| 57 | const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | 59 | const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; |
| 58 | 60 | ||
| 61 | const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean => | ||
| 62 | loadable.status === "error" | ||
| 63 | && loadable.error.message.toLowerCase().includes("source not found"); | ||
| 64 | |||
| 59 | type TransportValue = "" | NonNullable<Source["transport"]>; | 65 | type TransportValue = "" | NonNullable<Source["transport"]>; |
| 60 | 66 | ||
| 61 | type SourceFormState = { | 67 | type SourceFormState = { |
| @@ -495,7 +501,16 @@ export function NewSourcePage() { · 433 unmodified lines | |||
| 495 | } | 501 | } |
| 496 | 502 | ||
| 497 | export function EditSourcePage(props: { sourceId: string }) { | 503 | export function EditSourcePage(props: { sourceId: string }) { |
| 504 | const sources = useSources(); | ||
| 498 | const source = useSource(props.sourceId); | 505 | const source = useSource(props.sourceId); |
| 506 | const missingSource = | ||
| 507 | (sources.status === "ready" | ||
| 508 | && !sources.data.some((candidate) => candidate.id === props.sourceId)) | ||
| 509 | || isSourceNotFoundLoadable(source); | ||
| 510 | |||
| 511 | if (missingSource) { | ||
| 512 | return <SourceNotFoundState />; | ||
| 513 | } | ||
| 499 | 514 | ||
| 500 | return ( | 515 | return ( |
| 501 | <LoadableBlock loadable={source} loading="Loading source..."> | 516 | <LoadableBlock loadable={source} loading="Loading source..."> |
| @@ -111,6 +111,7 @@ export function createToolCatalogFromTools(input: { · 110 unmodified lines | |||
| 111 | }) | 111 | }) |
| 112 | : null, | 112 | : null, |
| 113 | ), | 113 | ), |
| 114 | getSchemaBundle: () => Effect.succeed(null), | ||
| 114 | searchTools: ({ query, namespace, limit }) => { | 115 | searchTools: ({ query, namespace, limit }) => { |
| 115 | const queryTokens = tokenize(query); | 116 | const queryTokens = tokenize(query); |
| 116 | 117 | ||
| @@ -184,6 +185,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 67 unmodified lines | |||
| 184 | const describe: DescribePrimitive = { | 185 | const describe: DescribePrimitive = { |
| 185 | tool: ({ path, includeSchemas = false }) => | 186 | tool: ({ path, includeSchemas = false }) => |
| 186 | catalog.getToolByPath({ path, includeSchemas }), | 187 | catalog.getToolByPath({ path, includeSchemas }), |
| 188 | schemaBundle: ({ id }) => catalog.getSchemaBundle({ id }), | ||
| 187 | }; | 189 | }; |
| 188 | 190 | ||
| 189 | const discover: DiscoverPrimitive = ({ | 191 | const discover: DiscoverPrimitive = ({ |
| @@ -234,6 +236,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 44 unmodified lines | |||
| 234 | ? { | 236 | ? { |
| 235 | inputSchemaJson: descriptor.inputSchemaJson, | 237 | inputSchemaJson: descriptor.inputSchemaJson, |
| 236 | outputSchemaJson: descriptor.outputSchemaJson, | 238 | outputSchemaJson: descriptor.outputSchemaJson, |
| 239 | schemaBundleId: descriptor.schemaBundleId, | ||
| 237 | } | 240 | } |
| 238 | : {}), | 241 | : {}), |
| 239 | }; | 242 | }; |
| @@ -296,7 +299,8 @@ export function buildDynamicExecuteDescription(input: { · 56 unmodified lines | |||
| 296 | "Workflow:", | 299 | "Workflow:", |
| 297 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | 300 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 298 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | 301 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 299 | "3) Call selected tools.<path>(input).", | 302 | "3) If details.schemaBundleId is present, fetch it once with tools.describe.schemaBundle({ id }).", |
| 303 | "4) Call selected tools.<path>(input).", | ||
| 300 | "Do not use fetch; use tools.* only.", | 304 | "Do not use fetch; use tools.* only.", |
| 301 | ].join("\n"); | 305 | ].join("\n"); |
| 302 | }); | 306 | }); |
| @@ -331,6 +331,7 @@ describe("codemode-core", () => { · 330 unmodified lines | |||
| 331 | ), | 331 | ), |
| 332 | getToolByPath: ({ path }) => | 332 | getToolByPath: ({ path }) => |
| 333 | Effect.succeed(descriptors[path] ?? null), | 333 | Effect.succeed(descriptors[path] ?? null), |
| 334 | getSchemaBundle: () => Effect.succeed(null), | ||
| 334 | searchTools: () => | 335 | searchTools: () => |
| 335 | Effect.succeed([ | 336 | Effect.succeed([ |
| 336 | { path: asToolPath("source.issues.create"), score: 0.93 }, | 337 | { path: asToolPath("source.issues.create"), score: 0.93 }, |
| @@ -51,6 +51,7 @@ const catalogToolsOutputSchema = Schema.standardSchemaV1( · 50 unmodified lines | |||
| 51 | outputType: Schema.optional(Schema.String), | 51 | outputType: Schema.optional(Schema.String), |
| 52 | inputSchemaJson: Schema.optional(Schema.String), | 52 | inputSchemaJson: Schema.optional(Schema.String), |
| 53 | outputSchemaJson: Schema.optional(Schema.String), | 53 | outputSchemaJson: Schema.optional(Schema.String), |
| 54 | schemaBundleId: Schema.optional(Schema.String), | ||
| 54 | exampleInputJson: Schema.optional(Schema.String), | 55 | exampleInputJson: Schema.optional(Schema.String), |
| 55 | exampleOutputJson: Schema.optional(Schema.String), | 56 | exampleOutputJson: Schema.optional(Schema.String), |
| 56 | providerKind: Schema.optional(Schema.String), | 57 | providerKind: Schema.optional(Schema.String), |
| @@ -71,6 +72,23 @@ const describeToolOutputSchema = Schema.standardSchemaV1( · 14 unmodified lines | |||
| 71 | Schema.NullOr(Schema.Unknown), | 72 | Schema.NullOr(Schema.Unknown), |
| 72 | ); | 73 | ); |
| 73 | 74 | ||
| 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 | |||
| 74 | const discoverInputSchema = Schema.standardSchemaV1( | 92 | const discoverInputSchema = Schema.standardSchemaV1( |
| 75 | Schema.Struct({ | 93 | Schema.Struct({ |
| 76 | query: Schema.String, | 94 | query: Schema.String, |
| @@ -88,6 +106,7 @@ const discoverResultItemSchema = Schema.Struct({ · 11 unmodified lines | |||
| 88 | outputType: Schema.optional(Schema.String), | 106 | outputType: Schema.optional(Schema.String), |
| 89 | inputSchemaJson: Schema.optional(Schema.String), | 107 | inputSchemaJson: Schema.optional(Schema.String), |
| 90 | outputSchemaJson: Schema.optional(Schema.String), | 108 | outputSchemaJson: Schema.optional(Schema.String), |
| 109 | schemaBundleId: Schema.optional(Schema.String), | ||
| 91 | }); | 110 | }); |
| 92 | 111 | ||
| 93 | const discoverOutputSchema = Schema.standardSchemaV1( | 112 | const discoverOutputSchema = Schema.standardSchemaV1( |
| @@ -182,6 +201,24 @@ export const createSystemToolMap = ( · 88 unmodified lines | |||
| 182 | interaction: "auto", | 201 | interaction: "auto", |
| 183 | }, | 202 | }, |
| 184 | }); | 203 | }); |
| 204 | |||
| 205 | tools["describe.schemaBundle"] = toTool({ | ||
| 206 | tool: { | ||
| 207 | description: "Get a shared schema bundle by id", | ||
| 208 | inputSchema: describeSchemaBundleInputSchema, | ||
| 209 | outputSchema: describeSchemaBundleOutputSchema, | ||
| 210 | execute: ({ id }: { id: string }) => | ||
| 211 | Effect.runPromise( | ||
| 212 | primitives.describe!.schemaBundle({ | ||
| 213 | id, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | }, | ||
| 217 | metadata: { | ||
| 218 | sourceKey, | ||
| 219 | interaction: "auto", | ||
| 220 | }, | ||
| 221 | }); | ||
| 185 | } | 222 | } |
| 186 | 223 | ||
| 187 | if (primitives.discover) { | 224 | if (primitives.discover) { |
| @@ -438,6 +438,7 @@ export function toolDescriptorsFromTools(input: { · 437 unmodified lines | |||
| 438 | metadata?.outputType ?? inferTypeFromSchemaJson(outputSchemaJson, "unknown"), | 438 | metadata?.outputType ?? inferTypeFromSchemaJson(outputSchemaJson, "unknown"), |
| 439 | inputSchemaJson, | 439 | inputSchemaJson, |
| 440 | outputSchemaJson, | 440 | outputSchemaJson, |
| 441 | ...(metadata?.schemaBundleId ? { schemaBundleId: metadata.schemaBundleId } : {}), | ||
| 441 | ...(metadata?.exampleInputJson | 442 | ...(metadata?.exampleInputJson |
| 442 | ? { exampleInputJson: metadata.exampleInputJson } | 443 | ? { exampleInputJson: metadata.exampleInputJson } |
| 443 | : {}), | 444 | : {}), |
| @@ -81,6 +81,7 @@ export type ToolMetadata = { · 80 unmodified lines | |||
| 81 | outputType?: string; | 81 | outputType?: string; |
| 82 | inputSchemaJson?: string; | 82 | inputSchemaJson?: string; |
| 83 | outputSchemaJson?: string; | 83 | outputSchemaJson?: string; |
| 84 | schemaBundleId?: string; | ||
| 84 | exampleInputJson?: string; | 85 | exampleInputJson?: string; |
| 85 | exampleOutputJson?: string; | 86 | exampleOutputJson?: string; |
| 86 | sourceKey?: string; | 87 | sourceKey?: string; |
| @@ -134,12 +135,20 @@ export type ToolDescriptor = { · 47 unmodified lines | |||
| 134 | outputType?: string; | 135 | outputType?: string; |
| 135 | inputSchemaJson?: string; | 136 | inputSchemaJson?: string; |
| 136 | outputSchemaJson?: string; | 137 | outputSchemaJson?: string; |
| 138 | schemaBundleId?: string; | ||
| 137 | exampleInputJson?: string; | 139 | exampleInputJson?: string; |
| 138 | exampleOutputJson?: string; | 140 | exampleOutputJson?: string; |
| 139 | providerKind?: string; | 141 | providerKind?: string; |
| 140 | providerDataJson?: string; | 142 | providerDataJson?: string; |
| 141 | }; | 143 | }; |
| 142 | 144 | ||
| 145 | export type ToolSchemaBundle = { | ||
| 146 | id: string; | ||
| 147 | kind: string; | ||
| 148 | hash: string; | ||
| 149 | refsJson: string; | ||
| 150 | }; | ||
| 151 | |||
| 143 | export type ToolNamespace = { | 152 | export type ToolNamespace = { |
| 144 | namespace: string; | 153 | namespace: string; |
| 145 | displayName?: string; | 154 | displayName?: string; |
| @@ -168,6 +177,10 @@ export interface ToolCatalog { · 22 unmodified lines | |||
| 168 | includeSchemas: boolean; | 177 | includeSchemas: boolean; |
| 169 | }): Effect.Effect<ToolDescriptor | null, unknown>; | 178 | }): Effect.Effect<ToolDescriptor | null, unknown>; |
| 170 | 179 | ||
| 180 | getSchemaBundle(input: { | ||
| 181 | id: string; | ||
| 182 | }): Effect.Effect<ToolSchemaBundle | null, unknown>; | ||
| 183 | |||
| 171 | searchTools(input: { | 184 | searchTools(input: { |
| 172 | query: string; | 185 | query: string; |
| 173 | namespace?: string; | 186 | namespace?: string; |
| @@ -196,6 +209,9 @@ export type DescribePrimitive = { · 22 unmodified lines | |||
| 196 | path: ToolPath; | 209 | path: ToolPath; |
| 197 | includeSchemas?: boolean; | 210 | includeSchemas?: boolean; |
| 198 | }): Effect.Effect<ToolDescriptor | null, unknown>; | 211 | }): Effect.Effect<ToolDescriptor | null, unknown>; |
| 212 | schemaBundle(input: { | ||
| 213 | id: string; | ||
| 214 | }): Effect.Effect<ToolSchemaBundle | null, unknown>; | ||
| 199 | }; | 215 | }; |
| 200 | 216 | ||
| 201 | export type DiscoverPrimitive = (input: { | 217 | export type DiscoverPrimitive = (input: { |
| @@ -19,6 +19,7 @@ export { · 18 unmodified lines | |||
| 19 | } from "./openapi-tool-presentation"; | 19 | } from "./openapi-tool-presentation"; |
| 20 | export { | 20 | export { |
| 21 | OpenApiToolInvocationError, | 21 | OpenApiToolInvocationError, |
| 22 | createOpenApiToolFromDefinition, | ||
| 22 | createOpenApiToolsFromManifest, | 23 | createOpenApiToolsFromManifest, |
| 23 | createOpenApiToolsFromSpec, | 24 | createOpenApiToolsFromSpec, |
| 24 | } from "./openapi-tools"; | 25 | } from "./openapi-tools"; |
| @@ -39,6 +40,9 @@ export { · 14 unmodified lines | |||
| 39 | OpenApiRequestBodyDocumentationSchema, | 40 | OpenApiRequestBodyDocumentationSchema, |
| 40 | OpenApiResponseDocumentationSchema, | 41 | OpenApiResponseDocumentationSchema, |
| 41 | OpenApiToolDocumentationSchema, | 42 | OpenApiToolDocumentationSchema, |
| 43 | OpenApiToolProviderDataSchema, | ||
| 44 | OpenApiRefHintTableSchema, | ||
| 45 | OpenApiRefHintValueSchema, | ||
| 42 | type DiscoveryTypingPayload, | 46 | type DiscoveryTypingPayload, |
| 43 | type OpenApiExtractedTool, | 47 | type OpenApiExtractedTool, |
| 44 | type OpenApiHttpMethod, | 48 | type OpenApiHttpMethod, |
| @@ -49,6 +53,9 @@ export { · 4 unmodified lines | |||
| 49 | type OpenApiParameterLocation, | 53 | type OpenApiParameterLocation, |
| 50 | type OpenApiSpecInput, | 54 | type OpenApiSpecInput, |
| 51 | type OpenApiToolManifest, | 55 | type OpenApiToolManifest, |
| 56 | type OpenApiToolProviderData, | ||
| 57 | type OpenApiRefHintTable, | ||
| 58 | type OpenApiRefHintValue, | ||
| 52 | type OpenApiToolParameter, | 59 | type OpenApiToolParameter, |
| 53 | type OpenApiToolRequestBody, | 60 | type OpenApiToolRequestBody, |
| 54 | } from "./openapi-types"; | 61 | } from "./openapi-types"; |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | |||
| 5 | OpenApiHttpMethodSchema, | 5 | OpenApiHttpMethodSchema, |
| 6 | OpenApiInvocationPayloadSchema, | 6 | OpenApiInvocationPayloadSchema, |
| 7 | OpenApiToolDocumentationSchema, | 7 | OpenApiToolDocumentationSchema, |
| 8 | OpenApiToolProviderDataSchema, | ||
| 8 | type OpenApiExtractedTool, | 9 | type OpenApiExtractedTool, |
| 9 | type OpenApiToolManifest, | 10 | type OpenApiToolManifest, |
| 10 | } from "./openapi-types"; | 11 | } from "./openapi-types"; |
| @@ -250,5 +251,6 @@ export const openApiProviderDataJsonFromDefinition = ( · 239 unmodified lines | |||
| 250 | method: definition.method, | 251 | method: definition.method, |
| 251 | path: definition.path, | 252 | path: definition.path, |
| 252 | operationHash: definition.operationHash, | 253 | operationHash: definition.operationHash, |
| 254 | invocation: definition.invocation, | ||
| 253 | documentation: definition.documentation, | 255 | documentation: definition.documentation, |
| 254 | }); | 256 | } satisfies typeof OpenApiToolProviderDataSchema.Type); |
| @@ -1,4 +1,5 @@ | |||
| 1 | type JsonObject = Record<string, unknown>; | 1 | type JsonObject = Record<string, unknown>; |
| 2 | type RefHintValue = string | JsonObject; | ||
| 2 | 3 | ||
| 3 | type DiscoveryTypingLike = { | 4 | type DiscoveryTypingLike = { |
| 4 | inputSchemaJson?: string; | 5 | inputSchemaJson?: string; |
| @@ -18,7 +19,7 @@ const parseJson = (value: string): unknown | null => { · 13 unmodified lines | |||
| 18 | 19 | ||
| 19 | const resolveSchemaNode = ( | 20 | const resolveSchemaNode = ( |
| 20 | value: unknown, | 21 | value: unknown, |
| 21 | refHintTable: Readonly<Record<string, string>>, | 22 | refHintTable: Readonly<Record<string, RefHintValue>>, |
| 22 | parsedHintCache: Map<string, unknown>, | 23 | parsedHintCache: Map<string, unknown>, |
| 23 | activeRefs: ReadonlySet<string>, | 24 | activeRefs: ReadonlySet<string>, |
| 24 | ): unknown => { | 25 | ): unknown => { |
| @@ -38,7 +39,12 @@ const resolveSchemaNode = ( · 13 unmodified lines | |||
| 38 | 39 | ||
| 39 | if (resolvedRefValue === undefined) { | 40 | if (resolvedRefValue === undefined) { |
| 40 | const rawHint = refHintTable[ref]; | 41 | const rawHint = refHintTable[ref]; |
| 41 | resolvedRefValue = typeof rawHint === "string" ? parseJson(rawHint) : null; | 42 | resolvedRefValue = |
| 43 | typeof rawHint === "string" | ||
| 44 | ? parseJson(rawHint) | ||
| 45 | : isJsonObject(rawHint) | ||
| 46 | ? rawHint | ||
| 47 | : null; | ||
| 42 | parsedHintCache.set(ref, resolvedRefValue); | 48 | parsedHintCache.set(ref, resolvedRefValue); |
| 43 | } | 49 | } |
| 44 | 50 | ||
| @@ -88,7 +94,7 @@ const resolveSchemaNode = ( · 43 unmodified lines | |||
| 88 | 94 | ||
| 89 | export const resolveSchemaJsonWithRefHints = ( | 95 | export const resolveSchemaJsonWithRefHints = ( |
| 90 | schemaJson: string | undefined, | 96 | schemaJson: string | undefined, |
| 91 | refHintTable: Readonly<Record<string, string>> | undefined, | 97 | refHintTable: Readonly<Record<string, RefHintValue>> | undefined, |
| 92 | ): string | null => { | 98 | ): string | null => { |
| 93 | if (!schemaJson) { | 99 | if (!schemaJson) { |
| 94 | return null; | 100 | return null; |
| @@ -119,7 +125,7 @@ export const resolveSchemaJsonWithRefHints = ( · 24 unmodified lines | |||
| 119 | 125 | ||
| 120 | export const resolveTypingSchemasWithRefHints = ( | 126 | export const resolveTypingSchemasWithRefHints = ( |
| 121 | typing: DiscoveryTypingLike | undefined, | 127 | typing: DiscoveryTypingLike | undefined, |
| 122 | refHintTable: Readonly<Record<string, string>> | undefined, | 128 | refHintTable: Readonly<Record<string, RefHintValue>> | undefined, |
| 123 | ): { | 129 | ): { |
| 124 | inputSchemaJson: string | null; | 130 | inputSchemaJson: string | null; |
| 125 | outputSchemaJson: string | null; | 131 | outputSchemaJson: string | null; |
| @@ -1,14 +1,13 @@ | |||
| 1 | import { typeSignatureFromSchemaJson } from "@executor/codemode-core"; | 1 | import { typeSignatureFromSchemaJson } from "@executor/codemode-core"; |
| 2 | 2 | ||
| 3 | import { | ||
| 4 | resolveTypingSchemasWithRefHints, | ||
| 5 | } from "./openapi-schema-refs"; | ||
| 6 | import type { | 3 | import type { |
| 7 | OpenApiExample, | 4 | OpenApiExample, |
| 8 | OpenApiToolDocumentation, | 5 | OpenApiToolDocumentation, |
| 9 | OpenApiToolManifest, | ||
| 10 | } from "./openapi-types"; | 6 | } from "./openapi-types"; |
| 11 | import type { OpenApiToolDefinition } from "./openapi-definitions"; | 7 | import { |
| 8 | openApiProviderDataJsonFromDefinition, | ||
| 9 | type OpenApiToolDefinition, | ||
| 10 | } from "./openapi-definitions"; | ||
| 12 | 11 | ||
| 13 | const parseJson = (value: string): unknown | undefined => { | 12 | const parseJson = (value: string): unknown | undefined => { |
| 14 | try { | 13 | try { |
| @@ -104,15 +103,10 @@ export type OpenApiToolPresentation = { · 89 unmodified lines | |||
| 104 | }; | 103 | }; |
| 105 | 104 | ||
| 106 | export const buildOpenApiToolPresentation = (input: { | 105 | export const buildOpenApiToolPresentation = (input: { |
| 107 | manifest: OpenApiToolManifest; | ||
| 108 | definition: OpenApiToolDefinition; | 106 | definition: OpenApiToolDefinition; |
| 109 | }): OpenApiToolPresentation => { | 107 | }): OpenApiToolPresentation => { |
| 110 | const resolvedSchemas = resolveTypingSchemasWithRefHints( | 108 | const inputSchemaJson = input.definition.typing?.inputSchemaJson; |
| 111 | input.definition.typing, | 109 | const outputSchemaJson = input.definition.typing?.outputSchemaJson; |
| 112 | input.manifest.refHintTable, | ||
| 113 | ); | ||
| 114 | const inputSchemaJson = resolvedSchemas.inputSchemaJson ?? undefined; | ||
| 115 | const outputSchemaJson = resolvedSchemas.outputSchemaJson ?? undefined; | ||
| 116 | const exampleInputJson = buildExampleInputJson(input.definition.documentation); | 110 | const exampleInputJson = buildExampleInputJson(input.definition.documentation); |
| 117 | const exampleOutputJson = buildExampleOutputJson(input.definition.documentation); | 111 | const exampleOutputJson = buildExampleOutputJson(input.definition.documentation); |
| 118 | 112 | ||
| @@ -123,19 +117,6 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines | |||
| 123 | ...(outputSchemaJson ? { outputSchemaJson } : {}), | 117 | ...(outputSchemaJson ? { outputSchemaJson } : {}), |
| 124 | ...(exampleInputJson ? { exampleInputJson } : {}), | 118 | ...(exampleInputJson ? { exampleInputJson } : {}), |
| 125 | ...(exampleOutputJson ? { exampleOutputJson } : {}), | 119 | ...(exampleOutputJson ? { exampleOutputJson } : {}), |
| 126 | providerDataJson: JSON.stringify({ | 120 | providerDataJson: openApiProviderDataJsonFromDefinition(input.definition), |
| 127 | kind: "openapi", | ||
| 128 | toolId: input.definition.toolId, | ||
| 129 | rawToolId: input.definition.rawToolId, | ||
| 130 | operationId: input.definition.operationId, | ||
| 131 | group: input.definition.group, | ||
| 132 | leaf: input.definition.leaf, | ||
| 133 | tags: input.definition.tags, | ||
| 134 | versionSegment: input.definition.versionSegment, | ||
| 135 | method: input.definition.method, | ||
| 136 | path: input.definition.path, | ||
| 137 | operationHash: input.definition.operationHash, | ||
| 138 | documentation: input.definition.documentation, | ||
| 139 | }), | ||
| 140 | }; | 121 | }; |
| 141 | }; | 122 | }; |
| @@ -19,11 +19,13 @@ import { · 18 unmodified lines | |||
| 19 | type OpenApiToolDefinition, | 19 | type OpenApiToolDefinition, |
| 20 | } from "./openapi-definitions"; | 20 | } from "./openapi-definitions"; |
| 21 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; | 21 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; |
| 22 | import { resolveSchemaJsonWithRefHints } from "./openapi-schema-refs"; | ||
| 22 | import { | 23 | import { |
| 23 | extractOpenApiManifest, | 24 | extractOpenApiManifest, |
| 24 | type OpenApiExtractionError, | 25 | type OpenApiExtractionError, |
| 25 | } from "./openapi-extraction"; | 26 | } from "./openapi-extraction"; |
| 26 | import { | 27 | import { |
| 28 | type OpenApiRefHintTable, | ||
| 27 | type OpenApiInvocationPayload, | 29 | type OpenApiInvocationPayload, |
| 28 | type OpenApiSpecInput, | 30 | type OpenApiSpecInput, |
| 29 | type OpenApiToolManifest, | 31 | type OpenApiToolManifest, |
| @@ -256,13 +258,21 @@ const summarizeHttpResponseBody = (body: unknown): string | null => { · 226 unmodified lines | |||
| 256 | } | 258 | } |
| 257 | }; | 259 | }; |
| 258 | 260 | ||
| 259 | const inputSchemaFromTypingJson = (inputSchemaJson: string | undefined) => { | 261 | const inputSchemaFromTypingJson = (input: { |
| 260 | if (!inputSchemaJson) { | 262 | inputSchemaJson: string | undefined; |
| 263 | refHintTable?: Readonly<OpenApiRefHintTable>; | ||
| 264 | }) => { | ||
| 265 | const resolvedSchemaJson = resolveSchemaJsonWithRefHints( | ||
| 266 | input.inputSchemaJson, | ||
| 267 | input.refHintTable, | ||
| 268 | ) ?? input.inputSchemaJson; | ||
| 269 | |||
| 270 | if (!resolvedSchemaJson) { | ||
| 261 | return unknownInputSchema; | 271 | return unknownInputSchema; |
| 262 | } | 272 | } |
| 263 | 273 | ||
| 264 | try { | 274 | try { |
| 265 | return standardSchemaFromJsonSchema(JSON.parse(inputSchemaJson), { | 275 | return standardSchemaFromJsonSchema(JSON.parse(resolvedSchemaJson), { |
| 266 | vendor: "openapi", | 276 | vendor: "openapi", |
| 267 | fallback: unknownInputSchema, | 277 | fallback: unknownInputSchema, |
| 268 | }); | 278 | }); |
| @@ -396,6 +406,97 @@ const buildFetchRequest = (input: { · 127 unmodified lines | |||
| 396 | const createToolPath = (namespace: string | undefined, definition: OpenApiToolDefinition): string => | 406 | const createToolPath = (namespace: string | undefined, definition: OpenApiToolDefinition): string => |
| 397 | namespace ? `${namespace}.${definition.toolId}` : definition.toolId; | 407 | namespace ? `${namespace}.${definition.toolId}` : definition.toolId; |
| 398 | 408 | ||
| 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 | |||
| 399 | export const createOpenApiToolsFromManifest = ( | 500 | export const createOpenApiToolsFromManifest = ( |
| 400 | input: CreateOpenApiToolsFromManifestInput, | 501 | input: CreateOpenApiToolsFromManifestInput, |
| 401 | ): ToolMap => { | 502 | ): ToolMap => { |
| @@ -410,77 +511,15 @@ export const createOpenApiToolsFromManifest = ( · 8 unmodified lines | |||
| 410 | 511 | ||
| 411 | for (const definition of definitions) { | 512 | for (const definition of definitions) { |
| 412 | const toolPath = createToolPath(input.namespace, definition); | 513 | const toolPath = createToolPath(input.namespace, definition); |
| 413 | const presentation = buildOpenApiToolPresentation({ | 514 | result[toolPath] = createOpenApiToolFromDefinition({ |
| 414 | manifest: input.manifest, | ||
| 415 | definition, | 515 | definition, |
| 416 | }); | 516 | path: toolPath, |
| 417 | 517 | sourceKey, | |
| 418 | result[toolPath] = toTool({ | 518 | baseUrl, |
| 419 | tool: { | 519 | defaultHeaders, |
| 420 | description: definition.description, | 520 | credentialHeaders, |
| 421 | inputSchema: inputSchemaFromTypingJson( | 521 | refHintTable: input.manifest.refHintTable, |
| 422 | presentation.inputSchemaJson ?? definition.typing?.inputSchemaJson, | 522 | httpClientLayer, |
| 423 | ), | ||
| 424 | execute: async (args: unknown) => { | ||
| 425 | const decodedArgs = asToolArgs(args); | ||
| 426 | const request = buildFetchRequest({ | ||
| 427 | payload: definition.invocation, | ||
| 428 | args: decodedArgs, | ||
| 429 | baseUrl, | ||
| 430 | defaultHeaders, | ||
| 431 | credentialHeaders, | ||
| 432 | }); | ||
| 433 | |||
| 434 | return Effect.runPromise( | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const client = yield* HttpClient.HttpClient; | ||
| 437 | let clientRequest = HttpClientRequest.make( | ||
| 438 | request.method as Parameters<typeof HttpClientRequest.make>[0], | ||
| 439 | )(request.url, { | ||
| 440 | headers: request.headers, | ||
| 441 | }); | ||
| 442 | |||
| 443 | if (request.body !== undefined) { | ||
| 444 | clientRequest = HttpClientRequest.bodyText( | ||
| 445 | clientRequest, | ||
| 446 | request.body, | ||
| 447 | request.headers["content-type"], | ||
| 448 | ); | ||
| 449 | } | ||
| 450 | |||
| 451 | const response = yield* client.execute(clientRequest).pipe( | ||
| 452 | Effect.mapError((cause) => | ||
| 453 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 454 | ), | ||
| 455 | ); | ||
| 456 | const body = yield* decodeHttpClientResponseBody(response); | ||
| 457 | |||
| 458 | if (response.status < 200 || response.status >= 300) { | ||
| 459 | throw new OpenApiToolInvocationError({ | ||
| 460 | operation: "http_response", | ||
| 461 | message: `OpenAPI request failed with HTTP ${response.status}`, | ||
| 462 | details: summarizeHttpResponseBody(body), | ||
| 463 | }); | ||
| 464 | } | ||
| 465 | |||
| 466 | return body; | ||
| 467 | }).pipe( | ||
| 468 | Effect.provide(httpClientLayer), | ||
| 469 | ), | ||
| 470 | ); | ||
| 471 | }, | ||
| 472 | }, | ||
| 473 | metadata: { | ||
| 474 | sourceKey, | ||
| 475 | inputType: presentation.inputType, | ||
| 476 | outputType: presentation.outputType, | ||
| 477 | inputSchemaJson: presentation.inputSchemaJson, | ||
| 478 | outputSchemaJson: presentation.outputSchemaJson, | ||
| 479 | exampleInputJson: presentation.exampleInputJson, | ||
| 480 | exampleOutputJson: presentation.exampleOutputJson, | ||
| 481 | providerKind: "openapi", | ||
| 482 | providerDataJson: presentation.providerDataJson, | ||
| 483 | }, | ||
| 484 | }); | 523 | }); |
| 485 | } | 524 | } |
| 486 | 525 | ||
| @@ -63,6 +63,19 @@ export const DiscoveryTypingPayloadSchema = Schema.Struct({ · 62 unmodified lines | |||
| 63 | refHintKeys: Schema.optional(Schema.Array(Schema.String)), | 63 | refHintKeys: Schema.optional(Schema.Array(Schema.String)), |
| 64 | }); | 64 | }); |
| 65 | 65 | ||
| 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 | |||
| 66 | export const OpenApiExampleSchema = Schema.Struct({ | 79 | export const OpenApiExampleSchema = Schema.Struct({ |
| 67 | valueJson: Schema.String, | 80 | valueJson: Schema.String, |
| 68 | mediaType: Schema.optional(Schema.String), | 81 | mediaType: Schema.optional(Schema.String), |
| @@ -97,6 +110,22 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 28 unmodified lines | |||
| 97 | response: Schema.optional(OpenApiResponseDocumentationSchema), | 110 | response: Schema.optional(OpenApiResponseDocumentationSchema), |
| 98 | }); | 111 | }); |
| 99 | 112 | ||
| 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 | |||
| 100 | export const OpenApiExtractedToolSchema = Schema.Struct({ | 129 | export const OpenApiExtractedToolSchema = Schema.Struct({ |
| 101 | toolId: Schema.String, | 130 | toolId: Schema.String, |
| 102 | operationId: Schema.optional(Schema.String), | 131 | operationId: Schema.optional(Schema.String), |
| @@ -129,10 +158,13 @@ export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; · 26 unmodified lines | |||
| 129 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; | 158 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; |
| 130 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; | 159 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; |
| 131 | export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type; | 160 | export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type; |
| 161 | export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type; | ||
| 162 | export type OpenApiRefHintTable = typeof OpenApiRefHintTableSchema.Type; | ||
| 132 | export type OpenApiExample = typeof OpenApiExampleSchema.Type; | 163 | export type OpenApiExample = typeof OpenApiExampleSchema.Type; |
| 133 | export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type; | 164 | export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type; |
| 134 | export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type; | 165 | export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type; |
| 135 | export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type; | 166 | export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type; |
| 136 | export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type; | 167 | export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type; |
| 168 | export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type; | ||
| 137 | export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type; | 169 | export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type; |
| 138 | export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type; | 170 | export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type; |
| @@ -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"); | ||
| @@ -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"; | ||
| @@ -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 | ); | ||
| @@ -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"; | ||
| @@ -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 | ||
| @@ -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 | ||
| @@ -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"; | ||
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | } from "./organizations/api"; | 11 | } from "./organizations/api"; |
| 12 | import { CreatePolicyPayloadSchema } from "./policies/api"; | 12 | import { CreatePolicyPayloadSchema } from "./policies/api"; |
| 13 | import { | 13 | import { |
| 14 | ConnectSourcePayloadSchema, | ||
| 14 | CreateSourcePayloadSchema, | 15 | CreateSourcePayloadSchema, |
| 15 | UpdateSourcePayloadSchema, | 16 | UpdateSourcePayloadSchema, |
| 16 | } from "./sources/api"; | 17 | } from "./sources/api"; |
| @@ -56,6 +57,18 @@ describe("control-plane payload schemas", () => { · 39 unmodified lines | |||
| 56 | code: "console.log('ok')", | 57 | code: "console.log('ok')", |
| 57 | }); | 58 | }); |
| 58 | 59 | ||
| 60 | expect( | ||
| 61 | Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({ | ||
| 62 | kind: "openapi", | ||
| 63 | endpoint: " https://api.github.com ", | ||
| 64 | specUrl: " https://example.com/openapi.json ", | ||
| 65 | }), | ||
| 66 | ).toEqual({ | ||
| 67 | kind: "openapi", | ||
| 68 | endpoint: "https://api.github.com", | ||
| 69 | specUrl: "https://example.com/openapi.json", | ||
| 70 | }); | ||
| 71 | |||
| 59 | expect( | 72 | expect( |
| 60 | Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({ | 73 | Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({ |
| 61 | resourcePattern: " source.github.* ", | 74 | resourcePattern: " source.github.* ", |
| @@ -83,5 +96,12 @@ describe("control-plane payload schemas", () => { · 21 unmodified lines | |||
| 83 | endpoint: " ", | 96 | endpoint: " ", |
| 84 | }) | 97 | }) |
| 85 | ); | 98 | ); |
| 99 | |||
| 100 | throws(() => | ||
| 101 | Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({ | ||
| 102 | kind: "graphql", | ||
| 103 | endpoint: " ", | ||
| 104 | }) | ||
| 105 | ); | ||
| 86 | }); | 106 | }); |
| 87 | }); | 107 | }); |
| @@ -1,15 +1,16 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { | 2 | import { |
| 3 | ExecutionInteractionIdSchema, | 3 | ExecutionInteractionIdSchema, |
| 4 | SecretRefSchema, | ||
| 5 | SourceAuthSchema, | 4 | SourceAuthSchema, |
| 6 | SourceAuthSessionIdSchema, | 5 | SourceAuthSessionIdSchema, |
| 7 | SourceDiscoveryResultSchema, | 6 | SourceDiscoveryResultSchema, |
| 8 | SourceIdSchema, | 7 | SourceIdSchema, |
| 9 | SourceInspectionDiscoverPayloadSchema, | 8 | SourceInspectionDiscoverPayloadSchema, |
| 10 | SourceInspectionDiscoverResultSchema, | 9 | SourceInspectionDiscoverResultSchema, |
| 10 | SourceInspectionSchemaBundleSchema, | ||
| 11 | SourceInspectionSchema, | 11 | SourceInspectionSchema, |
| 12 | SourceInspectionToolDetailSchema, | 12 | SourceInspectionToolDetailSchema, |
| 13 | SourceImportAuthPolicySchema, | ||
| 13 | SourceKindSchema, | 14 | SourceKindSchema, |
| 14 | SourceProbeAuthSchema, | 15 | SourceProbeAuthSchema, |
| 15 | SourceSchema, | 16 | SourceSchema, |
| @@ -31,6 +32,10 @@ import { · 15 unmodified lines | |||
| 31 | OptionalTrimmedNonEmptyStringSchema, | 32 | OptionalTrimmedNonEmptyStringSchema, |
| 32 | TrimmedNonEmptyStringSchema, | 33 | TrimmedNonEmptyStringSchema, |
| 33 | } from "../string-schemas"; | 34 | } from "../string-schemas"; |
| 35 | import { | ||
| 36 | ConnectSourcePayloadSchema, | ||
| 37 | type ConnectSourcePayload, | ||
| 38 | } from "../../runtime/source-adapters"; | ||
| 34 | 39 | ||
| 35 | const createSourcePayloadRequiredSchema = Schema.Struct({ | 40 | const createSourcePayloadRequiredSchema = Schema.Struct({ |
| 36 | name: TrimmedNonEmptyStringSchema, | 41 | name: TrimmedNonEmptyStringSchema, |
| @@ -47,6 +52,8 @@ const createSourcePayloadOptionalSchema = Schema.Struct({ · 10 unmodified lines | |||
| 47 | headers: Schema.optional(Schema.NullOr(StringMapSchema)), | 52 | headers: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 48 | specUrl: Schema.optional(Schema.NullOr(Schema.String)), | 53 | specUrl: Schema.optional(Schema.NullOr(Schema.String)), |
| 49 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), | 54 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 55 | importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema), | ||
| 56 | importAuth: Schema.optional(SourceAuthSchema), | ||
| 50 | auth: Schema.optional(SourceAuthSchema), | 57 | auth: Schema.optional(SourceAuthSchema), |
| 51 | sourceHash: Schema.optional(Schema.NullOr(Schema.String)), | 58 | sourceHash: Schema.optional(Schema.NullOr(Schema.String)), |
| 52 | lastError: Schema.optional(Schema.NullOr(Schema.String)), | 59 | lastError: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -71,6 +78,8 @@ export const UpdateSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines | |||
| 71 | headers: Schema.optional(Schema.NullOr(StringMapSchema)), | 78 | headers: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 72 | specUrl: Schema.optional(Schema.NullOr(Schema.String)), | 79 | specUrl: Schema.optional(Schema.NullOr(Schema.String)), |
| 73 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), | 80 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 81 | importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema), | ||
| 82 | importAuth: Schema.optional(SourceAuthSchema), | ||
| 74 | auth: Schema.optional(SourceAuthSchema), | 83 | auth: Schema.optional(SourceAuthSchema), |
| 75 | sourceHash: Schema.optional(Schema.NullOr(Schema.String)), | 84 | sourceHash: Schema.optional(Schema.NullOr(Schema.String)), |
| 76 | lastError: Schema.optional(Schema.NullOr(Schema.String)), | 85 | lastError: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -81,6 +90,7 @@ export type UpdateSourcePayload = typeof UpdateSourcePayloadSchema.Type; · 4 unmodified lines | |||
| 81 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); | 90 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 82 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); | 91 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); |
| 83 | const toolPathParam = HttpApiSchema.param("toolPath", Schema.String); | 92 | const toolPathParam = HttpApiSchema.param("toolPath", Schema.String); |
| 93 | const schemaBundleIdParam = HttpApiSchema.param("schemaBundleId", Schema.String); | ||
| 84 | 94 | ||
| 85 | const CredentialPageUrlParamsSchema = Schema.Struct({ | 95 | const CredentialPageUrlParamsSchema = Schema.Struct({ |
| 86 | interactionId: ExecutionInteractionIdSchema, | 96 | interactionId: ExecutionInteractionIdSchema, |
| @@ -105,59 +115,6 @@ const DiscoverSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines | |||
| 105 | 115 | ||
| 106 | export type DiscoverSourcePayload = typeof DiscoverSourcePayloadSchema.Type; | 116 | export type DiscoverSourcePayload = typeof DiscoverSourcePayloadSchema.Type; |
| 107 | 117 | ||
| 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 | |||
| 161 | const ConnectSourceResultSchema = Schema.Union( | 118 | const ConnectSourceResultSchema = Schema.Union( |
| 162 | Schema.Struct({ | 119 | Schema.Struct({ |
| 163 | kind: Schema.Literal("connected"), | 120 | kind: Schema.Literal("connected"), |
| @@ -166,6 +123,7 @@ const ConnectSourceResultSchema = Schema.Union( · 2 unmodified lines | |||
| 166 | Schema.Struct({ | 123 | Schema.Struct({ |
| 167 | kind: Schema.Literal("credential_required"), | 124 | kind: Schema.Literal("credential_required"), |
| 168 | source: SourceSchema, | 125 | source: SourceSchema, |
| 126 | credentialSlot: Schema.Literal("runtime", "import"), | ||
| 169 | }), | 127 | }), |
| 170 | Schema.Struct({ | 128 | Schema.Struct({ |
| 171 | kind: Schema.Literal("oauth_required"), | 129 | kind: Schema.Literal("oauth_required"), |
| @@ -183,6 +141,8 @@ export { · 11 unmodified lines | |||
| 183 | DiscoverSourcePayloadSchema, | 141 | DiscoverSourcePayloadSchema, |
| 184 | }; | 142 | }; |
| 185 | 143 | ||
| 144 | export type { ConnectSourcePayload }; | ||
| 145 | |||
| 186 | const HtmlSchema = HttpApiSchema.Text({ | 146 | const HtmlSchema = HttpApiSchema.Text({ |
| 187 | contentType: "text/html", | 147 | contentType: "text/html", |
| 188 | }); | 148 | }); |
| @@ -292,6 +252,15 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 103 unmodified lines | |||
| 292 | .addError(ControlPlaneNotFoundError) | 252 | .addError(ControlPlaneNotFoundError) |
| 293 | .addError(ControlPlaneStorageError), | 253 | .addError(ControlPlaneStorageError), |
| 294 | ) | 254 | ) |
| 255 | .add( | ||
| 256 | HttpApiEndpoint.get("inspectionSchemaBundle")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/schema-bundles/${schemaBundleIdParam}` | ||
| 257 | .addSuccess(SourceInspectionSchemaBundleSchema) | ||
| 258 | .addError(ControlPlaneBadRequestError) | ||
| 259 | .addError(ControlPlaneUnauthorizedError) | ||
| 260 | .addError(ControlPlaneForbiddenError) | ||
| 261 | .addError(ControlPlaneNotFoundError) | ||
| 262 | .addError(ControlPlaneStorageError), | ||
| 263 | ) | ||
| 295 | .add( | 264 | .add( |
| 296 | HttpApiEndpoint.post("inspectionDiscover")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/discover` | 265 | HttpApiEndpoint.post("inspectionDiscover")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/discover` |
| 297 | .setPayload(SourceInspectionDiscoverPayloadSchema) | 266 | .setPayload(SourceInspectionDiscoverPayloadSchema) |
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | |||
| 21 | import { | 21 | import { |
| 22 | discoverSourceInspectionTools, | 22 | discoverSourceInspectionTools, |
| 23 | getSourceInspection, | 23 | getSourceInspection, |
| 24 | getSourceInspectionSchemaBundle, | ||
| 24 | getSourceInspectionToolDetail, | 25 | getSourceInspectionToolDetail, |
| 25 | } from "../../runtime/source-inspection"; | 26 | } from "../../runtime/source-inspection"; |
| 26 | import { | 27 | import { |
| @@ -29,6 +30,7 @@ import { · 2 unmodified lines | |||
| 29 | submitSourceCredentialInteraction, | 30 | submitSourceCredentialInteraction, |
| 30 | } from "../../runtime/local-operations"; | 31 | } from "../../runtime/local-operations"; |
| 31 | import { discoverSource } from "../../runtime/source-discovery"; | 32 | import { discoverSource } from "../../runtime/source-discovery"; |
| 33 | import { hasSourceAdapterFamily } from "../../runtime/source-adapters"; | ||
| 32 | import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service"; | 34 | import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service"; |
| 33 | 35 | ||
| 34 | import { | 36 | import { |
| @@ -682,7 +684,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 647 unmodified lines | |||
| 682 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | 684 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 683 | const baseUrl = resolveRequestOrigin(request); | 685 | const baseUrl = resolveRequestOrigin(request); |
| 684 | 686 | ||
| 685 | if (payload.kind === "mcp") { | 687 | if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) { |
| 686 | return yield* sourceAuthService.connectMcpSource({ | 688 | return yield* sourceAuthService.connectMcpSource({ |
| 687 | workspaceId: path.workspaceId, | 689 | workspaceId: path.workspaceId, |
| 688 | actorAccountId: actor.principal.accountId, | 690 | actorAccountId: actor.principal.accountId, |
| @@ -696,35 +698,13 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 7 unmodified lines | |||
| 696 | }); | 698 | }); |
| 697 | } | 699 | } |
| 698 | 700 | ||
| 699 | if (payload.kind === "openapi") { | ||
| 700 | return yield* sourceAuthService.addExecutorSource( | ||
| 701 | { | ||
| 702 | workspaceId: path.workspaceId, | ||
| 703 | actorAccountId: actor.principal.accountId, | ||
| 704 | executionId: null, | ||
| 705 | interactionId: null, | ||
| 706 | kind: "openapi", | ||
| 707 | endpoint: payload.endpoint, | ||
| 708 | specUrl: payload.specUrl, | ||
| 709 | name: payload.name, | ||
| 710 | namespace: payload.namespace, | ||
| 711 | auth: payload.auth, | ||
| 712 | }, | ||
| 713 | { baseUrl }, | ||
| 714 | ); | ||
| 715 | } | ||
| 716 | |||
| 717 | return yield* sourceAuthService.addExecutorSource( | 701 | return yield* sourceAuthService.addExecutorSource( |
| 718 | { | 702 | { |
| 719 | workspaceId: path.workspaceId, | 703 | workspaceId: path.workspaceId, |
| 720 | actorAccountId: actor.principal.accountId, | 704 | actorAccountId: actor.principal.accountId, |
| 721 | executionId: null, | 705 | executionId: null, |
| 722 | interactionId: null, | 706 | interactionId: null, |
| 723 | kind: "graphql", | 707 | ...payload, |
| 724 | endpoint: payload.endpoint, | ||
| 725 | name: payload.name, | ||
| 726 | namespace: payload.namespace, | ||
| 727 | auth: payload.auth, | ||
| 728 | }, | 708 | }, |
| 729 | { baseUrl }, | 709 | { baseUrl }, |
| 730 | ); | 710 | ); |
| @@ -781,6 +761,17 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 50 unmodified lines | |||
| 781 | ), | 761 | ), |
| 782 | ), | 762 | ), |
| 783 | ) | 763 | ) |
| 764 | .handle("inspectionSchemaBundle", ({ path }) => | ||
| 765 | withWorkspaceRequestActor("sources.inspection_schema_bundle", path.workspaceId, () => | ||
| 766 | withPolicy(requireReadSources(path.workspaceId))( | ||
| 767 | getSourceInspectionSchemaBundle({ | ||
| 768 | workspaceId: path.workspaceId, | ||
| 769 | sourceId: path.sourceId, | ||
| 770 | schemaBundleId: path.schemaBundleId, | ||
| 771 | }), | ||
| 772 | ), | ||
| 773 | ), | ||
| 774 | ) | ||
| 784 | .handle("inspectionDiscover", ({ path, payload }) => | 775 | .handle("inspectionDiscover", ({ path, payload }) => |
| 785 | withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () => | 776 | withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () => |
| 786 | withPolicy(requireReadSources(path.workspaceId))( | 777 | withPolicy(requireReadSources(path.workspaceId))( |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | SourceAuthSessionIdSchema, | 11 | SourceAuthSessionIdSchema, |
| 12 | SourceIdSchema, | 12 | SourceIdSchema, |
| 13 | SourceRecipeIdSchema, | 13 | SourceRecipeIdSchema, |
| 14 | SourceRecipeSchemaBundleIdSchema, | ||
| 14 | SourceRecipeRevisionIdSchema, | 15 | SourceRecipeRevisionIdSchema, |
| 15 | WorkspaceIdSchema, | 16 | WorkspaceIdSchema, |
| 16 | } from "#schema"; | 17 | } from "#schema"; |
| @@ -39,6 +40,35 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 22 unmodified lines | |||
| 39 | 40 | ||
| 40 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); | 41 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 41 | 42 | ||
| 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 | |||
| 42 | const seedWorkspaceSourceState = (input: { | 72 | const seedWorkspaceSourceState = (input: { |
| 43 | persistence: SqlControlPlanePersistence; | 73 | persistence: SqlControlPlanePersistence; |
| 44 | accountId: ReturnType<typeof AccountIdSchema.make>; | 74 | accountId: ReturnType<typeof AccountIdSchema.make>; |
| @@ -68,6 +98,33 @@ const seedWorkspaceSourceState = (input: { · 23 unmodified lines | |||
| 68 | createdAt: now, | 98 | createdAt: now, |
| 69 | updatedAt: now, | 99 | updatedAt: now, |
| 70 | }); | 100 | }); |
| 101 | yield* input.persistence.rows.sourceRecipes.upsert({ | ||
| 102 | id: recipeId, | ||
| 103 | kind: "http_api", | ||
| 104 | importerKind: "openapi", | ||
| 105 | providerKey: "openapi:https://api.github.com", | ||
| 106 | name: "Github", | ||
| 107 | summary: null, | ||
| 108 | visibility: "workspace", | ||
| 109 | latestRevisionId: recipeRevisionId, | ||
| 110 | createdAt: now, | ||
| 111 | updatedAt: now, | ||
| 112 | }); | ||
| 113 | yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | ||
| 114 | id: recipeRevisionId, | ||
| 115 | recipeId, | ||
| 116 | revisionNumber: 1, | ||
| 117 | sourceConfigJson: JSON.stringify({ | ||
| 118 | kind: "openapi", | ||
| 119 | endpoint: "https://api.github.com", | ||
| 120 | specUrl: "https://api.github.com/openapi.json", | ||
| 121 | }), | ||
| 122 | manifestJson: null, | ||
| 123 | manifestHash: null, | ||
| 124 | materializationHash: null, | ||
| 125 | createdAt: now, | ||
| 126 | updatedAt: now, | ||
| 127 | })); | ||
| 71 | yield* input.persistence.rows.sources.insert({ | 128 | yield* input.persistence.rows.sources.insert({ |
| 72 | id: input.sourceId, | 129 | id: input.sourceId, |
| 73 | workspaceId: input.workspaceId, | 130 | workspaceId: input.workspaceId, |
| @@ -79,14 +136,9 @@ const seedWorkspaceSourceState = (input: { · 5 unmodified lines | |||
| 79 | status: "connected", | 136 | status: "connected", |
| 80 | enabled: true, | 137 | enabled: true, |
| 81 | namespace: "github", | 138 | namespace: "github", |
| 82 | transport: null, | 139 | importAuthPolicy: "reuse_runtime", |
| 83 | bindingConfigJson: null, | 140 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), |
| 84 | queryParamsJson: null, | ||
| 85 | headersJson: null, | ||
| 86 | specUrl: "https://api.github.com/openapi.json", | ||
| 87 | defaultHeadersJson: null, | ||
| 88 | sourceHash: null, | 141 | sourceHash: null, |
| 89 | sourceDocumentText: null, | ||
| 90 | lastError: null, | 142 | lastError: null, |
| 91 | createdAt: now, | 143 | createdAt: now, |
| 92 | updatedAt: now, | 144 | updatedAt: now, |
| @@ -131,6 +183,7 @@ const seedWorkspaceCredentialState = (input: { · 38 unmodified lines | |||
| 131 | workspaceId: input.workspaceId, | 183 | workspaceId: input.workspaceId, |
| 132 | sourceId: input.sourceId, | 184 | sourceId: input.sourceId, |
| 133 | actorAccountId: input.accountId, | 185 | actorAccountId: input.accountId, |
| 186 | slot: "runtime", | ||
| 134 | authKind: "oauth2", | 187 | authKind: "oauth2", |
| 135 | authHeaderName: "Authorization", | 188 | authHeaderName: "Authorization", |
| 136 | authPrefix: "Bearer ", | 189 | authPrefix: "Bearer ", |
| @@ -149,6 +202,7 @@ const seedWorkspaceCredentialState = (input: { · 12 unmodified lines | |||
| 149 | executionId: null, | 202 | executionId: null, |
| 150 | interactionId: null, | 203 | interactionId: null, |
| 151 | providerKind: "mcp_oauth", | 204 | providerKind: "mcp_oauth", |
| 205 | credentialSlot: "runtime", | ||
| 152 | status: "pending", | 206 | status: "pending", |
| 153 | state: `state_${input.workspaceId}`, | 207 | state: `state_${input.workspaceId}`, |
| 154 | sessionDataJson: encodeSessionDataJson({ | 208 | sessionDataJson: encodeSessionDataJson({ |
| @@ -212,14 +266,9 @@ describe("control-plane-persistence-drizzle", () => { · 57 unmodified lines | |||
| 212 | status: "draft", | 266 | status: "draft", |
| 213 | enabled: true, | 267 | enabled: true, |
| 214 | namespace: "github", | 268 | namespace: "github", |
| 215 | transport: null, | 269 | importAuthPolicy: "reuse_runtime", |
| 216 | bindingConfigJson: null, | 270 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), |
| 217 | queryParamsJson: null, | ||
| 218 | headersJson: null, | ||
| 219 | specUrl: "https://api.github.com/openapi.json", | ||
| 220 | defaultHeadersJson: null, | ||
| 221 | sourceHash: null, | 271 | sourceHash: null, |
| 222 | sourceDocumentText: null, | ||
| 223 | lastError: null, | 272 | lastError: null, |
| 224 | createdAt: now, | 273 | createdAt: now, |
| 225 | updatedAt: now, | 274 | updatedAt: now, |
| @@ -302,6 +351,94 @@ describe("control-plane-persistence-drizzle", () => { · 76 unmodified lines | |||
| 302 | }), | 351 | }), |
| 303 | ); | 352 | ); |
| 304 | 353 | ||
| 354 | it.scoped("batches large recipe operation replacement inserts", () => | ||
| 355 | Effect.gen(function* () { | ||
| 356 | const persistence = yield* makePersistence; | ||
| 357 | const now = Date.now(); | ||
| 358 | const recipeId = SourceRecipeIdSchema.make("src_recipe_bulk_ops"); | ||
| 359 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make("src_recipe_rev_bulk_ops"); | ||
| 360 | const operationCount = 3_000; | ||
| 361 | |||
| 362 | yield* persistence.rows.sourceRecipes.upsert({ | ||
| 363 | id: recipeId, | ||
| 364 | kind: "http_api", | ||
| 365 | importerKind: "openapi", | ||
| 366 | providerKey: "openapi:https://api.cloudflare.com/client/v4", | ||
| 367 | name: "Cloudflare API", | ||
| 368 | summary: null, | ||
| 369 | visibility: "private", | ||
| 370 | latestRevisionId: recipeRevisionId, | ||
| 371 | createdAt: now, | ||
| 372 | updatedAt: now, | ||
| 373 | }); | ||
| 374 | |||
| 375 | yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | ||
| 376 | id: recipeRevisionId, | ||
| 377 | recipeId, | ||
| 378 | revisionNumber: 1, | ||
| 379 | sourceConfigJson: JSON.stringify({ | ||
| 380 | endpoint: "https://api.cloudflare.com/client/v4", | ||
| 381 | specUrl: | ||
| 382 | "https://raw.githubusercontent.com/cloudflare/api-schemas/refs/heads/main/openapi.json", | ||
| 383 | }), | ||
| 384 | manifestHash: null, | ||
| 385 | manifestJson: null, | ||
| 386 | materializationHash: null, | ||
| 387 | createdAt: now, | ||
| 388 | updatedAt: now, | ||
| 389 | })); | ||
| 390 | |||
| 391 | yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | ||
| 392 | recipeRevisionId, | ||
| 393 | operations: Array.from({ length: operationCount }, (_, index) => ({ | ||
| 394 | id: `src_recipe_op_${index}`, | ||
| 395 | recipeRevisionId, | ||
| 396 | operationKey: `zones.get${index}`, | ||
| 397 | transportKind: "http" as const, | ||
| 398 | toolId: `zones.get${index}`, | ||
| 399 | title: `Get zone ${index}`, | ||
| 400 | description: `Operation ${index}`, | ||
| 401 | operationKind: "read" as const, | ||
| 402 | searchText: `zones get ${index}`, | ||
| 403 | inputSchemaJson: null, | ||
| 404 | outputSchemaJson: null, | ||
| 405 | providerKind: "openapi" as const, | ||
| 406 | providerDataJson: JSON.stringify({ | ||
| 407 | kind: "openapi", | ||
| 408 | toolId: `zones.get${index}`, | ||
| 409 | rawToolId: `getZone${index}`, | ||
| 410 | operationId: `getZone${index}`, | ||
| 411 | group: "zones", | ||
| 412 | leaf: `get${index}`, | ||
| 413 | tags: ["zones"], | ||
| 414 | method: "get", | ||
| 415 | path: `/zones/${index}`, | ||
| 416 | operationHash: `hash_${index}`, | ||
| 417 | invocation: { | ||
| 418 | method: "get", | ||
| 419 | pathTemplate: `/zones/${index}`, | ||
| 420 | parameters: [], | ||
| 421 | requestBody: null, | ||
| 422 | }, | ||
| 423 | }), | ||
| 424 | createdAt: now, | ||
| 425 | updatedAt: now, | ||
| 426 | })), | ||
| 427 | }); | ||
| 428 | |||
| 429 | const operations = yield* persistence.rows.sourceRecipeOperations.listByRevisionId( | ||
| 430 | recipeRevisionId, | ||
| 431 | ); | ||
| 432 | expect(operations).toHaveLength(operationCount); | ||
| 433 | expect(operations.some((operation) => operation.toolId === "zones.get0")).toBe(true); | ||
| 434 | expect( | ||
| 435 | operations.some( | ||
| 436 | (operation) => operation.toolId === `zones.get${operationCount - 1}`, | ||
| 437 | ), | ||
| 438 | ).toBe(true); | ||
| 439 | }), | ||
| 440 | ); | ||
| 441 | |||
| 305 | it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () => | 442 | it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () => |
| 306 | Effect.gen(function* () { | 443 | Effect.gen(function* () { |
| 307 | const persistence = yield* makePersistence; | 444 | const persistence = yield* makePersistence; |
| @@ -329,6 +466,7 @@ describe("control-plane-persistence-drizzle", () => { · 21 unmodified lines | |||
| 329 | workspaceId, | 466 | workspaceId, |
| 330 | sourceId, | 467 | sourceId, |
| 331 | actorAccountId: null, | 468 | actorAccountId: null, |
| 469 | slot: "runtime", | ||
| 332 | authKind: "bearer", | 470 | authKind: "bearer", |
| 333 | authHeaderName: "Authorization", | 471 | authHeaderName: "Authorization", |
| 334 | authPrefix: "Bearer ", | 472 | authPrefix: "Bearer ", |
| @@ -344,6 +482,7 @@ describe("control-plane-persistence-drizzle", () => { · 9 unmodified lines | |||
| 344 | workspaceId, | 482 | workspaceId, |
| 345 | sourceId, | 483 | sourceId, |
| 346 | actorAccountId: null, | 484 | actorAccountId: null, |
| 485 | slot: "runtime", | ||
| 347 | authKind: "bearer", | 486 | authKind: "bearer", |
| 348 | authHeaderName: "Authorization", | 487 | authHeaderName: "Authorization", |
| 349 | authPrefix: "Bearer ", | 488 | authPrefix: "Bearer ", |
| @@ -362,6 +501,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines | |||
| 362 | workspaceId, | 501 | workspaceId, |
| 363 | sourceId, | 502 | sourceId, |
| 364 | actorAccountId: null, | 503 | actorAccountId: null, |
| 504 | slot: "runtime", | ||
| 365 | authKind: "oauth2", | 505 | authKind: "oauth2", |
| 366 | authHeaderName: "X-Auth", | 506 | authHeaderName: "X-Auth", |
| 367 | authPrefix: "Token ", | 507 | authPrefix: "Token ", |
| @@ -378,6 +518,7 @@ describe("control-plane-persistence-drizzle", () => { · 10 unmodified lines | |||
| 378 | workspaceId, | 518 | workspaceId, |
| 379 | sourceId, | 519 | sourceId, |
| 380 | actorAccountId: accountId, | 520 | actorAccountId: accountId, |
| 521 | slot: "runtime", | ||
| 381 | authKind: "bearer", | 522 | authKind: "bearer", |
| 382 | authHeaderName: "Authorization", | 523 | authHeaderName: "Authorization", |
| 383 | authPrefix: "Bearer ", | 524 | authPrefix: "Bearer ", |
| @@ -418,6 +559,7 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines | |||
| 418 | workspaceId, | 559 | workspaceId, |
| 419 | sourceId, | 560 | sourceId, |
| 420 | actorAccountId: null, | 561 | actorAccountId: null, |
| 562 | slot: "runtime", | ||
| 421 | }); | 563 | }); |
| 422 | assertTrue(Option.isSome(nullActorOnly)); | 564 | assertTrue(Option.isSome(nullActorOnly)); |
| 423 | if (Option.isSome(nullActorOnly)) { | 565 | if (Option.isSome(nullActorOnly)) { |
| @@ -428,6 +570,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines | |||
| 428 | workspaceId, | 570 | workspaceId, |
| 429 | sourceId, | 571 | sourceId, |
| 430 | actorAccountId: AccountIdSchema.make("acc_missing_credentials"), | 572 | actorAccountId: AccountIdSchema.make("acc_missing_credentials"), |
| 573 | slot: "runtime", | ||
| 431 | }); | 574 | }); |
| 432 | expect(Option.isNone(missingActor)).toBe(true); | 575 | expect(Option.isNone(missingActor)).toBe(true); |
| 433 | }), | 576 | }), |
| @@ -440,6 +583,7 @@ describe("control-plane-persistence-drizzle", () => { · 6 unmodified lines | |||
| 440 | const organizationId = OrganizationIdSchema.make("org_ws_cleanup"); | 583 | const organizationId = OrganizationIdSchema.make("org_ws_cleanup"); |
| 441 | const workspaceId = WorkspaceIdSchema.make("ws_cleanup"); | 584 | const workspaceId = WorkspaceIdSchema.make("ws_cleanup"); |
| 442 | const sourceId = SourceIdSchema.make("src_ws_cleanup"); | 585 | const sourceId = SourceIdSchema.make("src_ws_cleanup"); |
| 586 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`); | ||
| 443 | 587 | ||
| 444 | const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({ | 588 | const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({ |
| 445 | persistence, | 589 | persistence, |
| @@ -448,17 +592,295 @@ describe("control-plane-persistence-drizzle", () => { · 2 unmodified lines | |||
| 448 | workspaceId, | 592 | workspaceId, |
| 449 | sourceId, | 593 | sourceId, |
| 450 | }); | 594 | }); |
| 595 | yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | ||
| 596 | recipeRevisionId, | ||
| 597 | documents: [ | ||
| 598 | { | ||
| 599 | id: "src_recipe_doc_ws_cleanup", | ||
| 600 | recipeRevisionId, | ||
| 601 | documentKind: "openapi", | ||
| 602 | documentKey: "https://api.github.com/openapi.json", | ||
| 603 | contentText: "{\"openapi\":\"3.1.0\"}", | ||
| 604 | contentHash: "doc_hash_ws_cleanup", | ||
| 605 | fetchedAt: null, | ||
| 606 | createdAt: Date.now(), | ||
| 607 | updatedAt: Date.now(), | ||
| 608 | }, | ||
| 609 | ], | ||
| 610 | }); | ||
| 611 | yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | ||
| 612 | recipeRevisionId, | ||
| 613 | bundles: [ | ||
| 614 | { | ||
| 615 | id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_ws_cleanup"), | ||
| 616 | recipeRevisionId, | ||
| 617 | bundleKind: "json_schema_ref_map", | ||
| 618 | refsJson: "{}", | ||
| 619 | contentHash: "bundle_hash_ws_cleanup", | ||
| 620 | createdAt: Date.now(), | ||
| 621 | updatedAt: Date.now(), | ||
| 622 | }, | ||
| 623 | ], | ||
| 624 | }); | ||
| 625 | yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | ||
| 626 | recipeRevisionId, | ||
| 627 | operations: [ | ||
| 628 | { | ||
| 629 | id: "src_recipe_op_ws_cleanup", | ||
| 630 | recipeRevisionId, | ||
| 631 | operationKey: "users.getAuthenticated", | ||
| 632 | transportKind: "http", | ||
| 633 | toolId: "users.getAuthenticated", | ||
| 634 | title: "Get authenticated user", | ||
| 635 | description: null, | ||
| 636 | operationKind: "read", | ||
| 637 | searchText: "users get authenticated", | ||
| 638 | inputSchemaJson: null, | ||
| 639 | outputSchemaJson: null, | ||
| 640 | providerKind: "openapi", | ||
| 641 | providerDataJson: JSON.stringify({ | ||
| 642 | kind: "openapi", | ||
| 643 | toolId: "users.getAuthenticated", | ||
| 644 | rawToolId: "users.getAuthenticated", | ||
| 645 | operationId: "users.getAuthenticated", | ||
| 646 | group: "users", | ||
| 647 | leaf: "getAuthenticated", | ||
| 648 | tags: ["users"], | ||
| 649 | method: "get", | ||
| 650 | path: "/user", | ||
| 651 | operationHash: "op_hash_ws_cleanup", | ||
| 652 | invocation: { | ||
| 653 | method: "get", | ||
| 654 | pathTemplate: "/user", | ||
| 655 | pathParameterOrder: [], | ||
| 656 | serverUrl: "https://api.github.com", | ||
| 657 | parameters: [], | ||
| 658 | requestBody: null, | ||
| 659 | }, | ||
| 660 | }), | ||
| 661 | createdAt: Date.now(), | ||
| 662 | updatedAt: Date.now(), | ||
| 663 | }, | ||
| 664 | ], | ||
| 665 | }); | ||
| 451 | 666 | ||
| 452 | const removed = yield* persistence.rows.workspaces.removeById(workspaceId); | 667 | const removed = yield* persistence.rows.workspaces.removeById(workspaceId); |
| 453 | expect(removed).toBe(true); | 668 | expect(removed).toBe(true); |
| 454 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); | 669 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 455 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); | 670 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 456 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 671 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 672 | expect( | ||
| 673 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId), | ||
| 674 | ).toHaveLength(0); | ||
| 675 | expect( | ||
| 676 | yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(recipeRevisionId), | ||
| 677 | ).toHaveLength(0); | ||
| 678 | expect( | ||
| 679 | yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId), | ||
| 680 | ).toHaveLength(0); | ||
| 457 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); | 681 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 458 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); | 682 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); |
| 459 | }), | 683 | }), |
| 460 | ); | 684 | ); |
| 461 | 685 | ||
| 686 | it.scoped("deleting a workspace retains shared recipe data still referenced elsewhere", () => | ||
| 687 | Effect.gen(function* () { | ||
| 688 | const persistence = yield* makePersistence; | ||
| 689 | const now = Date.now(); | ||
| 690 | const accountId = AccountIdSchema.make("acc_shared_recipe"); | ||
| 691 | const organizationId = OrganizationIdSchema.make("org_shared_recipe"); | ||
| 692 | const removedWorkspaceId = WorkspaceIdSchema.make("ws_shared_removed"); | ||
| 693 | const remainingWorkspaceId = WorkspaceIdSchema.make("ws_shared_remaining"); | ||
| 694 | const sharedRecipeId = SourceRecipeIdSchema.make("src_recipe_shared_workspace"); | ||
| 695 | const sharedRecipeRevisionId = SourceRecipeRevisionIdSchema.make( | ||
| 696 | "src_recipe_rev_shared_workspace", | ||
| 697 | ); | ||
| 698 | |||
| 699 | yield* persistence.rows.organizations.insert({ | ||
| 700 | id: organizationId, | ||
| 701 | slug: "org-shared-recipe", | ||
| 702 | name: "Org Shared Recipe", | ||
| 703 | status: "active", | ||
| 704 | createdByAccountId: accountId, | ||
| 705 | createdAt: now, | ||
| 706 | updatedAt: now, | ||
| 707 | }); | ||
| 708 | yield* persistence.rows.workspaces.insert({ | ||
| 709 | id: removedWorkspaceId, | ||
| 710 | organizationId, | ||
| 711 | name: "Workspace Removed", | ||
| 712 | createdByAccountId: accountId, | ||
| 713 | createdAt: now, | ||
| 714 | updatedAt: now, | ||
| 715 | }); | ||
| 716 | yield* persistence.rows.workspaces.insert({ | ||
| 717 | id: remainingWorkspaceId, | ||
| 718 | organizationId, | ||
| 719 | name: "Workspace Remaining", | ||
| 720 | createdByAccountId: accountId, | ||
| 721 | createdAt: now, | ||
| 722 | updatedAt: now, | ||
| 723 | }); | ||
| 724 | yield* persistence.rows.sourceRecipes.upsert({ | ||
| 725 | id: sharedRecipeId, | ||
| 726 | kind: "http_api", | ||
| 727 | importerKind: "openapi", | ||
| 728 | providerKey: "openapi:https://api.github.com", | ||
| 729 | name: "Github", | ||
| 730 | summary: null, | ||
| 731 | visibility: "workspace", | ||
| 732 | latestRevisionId: sharedRecipeRevisionId, | ||
| 733 | createdAt: now, | ||
| 734 | updatedAt: now, | ||
| 735 | }); | ||
| 736 | yield* persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | ||
| 737 | id: sharedRecipeRevisionId, | ||
| 738 | recipeId: sharedRecipeId, | ||
| 739 | revisionNumber: 1, | ||
| 740 | sourceConfigJson: JSON.stringify({ | ||
| 741 | kind: "openapi", | ||
| 742 | endpoint: "https://api.github.com", | ||
| 743 | specUrl: "https://api.github.com/openapi.json", | ||
| 744 | }), | ||
| 745 | manifestJson: null, | ||
| 746 | manifestHash: null, | ||
| 747 | materializationHash: null, | ||
| 748 | createdAt: now, | ||
| 749 | updatedAt: now, | ||
| 750 | })); | ||
| 751 | yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | ||
| 752 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 753 | documents: [ | ||
| 754 | { | ||
| 755 | id: "src_recipe_doc_shared_workspace", | ||
| 756 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 757 | documentKind: "openapi", | ||
| 758 | documentKey: "https://api.github.com/openapi.json", | ||
| 759 | contentText: "{\"openapi\":\"3.1.0\"}", | ||
| 760 | contentHash: "doc_hash_shared_workspace", | ||
| 761 | fetchedAt: null, | ||
| 762 | createdAt: now, | ||
| 763 | updatedAt: now, | ||
| 764 | }, | ||
| 765 | ], | ||
| 766 | }); | ||
| 767 | yield* persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | ||
| 768 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 769 | bundles: [ | ||
| 770 | { | ||
| 771 | id: SourceRecipeSchemaBundleIdSchema.make("src_recipe_bundle_shared_workspace"), | ||
| 772 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 773 | bundleKind: "json_schema_ref_map", | ||
| 774 | refsJson: "{}", | ||
| 775 | contentHash: "bundle_hash_shared_workspace", | ||
| 776 | createdAt: now, | ||
| 777 | updatedAt: now, | ||
| 778 | }, | ||
| 779 | ], | ||
| 780 | }); | ||
| 781 | yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | ||
| 782 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 783 | operations: [ | ||
| 784 | { | ||
| 785 | id: "src_recipe_op_shared_workspace", | ||
| 786 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 787 | operationKey: "users.getAuthenticated", | ||
| 788 | transportKind: "http", | ||
| 789 | toolId: "users.getAuthenticated", | ||
| 790 | title: "Get authenticated user", | ||
| 791 | description: null, | ||
| 792 | operationKind: "read", | ||
| 793 | searchText: "users get authenticated", | ||
| 794 | inputSchemaJson: null, | ||
| 795 | outputSchemaJson: null, | ||
| 796 | providerKind: "openapi", | ||
| 797 | providerDataJson: JSON.stringify({ | ||
| 798 | kind: "openapi", | ||
| 799 | toolId: "users.getAuthenticated", | ||
| 800 | rawToolId: "users.getAuthenticated", | ||
| 801 | operationId: "users.getAuthenticated", | ||
| 802 | group: "users", | ||
| 803 | leaf: "getAuthenticated", | ||
| 804 | tags: ["users"], | ||
| 805 | method: "get", | ||
| 806 | path: "/user", | ||
| 807 | operationHash: "op_hash_shared_workspace", | ||
| 808 | invocation: { | ||
| 809 | method: "get", | ||
| 810 | pathTemplate: "/user", | ||
| 811 | pathParameterOrder: [], | ||
| 812 | serverUrl: "https://api.github.com", | ||
| 813 | parameters: [], | ||
| 814 | requestBody: null, | ||
| 815 | }, | ||
| 816 | }), | ||
| 817 | createdAt: now, | ||
| 818 | updatedAt: now, | ||
| 819 | }, | ||
| 820 | ], | ||
| 821 | }); | ||
| 822 | yield* persistence.rows.sources.insert({ | ||
| 823 | id: SourceIdSchema.make("src_shared_removed"), | ||
| 824 | workspaceId: removedWorkspaceId, | ||
| 825 | recipeId: sharedRecipeId, | ||
| 826 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 827 | name: "Github Removed", | ||
| 828 | kind: "openapi", | ||
| 829 | endpoint: "https://api.github.com", | ||
| 830 | status: "connected", | ||
| 831 | enabled: true, | ||
| 832 | namespace: "github", | ||
| 833 | importAuthPolicy: "reuse_runtime", | ||
| 834 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | ||
| 835 | sourceHash: null, | ||
| 836 | lastError: null, | ||
| 837 | createdAt: now, | ||
| 838 | updatedAt: now, | ||
| 839 | }); | ||
| 840 | yield* persistence.rows.sources.insert({ | ||
| 841 | id: SourceIdSchema.make("src_shared_remaining"), | ||
| 842 | workspaceId: remainingWorkspaceId, | ||
| 843 | recipeId: sharedRecipeId, | ||
| 844 | recipeRevisionId: sharedRecipeRevisionId, | ||
| 845 | name: "Github Remaining", | ||
| 846 | kind: "openapi", | ||
| 847 | endpoint: "https://api.github.com", | ||
| 848 | status: "connected", | ||
| 849 | enabled: true, | ||
| 850 | namespace: "github", | ||
| 851 | importAuthPolicy: "reuse_runtime", | ||
| 852 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | ||
| 853 | sourceHash: null, | ||
| 854 | lastError: null, | ||
| 855 | createdAt: now, | ||
| 856 | updatedAt: now, | ||
| 857 | }); | ||
| 858 | |||
| 859 | const removed = yield* persistence.rows.workspaces.removeById(removedWorkspaceId); | ||
| 860 | expect(removed).toBe(true); | ||
| 861 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(removedWorkspaceId))).toBe(true); | ||
| 862 | expect(Option.isSome(yield* persistence.rows.workspaces.getById(remainingWorkspaceId))).toBe(true); | ||
| 863 | expect( | ||
| 864 | yield* persistence.rows.sources.listByWorkspaceId(remainingWorkspaceId), | ||
| 865 | ).toHaveLength(1); | ||
| 866 | expect( | ||
| 867 | Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId)), | ||
| 868 | ).toBe(true); | ||
| 869 | expect( | ||
| 870 | Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)), | ||
| 871 | ).toBe(true); | ||
| 872 | expect( | ||
| 873 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId), | ||
| 874 | ).toHaveLength(1); | ||
| 875 | expect( | ||
| 876 | yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId(sharedRecipeRevisionId), | ||
| 877 | ).toHaveLength(1); | ||
| 878 | expect( | ||
| 879 | yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId), | ||
| 880 | ).toHaveLength(1); | ||
| 881 | }), | ||
| 882 | ); | ||
| 883 | |||
| 462 | it.scoped("deleting an organization removes workspace credential state and postgres secrets", () => | 884 | it.scoped("deleting an organization removes workspace credential state and postgres secrets", () => |
| 463 | Effect.gen(function* () { | 885 | Effect.gen(function* () { |
| 464 | const persistence = yield* makePersistence; | 886 | const persistence = yield* makePersistence; |
| @@ -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 | }); | ||
| @@ -7,6 +7,7 @@ import { createDrizzleClient, type DrizzleClient } from "./client"; · 6 unmodified lines | |||
| 7 | import { | 7 | import { |
| 8 | createAccountsRepo, | 8 | createAccountsRepo, |
| 9 | createCredentialsRepo, | 9 | createCredentialsRepo, |
| 10 | createDataMigrationsRepo, | ||
| 10 | createExecutionInteractionsRepo, | 11 | createExecutionInteractionsRepo, |
| 11 | createExecutionsRepo, | 12 | createExecutionsRepo, |
| 12 | createLocalInstallationsRepo, | 13 | createLocalInstallationsRepo, |
| @@ -17,11 +18,11 @@ import { · 4 unmodified lines | |||
| 17 | createSourceAuthSessionsRepo, | 18 | createSourceAuthSessionsRepo, |
| 18 | createSourceOauthClientsRepo, | 19 | createSourceOauthClientsRepo, |
| 19 | createSourceRecipeDocumentsRepo, | 20 | createSourceRecipeDocumentsRepo, |
| 21 | createSourceRecipeSchemaBundlesRepo, | ||
| 20 | createSourceRecipeOperationsRepo, | 22 | createSourceRecipeOperationsRepo, |
| 21 | createSourceRecipeRevisionsRepo, | 23 | createSourceRecipeRevisionsRepo, |
| 22 | createSourceRecipesRepo, | 24 | createSourceRecipesRepo, |
| 23 | createSourcesRepo, | 25 | createSourcesRepo, |
| 24 | createToolArtifactsRepo, | ||
| 25 | createWorkspacesRepo, | 26 | createWorkspacesRepo, |
| 26 | } from "./repos"; | 27 | } from "./repos"; |
| 27 | import { drizzleSchema, tableNames, type DrizzleTables } from "./schema"; | 28 | import { drizzleSchema, tableNames, type DrizzleTables } from "./schema"; |
| @@ -33,7 +34,7 @@ import { · 5 unmodified lines | |||
| 33 | type SqlBackend, | 34 | type SqlBackend, |
| 34 | type SqlRuntime, | 35 | type SqlRuntime, |
| 35 | } from "./sql-runtime"; | 36 | } from "./sql-runtime"; |
| 36 | import { runPostMigrationRepairs } from "./post-migrations"; | 37 | import { runDataMigrations } from "./data-migrations"; |
| 37 | 38 | ||
| 38 | export { tableNames, type DrizzleTables } from "./schema"; | 39 | export { tableNames, type DrizzleTables } from "./schema"; |
| 39 | export { | 40 | export { |
| @@ -60,10 +61,11 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 20 unmodified lines | |||
| 60 | sourceRecipes: createSourceRecipesRepo(client, tables), | 61 | sourceRecipes: createSourceRecipesRepo(client, tables), |
| 61 | sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables), | 62 | sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables), |
| 62 | sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables), | 63 | sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables), |
| 64 | sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables), | ||
| 63 | sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables), | 65 | sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables), |
| 66 | dataMigrations: createDataMigrationsRepo(client, tables), | ||
| 64 | credentials: createCredentialsRepo(client, tables), | 67 | credentials: createCredentialsRepo(client, tables), |
| 65 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), | 68 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), |
| 66 | toolArtifacts: createToolArtifactsRepo(client, tables), | ||
| 67 | secretMaterials: createSecretMaterialsRepo(client, tables), | 69 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 68 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), | 70 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| 69 | policies: createPoliciesRepo(client, tables), | 71 | policies: createPoliciesRepo(client, tables), |
| @@ -126,8 +128,14 @@ const closeRuntimeEffect = (runtime: SqlRuntime) => · 56 unmodified lines | |||
| 126 | cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")), | 128 | cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")), |
| 127 | }).pipe(Effect.orDie); | 129 | }).pipe(Effect.orDie); |
| 128 | 130 | ||
| 131 | export type CreateSqlControlPlanePersistenceOptions = | ||
| 132 | & CreateSqlRuntimeOptions | ||
| 133 | & { | ||
| 134 | runDataMigrations?: boolean; | ||
| 135 | }; | ||
| 136 | |||
| 129 | export const createSqlControlPlanePersistence = ( | 137 | export const createSqlControlPlanePersistence = ( |
| 130 | options: CreateSqlRuntimeOptions, | 138 | options: CreateSqlControlPlanePersistenceOptions, |
| 131 | ): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> => | 139 | ): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> => |
| 132 | Effect.flatMap(createRuntimeEffect(options), (runtime) => | 140 | Effect.flatMap(createRuntimeEffect(options), (runtime) => |
| 133 | runMigrationsEffect(runtime, options.migrationsFolder).pipe( | 141 | runMigrationsEffect(runtime, options.migrationsFolder).pipe( |
| @@ -146,10 +154,12 @@ export const createSqlControlPlanePersistence = ( · 12 unmodified lines | |||
| 146 | } satisfies SqlControlPlanePersistence; | 154 | } satisfies SqlControlPlanePersistence; |
| 147 | }), | 155 | }), |
| 148 | Effect.flatMap((persistence) => | 156 | Effect.flatMap((persistence) => |
| 149 | runPostMigrationRepairs(persistence.rows).pipe( | 157 | options.runDataMigrations === false |
| 150 | Effect.mapError(toBootstrapError), | 158 | ? Effect.succeed(persistence) |
| 151 | Effect.map(() => persistence), | 159 | : runDataMigrations(persistence.rows).pipe( |
| 152 | )), | 160 | Effect.mapError(toBootstrapError), |
| 161 | Effect.map(() => persistence), | ||
| 162 | )), | ||
| 153 | Effect.catchAll((error) => | 163 | Effect.catchAll((error) => |
| 154 | closeRuntimeEffect(runtime).pipe( | 164 | closeRuntimeEffect(runtime).pipe( |
| 155 | Effect.zipRight(Effect.fail(error)), | 165 | Effect.zipRight(Effect.fail(error)), |
| @@ -157,7 +167,7 @@ export const createSqlControlPlanePersistence = ( · 1 unmodified line | |||
| 157 | )); | 167 | )); |
| 158 | 168 | ||
| 159 | export const SqlControlPlanePersistenceLive = ( | 169 | export const SqlControlPlanePersistenceLive = ( |
| 160 | options: CreateSqlRuntimeOptions, | 170 | options: CreateSqlControlPlanePersistenceOptions, |
| 161 | ) => | 171 | ) => |
| 162 | Layer.scoped( | 172 | Layer.scoped( |
| 163 | SqlControlPlanePersistenceService, | 173 | SqlControlPlanePersistenceService, |
| @@ -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 | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { | 7 | import { |
| 3 | AccountIdSchema, | 8 | AccountIdSchema, |
| @@ -13,29 +18,88 @@ import * as Effect from "effect/Effect"; · 9 unmodified lines | |||
| 13 | import * as Option from "effect/Option"; | 18 | import * as Option from "effect/Option"; |
| 14 | import * as Schema from "effect/Schema"; | 19 | import * as Schema from "effect/Schema"; |
| 15 | import * as Scope from "effect/Scope"; | 20 | import * as Scope from "effect/Scope"; |
| 16 | import { | 21 | import { buildSchema, getIntrospectionQuery, graphqlSync } from "graphql"; |
| 17 | buildSchema, | 22 | import { sql } from "drizzle-orm"; |
| 18 | getIntrospectionQuery, | ||
| 19 | graphqlSync, | ||
| 20 | } from "graphql"; | ||
| 21 | 23 | ||
| 22 | import { | 24 | import { |
| 23 | createSqlControlPlanePersistence, | 25 | createSqlControlPlanePersistence, |
| 24 | type SqlControlPlanePersistence, | 26 | type SqlControlPlanePersistence, |
| 25 | } from "./index"; | 27 | } from "./index"; |
| 26 | import { runPostMigrationRepairs } from "./post-migrations"; | 28 | import { runDataMigrations } from "./data-migrations"; |
| 29 | import { loadSourceById } from "../runtime/source-store"; | ||
| 27 | 30 | ||
| 28 | const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | 31 | const openApiBindingConfigJson = (specUrl: string): string => |
| 29 | Effect.acquireRelease( | 32 | JSON.stringify({ |
| 30 | createSqlControlPlanePersistence({ | 33 | adapterKey: "openapi", |
| 31 | localDataDir: ":memory:", | 34 | specUrl, |
| 32 | }), | 35 | defaultHeaders: null, |
| 33 | (persistence) => | 36 | }); |
| 34 | Effect.tryPromise({ | 37 | |
| 35 | try: () => persistence.close(), | 38 | const graphqlBindingConfigJson = (): string => |
| 36 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | 39 | JSON.stringify({ |
| 37 | }).pipe(Effect.orDie), | 40 | adapterKey: "graphql", |
| 38 | ); | 41 | defaultHeaders: null, |
| 42 | }); | ||
| 43 | |||
| 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 | ); | ||
| 39 | 103 | ||
| 40 | const seedMigratedSourceRecipe = (input: { | 104 | const seedMigratedSourceRecipe = (input: { |
| 41 | persistence: SqlControlPlanePersistence; | 105 | persistence: SqlControlPlanePersistence; |
| @@ -43,15 +107,21 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line | |||
| 43 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | 107 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; |
| 44 | sourceId: ReturnType<typeof SourceIdSchema.make>; | 108 | sourceId: ReturnType<typeof SourceIdSchema.make>; |
| 45 | documentText: string; | 109 | documentText: string; |
| 46 | }): Effect.Effect<{ | 110 | }): Effect.Effect< |
| 47 | recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>; | 111 | { |
| 48 | }, unknown, never> => | 112 | recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>; |
| 113 | }, | ||
| 114 | unknown, | ||
| 115 | never | ||
| 116 | > => | ||
| 49 | Effect.gen(function* () { | 117 | Effect.gen(function* () { |
| 50 | const now = Date.now(); | 118 | const now = Date.now(); |
| 51 | const accountId = AccountIdSchema.make(`acc_${input.sourceId}`); | 119 | const accountId = AccountIdSchema.make(`acc_${input.sourceId}`); |
| 52 | const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`); | 120 | const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`); |
| 53 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); | 121 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); |
| 54 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`); | 122 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( |
| 123 | `src_recipe_rev_${input.sourceId}`, | ||
| 124 | ); | ||
| 55 | 125 | ||
| 56 | yield* input.persistence.rows.organizations.insert({ | 126 | yield* input.persistence.rows.organizations.insert({ |
| 57 | id: organizationId, | 127 | id: organizationId, |
| @@ -72,9 +142,11 @@ const seedMigratedSourceRecipe = (input: { · 14 unmodified lines | |||
| 72 | }); | 142 | }); |
| 73 | yield* input.persistence.rows.sourceRecipes.upsert({ | 143 | yield* input.persistence.rows.sourceRecipes.upsert({ |
| 74 | id: recipeId, | 144 | id: recipeId, |
| 75 | kind: input.kind === "openapi" ? "http_recipe" : "graphql_recipe", | 145 | kind: "http_api", |
| 76 | importerKind: input.kind === "openapi" ? "openapi" : "graphql_introspection", | 146 | importerKind: |
| 77 | providerKey: input.kind === "openapi" ? "generic_http" : "generic_graphql", | 147 | input.kind === "openapi" ? "openapi" : "graphql_introspection", |
| 148 | providerKey: | ||
| 149 | input.kind === "openapi" ? "generic_http" : "generic_graphql", | ||
| 78 | name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo", | 150 | name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo", |
| 79 | summary: null, | 151 | summary: null, |
| 80 | visibility: "workspace", | 152 | visibility: "workspace", |
| @@ -82,7 +154,7 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line | |||
| 82 | createdAt: now, | 154 | createdAt: now, |
| 83 | updatedAt: now, | 155 | updatedAt: now, |
| 84 | }); | 156 | }); |
| 85 | yield* input.persistence.rows.sourceRecipeRevisions.upsert({ | 157 | yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ |
| 86 | id: recipeRevisionId, | 158 | id: recipeRevisionId, |
| 87 | recipeId, | 159 | recipeId, |
| 88 | revisionNumber: 1, | 160 | revisionNumber: 1, |
| @@ -102,9 +174,10 @@ const seedMigratedSourceRecipe = (input: { · 13 unmodified lines | |||
| 102 | ), | 174 | ), |
| 103 | manifestJson: null, | 175 | manifestJson: null, |
| 104 | manifestHash: null, | 176 | manifestHash: null, |
| 177 | materializationHash: null, | ||
| 105 | createdAt: now, | 178 | createdAt: now, |
| 106 | updatedAt: now, | 179 | updatedAt: now, |
| 107 | }); | 180 | })); |
| 108 | yield* input.persistence.rows.sources.insert({ | 181 | yield* input.persistence.rows.sources.insert({ |
| 109 | id: input.sourceId, | 182 | id: input.sourceId, |
| 110 | workspaceId: input.workspaceId, | 183 | workspaceId: input.workspaceId, |
| @@ -119,45 +192,47 @@ const seedMigratedSourceRecipe = (input: { · 8 unmodified lines | |||
| 119 | status: "connected", | 192 | status: "connected", |
| 120 | enabled: true, | 193 | enabled: true, |
| 121 | namespace: input.kind === "openapi" ? "github" : "graphql", | 194 | namespace: input.kind === "openapi" ? "github" : "graphql", |
| 122 | transport: null, | 195 | importAuthPolicy: "reuse_runtime", |
| 123 | bindingConfigJson: null, | 196 | bindingConfigJson: |
| 124 | queryParamsJson: null, | 197 | input.kind === "openapi" |
| 125 | headersJson: null, | 198 | ? openApiBindingConfigJson("https://api.example.com/openapi.json") |
| 126 | specUrl: input.kind === "openapi" ? "https://api.example.com/openapi.json" : null, | 199 | : graphqlBindingConfigJson(), |
| 127 | defaultHeadersJson: null, | ||
| 128 | sourceHash: null, | 200 | sourceHash: null, |
| 129 | sourceDocumentText: null, | ||
| 130 | lastError: null, | 201 | lastError: null, |
| 131 | createdAt: now, | 202 | createdAt: now, |
| 132 | updatedAt: now, | 203 | updatedAt: now, |
| 133 | }); | 204 | }); |
| 134 | yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ | 205 | yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ |
| 135 | recipeRevisionId, | 206 | recipeRevisionId, |
| 136 | documents: [{ | 207 | documents: [ |
| 137 | id: `src_recipe_doc_${input.sourceId}`, | 208 | { |
| 138 | recipeRevisionId, | 209 | id: `src_recipe_doc_${input.sourceId}`, |
| 139 | documentKind: input.kind === "openapi" ? "openapi" : "graphql_introspection", | 210 | recipeRevisionId, |
| 140 | documentKey: | 211 | documentKind: |
| 141 | input.kind === "openapi" | 212 | input.kind === "openapi" ? "openapi" : "graphql_introspection", |
| 142 | ? "https://api.example.com/openapi.json" | 213 | documentKey: |
| 143 | : "https://api.example.com/graphql", | 214 | input.kind === "openapi" |
| 144 | contentText: input.documentText, | 215 | ? "https://api.example.com/openapi.json" |
| 145 | contentHash: `hash_${input.sourceId}`, | 216 | : "https://api.example.com/graphql", |
| 146 | fetchedAt: now, | 217 | contentText: input.documentText, |
| 147 | createdAt: now, | 218 | contentHash: `hash_${input.sourceId}`, |
| 148 | updatedAt: now, | 219 | fetchedAt: now, |
| 149 | }], | 220 | createdAt: now, |
| 221 | updatedAt: now, | ||
| 222 | }, | ||
| 223 | ], | ||
| 150 | }); | 224 | }); |
| 151 | 225 | ||
| 152 | return { recipeRevisionId }; | 226 | return { recipeRevisionId }; |
| 153 | }); | 227 | }); |
| 154 | 228 | ||
| 155 | describe("post-migrations", () => { | 229 | describe("data-migrations", () => { |
| 156 | it.scoped("repairs migrated OpenAPI recipes from stored documents", () => | 230 | it.scoped("repairs migrated OpenAPI recipes from stored documents", () => |
| 157 | Effect.gen(function* () { | 231 | Effect.gen(function* () { |
| 158 | const persistence = yield* makePersistence; | 232 | const persistence = yield* makePersistence; |
| 159 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi"); | 233 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi"); |
| 160 | const sourceId = SourceIdSchema.make("src_post_migration_openapi"); | 234 | const sourceId = SourceIdSchema.make("src_post_migration_openapi"); |
| 235 | yield* persistence.rows.dataMigrations.clearAll(); | ||
| 161 | 236 | ||
| 162 | const openApiDocument = JSON.stringify({ | 237 | const openApiDocument = JSON.stringify({ |
| 163 | openapi: "3.0.3", | 238 | openapi: "3.0.3", |
| @@ -189,11 +264,7 @@ describe("post-migrations", () => { · 25 unmodified lines | |||
| 189 | content: { | 264 | content: { |
| 190 | "application/json": { | 265 | "application/json": { |
| 191 | schema: { | 266 | schema: { |
| 192 | type: "object", | 267 | $ref: "#/components/schemas/Repository", |
| 193 | properties: { | ||
| 194 | full_name: { type: "string" }, | ||
| 195 | }, | ||
| 196 | required: ["full_name"], | ||
| 197 | }, | 268 | }, |
| 198 | }, | 269 | }, |
| 199 | }, | 270 | }, |
| @@ -202,6 +273,17 @@ describe("post-migrations", () => { · 2 unmodified lines | |||
| 202 | }, | 273 | }, |
| 203 | }, | 274 | }, |
| 204 | }, | 275 | }, |
| 276 | components: { | ||
| 277 | schemas: { | ||
| 278 | Repository: { | ||
| 279 | type: "object", | ||
| 280 | properties: { | ||
| 281 | full_name: { type: "string" }, | ||
| 282 | }, | ||
| 283 | required: ["full_name"], | ||
| 284 | }, | ||
| 285 | }, | ||
| 286 | }, | ||
| 205 | }); | 287 | }); |
| 206 | 288 | ||
| 207 | const { recipeRevisionId } = yield* seedMigratedSourceRecipe({ | 289 | const { recipeRevisionId } = yield* seedMigratedSourceRecipe({ |
| @@ -212,13 +294,22 @@ describe("post-migrations", () => { · 4 unmodified lines | |||
| 212 | documentText: openApiDocument, | 294 | documentText: openApiDocument, |
| 213 | }); | 295 | }); |
| 214 | 296 | ||
| 215 | yield* runPostMigrationRepairs(persistence.rows); | 297 | yield* runDataMigrations(persistence.rows); |
| 216 | 298 | ||
| 217 | const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId); | 299 | const revision = |
| 300 | yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId); | ||
| 218 | expect(Option.isSome(revision)).toBe(true); | 301 | expect(Option.isSome(revision)).toBe(true); |
| 219 | expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); | 302 | expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); |
| 220 | expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length) | 303 | expect( |
| 221 | .toBeGreaterThan(0); | 304 | (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId( |
| 305 | recipeRevisionId, | ||
| 306 | )).length, | ||
| 307 | ).toBe(1); | ||
| 308 | expect( | ||
| 309 | (yield* persistence.rows.sourceRecipeOperations.listByRevisionId( | ||
| 310 | recipeRevisionId, | ||
| 311 | )).length, | ||
| 312 | ).toBeGreaterThan(0); | ||
| 222 | }), | 313 | }), |
| 223 | ); | 314 | ); |
| 224 | 315 | ||
| @@ -227,6 +318,7 @@ describe("post-migrations", () => { · 2 unmodified lines | |||
| 227 | const persistence = yield* makePersistence; | 318 | const persistence = yield* makePersistence; |
| 228 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql"); | 319 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql"); |
| 229 | const sourceId = SourceIdSchema.make("src_post_migration_graphql"); | 320 | const sourceId = SourceIdSchema.make("src_post_migration_graphql"); |
| 321 | yield* persistence.rows.dataMigrations.clearAll(); | ||
| 230 | const schema = buildSchema(` | 322 | const schema = buildSchema(` |
| 231 | type Query { | 323 | type Query { |
| 232 | viewer: User! | 324 | viewer: User! |
| @@ -260,13 +352,22 @@ describe("post-migrations", () => { · 27 unmodified lines | |||
| 260 | documentText: graphqlDocument, | 352 | documentText: graphqlDocument, |
| 261 | }); | 353 | }); |
| 262 | 354 | ||
| 263 | yield* runPostMigrationRepairs(persistence.rows); | 355 | yield* runDataMigrations(persistence.rows); |
| 264 | 356 | ||
| 265 | const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId); | 357 | const revision = |
| 358 | yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId); | ||
| 266 | expect(Option.isSome(revision)).toBe(true); | 359 | expect(Option.isSome(revision)).toBe(true); |
| 267 | expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); | 360 | expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); |
| 268 | expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length) | 361 | expect( |
| 269 | .toBeGreaterThan(0); | 362 | (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId( |
| 363 | recipeRevisionId, | ||
| 364 | )).length, | ||
| 365 | ).toBe(1); | ||
| 366 | expect( | ||
| 367 | (yield* persistence.rows.sourceRecipeOperations.listByRevisionId( | ||
| 368 | recipeRevisionId, | ||
| 369 | )).length, | ||
| 370 | ).toBeGreaterThan(0); | ||
| 270 | }), | 371 | }), |
| 271 | ); | 372 | ); |
| 272 | 373 | ||
| @@ -275,7 +376,10 @@ describe("post-migrations", () => { · 2 unmodified lines | |||
| 275 | const persistence = yield* makePersistence; | 376 | const persistence = yield* makePersistence; |
| 276 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_session"); | 377 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_session"); |
| 277 | const sourceId = SourceIdSchema.make("src_post_migration_session"); | 378 | const sourceId = SourceIdSchema.make("src_post_migration_session"); |
| 278 | const sessionId = SourceAuthSessionIdSchema.make("src_auth_post_migration_session"); | 379 | yield* persistence.rows.dataMigrations.clearAll(); |
| 380 | const sessionId = SourceAuthSessionIdSchema.make( | ||
| 381 | "src_auth_post_migration_session", | ||
| 382 | ); | ||
| 279 | const now = Date.now(); | 383 | const now = Date.now(); |
| 280 | 384 | ||
| 281 | yield* persistence.rows.sourceAuthSessions.insert({ | 385 | yield* persistence.rows.sourceAuthSessions.insert({ |
| @@ -283,20 +387,23 @@ describe("post-migrations", () => { · 1 unmodified line | |||
| 283 | workspaceId, | 387 | workspaceId, |
| 284 | sourceId, | 388 | sourceId, |
| 285 | actorAccountId: null, | 389 | actorAccountId: null, |
| 286 | executionId: null, | 390 | executionId: null, |
| 287 | interactionId: null, | 391 | interactionId: null, |
| 288 | providerKind: "mcp_oauth", | 392 | providerKind: "mcp_oauth", |
| 289 | status: "pending", | 393 | credentialSlot: "runtime", |
| 394 | status: "pending", | ||
| 290 | state: "state_post_migration_session", | 395 | state: "state_post_migration_session", |
| 291 | sessionDataJson: JSON.stringify({ | 396 | sessionDataJson: JSON.stringify({ |
| 292 | kind: "mcp_oauth", | 397 | kind: "mcp_oauth", |
| 293 | endpoint: "https://api.github.com", | 398 | endpoint: "https://api.github.com", |
| 294 | redirectUri: "http://127.0.0.1/callback", | 399 | redirectUri: "http://127.0.0.1/callback", |
| 295 | scope: null, | 400 | scope: null, |
| 296 | resourceMetadataUrl: "https://api.github.com/.well-known/oauth-protected-resource", | 401 | resourceMetadataUrl: |
| 402 | "https://api.github.com/.well-known/oauth-protected-resource", | ||
| 297 | authorizationServerUrl: "https://github.com/login/oauth", | 403 | authorizationServerUrl: "https://github.com/login/oauth", |
| 298 | resourceMetadataJson: '{"issuer":"https://api.github.com"}', | 404 | resourceMetadataJson: '{"issuer":"https://api.github.com"}', |
| 299 | authorizationServerMetadataJson: '{"token_endpoint":"https://github.com/login/oauth/access_token"}', | 405 | authorizationServerMetadataJson: |
| 406 | '{"token_endpoint":"https://github.com/login/oauth/access_token"}', | ||
| 300 | clientInformationJson: '{"client_id":"abc123"}', | 407 | clientInformationJson: '{"client_id":"abc123"}', |
| 301 | codeVerifier: "verifier", | 408 | codeVerifier: "verifier", |
| 302 | authorizationUrl: "https://github.com/login/oauth/authorize", | 409 | authorizationUrl: "https://github.com/login/oauth/authorize", |
| @@ -307,15 +414,16 @@ describe("post-migrations", () => { · 4 unmodified lines | |||
| 307 | updatedAt: now, | 414 | updatedAt: now, |
| 308 | }); | 415 | }); |
| 309 | 416 | ||
| 310 | yield* runPostMigrationRepairs(persistence.rows); | 417 | yield* runDataMigrations(persistence.rows); |
| 311 | 418 | ||
| 312 | const session = yield* persistence.rows.sourceAuthSessions.getById(sessionId); | 419 | const session = |
| 420 | yield* persistence.rows.sourceAuthSessions.getById(sessionId); | ||
| 313 | expect(Option.isSome(session)).toBe(true); | 421 | expect(Option.isSome(session)).toBe(true); |
| 314 | const repairedSession = session.pipe(Option.getOrNull); | 422 | const repairedSession = session.pipe(Option.getOrNull); |
| 315 | expect(repairedSession).not.toBeNull(); | 423 | expect(repairedSession).not.toBeNull(); |
| 316 | const repaired = Schema.decodeUnknownSync(McpSourceAuthSessionDataJsonSchema)( | 424 | const repaired = Schema.decodeUnknownSync( |
| 317 | repairedSession!.sessionDataJson, | 425 | McpSourceAuthSessionDataJsonSchema, |
| 318 | ); | 426 | )(repairedSession!.sessionDataJson); |
| 319 | expect(repaired.resourceMetadata).toEqual({ | 427 | expect(repaired.resourceMetadata).toEqual({ |
| 320 | issuer: "https://api.github.com", | 428 | issuer: "https://api.github.com", |
| 321 | }); | 429 | }); |
| @@ -327,4 +435,287 @@ describe("post-migrations", () => { · 5 unmodified lines | |||
| 327 | }); | 435 | }); |
| 328 | }), | 436 | }), |
| 329 | ); | 437 | ); |
| 438 | |||
| 439 | it("upgrades legacy source binding columns into adapter-owned binding config", async () => { | ||
| 440 | const localDataDir = mkdtempSync(path.join(tmpdir(), "executor-cp-db-")); | ||
| 441 | const legacyMigrationsDir = copyMigrationsBefore( | ||
| 442 | "20260311010000_source_binding_configs", | ||
| 443 | ); | ||
| 444 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_source_bindings"); | ||
| 445 | const accountId = AccountIdSchema.make("acc_legacy_source_bindings"); | ||
| 446 | const organizationId = OrganizationIdSchema.make("org_legacy_source_bindings"); | ||
| 447 | const now = Date.now(); | ||
| 448 | let legacyPersistence: SqlControlPlanePersistence | null = null; | ||
| 449 | let upgradedPersistence: SqlControlPlanePersistence | null = null; | ||
| 450 | |||
| 451 | try { | ||
| 452 | legacyPersistence = await Effect.runPromise( | ||
| 453 | createSqlControlPlanePersistence({ | ||
| 454 | localDataDir, | ||
| 455 | migrationsFolder: legacyMigrationsDir, | ||
| 456 | runDataMigrations: false, | ||
| 457 | }), | ||
| 458 | ); | ||
| 459 | |||
| 460 | await Effect.runPromise(legacyPersistence.rows.organizations.insert({ | ||
| 461 | id: organizationId, | ||
| 462 | slug: "org-legacy-source-bindings", | ||
| 463 | name: "Legacy Org", | ||
| 464 | status: "active", | ||
| 465 | createdByAccountId: accountId, | ||
| 466 | createdAt: now, | ||
| 467 | updatedAt: now, | ||
| 468 | })); | ||
| 469 | await Effect.runPromise(legacyPersistence.rows.workspaces.insert({ | ||
| 470 | id: workspaceId, | ||
| 471 | organizationId, | ||
| 472 | name: "Legacy Workspace", | ||
| 473 | createdByAccountId: accountId, | ||
| 474 | createdAt: now, | ||
| 475 | updatedAt: now, | ||
| 476 | })); | ||
| 477 | |||
| 478 | const seedRecipe = async (input: { | ||
| 479 | sourceId: string; | ||
| 480 | kind: "openapi" | "graphql" | "mcp"; | ||
| 481 | importerKind: string; | ||
| 482 | providerKey: string; | ||
| 483 | name: string; | ||
| 484 | endpoint: string; | ||
| 485 | sourceConfigJson: string; | ||
| 486 | }) => { | ||
| 487 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); | ||
| 488 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( | ||
| 489 | `src_recipe_rev_${input.sourceId}`, | ||
| 490 | ); | ||
| 491 | |||
| 492 | await Effect.runPromise(legacyPersistence!.rows.sourceRecipes.upsert({ | ||
| 493 | id: recipeId, | ||
| 494 | kind: input.kind === "mcp" ? "mcp" : "http_api", | ||
| 495 | importerKind: input.importerKind, | ||
| 496 | providerKey: input.providerKey, | ||
| 497 | name: input.name, | ||
| 498 | summary: null, | ||
| 499 | visibility: "workspace", | ||
| 500 | latestRevisionId: recipeRevisionId, | ||
| 501 | createdAt: now, | ||
| 502 | updatedAt: now, | ||
| 503 | })); | ||
| 504 | await legacyPersistence!.db.execute(sql` | ||
| 505 | INSERT INTO "source_recipe_revisions" ( | ||
| 506 | "id", | ||
| 507 | "recipe_id", | ||
| 508 | "revision_number", | ||
| 509 | "source_config_json", | ||
| 510 | "manifest_json", | ||
| 511 | "manifest_hash", | ||
| 512 | "created_at", | ||
| 513 | "updated_at" | ||
| 514 | ) VALUES ( | ||
| 515 | ${recipeRevisionId}, | ||
| 516 | ${recipeId}, | ||
| 517 | 1, | ||
| 518 | ${input.sourceConfigJson}, | ||
| 519 | NULL, | ||
| 520 | NULL, | ||
| 521 | ${now}, | ||
| 522 | ${now} | ||
| 523 | ) | ||
| 524 | `); | ||
| 525 | |||
| 526 | return { recipeId, recipeRevisionId }; | ||
| 527 | }; | ||
| 528 | |||
| 529 | const openapi = await seedRecipe({ | ||
| 530 | sourceId: "src_legacy_openapi", | ||
| 531 | kind: "openapi", | ||
| 532 | importerKind: "openapi", | ||
| 533 | providerKey: "generic_http", | ||
| 534 | name: "Legacy OpenAPI", | ||
| 535 | endpoint: "https://api.example.com", | ||
| 536 | sourceConfigJson: JSON.stringify({ | ||
| 537 | kind: "openapi", | ||
| 538 | endpoint: "https://api.example.com", | ||
| 539 | specUrl: "https://api.example.com/openapi.json", | ||
| 540 | defaultHeaders: { accept: "application/json" }, | ||
| 541 | }), | ||
| 542 | }); | ||
| 543 | const graphql = await seedRecipe({ | ||
| 544 | sourceId: "src_legacy_graphql", | ||
| 545 | kind: "graphql", | ||
| 546 | importerKind: "graphql_introspection", | ||
| 547 | providerKey: "generic_graphql", | ||
| 548 | name: "Legacy GraphQL", | ||
| 549 | endpoint: "https://api.example.com/graphql", | ||
| 550 | sourceConfigJson: JSON.stringify({ | ||
| 551 | kind: "graphql", | ||
| 552 | endpoint: "https://api.example.com/graphql", | ||
| 553 | defaultHeaders: { accept: "application/json" }, | ||
| 554 | }), | ||
| 555 | }); | ||
| 556 | const mcp = await seedRecipe({ | ||
| 557 | sourceId: "src_legacy_mcp", | ||
| 558 | kind: "mcp", | ||
| 559 | importerKind: "mcp_manifest", | ||
| 560 | providerKey: "generic_mcp", | ||
| 561 | name: "Legacy MCP", | ||
| 562 | endpoint: "https://api.example.com/mcp", | ||
| 563 | sourceConfigJson: JSON.stringify({ | ||
| 564 | kind: "mcp", | ||
| 565 | endpoint: "https://api.example.com/mcp", | ||
| 566 | transport: "streamable-http", | ||
| 567 | queryParams: { tenant: "acme" }, | ||
| 568 | headers: { "x-tenant": "acme" }, | ||
| 569 | }), | ||
| 570 | }); | ||
| 571 | |||
| 572 | await legacyPersistence.db.execute(sql` | ||
| 573 | INSERT INTO "sources" ( | ||
| 574 | "workspace_id", | ||
| 575 | "source_id", | ||
| 576 | "recipe_id", | ||
| 577 | "recipe_revision_id", | ||
| 578 | "name", | ||
| 579 | "kind", | ||
| 580 | "endpoint", | ||
| 581 | "status", | ||
| 582 | "enabled", | ||
| 583 | "namespace", | ||
| 584 | "binding_config_json", | ||
| 585 | "transport", | ||
| 586 | "query_params_json", | ||
| 587 | "headers_json", | ||
| 588 | "spec_url", | ||
| 589 | "default_headers_json", | ||
| 590 | "source_hash", | ||
| 591 | "source_document_text", | ||
| 592 | "last_error", | ||
| 593 | "created_at", | ||
| 594 | "updated_at" | ||
| 595 | ) VALUES | ||
| 596 | ( | ||
| 597 | ${workspaceId}, | ||
| 598 | ${SourceIdSchema.make("src_legacy_openapi")}, | ||
| 599 | ${openapi.recipeId}, | ||
| 600 | ${openapi.recipeRevisionId}, | ||
| 601 | 'Legacy OpenAPI', | ||
| 602 | 'openapi', | ||
| 603 | 'https://api.example.com', | ||
| 604 | 'connected', | ||
| 605 | true, | ||
| 606 | 'legacy.openapi', | ||
| 607 | NULL, | ||
| 608 | NULL, | ||
| 609 | NULL, | ||
| 610 | NULL, | ||
| 611 | 'https://api.example.com/openapi.json', | ||
| 612 | '{"accept":"application/json"}', | ||
| 613 | NULL, | ||
| 614 | NULL, | ||
| 615 | NULL, | ||
| 616 | ${now}, | ||
| 617 | ${now} | ||
| 618 | ), | ||
| 619 | ( | ||
| 620 | ${workspaceId}, | ||
| 621 | ${SourceIdSchema.make("src_legacy_graphql")}, | ||
| 622 | ${graphql.recipeId}, | ||
| 623 | ${graphql.recipeRevisionId}, | ||
| 624 | 'Legacy GraphQL', | ||
| 625 | 'graphql', | ||
| 626 | 'https://api.example.com/graphql', | ||
| 627 | 'connected', | ||
| 628 | true, | ||
| 629 | 'legacy.graphql', | ||
| 630 | NULL, | ||
| 631 | NULL, | ||
| 632 | NULL, | ||
| 633 | NULL, | ||
| 634 | NULL, | ||
| 635 | '{"accept":"application/json"}', | ||
| 636 | NULL, | ||
| 637 | NULL, | ||
| 638 | NULL, | ||
| 639 | ${now}, | ||
| 640 | ${now} | ||
| 641 | ), | ||
| 642 | ( | ||
| 643 | ${workspaceId}, | ||
| 644 | ${SourceIdSchema.make("src_legacy_mcp")}, | ||
| 645 | ${mcp.recipeId}, | ||
| 646 | ${mcp.recipeRevisionId}, | ||
| 647 | 'Legacy MCP', | ||
| 648 | 'mcp', | ||
| 649 | 'https://api.example.com/mcp', | ||
| 650 | 'connected', | ||
| 651 | true, | ||
| 652 | 'legacy.mcp', | ||
| 653 | NULL, | ||
| 654 | 'streamable-http', | ||
| 655 | '{"tenant":"acme"}', | ||
| 656 | '{"x-tenant":"acme"}', | ||
| 657 | NULL, | ||
| 658 | NULL, | ||
| 659 | NULL, | ||
| 660 | NULL, | ||
| 661 | NULL, | ||
| 662 | ${now}, | ||
| 663 | ${now} | ||
| 664 | ); | ||
| 665 | `); | ||
| 666 | |||
| 667 | await legacyPersistence.close(); | ||
| 668 | legacyPersistence = null; | ||
| 669 | |||
| 670 | upgradedPersistence = await Effect.runPromise( | ||
| 671 | createSqlControlPlanePersistence({ | ||
| 672 | localDataDir, | ||
| 673 | }), | ||
| 674 | ); | ||
| 675 | |||
| 676 | const openApiSource = await Effect.runPromise(loadSourceById( | ||
| 677 | upgradedPersistence.rows, | ||
| 678 | { | ||
| 679 | workspaceId, | ||
| 680 | sourceId: SourceIdSchema.make("src_legacy_openapi"), | ||
| 681 | }, | ||
| 682 | )); | ||
| 683 | expect(openApiSource.specUrl).toBe("https://api.example.com/openapi.json"); | ||
| 684 | expect(openApiSource.defaultHeaders).toEqual({ | ||
| 685 | accept: "application/json", | ||
| 686 | }); | ||
| 687 | |||
| 688 | const graphqlSource = await Effect.runPromise(loadSourceById( | ||
| 689 | upgradedPersistence.rows, | ||
| 690 | { | ||
| 691 | workspaceId, | ||
| 692 | sourceId: SourceIdSchema.make("src_legacy_graphql"), | ||
| 693 | }, | ||
| 694 | )); | ||
| 695 | expect(graphqlSource.defaultHeaders).toEqual({ | ||
| 696 | accept: "application/json", | ||
| 697 | }); | ||
| 698 | expect(graphqlSource.specUrl).toBeNull(); | ||
| 699 | |||
| 700 | const mcpSource = await Effect.runPromise(loadSourceById( | ||
| 701 | upgradedPersistence.rows, | ||
| 702 | { | ||
| 703 | workspaceId, | ||
| 704 | sourceId: SourceIdSchema.make("src_legacy_mcp"), | ||
| 705 | }, | ||
| 706 | )); | ||
| 707 | expect(mcpSource.transport).toBe("streamable-http"); | ||
| 708 | expect(mcpSource.queryParams).toEqual({ | ||
| 709 | tenant: "acme", | ||
| 710 | }); | ||
| 711 | expect(mcpSource.headers).toEqual({ | ||
| 712 | "x-tenant": "acme", | ||
| 713 | }); | ||
| 714 | } finally { | ||
| 715 | await legacyPersistence?.close().catch(() => undefined); | ||
| 716 | await upgradedPersistence?.close().catch(() => undefined); | ||
| 717 | rmSync(localDataDir, { recursive: true, force: true }); | ||
| 718 | rmSync(legacyMigrationsDir, { recursive: true, force: true }); | ||
| 719 | } | ||
| 720 | }); | ||
| 330 | }); | 721 | }); |
| @@ -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 | }); | ||
| @@ -88,6 +88,7 @@ export const createCredentialsRepo = ( · 87 unmodified lines | |||
| 88 | ), | 88 | ), |
| 89 | ) | 89 | ) |
| 90 | .orderBy( | 90 | .orderBy( |
| 91 | asc(tables.credentialsTable.slot), | ||
| 91 | asc(tables.credentialsTable.actorAccountId), | 92 | asc(tables.credentialsTable.actorAccountId), |
| 92 | asc(tables.credentialsTable.updatedAt), | 93 | asc(tables.credentialsTable.updatedAt), |
| 93 | asc(tables.credentialsTable.id), | 94 | asc(tables.credentialsTable.id), |
| @@ -100,6 +101,7 @@ export const createCredentialsRepo = ( · 6 unmodified lines | |||
| 100 | workspaceId: Credential["workspaceId"]; | 101 | workspaceId: Credential["workspaceId"]; |
| 101 | sourceId: Credential["sourceId"]; | 102 | sourceId: Credential["sourceId"]; |
| 102 | actorAccountId: Credential["actorAccountId"]; | 103 | actorAccountId: Credential["actorAccountId"]; |
| 104 | slot: Credential["slot"]; | ||
| 103 | }) => | 105 | }) => |
| 104 | client.use("rows.credentials.get_by_workspace_source_actor", async (db) => { | 106 | client.use("rows.credentials.get_by_workspace_source_actor", async (db) => { |
| 105 | const rows = await db | 107 | const rows = await db |
| @@ -109,6 +111,7 @@ export const createCredentialsRepo = ( · 3 unmodified lines | |||
| 109 | and( | 111 | and( |
| 110 | eq(tables.credentialsTable.workspaceId, input.workspaceId), | 112 | eq(tables.credentialsTable.workspaceId, input.workspaceId), |
| 111 | eq(tables.credentialsTable.sourceId, input.sourceId), | 113 | eq(tables.credentialsTable.sourceId, input.sourceId), |
| 114 | eq(tables.credentialsTable.slot, input.slot), | ||
| 112 | input.actorAccountId === null | 115 | input.actorAccountId === null |
| 113 | ? isNull(tables.credentialsTable.actorAccountId) | 116 | ? isNull(tables.credentialsTable.actorAccountId) |
| 114 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | 117 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), |
| @@ -134,6 +137,7 @@ export const createCredentialsRepo = ( · 19 unmodified lines | |||
| 134 | and( | 137 | and( |
| 135 | eq(tables.credentialsTable.workspaceId, credential.workspaceId), | 138 | eq(tables.credentialsTable.workspaceId, credential.workspaceId), |
| 136 | eq(tables.credentialsTable.sourceId, credential.sourceId), | 139 | eq(tables.credentialsTable.sourceId, credential.sourceId), |
| 140 | eq(tables.credentialsTable.slot, credential.slot), | ||
| 137 | isNull(tables.credentialsTable.actorAccountId), | 141 | isNull(tables.credentialsTable.actorAccountId), |
| 138 | ), | 142 | ), |
| 139 | ) | 143 | ) |
| @@ -170,6 +174,7 @@ export const createCredentialsRepo = ( · 30 unmodified lines | |||
| 170 | tables.credentialsTable.workspaceId, | 174 | tables.credentialsTable.workspaceId, |
| 171 | tables.credentialsTable.sourceId, | 175 | tables.credentialsTable.sourceId, |
| 172 | tables.credentialsTable.actorAccountId, | 176 | tables.credentialsTable.actorAccountId, |
| 177 | tables.credentialsTable.slot, | ||
| 173 | ], | 178 | ], |
| 174 | set: { | 179 | set: { |
| 175 | ...credentialUpdateSet(credential), | 180 | ...credentialUpdateSet(credential), |
| @@ -181,6 +186,7 @@ export const createCredentialsRepo = ( · 5 unmodified lines | |||
| 181 | workspaceId: Credential["workspaceId"]; | 186 | workspaceId: Credential["workspaceId"]; |
| 182 | sourceId: Credential["sourceId"]; | 187 | sourceId: Credential["sourceId"]; |
| 183 | actorAccountId: Credential["actorAccountId"]; | 188 | actorAccountId: Credential["actorAccountId"]; |
| 189 | slot?: Credential["slot"]; | ||
| 184 | }) => | 190 | }) => |
| 185 | client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => { | 191 | client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => { |
| 186 | const deleted = await db | 192 | const deleted = await db |
| @@ -189,6 +195,9 @@ export const createCredentialsRepo = ( · 2 unmodified lines | |||
| 189 | and( | 195 | and( |
| 190 | eq(tables.credentialsTable.workspaceId, input.workspaceId), | 196 | eq(tables.credentialsTable.workspaceId, input.workspaceId), |
| 191 | eq(tables.credentialsTable.sourceId, input.sourceId), | 197 | eq(tables.credentialsTable.sourceId, input.sourceId), |
| 198 | input.slot === undefined | ||
| 199 | ? undefined | ||
| 200 | : eq(tables.credentialsTable.slot, input.slot), | ||
| 192 | input.actorAccountId === null | 201 | input.actorAccountId === null |
| 193 | ? isNull(tables.credentialsTable.actorAccountId) | 202 | ? isNull(tables.credentialsTable.actorAccountId) |
| 194 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | 203 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), |
| @@ -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 | }); | ||
| @@ -6,10 +6,11 @@ export { createSourcesRepo } from "./sources-repo"; · 5 unmodified lines | |||
| 6 | export { createSourceRecipesRepo } from "./source-recipes-repo"; | 6 | export { createSourceRecipesRepo } from "./source-recipes-repo"; |
| 7 | export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo"; | 7 | export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo"; |
| 8 | export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo"; | 8 | export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo"; |
| 9 | export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo"; | ||
| 9 | export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo"; | 10 | export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo"; |
| 11 | export { createDataMigrationsRepo } from "./data-migrations-repo"; | ||
| 10 | export { createCredentialsRepo } from "./credentials-repo"; | 12 | export { createCredentialsRepo } from "./credentials-repo"; |
| 11 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; | 13 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; |
| 12 | export { createToolArtifactsRepo } from "./tool-artifacts-repo"; | ||
| 13 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; | 14 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 14 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; | 15 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| 15 | export { createPoliciesRepo } from "./policies-repo"; | 16 | export { createPoliciesRepo } from "./policies-repo"; |
| @@ -6,11 +6,12 @@ import { · 5 unmodified lines | |||
| 6 | } from "#schema"; | 6 | } from "#schema"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import { Schema } from "effect"; | 8 | import { Schema } from "effect"; |
| 9 | import { and, asc, eq, inArray, or } from "drizzle-orm"; | 9 | import { asc, eq, inArray } from "drizzle-orm"; |
| 10 | 10 | ||
| 11 | import type { DrizzleClient } from "../client"; | 11 | import type { DrizzleClient } from "../client"; |
| 12 | import type { DrizzleTables } from "../schema"; | 12 | import type { DrizzleTables } from "../schema"; |
| 13 | import { | 13 | import { |
| 14 | cleanupOrphanedSourceRecipes, | ||
| 14 | firstOption, | 15 | firstOption, |
| 15 | postgresSecretHandlesFromCredentials, | 16 | postgresSecretHandlesFromCredentials, |
| 16 | withoutCreatedAt, | 17 | withoutCreatedAt, |
| @@ -138,8 +139,8 @@ export const createOrganizationsRepo = ( · 121 unmodified lines | |||
| 138 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); | 139 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| 139 | const sourceRows = await tx | 140 | const sourceRows = await tx |
| 140 | .select({ | 141 | .select({ |
| 141 | sourceId: tables.sourcesTable.sourceId, | ||
| 142 | recipeId: tables.sourcesTable.recipeId, | 142 | recipeId: tables.sourcesTable.recipeId, |
| 143 | recipeRevisionId: tables.sourcesTable.recipeRevisionId, | ||
| 143 | }) | 144 | }) |
| 144 | .from(tables.sourcesTable) | 145 | .from(tables.sourcesTable) |
| 145 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | 146 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| @@ -154,28 +155,8 @@ export const createOrganizationsRepo = ( · 8 unmodified lines | |||
| 154 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | 155 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); |
| 155 | 156 | ||
| 156 | const executionIds = executionRows.map((execution) => execution.id); | 157 | const executionIds = executionRows.map((execution) => execution.id); |
| 157 | const sourceIds = sourceRows.map((source) => source.sourceId); | ||
| 158 | const recipeIds = sourceRows.map((source) => source.recipeId); | 158 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 159 | const recipeRevisionRows = recipeIds.length > 0 | 159 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); |
| 160 | ? await tx | ||
| 161 | .select({ id: tables.sourceRecipeRevisionsTable.id }) | ||
| 162 | .from(tables.sourceRecipeRevisionsTable) | ||
| 163 | .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)) | ||
| 164 | : []; | ||
| 165 | const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id); | ||
| 166 | const toolArtifactPaths = sourceIds.length > 0 | ||
| 167 | ? ( | ||
| 168 | await tx | ||
| 169 | .select({ path: tables.toolArtifactsTable.path }) | ||
| 170 | .from(tables.toolArtifactsTable) | ||
| 171 | .where( | ||
| 172 | and( | ||
| 173 | inArray(tables.toolArtifactsTable.workspaceId, workspaceIds), | ||
| 174 | inArray(tables.toolArtifactsTable.sourceId, sourceIds), | ||
| 175 | ), | ||
| 176 | ) | ||
| 177 | ).map((row) => row.path) | ||
| 178 | : []; | ||
| 179 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | 160 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 180 | 161 | ||
| 181 | if (executionIds.length > 0) { | 162 | if (executionIds.length > 0) { |
| @@ -186,39 +167,6 @@ export const createOrganizationsRepo = ( · 4 unmodified lines | |||
| 186 | ); | 167 | ); |
| 187 | } | 168 | } |
| 188 | 169 | ||
| 189 | if (toolArtifactPaths.length > 0) { | ||
| 190 | await tx | ||
| 191 | .delete(tables.toolArtifactParametersTable) | ||
| 192 | .where( | ||
| 193 | and( | ||
| 194 | inArray(tables.toolArtifactParametersTable.workspaceId, workspaceIds), | ||
| 195 | or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))), | ||
| 196 | ), | ||
| 197 | ); | ||
| 198 | |||
| 199 | await tx | ||
| 200 | .delete(tables.toolArtifactRequestBodyContentTypesTable) | ||
| 201 | .where( | ||
| 202 | and( | ||
| 203 | inArray(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceIds), | ||
| 204 | or( | ||
| 205 | ...toolArtifactPaths.map((path) => | ||
| 206 | eq(tables.toolArtifactRequestBodyContentTypesTable.path, path) | ||
| 207 | ), | ||
| 208 | ), | ||
| 209 | ), | ||
| 210 | ); | ||
| 211 | |||
| 212 | await tx | ||
| 213 | .delete(tables.toolArtifactRefHintKeysTable) | ||
| 214 | .where( | ||
| 215 | and( | ||
| 216 | inArray(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceIds), | ||
| 217 | or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))), | ||
| 218 | ), | ||
| 219 | ); | ||
| 220 | } | ||
| 221 | |||
| 222 | await tx | 170 | await tx |
| 223 | .delete(tables.executionsTable) | 171 | .delete(tables.executionsTable) |
| 224 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); | 172 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| @@ -235,33 +183,16 @@ export const createOrganizationsRepo = ( · 10 unmodified lines | |||
| 235 | .delete(tables.credentialsTable) | 183 | .delete(tables.credentialsTable) |
| 236 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | 184 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); |
| 237 | 185 | ||
| 238 | await tx | ||
| 239 | .delete(tables.toolArtifactsTable) | ||
| 240 | .where(inArray(tables.toolArtifactsTable.workspaceId, workspaceIds)); | ||
| 241 | |||
| 242 | await tx | 186 | await tx |
| 243 | .delete(tables.sourcesTable) | 187 | .delete(tables.sourcesTable) |
| 244 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | 188 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| 245 | 189 | ||
| 246 | if (recipeRevisionIds.length > 0) { | 190 | await cleanupOrphanedSourceRecipes({ |
| 247 | await tx | 191 | tx, |
| 248 | .delete(tables.sourceRecipeDocumentsTable) | 192 | tables, |
| 249 | .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds)); | 193 | candidateRecipeIds: recipeIds, |
| 250 | 194 | candidateRecipeRevisionIds: recipeRevisionIds, | |
| 251 | await tx | 195 | }); |
| 252 | .delete(tables.sourceRecipeOperationsTable) | ||
| 253 | .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds)); | ||
| 254 | } | ||
| 255 | |||
| 256 | if (recipeIds.length > 0) { | ||
| 257 | await tx | ||
| 258 | .delete(tables.sourceRecipeRevisionsTable) | ||
| 259 | .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)); | ||
| 260 | |||
| 261 | await tx | ||
| 262 | .delete(tables.sourceRecipesTable) | ||
| 263 | .where(inArray(tables.sourceRecipesTable.id, recipeIds)); | ||
| 264 | } | ||
| 265 | 196 | ||
| 266 | await tx | 197 | await tx |
| 267 | .delete(tables.policiesTable) | 198 | .delete(tables.policiesTable) |
| @@ -1,5 +1,9 @@ | |||
| 1 | import { inArray } from "drizzle-orm"; | ||
| 1 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 2 | 3 | ||
| 4 | import type { DrizzleClient } from "../client"; | ||
| 5 | import type { DrizzleTables } from "../schema"; | ||
| 6 | |||
| 3 | export const firstOption = <A>(rows: ReadonlyArray<A>): Option.Option<A> => | 7 | export const firstOption = <A>(rows: ReadonlyArray<A>): Option.Option<A> => |
| 4 | rows.length > 0 ? Option.some(rows[0] as A) : Option.none<A>(); | 8 | rows.length > 0 ? Option.some(rows[0] as A) : Option.none<A>(); |
| 5 | 9 | ||
| @@ -37,3 +41,95 @@ export const postgresSecretHandlesFromCredentials = ( · 31 unmodified lines | |||
| 37 | 41 | ||
| 38 | return [...handles]; | 42 | return [...handles]; |
| 39 | }; | 43 | }; |
| 44 | |||
| 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 | }; | ||
| @@ -74,6 +74,7 @@ export const createSourceAuthSessionsRepo = ( · 73 unmodified lines | |||
| 74 | workspaceId: SourceAuthSession["workspaceId"]; | 74 | workspaceId: SourceAuthSession["workspaceId"]; |
| 75 | sourceId: SourceAuthSession["sourceId"]; | 75 | sourceId: SourceAuthSession["sourceId"]; |
| 76 | actorAccountId: SourceAuthSession["actorAccountId"]; | 76 | actorAccountId: SourceAuthSession["actorAccountId"]; |
| 77 | credentialSlot?: SourceAuthSession["credentialSlot"]; | ||
| 77 | }) => | 78 | }) => |
| 78 | client.use( | 79 | client.use( |
| 79 | "rows.source_auth_sessions.get_pending_by_workspace_source_actor", | 80 | "rows.source_auth_sessions.get_pending_by_workspace_source_actor", |
| @@ -85,6 +86,9 @@ export const createSourceAuthSessionsRepo = ( · 5 unmodified lines | |||
| 85 | and( | 86 | and( |
| 86 | eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId), | 87 | eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId), |
| 87 | eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId), | 88 | eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId), |
| 89 | input.credentialSlot === undefined | ||
| 90 | ? undefined | ||
| 91 | : eq(tables.sourceAuthSessionsTable.credentialSlot, input.credentialSlot), | ||
| 88 | input.actorAccountId === null | 92 | input.actorAccountId === null |
| 89 | ? isNull(tables.sourceAuthSessionsTable.actorAccountId) | 93 | ? isNull(tables.sourceAuthSessionsTable.actorAccountId) |
| 90 | : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId), | 94 | : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId), |
| @@ -7,11 +7,14 @@ import { asc, eq, inArray } from "drizzle-orm"; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | import type { DrizzleClient } from "../client"; | 8 | import type { DrizzleClient } from "../client"; |
| 9 | import type { DrizzleTables } from "../schema"; | 9 | import type { DrizzleTables } from "../schema"; |
| 10 | import { chunkArray } from "./shared"; | ||
| 10 | 11 | ||
| 11 | const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync( | 12 | const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync( |
| 12 | StoredSourceRecipeDocumentRecordSchema, | 13 | StoredSourceRecipeDocumentRecordSchema, |
| 13 | ); | 14 | ); |
| 14 | 15 | ||
| 16 | const RECIPE_DOCUMENT_INSERT_BATCH_SIZE = 500; | ||
| 17 | |||
| 15 | export const createSourceRecipeDocumentsRepo = ( | 18 | export const createSourceRecipeDocumentsRepo = ( |
| 16 | client: DrizzleClient, | 19 | client: DrizzleClient, |
| 17 | tables: DrizzleTables, | 20 | tables: DrizzleTables, |
| @@ -61,7 +64,12 @@ export const createSourceRecipeDocumentsRepo = ( · 43 unmodified lines | |||
| 61 | .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId)); | 64 | .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId)); |
| 62 | 65 | ||
| 63 | if (input.documents.length > 0) { | 66 | if (input.documents.length > 0) { |
| 64 | await tx.insert(tables.sourceRecipeDocumentsTable).values([...input.documents]); | 67 | for (const batch of chunkArray( |
| 68 | input.documents, | ||
| 69 | RECIPE_DOCUMENT_INSERT_BATCH_SIZE, | ||
| 70 | )) { | ||
| 71 | await tx.insert(tables.sourceRecipeDocumentsTable).values([...batch]); | ||
| 72 | } | ||
| 65 | } | 73 | } |
| 66 | }), | 74 | }), |
| 67 | 75 | ||
| @@ -7,11 +7,15 @@ import { asc, eq, inArray } from "drizzle-orm"; · 6 unmodified lines | |||
| 7 | 7 | ||
| 8 | import type { DrizzleClient } from "../client"; | 8 | import type { DrizzleClient } from "../client"; |
| 9 | import type { DrizzleTables } from "../schema"; | 9 | import type { DrizzleTables } from "../schema"; |
| 10 | import { chunkArray } from "./shared"; | ||
| 10 | 11 | ||
| 11 | const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync( | 12 | const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync( |
| 12 | StoredSourceRecipeOperationRecordSchema, | 13 | StoredSourceRecipeOperationRecordSchema, |
| 13 | ); | 14 | ); |
| 14 | 15 | ||
| 16 | // Keep wide recipe-operation inserts well below Postgres/PGlite parameter limits. | ||
| 17 | const RECIPE_OPERATION_INSERT_BATCH_SIZE = 200; | ||
| 18 | |||
| 15 | export const createSourceRecipeOperationsRepo = ( | 19 | export const createSourceRecipeOperationsRepo = ( |
| 16 | client: DrizzleClient, | 20 | client: DrizzleClient, |
| 17 | tables: DrizzleTables, | 21 | tables: DrizzleTables, |
| @@ -61,7 +65,12 @@ export const createSourceRecipeOperationsRepo = ( · 43 unmodified lines | |||
| 61 | .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId)); | 65 | .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId)); |
| 62 | 66 | ||
| 63 | if (input.operations.length > 0) { | 67 | if (input.operations.length > 0) { |
| 64 | await tx.insert(tables.sourceRecipeOperationsTable).values([...input.operations]); | 68 | for (const batch of chunkArray( |
| 69 | input.operations, | ||
| 70 | RECIPE_OPERATION_INSERT_BATCH_SIZE, | ||
| 71 | )) { | ||
| 72 | await tx.insert(tables.sourceRecipeOperationsTable).values([...batch]); | ||
| 73 | } | ||
| 65 | } | 74 | } |
| 66 | }), | 75 | }), |
| 67 | 76 | ||
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | } from "#schema"; | 4 | } from "#schema"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import { Schema } from "effect"; | 6 | import { Schema } from "effect"; |
| 7 | import { asc, eq, inArray } from "drizzle-orm"; | 7 | import { asc, desc, eq, inArray, and } from "drizzle-orm"; |
| 8 | 8 | ||
| 9 | import type { DrizzleClient } from "../client"; | 9 | import type { DrizzleClient } from "../client"; |
| 10 | import type { DrizzleTables } from "../schema"; | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -69,6 +69,42 @@ export const createSourceRecipeRevisionsRepo = ( · 58 unmodified lines | |||
| 69 | : Option.none<StoredSourceRecipeRevisionRecord>(); | 69 | : Option.none<StoredSourceRecipeRevisionRecord>(); |
| 70 | }), | 70 | }), |
| 71 | 71 | ||
| 72 | getByRecipeAndMaterializationHash: (input: { | ||
| 73 | recipeId: StoredSourceRecipeRevisionRecord["recipeId"]; | ||
| 74 | materializationHash: string; | ||
| 75 | }) => | ||
| 76 | client.use("rows.source_recipe_revisions.get_by_recipe_materialization", async (db) => { | ||
| 77 | const rows = await db | ||
| 78 | .select() | ||
| 79 | .from(tables.sourceRecipeRevisionsTable) | ||
| 80 | .where( | ||
| 81 | and( | ||
| 82 | eq(tables.sourceRecipeRevisionsTable.recipeId, input.recipeId), | ||
| 83 | eq(tables.sourceRecipeRevisionsTable.materializationHash, input.materializationHash), | ||
| 84 | ), | ||
| 85 | ) | ||
| 86 | .limit(1); | ||
| 87 | |||
| 88 | const row = firstOption(rows); | ||
| 89 | return Option.isSome(row) | ||
| 90 | ? Option.some(decodeStoredSourceRecipeRevisionRecord(row.value)) | ||
| 91 | : Option.none<StoredSourceRecipeRevisionRecord>(); | ||
| 92 | }), | ||
| 93 | |||
| 94 | nextRevisionNumber: (recipeId: StoredSourceRecipeRevisionRecord["recipeId"]) => | ||
| 95 | client.use("rows.source_recipe_revisions.next_revision_number", async (db) => { | ||
| 96 | const rows = await db | ||
| 97 | .select({ | ||
| 98 | revisionNumber: tables.sourceRecipeRevisionsTable.revisionNumber, | ||
| 99 | }) | ||
| 100 | .from(tables.sourceRecipeRevisionsTable) | ||
| 101 | .where(eq(tables.sourceRecipeRevisionsTable.recipeId, recipeId)) | ||
| 102 | .orderBy(desc(tables.sourceRecipeRevisionsTable.revisionNumber)) | ||
| 103 | .limit(1); | ||
| 104 | |||
| 105 | return Number(rows[0]?.revisionNumber ?? 0) + 1; | ||
| 106 | }), | ||
| 107 | |||
| 72 | upsert: (revision: StoredSourceRecipeRevisionRecord) => | 108 | upsert: (revision: StoredSourceRecipeRevisionRecord) => |
| 73 | client.use("rows.source_recipe_revisions.upsert", async (db) => { | 109 | client.use("rows.source_recipe_revisions.upsert", async (db) => { |
| 74 | await db | 110 | await db |
| @@ -108,4 +144,14 @@ export const createSourceRecipeRevisionsRepo = ( · 33 unmodified lines | |||
| 108 | 144 | ||
| 109 | return deleted.length; | 145 | return deleted.length; |
| 110 | }), | 146 | }), |
| 147 | |||
| 148 | removeById: (id: StoredSourceRecipeRevisionRecord["id"]) => | ||
| 149 | client.use("rows.source_recipe_revisions.remove_by_id", async (db) => { | ||
| 150 | const deleted = await db | ||
| 151 | .delete(tables.sourceRecipeRevisionsTable) | ||
| 152 | .where(eq(tables.sourceRecipeRevisionsTable.id, id)) | ||
| 153 | .returning(); | ||
| 154 | |||
| 155 | return deleted.length > 0; | ||
| 156 | }), | ||
| 111 | }); | 157 | }); |
| @@ -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 | }); | ||
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | } from "#schema"; | 4 | } from "#schema"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import { Schema } from "effect"; | 6 | import { Schema } from "effect"; |
| 7 | import { and, asc, count, eq, or } from "drizzle-orm"; | 7 | import { and, asc, count, eq } from "drizzle-orm"; |
| 8 | 8 | ||
| 9 | import type { DrizzleClient } from "../client"; | 9 | import type { DrizzleClient } from "../client"; |
| 10 | import type { DrizzleTables } from "../schema"; | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -26,16 +26,9 @@ const toSourceUpdateSet = ( · 15 unmodified lines | |||
| 26 | if (patch.status !== undefined) set.status = patch.status; | 26 | if (patch.status !== undefined) set.status = patch.status; |
| 27 | if (patch.enabled !== undefined) set.enabled = patch.enabled; | 27 | if (patch.enabled !== undefined) set.enabled = patch.enabled; |
| 28 | if (patch.namespace !== undefined) set.namespace = patch.namespace; | 28 | if (patch.namespace !== undefined) set.namespace = patch.namespace; |
| 29 | if (patch.importAuthPolicy !== undefined) set.importAuthPolicy = patch.importAuthPolicy; | ||
| 29 | if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson; | 30 | if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson; |
| 30 | if (patch.transport !== undefined) set.transport = patch.transport; | ||
| 31 | if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson; | ||
| 32 | if (patch.headersJson !== undefined) set.headersJson = patch.headersJson; | ||
| 33 | if (patch.specUrl !== undefined) set.specUrl = patch.specUrl; | ||
| 34 | if (patch.defaultHeadersJson !== undefined) { | ||
| 35 | set.defaultHeadersJson = patch.defaultHeadersJson; | ||
| 36 | } | ||
| 37 | if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash; | 31 | if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash; |
| 38 | if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText; | ||
| 39 | if (patch.lastError !== undefined) set.lastError = patch.lastError; | 32 | if (patch.lastError !== undefined) set.lastError = patch.lastError; |
| 40 | if (patch.updatedAt !== undefined) set.updatedAt = patch.updatedAt; | 33 | if (patch.updatedAt !== undefined) set.updatedAt = patch.updatedAt; |
| 41 | 34 | ||
| @@ -146,66 +139,6 @@ export const createSourcesRepo = ( · 104 unmodified lines | |||
| 146 | sourceId: StoredSourceRecord["id"], | 139 | sourceId: StoredSourceRecord["id"], |
| 147 | ) => | 140 | ) => |
| 148 | client.useTx("rows.sources.remove", async (tx) => { | 141 | client.useTx("rows.sources.remove", async (tx) => { |
| 149 | const existingToolPaths = ( | ||
| 150 | await tx | ||
| 151 | .select({ | ||
| 152 | path: tables.toolArtifactsTable.path, | ||
| 153 | }) | ||
| 154 | .from(tables.toolArtifactsTable) | ||
| 155 | .where( | ||
| 156 | and( | ||
| 157 | eq(tables.toolArtifactsTable.workspaceId, workspaceId), | ||
| 158 | eq(tables.toolArtifactsTable.sourceId, sourceId), | ||
| 159 | ), | ||
| 160 | ) | ||
| 161 | ).map((row) => row.path); | ||
| 162 | |||
| 163 | if (existingToolPaths.length > 0) { | ||
| 164 | await tx | ||
| 165 | .delete(tables.toolArtifactParametersTable) | ||
| 166 | .where( | ||
| 167 | and( | ||
| 168 | eq(tables.toolArtifactParametersTable.workspaceId, workspaceId), | ||
| 169 | or( | ||
| 170 | ...existingToolPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path)), | ||
| 171 | ), | ||
| 172 | ), | ||
| 173 | ); | ||
| 174 | |||
| 175 | await tx | ||
| 176 | .delete(tables.toolArtifactRequestBodyContentTypesTable) | ||
| 177 | .where( | ||
| 178 | and( | ||
| 179 | eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId), | ||
| 180 | or( | ||
| 181 | ...existingToolPaths.map((path) => | ||
| 182 | eq(tables.toolArtifactRequestBodyContentTypesTable.path, path) | ||
| 183 | ), | ||
| 184 | ), | ||
| 185 | ), | ||
| 186 | ); | ||
| 187 | |||
| 188 | await tx | ||
| 189 | .delete(tables.toolArtifactRefHintKeysTable) | ||
| 190 | .where( | ||
| 191 | and( | ||
| 192 | eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId), | ||
| 193 | or( | ||
| 194 | ...existingToolPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path)), | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | ); | ||
| 198 | } | ||
| 199 | |||
| 200 | await tx | ||
| 201 | .delete(tables.toolArtifactsTable) | ||
| 202 | .where( | ||
| 203 | and( | ||
| 204 | eq(tables.toolArtifactsTable.workspaceId, workspaceId), | ||
| 205 | eq(tables.toolArtifactsTable.sourceId, sourceId), | ||
| 206 | ), | ||
| 207 | ); | ||
| 208 | |||
| 209 | const deleted = await tx | 142 | const deleted = await tx |
| 210 | .delete(tables.sourcesTable) | 143 | .delete(tables.sourcesTable) |
| 211 | .where( | 144 | .where( |
| @@ -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 | }); | ||
| @@ -1,11 +1,15 @@ | |||
| 1 | import { type Workspace, WorkspaceSchema } from "#schema"; | 1 | import { type Workspace, WorkspaceSchema } from "#schema"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | import { Schema } from "effect"; | 3 | import { Schema } from "effect"; |
| 4 | import { and, asc, eq, inArray, or } from "drizzle-orm"; | 4 | import { and, asc, eq, inArray } from "drizzle-orm"; |
| 5 | 5 | ||
| 6 | import type { DrizzleClient } from "../client"; | 6 | import type { DrizzleClient } from "../client"; |
| 7 | import type { DrizzleTables } from "../schema"; | 7 | import type { DrizzleTables } from "../schema"; |
| 8 | import { firstOption, postgresSecretHandlesFromCredentials } from "./shared"; | 8 | import { |
| 9 | cleanupOrphanedSourceRecipes, | ||
| 10 | firstOption, | ||
| 11 | postgresSecretHandlesFromCredentials, | ||
| 12 | } from "./shared"; | ||
| 9 | 13 | ||
| 10 | const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); | 14 | const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); |
| 11 | 15 | ||
| @@ -71,14 +75,14 @@ export const createWorkspacesRepo = ( · 59 unmodified lines | |||
| 71 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); | 75 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| 72 | const sourceRows = await tx | 76 | const sourceRows = await tx |
| 73 | .select({ | 77 | .select({ |
| 74 | sourceId: tables.sourcesTable.sourceId, | ||
| 75 | recipeId: tables.sourcesTable.recipeId, | 78 | recipeId: tables.sourcesTable.recipeId, |
| 79 | recipeRevisionId: tables.sourcesTable.recipeRevisionId, | ||
| 76 | }) | 80 | }) |
| 77 | .from(tables.sourcesTable) | 81 | .from(tables.sourcesTable) |
| 78 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); | 82 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); |
| 79 | const executionIds = executionRows.map((execution) => execution.id); | 83 | const executionIds = executionRows.map((execution) => execution.id); |
| 80 | const sourceIds = sourceRows.map((source) => source.sourceId); | ||
| 81 | const recipeIds = sourceRows.map((source) => source.recipeId); | 84 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 85 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); | ||
| 82 | const credentials = await tx | 86 | const credentials = await tx |
| 83 | .select({ | 87 | .select({ |
| 84 | tokenProviderId: tables.credentialsTable.tokenProviderId, | 88 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| @@ -88,26 +92,6 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | |||
| 88 | }) | 92 | }) |
| 89 | .from(tables.credentialsTable) | 93 | .from(tables.credentialsTable) |
| 90 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | 94 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); |
| 91 | const recipeRevisionRows = recipeIds.length > 0 | ||
| 92 | ? await tx | ||
| 93 | .select({ id: tables.sourceRecipeRevisionsTable.id }) | ||
| 94 | .from(tables.sourceRecipeRevisionsTable) | ||
| 95 | .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)) | ||
| 96 | : []; | ||
| 97 | const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id); | ||
| 98 | const toolArtifactPaths = sourceIds.length > 0 | ||
| 99 | ? ( | ||
| 100 | await tx | ||
| 101 | .select({ path: tables.toolArtifactsTable.path }) | ||
| 102 | .from(tables.toolArtifactsTable) | ||
| 103 | .where( | ||
| 104 | and( | ||
| 105 | eq(tables.toolArtifactsTable.workspaceId, workspaceId), | ||
| 106 | inArray(tables.toolArtifactsTable.sourceId, sourceIds), | ||
| 107 | ), | ||
| 108 | ) | ||
| 109 | ).map((row) => row.path) | ||
| 110 | : []; | ||
| 111 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | 95 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 112 | 96 | ||
| 113 | if (executionIds.length > 0) { | 97 | if (executionIds.length > 0) { |
| @@ -116,39 +100,6 @@ export const createWorkspacesRepo = ( · 2 unmodified lines | |||
| 116 | .where(inArray(tables.executionInteractionsTable.executionId, executionIds)); | 100 | .where(inArray(tables.executionInteractionsTable.executionId, executionIds)); |
| 117 | } | 101 | } |
| 118 | 102 | ||
| 119 | if (toolArtifactPaths.length > 0) { | ||
| 120 | await tx | ||
| 121 | .delete(tables.toolArtifactParametersTable) | ||
| 122 | .where( | ||
| 123 | and( | ||
| 124 | eq(tables.toolArtifactParametersTable.workspaceId, workspaceId), | ||
| 125 | or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))), | ||
| 126 | ), | ||
| 127 | ); | ||
| 128 | |||
| 129 | await tx | ||
| 130 | .delete(tables.toolArtifactRequestBodyContentTypesTable) | ||
| 131 | .where( | ||
| 132 | and( | ||
| 133 | eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId), | ||
| 134 | or( | ||
| 135 | ...toolArtifactPaths.map((path) => | ||
| 136 | eq(tables.toolArtifactRequestBodyContentTypesTable.path, path) | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | ), | ||
| 140 | ); | ||
| 141 | |||
| 142 | await tx | ||
| 143 | .delete(tables.toolArtifactRefHintKeysTable) | ||
| 144 | .where( | ||
| 145 | and( | ||
| 146 | eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId), | ||
| 147 | or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))), | ||
| 148 | ), | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | |||
| 152 | await tx | 103 | await tx |
| 153 | .delete(tables.executionsTable) | 104 | .delete(tables.executionsTable) |
| 154 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); | 105 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| @@ -165,33 +116,16 @@ export const createWorkspacesRepo = ( · 10 unmodified lines | |||
| 165 | .delete(tables.credentialsTable) | 116 | .delete(tables.credentialsTable) |
| 166 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | 117 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); |
| 167 | 118 | ||
| 168 | await tx | ||
| 169 | .delete(tables.toolArtifactsTable) | ||
| 170 | .where(eq(tables.toolArtifactsTable.workspaceId, workspaceId)); | ||
| 171 | |||
| 172 | await tx | 119 | await tx |
| 173 | .delete(tables.sourcesTable) | 120 | .delete(tables.sourcesTable) |
| 174 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); | 121 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); |
| 175 | 122 | ||
| 176 | if (recipeRevisionIds.length > 0) { | 123 | await cleanupOrphanedSourceRecipes({ |
| 177 | await tx | 124 | tx, |
| 178 | .delete(tables.sourceRecipeDocumentsTable) | 125 | tables, |
| 179 | .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds)); | 126 | candidateRecipeIds: recipeIds, |
| 180 | 127 | candidateRecipeRevisionIds: recipeRevisionIds, | |
| 181 | await tx | 128 | }); |
| 182 | .delete(tables.sourceRecipeOperationsTable) | ||
| 183 | .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds)); | ||
| 184 | } | ||
| 185 | |||
| 186 | if (recipeIds.length > 0) { | ||
| 187 | await tx | ||
| 188 | .delete(tables.sourceRecipeRevisionsTable) | ||
| 189 | .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)); | ||
| 190 | |||
| 191 | await tx | ||
| 192 | .delete(tables.sourceRecipesTable) | ||
| 193 | .where(inArray(tables.sourceRecipesTable.id, recipeIds)); | ||
| 194 | } | ||
| 195 | 129 | ||
| 196 | await tx | 130 | await tx |
| 197 | .delete(tables.policiesTable) | 131 | .delete(tables.policiesTable) |
| @@ -19,11 +19,9 @@ export const tableNames = { · 18 unmodified lines | |||
| 19 | sourceRecipes: "source_recipes", | 19 | sourceRecipes: "source_recipes", |
| 20 | sourceRecipeRevisions: "source_recipe_revisions", | 20 | sourceRecipeRevisions: "source_recipe_revisions", |
| 21 | sourceRecipeDocuments: "source_recipe_documents", | 21 | sourceRecipeDocuments: "source_recipe_documents", |
| 22 | sourceRecipeSchemaBundles: "source_recipe_schema_bundles", | ||
| 22 | sourceRecipeOperations: "source_recipe_operations", | 23 | sourceRecipeOperations: "source_recipe_operations", |
| 23 | toolArtifacts: "tool_artifacts", | 24 | dataMigrations: "control_plane_data_migrations", |
| 24 | toolArtifactParameters: "tool_artifact_parameters", | ||
| 25 | toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types", | ||
| 26 | toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys", | ||
| 27 | credentials: "workspace_source_credentials", | 25 | credentials: "workspace_source_credentials", |
| 28 | workspaceSourceOauthClients: "workspace_source_oauth_clients", | 26 | workspaceSourceOauthClients: "workspace_source_oauth_clients", |
| 29 | secretMaterials: "secret_materials", | 27 | secretMaterials: "secret_materials", |
| @@ -145,14 +143,9 @@ export const sourcesTable = pgTable( · 115 unmodified lines | |||
| 145 | status: text("status").notNull(), | 143 | status: text("status").notNull(), |
| 146 | enabled: boolean("enabled").notNull(), | 144 | enabled: boolean("enabled").notNull(), |
| 147 | namespace: text("namespace"), | 145 | namespace: text("namespace"), |
| 148 | bindingConfigJson: text("binding_config_json"), | 146 | importAuthPolicy: text("import_auth_policy").notNull(), |
| 149 | transport: text("transport"), | 147 | bindingConfigJson: text("binding_config_json").notNull(), |
| 150 | queryParamsJson: text("query_params_json"), | ||
| 151 | headersJson: text("headers_json"), | ||
| 152 | specUrl: text("spec_url"), | ||
| 153 | defaultHeadersJson: text("default_headers_json"), | ||
| 154 | sourceHash: text("source_hash"), | 148 | sourceHash: text("source_hash"), |
| 155 | sourceDocumentText: text("source_document_text"), | ||
| 156 | lastError: text("last_error"), | 149 | lastError: text("last_error"), |
| 157 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | 150 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 158 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | 151 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| @@ -173,17 +166,13 @@ export const sourcesTable = pgTable( · 14 unmodified lines | |||
| 173 | table.sourceId, | 166 | table.sourceId, |
| 174 | ), | 167 | ), |
| 175 | uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name), | 168 | uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name), |
| 176 | check( | ||
| 177 | "sources_kind_check", | ||
| 178 | sql`${table.kind} in ('mcp', 'openapi', 'graphql', 'internal')`, | ||
| 179 | ), | ||
| 180 | check( | 169 | check( |
| 181 | "sources_status_check", | 170 | "sources_status_check", |
| 182 | sql`${table.status} in ('draft', 'probing', 'auth_required', 'connected', 'error')`, | 171 | sql`${table.status} in ('draft', 'probing', 'auth_required', 'connected', 'error')`, |
| 183 | ), | 172 | ), |
| 184 | check( | 173 | check( |
| 185 | "sources_transport_check", | 174 | "sources_import_auth_policy_check", |
| 186 | sql`${table.transport} is null or ${table.transport} in ('auto', 'streamable-http', 'sse')`, | 175 | sql`${table.importAuthPolicy} in ('none', 'reuse_runtime', 'separate')`, |
| 187 | ), | 176 | ), |
| 188 | ], | 177 | ], |
| 189 | ); | 178 | ); |
| @@ -215,19 +204,7 @@ export const sourceRecipesTable = pgTable( · 25 unmodified lines | |||
| 215 | ), | 204 | ), |
| 216 | check( | 205 | check( |
| 217 | "source_recipes_kind_check", | 206 | "source_recipes_kind_check", |
| 218 | sql`${table.kind} in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')`, | 207 | sql`${table.kind} in ('http_api', 'mcp', 'internal')`, |
| 219 | ), | ||
| 220 | check( | ||
| 221 | "source_recipes_importer_kind_check", | ||
| 222 | sql`${table.importerKind} in ( | ||
| 223 | 'openapi', | ||
| 224 | 'google_discovery', | ||
| 225 | 'postman_collection', | ||
| 226 | 'snippet_bundle', | ||
| 227 | 'graphql_introspection', | ||
| 228 | 'mcp_manifest', | ||
| 229 | 'internal_manifest' | ||
| 230 | )`, | ||
| 231 | ), | 208 | ), |
| 232 | check( | 209 | check( |
| 233 | "source_recipes_visibility_check", | 210 | "source_recipes_visibility_check", |
| @@ -245,6 +222,7 @@ export const sourceRecipeRevisionsTable = pgTable( · 11 unmodified lines | |||
| 245 | sourceConfigJson: text("source_config_json").notNull(), | 222 | sourceConfigJson: text("source_config_json").notNull(), |
| 246 | manifestJson: text("manifest_json"), | 223 | manifestJson: text("manifest_json"), |
| 247 | manifestHash: text("manifest_hash"), | 224 | manifestHash: text("manifest_hash"), |
| 225 | materializationHash: text("materialization_hash"), | ||
| 248 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | 226 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 249 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | 227 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 250 | }, | 228 | }, |
| @@ -253,7 +231,11 @@ export const sourceRecipeRevisionsTable = pgTable( · 2 unmodified lines | |||
| 253 | table.recipeId, | 231 | table.recipeId, |
| 254 | table.revisionNumber, | 232 | table.revisionNumber, |
| 255 | ), | 233 | ), |
| 256 | uniqueIndex("source_recipe_revisions_recipe_manifest_idx").on( | 234 | uniqueIndex("source_recipe_revisions_recipe_materialization_idx").on( |
| 235 | table.recipeId, | ||
| 236 | table.materializationHash, | ||
| 237 | ), | ||
| 238 | index("source_recipe_revisions_recipe_manifest_idx").on( | ||
| 257 | table.recipeId, | 239 | table.recipeId, |
| 258 | table.manifestHash, | 240 | table.manifestHash, |
| 259 | ), | 241 | ), |
| @@ -289,16 +271,29 @@ export const sourceRecipeDocumentsTable = pgTable( · 29 unmodified lines | |||
| 289 | table.createdAt, | 271 | table.createdAt, |
| 290 | table.id, | 272 | table.id, |
| 291 | ), | 273 | ), |
| 292 | check( | 274 | ], |
| 293 | "source_recipe_documents_kind_check", | 275 | ); |
| 294 | sql`${table.documentKind} in ( | 276 | |
| 295 | 'google_discovery', | 277 | export const sourceRecipeSchemaBundlesTable = pgTable( |
| 296 | 'openapi', | 278 | tableNames.sourceRecipeSchemaBundles, |
| 297 | 'postman_collection', | 279 | { |
| 298 | 'postman_environment', | 280 | id: text("id").notNull().primaryKey(), |
| 299 | 'graphql_introspection', | 281 | recipeRevisionId: text("recipe_revision_id").notNull(), |
| 300 | 'mcp_manifest' | 282 | bundleKind: text("bundle_kind").notNull(), |
| 301 | )`, | 283 | refsJson: text("refs_json").notNull(), |
| 284 | contentHash: text("content_hash").notNull(), | ||
| 285 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | ||
| 286 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | ||
| 287 | }, | ||
| 288 | (table) => [ | ||
| 289 | uniqueIndex("source_recipe_schema_bundles_revision_kind_idx").on( | ||
| 290 | table.recipeRevisionId, | ||
| 291 | table.bundleKind, | ||
| 292 | ), | ||
| 293 | index("source_recipe_schema_bundles_revision_created_idx").on( | ||
| 294 | table.recipeRevisionId, | ||
| 295 | table.createdAt, | ||
| 296 | table.id, | ||
| 302 | ), | 297 | ), |
| 303 | ], | 298 | ], |
| 304 | ); | 299 | ); |
| @@ -319,16 +314,6 @@ export const sourceRecipeOperationsTable = pgTable( · 14 unmodified lines | |||
| 319 | outputSchemaJson: text("output_schema_json"), | 314 | outputSchemaJson: text("output_schema_json"), |
| 320 | providerKind: text("provider_kind").notNull(), | 315 | providerKind: text("provider_kind").notNull(), |
| 321 | providerDataJson: text("provider_data_json"), | 316 | providerDataJson: text("provider_data_json"), |
| 322 | mcpToolName: text("mcp_tool_name"), | ||
| 323 | openApiMethod: text("openapi_method"), | ||
| 324 | openApiPathTemplate: text("openapi_path_template"), | ||
| 325 | openApiOperationHash: text("openapi_operation_hash"), | ||
| 326 | openApiRawToolId: text("openapi_raw_tool_id"), | ||
| 327 | openApiOperationId: text("openapi_operation_id"), | ||
| 328 | openApiTagsJson: text("openapi_tags_json"), | ||
| 329 | openApiRequestBodyRequired: boolean("openapi_request_body_required"), | ||
| 330 | graphqlOperationType: text("graphql_operation_type"), | ||
| 331 | graphqlOperationName: text("graphql_operation_name"), | ||
| 332 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | 317 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 333 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | 318 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 334 | }, | 319 | }, |
| @@ -355,13 +340,17 @@ export const sourceRecipeOperationsTable = pgTable( · 20 unmodified lines | |||
| 355 | "source_recipe_operations_kind_check", | 340 | "source_recipe_operations_kind_check", |
| 356 | sql`${table.operationKind} in ('read', 'write', 'delete', 'unknown')`, | 341 | sql`${table.operationKind} in ('read', 'write', 'delete', 'unknown')`, |
| 357 | ), | 342 | ), |
| 358 | check( | ||
| 359 | "source_recipe_operations_provider_kind_check", | ||
| 360 | sql`${table.providerKind} in ('mcp', 'openapi', 'graphql', 'internal')`, | ||
| 361 | ), | ||
| 362 | ], | 343 | ], |
| 363 | ); | 344 | ); |
| 364 | 345 | ||
| 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 | |||
| 365 | export const credentialsTable = pgTable( | 354 | export const credentialsTable = pgTable( |
| 366 | tableNames.credentials, | 355 | tableNames.credentials, |
| 367 | { | 356 | { |
| @@ -369,6 +358,7 @@ export const credentialsTable = pgTable( · 1 unmodified line | |||
| 369 | workspaceId: text("workspace_id").notNull(), | 358 | workspaceId: text("workspace_id").notNull(), |
| 370 | sourceId: text("source_id").notNull(), | 359 | sourceId: text("source_id").notNull(), |
| 371 | actorAccountId: text("actor_account_id"), | 360 | actorAccountId: text("actor_account_id"), |
| 361 | slot: text("slot").notNull(), | ||
| 372 | authKind: text("auth_kind").notNull(), | 362 | authKind: text("auth_kind").notNull(), |
| 373 | authHeaderName: text("auth_header_name").notNull(), | 363 | authHeaderName: text("auth_header_name").notNull(), |
| 374 | authPrefix: text("auth_prefix").notNull(), | 364 | authPrefix: text("auth_prefix").notNull(), |
| @@ -389,6 +379,7 @@ export const credentialsTable = pgTable( · 14 unmodified lines | |||
| 389 | table.workspaceId, | 379 | table.workspaceId, |
| 390 | table.sourceId, | 380 | table.sourceId, |
| 391 | table.actorAccountId, | 381 | table.actorAccountId, |
| 382 | table.slot, | ||
| 392 | ), | 383 | ), |
| 393 | index("credentials_workspace_source_idx").on( | 384 | index("credentials_workspace_source_idx").on( |
| 394 | table.workspaceId, | 385 | table.workspaceId, |
| @@ -400,6 +391,10 @@ export const credentialsTable = pgTable( · 5 unmodified lines | |||
| 400 | "credentials_auth_kind_check", | 391 | "credentials_auth_kind_check", |
| 401 | sql`${table.authKind} in ('bearer', 'oauth2')`, | 392 | sql`${table.authKind} in ('bearer', 'oauth2')`, |
| 402 | ), | 393 | ), |
| 394 | check( | ||
| 395 | "credentials_slot_check", | ||
| 396 | sql`${table.slot} in ('runtime', 'import')`, | ||
| 397 | ), | ||
| 403 | ], | 398 | ], |
| 404 | ); | 399 | ); |
| 405 | 400 | ||
| @@ -431,134 +426,6 @@ export const workspaceSourceOauthClientsTable = pgTable( · 25 unmodified lines | |||
| 431 | ], | 426 | ], |
| 432 | ); | 427 | ); |
| 433 | 428 | ||
| 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 | |||
| 562 | export const secretMaterialsTable = pgTable( | 429 | export const secretMaterialsTable = pgTable( |
| 563 | tableNames.secretMaterials, | 430 | tableNames.secretMaterials, |
| 564 | { | 431 | { |
| @@ -585,6 +452,7 @@ export const sourceAuthSessionsTable = pgTable( · 20 unmodified lines | |||
| 585 | workspaceId: text("workspace_id").notNull(), | 452 | workspaceId: text("workspace_id").notNull(), |
| 586 | sourceId: text("source_id").notNull(), | 453 | sourceId: text("source_id").notNull(), |
| 587 | actorAccountId: text("actor_account_id"), | 454 | actorAccountId: text("actor_account_id"), |
| 455 | credentialSlot: text("credential_slot").notNull(), | ||
| 588 | executionId: text("execution_id"), | 456 | executionId: text("execution_id"), |
| 589 | interactionId: text("interaction_id"), | 457 | interactionId: text("interaction_id"), |
| 590 | providerKind: text("provider_kind").notNull(), | 458 | providerKind: text("provider_kind").notNull(), |
| @@ -606,6 +474,7 @@ export const sourceAuthSessionsTable = pgTable( · 15 unmodified lines | |||
| 606 | table.workspaceId, | 474 | table.workspaceId, |
| 607 | table.sourceId, | 475 | table.sourceId, |
| 608 | table.actorAccountId, | 476 | table.actorAccountId, |
| 477 | table.credentialSlot, | ||
| 609 | table.status, | 478 | table.status, |
| 610 | table.updatedAt, | 479 | table.updatedAt, |
| 611 | table.id, | 480 | table.id, |
| @@ -619,6 +488,10 @@ export const sourceAuthSessionsTable = pgTable( · 7 unmodified lines | |||
| 619 | "source_auth_sessions_status_check", | 488 | "source_auth_sessions_status_check", |
| 620 | sql`${table.status} in ('pending', 'completed', 'failed', 'cancelled')`, | 489 | sql`${table.status} in ('pending', 'completed', 'failed', 'cancelled')`, |
| 621 | ), | 490 | ), |
| 491 | check( | ||
| 492 | "source_auth_sessions_credential_slot_check", | ||
| 493 | sql`${table.credentialSlot} in ('runtime', 'import')`, | ||
| 494 | ), | ||
| 622 | ], | 495 | ], |
| 623 | ); | 496 | ); |
| 624 | 497 | ||
| @@ -762,13 +635,11 @@ export const drizzleSchema = { · 137 unmodified lines | |||
| 762 | sourceRecipesTable, | 635 | sourceRecipesTable, |
| 763 | sourceRecipeRevisionsTable, | 636 | sourceRecipeRevisionsTable, |
| 764 | sourceRecipeDocumentsTable, | 637 | sourceRecipeDocumentsTable, |
| 638 | sourceRecipeSchemaBundlesTable, | ||
| 765 | sourceRecipeOperationsTable, | 639 | sourceRecipeOperationsTable, |
| 640 | dataMigrationsTable, | ||
| 766 | credentialsTable, | 641 | credentialsTable, |
| 767 | workspaceSourceOauthClientsTable, | 642 | workspaceSourceOauthClientsTable, |
| 768 | toolArtifactsTable, | ||
| 769 | toolArtifactParametersTable, | ||
| 770 | toolArtifactRequestBodyContentTypesTable, | ||
| 771 | toolArtifactRefHintKeysTable, | ||
| 772 | secretMaterialsTable, | 643 | secretMaterialsTable, |
| 773 | sourceAuthSessionsTable, | 644 | sourceAuthSessionsTable, |
| 774 | policiesTable, | 645 | policiesTable, |
| @@ -29,6 +29,13 @@ const makeRuntime = Effect.acquireRelease( · 28 unmodified lines | |||
| 29 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), | 29 | (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.orDie), |
| 30 | ); | 30 | ); |
| 31 | 31 | ||
| 32 | const openApiBindingConfigJson = (specUrl: string): string => | ||
| 33 | JSON.stringify({ | ||
| 34 | adapterKey: "openapi", | ||
| 35 | specUrl, | ||
| 36 | defaultHeaders: null, | ||
| 37 | }); | ||
| 38 | |||
| 32 | type OpenApiSpecServer = { | 39 | type OpenApiSpecServer = { |
| 33 | baseUrl: string; | 40 | baseUrl: string; |
| 34 | specUrl: string; | 41 | specUrl: string; |
| @@ -288,14 +295,9 @@ describe("control-plane-runtime", () => { · 253 unmodified lines | |||
| 288 | status: "auth_required", | 295 | status: "auth_required", |
| 289 | enabled: true, | 296 | enabled: true, |
| 290 | namespace: "github", | 297 | namespace: "github", |
| 291 | bindingConfigJson: null, | 298 | importAuthPolicy: "reuse_runtime", |
| 292 | transport: null, | 299 | bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), |
| 293 | queryParamsJson: null, | ||
| 294 | headersJson: null, | ||
| 295 | specUrl: "https://example.com/github-openapi.yaml", | ||
| 296 | defaultHeadersJson: null, | ||
| 297 | sourceHash: null, | 300 | sourceHash: null, |
| 298 | sourceDocumentText: null, | ||
| 299 | lastError: null, | 301 | lastError: null, |
| 300 | createdAt: now, | 302 | createdAt: now, |
| 301 | updatedAt: now, | 303 | updatedAt: now, |
| @@ -448,14 +450,9 @@ describe("control-plane-runtime", () => { · 146 unmodified lines | |||
| 448 | status: "auth_required", | 450 | status: "auth_required", |
| 449 | enabled: true, | 451 | enabled: true, |
| 450 | namespace: "github", | 452 | namespace: "github", |
| 451 | bindingConfigJson: null, | 453 | importAuthPolicy: "reuse_runtime", |
| 452 | transport: null, | 454 | bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), |
| 453 | queryParamsJson: null, | ||
| 454 | headersJson: null, | ||
| 455 | specUrl: "https://example.com/github-openapi.yaml", | ||
| 456 | defaultHeadersJson: null, | ||
| 457 | sourceHash: null, | 455 | sourceHash: null, |
| 458 | sourceDocumentText: null, | ||
| 459 | lastError: null, | 456 | lastError: null, |
| 460 | createdAt: now, | 457 | createdAt: now, |
| 461 | updatedAt: now, | 458 | updatedAt: now, |
| @@ -36,59 +36,20 @@ import { · 35 unmodified lines | |||
| 36 | deriveSchemaJson, | 36 | deriveSchemaJson, |
| 37 | deriveSchemaTypeSignature, | 37 | deriveSchemaTypeSignature, |
| 38 | } from "./schema-type-signature"; | 38 | } from "./schema-type-signature"; |
| 39 | import { | ||
| 40 | ExecutorAddSourceInputSchema, | ||
| 41 | executorAddableSourceAdapters, | ||
| 42 | } from "./source-adapters"; | ||
| 39 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; | 43 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; |
| 40 | 44 | ||
| 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 | |||
| 69 | const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1( | 45 | const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1( |
| 70 | ExecutorSourcesAddSchema, | 46 | ExecutorAddSourceInputSchema, |
| 71 | ); | 47 | ); |
| 72 | 48 | ||
| 73 | const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(SourceSchema); | 49 | const ExecutorSourcesAddOutputSchema = Schema.standardSchemaV1(SourceSchema); |
| 74 | 50 | ||
| 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 | |||
| 90 | export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature( | 51 | export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature( |
| 91 | ExecutorSourcesAddInputSchema, | 52 | ExecutorAddSourceInputSchema, |
| 92 | 320, | 53 | 320, |
| 93 | ); | 54 | ); |
| 94 | 55 | ||
| @@ -98,7 +59,7 @@ export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature( · 3 unmodified lines | |||
| 98 | ); | 59 | ); |
| 99 | 60 | ||
| 100 | export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify( | 61 | export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify( |
| 101 | deriveSchemaJson(ExecutorSourcesAddSchema) ?? {}, | 62 | deriveSchemaJson(ExecutorAddSourceInputSchema) ?? {}, |
| 102 | ); | 63 | ); |
| 103 | 64 | ||
| 104 | export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON = JSON.stringify( | 65 | 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 | |||
| 107 | 68 | ||
| 108 | export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ | 69 | export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ |
| 109 | "Source add input shapes:", | 70 | "Source add input shapes:", |
| 110 | `- MCP: ${EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE}`, | 71 | ...executorAddableSourceAdapters.flatMap((adapter) => |
| 111 | ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.', | 72 | adapter.executorAddInputSchema |
| 112 | `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`, | 73 | && adapter.executorAddInputSignatureWidth !== null |
| 113 | " endpoint is the base API URL. specUrl is the OpenAPI document URL.", | 74 | && adapter.executorAddHelpText |
| 114 | `- GraphQL: ${EXECUTOR_SOURCES_ADD_GRAPHQL_INPUT_SIGNATURE}`, | 75 | ? [ |
| 115 | " endpoint is the GraphQL HTTP endpoint.", | 76 | `- ${adapter.displayName}: ${deriveSchemaTypeSignature(adapter.executorAddInputSchema, adapter.executorAddInputSignatureWidth)}`, |
| 77 | ...adapter.executorAddHelpText.map((line) => ` ${line}`), | ||
| 78 | ] | ||
| 79 | : [], | ||
| 80 | ), | ||
| 116 | " executor handles the credential setup for you.", | 81 | " executor handles the credential setup for you.", |
| 117 | ] as const; | 82 | ] as const; |
| 118 | 83 | ||
| @@ -168,6 +133,7 @@ const promptForSourceCredentialSelection = (input: { · 49 unmodified lines | |||
| 168 | name?: string | null; | 133 | name?: string | null; |
| 169 | namespace?: string | null; | 134 | namespace?: string | null; |
| 170 | }; | 135 | }; |
| 136 | credentialSlot: "runtime" | "import"; | ||
| 171 | source: Source; | 137 | source: Source; |
| 172 | executionId: string; | 138 | executionId: string; |
| 173 | interactionId: string; | 139 | interactionId: string; |
| @@ -200,7 +166,10 @@ const promptForSourceCredentialSelection = (input: { · 26 unmodified lines | |||
| 200 | context: input.invocation, | 166 | context: input.invocation, |
| 201 | elicitation: { | 167 | elicitation: { |
| 202 | mode: "url", | 168 | mode: "url", |
| 203 | message: `Open the secure credential page to connect ${input.source.name}`, | 169 | message: |
| 170 | input.credentialSlot === "import" | ||
| 171 | ? `Open the secure credential page to configure import access for ${input.source.name}` | ||
| 172 | : `Open the secure credential page to connect ${input.source.name}`, | ||
| 204 | url: resolveLocalCredentialUrl({ | 173 | url: resolveLocalCredentialUrl({ |
| 205 | baseUrl: input.localServerBaseUrl, | 174 | baseUrl: input.localServerBaseUrl, |
| 206 | workspaceId: input.args.workspaceId, | 175 | workspaceId: input.args.workspaceId, |
| @@ -272,7 +241,7 @@ export const createExecutorToolMap = (input: { · 65 unmodified lines | |||
| 272 | `executor.sources.add:${crypto.randomUUID()}`, | 241 | `executor.sources.add:${crypto.randomUUID()}`, |
| 273 | ); | 242 | ); |
| 274 | const preparedArgs: ExecutorAddSourceInput = | 243 | const preparedArgs: ExecutorAddSourceInput = |
| 275 | args.kind === "openapi" || args.kind === "graphql" | 244 | args.kind === "openapi" |
| 276 | ? { | 245 | ? { |
| 277 | ...args, | 246 | ...args, |
| 278 | workspaceId: input.workspaceId, | 247 | workspaceId: input.workspaceId, |
| @@ -280,6 +249,14 @@ export const createExecutorToolMap = (input: { · 1 unmodified line | |||
| 280 | executionId, | 249 | executionId, |
| 281 | interactionId, | 250 | interactionId, |
| 282 | } | 251 | } |
| 252 | : args.kind === "graphql" | ||
| 253 | ? { | ||
| 254 | ...args, | ||
| 255 | workspaceId: input.workspaceId, | ||
| 256 | actorAccountId: input.accountId, | ||
| 257 | executionId, | ||
| 258 | interactionId, | ||
| 259 | } | ||
| 283 | : { | 260 | : { |
| 284 | kind: args.kind, | 261 | kind: args.kind, |
| 285 | endpoint: args.endpoint, | 262 | endpoint: args.endpoint, |
| @@ -290,7 +267,7 @@ export const createExecutorToolMap = (input: { · 4 unmodified lines | |||
| 290 | executionId, | 267 | executionId, |
| 291 | interactionId, | 268 | interactionId, |
| 292 | }; | 269 | }; |
| 293 | const result = await runEffect( | 270 | let result = await runEffect( |
| 294 | input.sourceAuthService.addExecutorSource( | 271 | input.sourceAuthService.addExecutorSource( |
| 295 | preparedArgs, | 272 | preparedArgs, |
| 296 | context?.onElicitation | 273 | context?.onElicitation |
| @@ -313,61 +290,88 @@ export const createExecutorToolMap = (input: { · 16 unmodified lines | |||
| 313 | } | 290 | } |
| 314 | 291 | ||
| 315 | if (result.kind === "credential_required") { | 292 | if (result.kind === "credential_required") { |
| 316 | const preparedHttpArgs = preparedArgs as Extract< | 293 | let pendingResult = result; |
| 294 | let pendingArgs = preparedArgs as Extract< | ||
| 317 | ExecutorAddSourceInput, | 295 | ExecutorAddSourceInput, |
| 318 | { kind: "openapi" | "graphql" } | 296 | { kind: "openapi" | "graphql" } |
| 319 | >; | 297 | >; |
| 320 | const selectedAuth = await runEffect( | 298 | |
| 321 | promptForSourceCredentialSelection({ | 299 | while (pendingResult.kind === "credential_required") { |
| 322 | args: { | 300 | const selectedAuth = await runEffect( |
| 323 | ...preparedHttpArgs, | 301 | promptForSourceCredentialSelection({ |
| 324 | workspaceId: input.workspaceId, | 302 | args: { |
| 325 | sourceId: result.source.id, | 303 | ...pendingArgs, |
| 326 | }, | 304 | workspaceId: input.workspaceId, |
| 327 | source: result.source, | 305 | sourceId: pendingResult.source.id, |
| 328 | executionId, | 306 | }, |
| 329 | interactionId, | 307 | credentialSlot: pendingResult.credentialSlot, |
| 330 | path: context?.path ?? asToolPath("executor.sources.add"), | 308 | source: pendingResult.source, |
| 331 | sourceKey: context?.sourceKey ?? "executor", | 309 | executionId, |
| 332 | localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(), | 310 | interactionId, |
| 333 | metadata: context?.metadata, | 311 | path: context?.path ?? asToolPath("executor.sources.add"), |
| 334 | invocation: context?.invocation, | 312 | sourceKey: context?.sourceKey ?? "executor", |
| 335 | onElicitation: context?.onElicitation, | 313 | localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(), |
| 336 | }), | 314 | metadata: context?.metadata, |
| 337 | ); | 315 | invocation: context?.invocation, |
| 338 | 316 | onElicitation: context?.onElicitation, | |
| 339 | const completed = await runEffect( | 317 | }), |
| 340 | input.sourceAuthService.addExecutorSource( | 318 | ); |
| 341 | { | 319 | |
| 342 | ...preparedHttpArgs, | 320 | pendingArgs = pendingResult.credentialSlot === "import" |
| 343 | auth: selectedAuth, | 321 | && pendingArgs.importAuthPolicy === "separate" |
| 344 | }, | 322 | ? { |
| 345 | context?.onElicitation | 323 | ...pendingArgs, |
| 346 | ? { | 324 | importAuth: selectedAuth, |
| 347 | mcpDiscoveryElicitation: { | ||
| 348 | onElicitation: context.onElicitation, | ||
| 349 | path: context.path ?? asToolPath("executor.sources.add"), | ||
| 350 | sourceKey: context.sourceKey, | ||
| 351 | args, | ||
| 352 | metadata: context.metadata, | ||
| 353 | invocation: context.invocation, | ||
| 354 | }, | ||
| 355 | } | 325 | } |
| 356 | : undefined, | 326 | : { |
| 357 | ), | 327 | ...pendingArgs, |
| 358 | ); | 328 | auth: selectedAuth, |
| 329 | }; | ||
| 330 | |||
| 331 | const completed = await runEffect( | ||
| 332 | input.sourceAuthService.addExecutorSource( | ||
| 333 | pendingArgs, | ||
| 334 | context?.onElicitation | ||
| 335 | ? { | ||
| 336 | mcpDiscoveryElicitation: { | ||
| 337 | onElicitation: context.onElicitation, | ||
| 338 | path: context.path ?? asToolPath("executor.sources.add"), | ||
| 339 | sourceKey: context.sourceKey, | ||
| 340 | args, | ||
| 341 | metadata: context.metadata, | ||
| 342 | invocation: context.invocation, | ||
| 343 | }, | ||
| 344 | } | ||
| 345 | : undefined, | ||
| 346 | ), | ||
| 347 | ); | ||
| 348 | |||
| 349 | if (completed.kind === "connected") { | ||
| 350 | return completed.source; | ||
| 351 | } | ||
| 352 | |||
| 353 | if (completed.kind === "credential_required") { | ||
| 354 | pendingResult = completed; | ||
| 355 | continue; | ||
| 356 | } | ||
| 359 | 357 | ||
| 360 | if (completed.kind === "connected") { | 358 | result = completed; |
| 361 | return completed.source; | 359 | break; |
| 362 | } | 360 | } |
| 363 | 361 | ||
| 364 | throw new Error(`Source add was not completed for ${result.source.id}`); | 362 | if (pendingResult.kind === "credential_required") { |
| 363 | result = pendingResult; | ||
| 364 | } | ||
| 365 | } | 365 | } |
| 366 | 366 | ||
| 367 | if (!context?.onElicitation) { | 367 | if (!context?.onElicitation) { |
| 368 | throw new Error("executor.sources.add requires an elicitation-capable host"); | 368 | throw new Error("executor.sources.add requires an elicitation-capable host"); |
| 369 | } | 369 | } |
| 370 | 370 | ||
| 371 | if (result.kind !== "oauth_required") { | ||
| 372 | throw new Error(`Source add did not reach OAuth continuation for ${result.source.id}`); | ||
| 373 | } | ||
| 374 | |||
| 371 | const response: ElicitationResponse = await runEffect( | 375 | const response: ElicitationResponse = await runEffect( |
| 372 | context.onElicitation({ | 376 | context.onElicitation({ |
| 373 | interactionId, | 377 | interactionId, |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | typeSignatureFromSchemaJson, | 10 | typeSignatureFromSchemaJson, |
| 11 | } from "@executor/codemode-core"; | 11 | } from "@executor/codemode-core"; |
| 12 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Schema from "effect/Schema"; | ||
| 13 | import { | 14 | import { |
| 14 | buildClientSchema, | 15 | buildClientSchema, |
| 15 | getIntrospectionQuery, | 16 | getIntrospectionQuery, |
| @@ -25,13 +26,18 @@ import { · 9 unmodified lines | |||
| 25 | isUnionType, | 26 | isUnionType, |
| 26 | type GraphQLArgument, | 27 | type GraphQLArgument, |
| 27 | type GraphQLField, | 28 | type GraphQLField, |
| 29 | type GraphQLInputObjectType, | ||
| 28 | type GraphQLInputType, | 30 | type GraphQLInputType, |
| 31 | type GraphQLInterfaceType, | ||
| 32 | type GraphQLNamedType, | ||
| 33 | type GraphQLObjectType, | ||
| 29 | type GraphQLOutputType, | 34 | type GraphQLOutputType, |
| 30 | type GraphQLSchema, | 35 | type GraphQLSchema, |
| 31 | type IntrospectionQuery, | 36 | type IntrospectionQuery, |
| 32 | } from "graphql"; | 37 | } from "graphql"; |
| 33 | 38 | ||
| 34 | type JsonSchema = Record<string, unknown>; | 39 | type JsonSchema = Record<string, unknown>; |
| 40 | type GraphqlSchemaRefTable = Record<string, string>; | ||
| 35 | 41 | ||
| 36 | type GraphqlToolKind = "request" | "field"; | 42 | type GraphqlToolKind = "request" | "field"; |
| 37 | 43 | ||
| @@ -72,6 +78,7 @@ export type GraphqlToolManifest = { · 34 unmodified lines | |||
| 72 | queryTypeName: string | null; | 78 | queryTypeName: string | null; |
| 73 | mutationTypeName: string | null; | 79 | mutationTypeName: string | null; |
| 74 | subscriptionTypeName: string | null; | 80 | subscriptionTypeName: string | null; |
| 81 | schemaRefTable?: GraphqlSchemaRefTable; | ||
| 75 | tools: readonly GraphqlToolManifestEntry[]; | 82 | tools: readonly GraphqlToolManifestEntry[]; |
| 76 | }; | 83 | }; |
| 77 | 84 | ||
| @@ -98,6 +105,28 @@ export type GraphqlToolPresentation = { · 20 unmodified lines | |||
| 98 | providerDataJson: string; | 105 | providerDataJson: string; |
| 99 | }; | 106 | }; |
| 100 | 107 | ||
| 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 | |||
| 101 | type SelectedGraphqlOutput = { | 130 | type SelectedGraphqlOutput = { |
| 102 | selectionSet: string; | 131 | selectionSet: string; |
| 103 | schema: JsonSchema; | 132 | schema: JsonSchema; |
| @@ -218,11 +247,11 @@ const normalizeHttpUrl = (value: string): string => { · 114 unmodified lines | |||
| 218 | }; | 247 | }; |
| 219 | 248 | ||
| 220 | const graphqlToolError = (message: string, cause?: unknown): Error => | 249 | const graphqlToolError = (message: string, cause?: unknown): Error => |
| 221 | new Error( | 250 | new Error(cause instanceof Error ? `${message}: ${cause.message}` : message); |
| 222 | cause instanceof Error ? `${message}: ${cause.message}` : message, | ||
| 223 | ); | ||
| 224 | 251 | ||
| 225 | const parseGraphqlResponseBody = async (response: Response): Promise<unknown> => { | 252 | const parseGraphqlResponseBody = async ( |
| 253 | response: Response, | ||
| 254 | ): Promise<unknown> => { | ||
| 226 | const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | 255 | const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; |
| 227 | if (contentType.includes("application/json")) { | 256 | if (contentType.includes("application/json")) { |
| 228 | return await response.json(); | 257 | return await response.json(); |
| @@ -271,10 +300,8 @@ const withSchemaDescription = ( · 42 unmodified lines | |||
| 271 | return trimmed ? { ...schema, description: trimmed } : schema; | 300 | return trimmed ? { ...schema, description: trimmed } : schema; |
| 272 | }; | 301 | }; |
| 273 | 302 | ||
| 274 | const withSchemaDefault = ( | 303 | const withSchemaDefault = (schema: JsonSchema, value: unknown): JsonSchema => |
| 275 | schema: JsonSchema, | 304 | value !== undefined ? { ...schema, default: value } : schema; |
| 276 | value: unknown, | ||
| 277 | ): JsonSchema => (value !== undefined ? { ...schema, default: value } : schema); | ||
| 278 | 305 | ||
| 279 | const withSchemaDeprecation = ( | 306 | const withSchemaDeprecation = ( |
| 280 | schema: JsonSchema, | 307 | schema: JsonSchema, |
| @@ -286,7 +313,9 @@ const withSchemaDeprecation = ( · 5 unmodified lines | |||
| 286 | : schema; | 313 | : schema; |
| 287 | }; | 314 | }; |
| 288 | 315 | ||
| 289 | const introspectionQueryFromDocument = (documentText: string): IntrospectionQuery => { | 316 | const introspectionQueryFromDocument = ( |
| 317 | documentText: string, | ||
| 318 | ): IntrospectionQuery => { | ||
| 290 | let parsed: unknown; | 319 | let parsed: unknown; |
| 291 | try { | 320 | try { |
| 292 | parsed = JSON.parse(documentText) as unknown; | 321 | parsed = JSON.parse(documentText) as unknown; |
| @@ -416,68 +445,286 @@ const scalarExampleValue = (name: string): unknown => { · 123 unmodified lines | |||
| 416 | } | 445 | } |
| 417 | }; | 446 | }; |
| 418 | 447 | ||
| 419 | const schemaForGraphqlInputType = ( | 448 | const GRAPHQL_SCHEMA_REF_PREFIX = "#/$defs/graphql"; |
| 420 | type: GraphQLInputType, | ||
| 421 | depth = 0, | ||
| 422 | ): JsonSchema => { | ||
| 423 | if (isNonNullType(type)) { | ||
| 424 | return schemaForGraphqlInputType(type.ofType, depth); | ||
| 425 | } | ||
| 426 | 449 | ||
| 427 | if (isListType(type)) { | 450 | const graphqlScalarRef = (name: string): string => |
| 428 | return { | 451 | `${GRAPHQL_SCHEMA_REF_PREFIX}/scalars/${name}`; |
| 429 | type: "array", | ||
| 430 | items: schemaForGraphqlInputType(type.ofType, depth + 1), | ||
| 431 | }; | ||
| 432 | } | ||
| 433 | 452 | ||
| 434 | const namedType = getNamedType(type); | 453 | const graphqlEnumRef = (name: string): string => |
| 435 | if (isScalarType(namedType)) { | 454 | `${GRAPHQL_SCHEMA_REF_PREFIX}/enums/${name}`; |
| 436 | return scalarInputSchema(namedType.name); | ||
| 437 | } | ||
| 438 | 455 | ||
| 439 | if (isEnumType(namedType)) { | 456 | const graphqlInputObjectRef = (name: string): string => |
| 440 | return { | 457 | `${GRAPHQL_SCHEMA_REF_PREFIX}/input/${name}`; |
| 441 | type: "string", | ||
| 442 | enum: namedType.getValues().map((value) => value.name), | ||
| 443 | }; | ||
| 444 | } | ||
| 445 | 458 | ||
| 446 | if (isInputObjectType(namedType)) { | 459 | const graphqlOutputTypeRef = (name: string, depth: number): string => |
| 447 | if (depth >= 2) { | 460 | `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`; |
| 448 | return { | 461 | |
| 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, { | ||
| 449 | type: "object", | 536 | type: "object", |
| 450 | additionalProperties: true, | 537 | properties, |
| 538 | ...(required.length > 0 ? { required } : {}), | ||
| 539 | additionalProperties: false, | ||
| 540 | }); | ||
| 541 | } | ||
| 542 | |||
| 543 | return schemaRef(ref); | ||
| 544 | }; | ||
| 545 | |||
| 546 | const inputSchemaForType = (type: GraphQLInputType): JsonSchema => { | ||
| 547 | if (isNonNullType(type)) { | ||
| 548 | return inputSchemaForType(type.ofType); | ||
| 549 | } | ||
| 550 | |||
| 551 | if (isListType(type)) { | ||
| 552 | return { | ||
| 553 | type: "array", | ||
| 554 | items: inputSchemaForType(type.ofType), | ||
| 451 | }; | 555 | }; |
| 452 | } | 556 | } |
| 453 | 557 | ||
| 454 | const fields = Object.values(namedType.getFields()); | 558 | const namedType = getNamedType(type); |
| 455 | const properties = Object.fromEntries( | 559 | if (isScalarType(namedType)) { |
| 456 | fields.map((field) => { | 560 | return ensureScalarRef(namedType.name); |
| 457 | const schema = schemaForGraphqlInputType(field.type, depth + 1); | 561 | } |
| 458 | const enriched = withSchemaDeprecation( | 562 | |
| 459 | withSchemaDefault( | 563 | if (isEnumType(namedType)) { |
| 460 | withSchemaDescription(schema, field.description), | 564 | return ensureEnumRef( |
| 461 | field.defaultValue, | 565 | namedType.name, |
| 462 | ), | 566 | namedType.getValues().map((value) => value.name), |
| 463 | field.deprecationReason, | 567 | ); |
| 464 | ); | 568 | } |
| 465 | return [field.name, enriched]; | 569 | |
| 466 | }), | 570 | if (isInputObjectType(namedType)) { |
| 571 | return ensureInputObjectRef(namedType); | ||
| 572 | } | ||
| 573 | |||
| 574 | return {}; | ||
| 575 | }; | ||
| 576 | |||
| 577 | const selectedOutputForNamedType = ( | ||
| 578 | namedType: GraphQLNamedType, | ||
| 579 | depth: number, | ||
| 580 | ): SelectedGraphqlOutput => { | ||
| 581 | if (isScalarType(namedType)) { | ||
| 582 | return { | ||
| 583 | selectionSet: "", | ||
| 584 | schema: ensureScalarRef(namedType.name), | ||
| 585 | }; | ||
| 586 | } | ||
| 587 | |||
| 588 | if (isEnumType(namedType)) { | ||
| 589 | return { | ||
| 590 | selectionSet: "", | ||
| 591 | schema: ensureEnumRef( | ||
| 592 | namedType.name, | ||
| 593 | namedType.getValues().map((value) => value.name), | ||
| 594 | ), | ||
| 595 | }; | ||
| 596 | } | ||
| 597 | |||
| 598 | const ref = graphqlOutputTypeRef(namedType.name, depth); | ||
| 599 | const cached = outputSelectionsByRef.get(ref); | ||
| 600 | if (cached) { | ||
| 601 | return cached; | ||
| 602 | } | ||
| 603 | |||
| 604 | if (isUnionType(namedType)) { | ||
| 605 | const result = { | ||
| 606 | selectionSet: "{ __typename }", | ||
| 607 | schema: schemaRef(ref), | ||
| 608 | } satisfies SelectedGraphqlOutput; | ||
| 609 | outputSelectionsByRef.set(ref, result); | ||
| 610 | defineRefSchema(ref, typenameOnlyObjectSchema()); | ||
| 611 | return result; | ||
| 612 | } | ||
| 613 | |||
| 614 | if (!isObjectType(namedType) && !isInterfaceType(namedType)) { | ||
| 615 | return { | ||
| 616 | selectionSet: "{ __typename }", | ||
| 617 | schema: typenameOnlyObjectSchema(), | ||
| 618 | }; | ||
| 619 | } | ||
| 620 | |||
| 621 | const fallback = { | ||
| 622 | selectionSet: "{ __typename }", | ||
| 623 | schema: schemaRef(ref), | ||
| 624 | } satisfies SelectedGraphqlOutput; | ||
| 625 | outputSelectionsByRef.set(ref, fallback); | ||
| 626 | |||
| 627 | if (depth >= 2) { | ||
| 628 | defineRefSchema(ref, typenameOnlyObjectSchema()); | ||
| 629 | return fallback; | ||
| 630 | } | ||
| 631 | |||
| 632 | const fields = Object.values(namedType.getFields()).filter( | ||
| 633 | (field) => !field.name.startsWith("__"), | ||
| 634 | ); | ||
| 635 | const leafFields = fields.filter((field) => | ||
| 636 | isLeafType(getNamedType(field.type)), | ||
| 637 | ); | ||
| 638 | const nestedFields = fields.filter( | ||
| 639 | (field) => !isLeafType(getNamedType(field.type)), | ||
| 467 | ); | 640 | ); |
| 468 | const required = fields | ||
| 469 | .filter((field) => isNonNullType(field.type) && field.defaultValue === undefined) | ||
| 470 | .map((field) => field.name); | ||
| 471 | 641 | ||
| 472 | return { | 642 | const selectedLeafFields = pickPreferredFields({ |
| 643 | fields: leafFields, | ||
| 644 | preferredNames: PREFERRED_LEAF_FIELD_NAMES, | ||
| 645 | limit: 3, | ||
| 646 | }); | ||
| 647 | const selectedNestedFields = pickPreferredFields({ | ||
| 648 | fields: nestedFields, | ||
| 649 | preferredNames: PREFERRED_NESTED_FIELD_NAMES, | ||
| 650 | limit: 2, | ||
| 651 | }); | ||
| 652 | const selectedFields = dedupeFields([ | ||
| 653 | ...selectedLeafFields, | ||
| 654 | ...selectedNestedFields, | ||
| 655 | ]); | ||
| 656 | |||
| 657 | if (selectedFields.length === 0) { | ||
| 658 | defineRefSchema(ref, typenameOnlyObjectSchema()); | ||
| 659 | return fallback; | ||
| 660 | } | ||
| 661 | |||
| 662 | const selectionParts: string[] = []; | ||
| 663 | const properties: Record<string, JsonSchema> = {}; | ||
| 664 | const required: string[] = []; | ||
| 665 | |||
| 666 | for (const field of selectedFields) { | ||
| 667 | const selected = selectedOutputForType(field.type, depth + 1); | ||
| 668 | const propertySchema = withSchemaDeprecation( | ||
| 669 | withSchemaDescription(selected.schema, field.description), | ||
| 670 | field.deprecationReason, | ||
| 671 | ); | ||
| 672 | |||
| 673 | properties[field.name] = propertySchema; | ||
| 674 | required.push(field.name); | ||
| 675 | selectionParts.push( | ||
| 676 | selected.selectionSet.length > 0 | ||
| 677 | ? `${field.name} ${selected.selectionSet}` | ||
| 678 | : field.name, | ||
| 679 | ); | ||
| 680 | } | ||
| 681 | |||
| 682 | properties.__typename = { type: "string" }; | ||
| 683 | required.push("__typename"); | ||
| 684 | selectionParts.push("__typename"); | ||
| 685 | |||
| 686 | defineRefSchema(ref, { | ||
| 473 | type: "object", | 687 | type: "object", |
| 474 | properties, | 688 | properties, |
| 475 | ...(required.length > 0 ? { required } : {}), | 689 | required, |
| 476 | additionalProperties: false, | 690 | additionalProperties: false, |
| 477 | }; | 691 | }); |
| 478 | } | ||
| 479 | 692 | ||
| 480 | return {}; | 693 | const result = { |
| 694 | selectionSet: `{ ${selectionParts.join(" ")} }`, | ||
| 695 | schema: schemaRef(ref), | ||
| 696 | } satisfies SelectedGraphqlOutput; | ||
| 697 | outputSelectionsByRef.set(ref, result); | ||
| 698 | return result; | ||
| 699 | }; | ||
| 700 | |||
| 701 | const selectedOutputForType = ( | ||
| 702 | type: GraphQLOutputType, | ||
| 703 | depth = 0, | ||
| 704 | ): SelectedGraphqlOutput => { | ||
| 705 | if (isNonNullType(type)) { | ||
| 706 | return selectedOutputForType(type.ofType, depth); | ||
| 707 | } | ||
| 708 | |||
| 709 | if (isListType(type)) { | ||
| 710 | const selectedItem = selectedOutputForType(type.ofType, depth); | ||
| 711 | return { | ||
| 712 | selectionSet: selectedItem.selectionSet, | ||
| 713 | schema: { | ||
| 714 | type: "array", | ||
| 715 | items: selectedItem.schema, | ||
| 716 | }, | ||
| 717 | }; | ||
| 718 | } | ||
| 719 | |||
| 720 | return selectedOutputForNamedType(getNamedType(type), depth); | ||
| 721 | }; | ||
| 722 | |||
| 723 | return { | ||
| 724 | refTable, | ||
| 725 | inputSchemaForType, | ||
| 726 | selectedOutputForType, | ||
| 727 | }; | ||
| 481 | }; | 728 | }; |
| 482 | 729 | ||
| 483 | const exampleValueForGraphqlInputType = ( | 730 | const exampleValueForGraphqlInputType = ( |
| @@ -510,14 +757,14 @@ const exampleValueForGraphqlInputType = ( · 26 unmodified lines | |||
| 510 | const requiredFields = fields.filter( | 757 | const requiredFields = fields.filter( |
| 511 | (field) => isNonNullType(field.type) && field.defaultValue === undefined, | 758 | (field) => isNonNullType(field.type) && field.defaultValue === undefined, |
| 512 | ); | 759 | ); |
| 513 | const fieldsForExample = requiredFields.length > 0 | 760 | const fieldsForExample = |
| 514 | ? requiredFields | 761 | requiredFields.length > 0 ? requiredFields : fields.slice(0, 1); |
| 515 | : fields.slice(0, 1); | ||
| 516 | 762 | ||
| 517 | return Object.fromEntries( | 763 | return Object.fromEntries( |
| 518 | fieldsForExample.map((field) => [ | 764 | fieldsForExample.map((field) => [ |
| 519 | field.name, | 765 | field.name, |
| 520 | field.defaultValue ?? exampleValueForGraphqlInputType(field.type, depth + 1), | 766 | field.defaultValue ?? |
| 767 | exampleValueForGraphqlInputType(field.type, depth + 1), | ||
| 521 | ]), | 768 | ]), |
| 522 | ); | 769 | ); |
| 523 | } | 770 | } |
| @@ -525,35 +772,9 @@ const exampleValueForGraphqlInputType = ( · 1 unmodified line | |||
| 525 | return {}; | 772 | return {}; |
| 526 | }; | 773 | }; |
| 527 | 774 | ||
| 528 | const scalarOutputSchema = (name: string): JsonSchema => scalarInputSchema(name); | 775 | const dedupeFields = <T extends { name: string }>( |
| 529 | 776 | fields: readonly T[], | |
| 530 | const selectedLeafOutputForType = (type: GraphQLOutputType): SelectedGraphqlOutput => { | 777 | ): T[] => { |
| 531 | const namedType = getNamedType(type); | ||
| 532 | |||
| 533 | if (isScalarType(namedType)) { | ||
| 534 | return { | ||
| 535 | selectionSet: "", | ||
| 536 | schema: scalarOutputSchema(namedType.name), | ||
| 537 | }; | ||
| 538 | } | ||
| 539 | |||
| 540 | if (isEnumType(namedType)) { | ||
| 541 | return { | ||
| 542 | selectionSet: "", | ||
| 543 | schema: { | ||
| 544 | type: "string", | ||
| 545 | enum: namedType.getValues().map((value) => value.name), | ||
| 546 | }, | ||
| 547 | }; | ||
| 548 | } | ||
| 549 | |||
| 550 | return { | ||
| 551 | selectionSet: "", | ||
| 552 | schema: {}, | ||
| 553 | }; | ||
| 554 | }; | ||
| 555 | |||
| 556 | const dedupeFields = <T extends { name: string }>(fields: readonly T[]): T[] => { | ||
| 557 | const seen = new Set<string>(); | 778 | const seen = new Set<string>(); |
| 558 | const deduped: T[] = []; | 779 | const deduped: T[] = []; |
| 559 | 780 | ||
| @@ -585,155 +806,6 @@ const pickPreferredFields = <T extends { name: string }>(input: { · 25 unmodified lines | |||
| 585 | return dedupeFields([...matches, ...input.fields]).slice(0, input.limit); | 806 | return dedupeFields([...matches, ...input.fields]).slice(0, input.limit); |
| 586 | }; | 807 | }; |
| 587 | 808 | ||
| 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 | |||
| 737 | const printGraphqlType = (type: GraphQLInputType): string => { | 809 | const printGraphqlType = (type: GraphQLInputType): string => { |
| 738 | if (isNonNullType(type)) { | 810 | if (isNonNullType(type)) { |
| 739 | return `${printGraphqlType(type.ofType)}!`; | 811 | return `${printGraphqlType(type.ofType)}!`; |
| @@ -746,10 +818,13 @@ const printGraphqlType = (type: GraphQLInputType): string => { · 6 unmodified lines | |||
| 746 | return type.name; | 818 | return type.name; |
| 747 | }; | 819 | }; |
| 748 | 820 | ||
| 749 | const inputSchemaForFieldArguments = (args: readonly GraphQLArgument[]): JsonSchema => { | 821 | const inputSchemaForFieldArguments = ( |
| 822 | args: readonly GraphQLArgument[], | ||
| 823 | bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>, | ||
| 824 | ): JsonSchema => { | ||
| 750 | const properties = Object.fromEntries( | 825 | const properties = Object.fromEntries( |
| 751 | args.map((arg) => { | 826 | args.map((arg) => { |
| 752 | const schema = schemaForGraphqlInputType(arg.type); | 827 | const schema = bundleBuilder.inputSchemaForType(arg.type); |
| 753 | const enriched = withSchemaDeprecation( | 828 | const enriched = withSchemaDeprecation( |
| 754 | withSchemaDefault( | 829 | withSchemaDefault( |
| 755 | withSchemaDescription(schema, arg.description), | 830 | withSchemaDescription(schema, arg.description), |
| @@ -781,8 +856,11 @@ const inputSchemaForFieldArguments = (args: readonly GraphQLArgument[]): JsonSch · 25 unmodified lines | |||
| 781 | }; | 856 | }; |
| 782 | }; | 857 | }; |
| 783 | 858 | ||
| 784 | const outputEnvelopeSchemaForField = (fieldType: GraphQLOutputType): JsonSchema => { | 859 | const outputEnvelopeSchemaForField = ( |
| 785 | const selectedOutput = selectedOutputForType(fieldType); | 860 | fieldType: GraphQLOutputType, |
| 861 | bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>, | ||
| 862 | ): JsonSchema => { | ||
| 863 | const selectedOutput = bundleBuilder.selectedOutputForType(fieldType); | ||
| 786 | 864 | ||
| 787 | return { | 865 | return { |
| 788 | type: "object", | 866 | type: "object", |
| @@ -801,7 +879,9 @@ const outputEnvelopeSchemaForField = (fieldType: GraphQLOutputType): JsonSchema · 12 unmodified lines | |||
| 801 | }; | 879 | }; |
| 802 | }; | 880 | }; |
| 803 | 881 | ||
| 804 | const exampleInputJsonForField = (args: readonly GraphQLArgument[]): string | undefined => { | 882 | const exampleInputJsonForField = ( |
| 883 | args: readonly GraphQLArgument[], | ||
| 884 | ): string | undefined => { | ||
| 805 | if (args.length === 0) { | 885 | if (args.length === 0) { |
| 806 | return JSON.stringify({}); | 886 | return JSON.stringify({}); |
| 807 | } | 887 | } |
| @@ -809,7 +889,8 @@ const exampleInputJsonForField = (args: readonly GraphQLArgument[]): string | un · 1 unmodified line | |||
| 809 | const requiredArgs = args.filter( | 889 | const requiredArgs = args.filter( |
| 810 | (arg) => isNonNullType(arg.type) && arg.defaultValue === undefined, | 890 | (arg) => isNonNullType(arg.type) && arg.defaultValue === undefined, |
| 811 | ); | 891 | ); |
| 812 | const argsForExample = requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1); | 892 | const argsForExample = |
| 893 | requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1); | ||
| 813 | const example = Object.fromEntries( | 894 | const example = Object.fromEntries( |
| 814 | argsForExample.map((arg) => [ | 895 | argsForExample.map((arg) => [ |
| 815 | arg.name, | 896 | arg.name, |
| @@ -825,8 +906,11 @@ const buildGraphqlFieldOperationDocument = (input: { · 9 unmodified lines | |||
| 825 | fieldName: string; | 906 | fieldName: string; |
| 826 | args: readonly GraphQLArgument[]; | 907 | args: readonly GraphQLArgument[]; |
| 827 | fieldType: GraphQLOutputType; | 908 | fieldType: GraphQLOutputType; |
| 909 | bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>; | ||
| 828 | }): { operationName: string; operationDocument: string } => { | 910 | }): { operationName: string; operationDocument: string } => { |
| 829 | const selectedOutput = selectedOutputForType(input.fieldType); | 911 | const selectedOutput = input.bundleBuilder.selectedOutputForType( |
| 912 | input.fieldType, | ||
| 913 | ); | ||
| 830 | const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`; | 914 | const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`; |
| 831 | const variableDefinitions = input.args | 915 | const variableDefinitions = input.args |
| 832 | .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`) | 916 | .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`) |
| @@ -834,12 +918,14 @@ const buildGraphqlFieldOperationDocument = (input: { · 1 unmodified line | |||
| 834 | const fieldArguments = input.args | 918 | const fieldArguments = input.args |
| 835 | .map((arg) => `${arg.name}: $${arg.name}`) | 919 | .map((arg) => `${arg.name}: $${arg.name}`) |
| 836 | .join(", "); | 920 | .join(", "); |
| 837 | const fieldCall = fieldArguments.length > 0 | 921 | const fieldCall = |
| 838 | ? `${input.fieldName}(${fieldArguments})` | 922 | fieldArguments.length > 0 |
| 839 | : input.fieldName; | 923 | ? `${input.fieldName}(${fieldArguments})` |
| 840 | const selectionSuffix = selectedOutput.selectionSet.length > 0 | 924 | : input.fieldName; |
| 841 | ? ` ${selectedOutput.selectionSet}` | 925 | const selectionSuffix = |
| 842 | : ""; | 926 | selectedOutput.selectionSet.length > 0 |
| 927 | ? ` ${selectedOutput.selectionSet}` | ||
| 928 | : ""; | ||
| 843 | 929 | ||
| 844 | return { | 930 | return { |
| 845 | operationName, | 931 | operationName, |
| @@ -907,23 +993,30 @@ const resolveGraphqlFieldToolIds = ( · 61 unmodified lines | |||
| 907 | } | 993 | } |
| 908 | }; | 994 | }; |
| 909 | 995 | ||
| 910 | applyDuplicates((draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`); | 996 | applyDuplicates( |
| 911 | applyDuplicates((draft) => | 997 | (draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`, |
| 912 | `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`, | 998 | ); |
| 999 | applyDuplicates( | ||
| 1000 | (draft) => | ||
| 1001 | `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`, | ||
| 913 | ); | 1002 | ); |
| 914 | 1003 | ||
| 915 | return staged | 1004 | return staged |
| 916 | .sort((left, right) => | 1005 | .sort( |
| 917 | left.toolId.localeCompare(right.toolId) | 1006 | (left, right) => |
| 918 | || left.fieldName.localeCompare(right.fieldName) | 1007 | left.toolId.localeCompare(right.toolId) || |
| 919 | || left.operationType.localeCompare(right.operationType), | 1008 | left.fieldName.localeCompare(right.fieldName) || |
| 1009 | left.operationType.localeCompare(right.operationType), | ||
| 920 | ) | 1010 | ) |
| 921 | .map((draft) => ({ ...draft })); | 1011 | .map((draft) => ({ ...draft })); |
| 922 | }; | 1012 | }; |
| 923 | 1013 | ||
| 924 | const fieldToolDraftsFromRootType = (input: { | 1014 | const fieldToolDraftsFromRootType = (input: { |
| 925 | rootType: ReturnType<GraphQLSchema["getQueryType"]> | ReturnType<GraphQLSchema["getMutationType"]>; | 1015 | rootType: |
| 1016 | | ReturnType<GraphQLSchema["getQueryType"]> | ||
| 1017 | | ReturnType<GraphQLSchema["getMutationType"]>; | ||
| 926 | operationType: GraphqlOperationType; | 1018 | operationType: GraphqlOperationType; |
| 1019 | bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>; | ||
| 927 | }): GraphqlFieldToolDraft[] => { | 1020 | }): GraphqlFieldToolDraft[] => { |
| 928 | if (!input.rootType) { | 1021 | if (!input.rootType) { |
| 929 | return []; | 1022 | return []; |
| @@ -933,14 +1026,20 @@ const fieldToolDraftsFromRootType = (input: { · 3 unmodified lines | |||
| 933 | .filter((field) => !field.name.startsWith("__")) | 1026 | .filter((field) => !field.name.startsWith("__")) |
| 934 | .map((field) => { | 1027 | .map((field) => { |
| 935 | const leaf = toCamelCase(field.name); | 1028 | const leaf = toCamelCase(field.name); |
| 936 | const outputSchemaJson = JSON.stringify(outputEnvelopeSchemaForField(field.type)); | 1029 | const outputSchemaJson = JSON.stringify( |
| 937 | const inputSchemaJson = JSON.stringify(inputSchemaForFieldArguments(field.args)); | 1030 | outputEnvelopeSchemaForField(field.type, input.bundleBuilder), |
| 938 | const { operationName, operationDocument } = buildGraphqlFieldOperationDocument({ | 1031 | ); |
| 939 | operationType: input.operationType, | 1032 | const inputSchemaJson = JSON.stringify( |
| 940 | fieldName: field.name, | 1033 | inputSchemaForFieldArguments(field.args, input.bundleBuilder), |
| 941 | args: field.args, | 1034 | ); |
| 942 | fieldType: field.type, | 1035 | const { operationName, operationDocument } = |
| 943 | }); | 1036 | buildGraphqlFieldOperationDocument({ |
| 1037 | operationType: input.operationType, | ||
| 1038 | fieldName: field.name, | ||
| 1039 | args: field.args, | ||
| 1040 | fieldType: field.type, | ||
| 1041 | bundleBuilder: input.bundleBuilder, | ||
| 1042 | }); | ||
| 944 | 1043 | ||
| 945 | return { | 1044 | return { |
| 946 | kind: "field", | 1045 | kind: "field", |
| @@ -983,14 +1082,17 @@ export const extractGraphqlManifest = ( · 36 unmodified lines | |||
| 983 | const introspection = introspectionQueryFromDocument(documentText); | 1082 | const introspection = introspectionQueryFromDocument(documentText); |
| 984 | const schema = buildClientSchema(introspection); | 1083 | const schema = buildClientSchema(introspection); |
| 985 | const sourceHash = createGraphqlSourceHash(documentText); | 1084 | const sourceHash = createGraphqlSourceHash(documentText); |
| 1085 | const bundleBuilder = createGraphqlSchemaRefTableBuilder(); | ||
| 986 | const fieldTools = resolveGraphqlFieldToolIds([ | 1086 | const fieldTools = resolveGraphqlFieldToolIds([ |
| 987 | ...fieldToolDraftsFromRootType({ | 1087 | ...fieldToolDraftsFromRootType({ |
| 988 | rootType: schema.getQueryType(), | 1088 | rootType: schema.getQueryType(), |
| 989 | operationType: "query", | 1089 | operationType: "query", |
| 1090 | bundleBuilder, | ||
| 990 | }), | 1091 | }), |
| 991 | ...fieldToolDraftsFromRootType({ | 1092 | ...fieldToolDraftsFromRootType({ |
| 992 | rootType: schema.getMutationType(), | 1093 | rootType: schema.getMutationType(), |
| 993 | operationType: "mutation", | 1094 | operationType: "mutation", |
| 1095 | bundleBuilder, | ||
| 994 | }), | 1096 | }), |
| 995 | ]); | 1097 | ]); |
| 996 | 1098 | ||
| @@ -1000,10 +1102,10 @@ export const extractGraphqlManifest = ( · 3 unmodified lines | |||
| 1000 | queryTypeName: schema.getQueryType()?.name ?? null, | 1102 | queryTypeName: schema.getQueryType()?.name ?? null, |
| 1001 | mutationTypeName: schema.getMutationType()?.name ?? null, | 1103 | mutationTypeName: schema.getMutationType()?.name ?? null, |
| 1002 | subscriptionTypeName: schema.getSubscriptionType()?.name ?? null, | 1104 | subscriptionTypeName: schema.getSubscriptionType()?.name ?? null, |
| 1003 | tools: [ | 1105 | ...(Object.keys(bundleBuilder.refTable).length > 0 |
| 1004 | ...fieldTools, | 1106 | ? { schemaRefTable: bundleBuilder.refTable } |
| 1005 | requestToolManifestEntry(sourceName), | 1107 | : {}), |
| 1006 | ], | 1108 | tools: [...fieldTools, requestToolManifestEntry(sourceName)], |
| 1007 | } satisfies GraphqlToolManifest; | 1109 | } satisfies GraphqlToolManifest; |
| 1008 | }, | 1110 | }, |
| 1009 | catch: (cause) => | 1111 | catch: (cause) => |
| @@ -1024,23 +1126,38 @@ export const compileGraphqlToolDefinitions = ( · 14 unmodified lines | |||
| 1024 | operationType: tool.kind === "field" ? tool.operationType : null, | 1126 | operationType: tool.kind === "field" ? tool.operationType : null, |
| 1025 | operationName: tool.kind === "field" ? tool.operationName : null, | 1127 | operationName: tool.kind === "field" ? tool.operationName : null, |
| 1026 | operationDocument: tool.kind === "field" ? tool.operationDocument : null, | 1128 | operationDocument: tool.kind === "field" ? tool.operationDocument : null, |
| 1027 | searchTerms: tool.kind === "field" ? tool.searchTerms : ["request", "graphql", "query", "mutation"], | 1129 | searchTerms: |
| 1130 | tool.kind === "field" | ||
| 1131 | ? tool.searchTerms | ||
| 1132 | : ["request", "graphql", "query", "mutation"], | ||
| 1028 | })); | 1133 | })); |
| 1029 | 1134 | ||
| 1030 | export const buildGraphqlToolPresentation = (input: { | 1135 | export const buildGraphqlToolPresentation = (input: { |
| 1031 | manifest: GraphqlToolManifest; | 1136 | manifest: GraphqlToolManifest; |
| 1032 | definition: GraphqlToolDefinition; | 1137 | definition: GraphqlToolDefinition; |
| 1033 | }): GraphqlToolPresentation => { | 1138 | }): GraphqlToolPresentation => { |
| 1034 | const entry = input.manifest.tools.find((tool) => tool.toolId === input.definition.toolId); | 1139 | const entry = input.manifest.tools.find( |
| 1140 | (tool) => tool.toolId === input.definition.toolId, | ||
| 1141 | ); | ||
| 1035 | const inputSchemaJson = entry?.inputSchemaJson; | 1142 | const inputSchemaJson = entry?.inputSchemaJson; |
| 1036 | const outputSchemaJson = entry?.outputSchemaJson; | 1143 | const outputSchemaJson = entry?.outputSchemaJson; |
| 1037 | 1144 | ||
| 1038 | return { | 1145 | return { |
| 1039 | inputType: typeSignatureFromSchemaJson(inputSchemaJson, "unknown", Infinity), | 1146 | inputType: typeSignatureFromSchemaJson( |
| 1040 | outputType: typeSignatureFromSchemaJson(outputSchemaJson, "unknown", Infinity), | 1147 | inputSchemaJson, |
| 1148 | "unknown", | ||
| 1149 | Infinity, | ||
| 1150 | ), | ||
| 1151 | outputType: typeSignatureFromSchemaJson( | ||
| 1152 | outputSchemaJson, | ||
| 1153 | "unknown", | ||
| 1154 | Infinity, | ||
| 1155 | ), | ||
| 1041 | ...(inputSchemaJson ? { inputSchemaJson } : {}), | 1156 | ...(inputSchemaJson ? { inputSchemaJson } : {}), |
| 1042 | ...(outputSchemaJson ? { outputSchemaJson } : {}), | 1157 | ...(outputSchemaJson ? { outputSchemaJson } : {}), |
| 1043 | ...(entry?.exampleInputJson ? { exampleInputJson: entry.exampleInputJson } : {}), | 1158 | ...(entry?.exampleInputJson |
| 1159 | ? { exampleInputJson: entry.exampleInputJson } | ||
| 1160 | : {}), | ||
| 1044 | providerDataJson: JSON.stringify({ | 1161 | providerDataJson: JSON.stringify({ |
| 1045 | kind: "graphql", | 1162 | kind: "graphql", |
| 1046 | toolKind: entry?.kind ?? "request", | 1163 | toolKind: entry?.kind ?? "request", |
| @@ -1051,11 +1168,196 @@ export const buildGraphqlToolPresentation = (input: { · 4 unmodified lines | |||
| 1051 | fieldName: input.definition.fieldName, | 1168 | fieldName: input.definition.fieldName, |
| 1052 | operationType: input.definition.operationType, | 1169 | operationType: input.definition.operationType, |
| 1053 | operationName: input.definition.operationName, | 1170 | operationName: input.definition.operationName, |
| 1171 | operationDocument: input.definition.operationDocument, | ||
| 1054 | queryTypeName: input.manifest.queryTypeName, | 1172 | queryTypeName: input.manifest.queryTypeName, |
| 1055 | mutationTypeName: input.manifest.mutationTypeName, | 1173 | mutationTypeName: input.manifest.mutationTypeName, |
| 1056 | subscriptionTypeName: input.manifest.subscriptionTypeName, | 1174 | subscriptionTypeName: input.manifest.subscriptionTypeName, |
| 1175 | } satisfies GraphqlToolProviderData), | ||
| 1176 | }; | ||
| 1177 | }; | ||
| 1178 | |||
| 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, | ||
| 1057 | }), | 1188 | }), |
| 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, | ||
| 1058 | }; | 1318 | }; |
| 1319 | |||
| 1320 | return toTool({ | ||
| 1321 | tool: { | ||
| 1322 | description: input.description, | ||
| 1323 | inputSchema: standardSchemaFromJsonSchema(inputSchema), | ||
| 1324 | execute: (args: unknown) => { | ||
| 1325 | if ( | ||
| 1326 | providerData.toolKind === "field" && | ||
| 1327 | providerData.fieldName && | ||
| 1328 | providerData.operationType && | ||
| 1329 | providerData.operationName && | ||
| 1330 | providerData.operationDocument | ||
| 1331 | ) { | ||
| 1332 | return Effect.runPromise( | ||
| 1333 | invokeGraphqlFieldTool({ | ||
| 1334 | entry: { | ||
| 1335 | fieldName: providerData.fieldName, | ||
| 1336 | operationName: providerData.operationName, | ||
| 1337 | operationDocument: providerData.operationDocument, | ||
| 1338 | }, | ||
| 1339 | endpoint: input.endpoint, | ||
| 1340 | path: input.path, | ||
| 1341 | defaultHeaders: input.defaultHeaders, | ||
| 1342 | credentialHeaders: input.credentialHeaders, | ||
| 1343 | args, | ||
| 1344 | }), | ||
| 1345 | ); | ||
| 1346 | } | ||
| 1347 | |||
| 1348 | return Effect.runPromise( | ||
| 1349 | invokeRawGraphqlTool({ | ||
| 1350 | endpoint: input.endpoint, | ||
| 1351 | path: input.path, | ||
| 1352 | defaultHeaders: input.defaultHeaders, | ||
| 1353 | credentialHeaders: input.credentialHeaders, | ||
| 1354 | args, | ||
| 1355 | }), | ||
| 1356 | ); | ||
| 1357 | }, | ||
| 1358 | }, | ||
| 1359 | metadata, | ||
| 1360 | }); | ||
| 1059 | }; | 1361 | }; |
| 1060 | 1362 | ||
| 1061 | export const graphqlToolDescriptorFromDefinition = (input: { | 1363 | export const graphqlToolDescriptorFromDefinition = (input: { |
| @@ -1074,7 +1376,8 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 12 unmodified lines | |||
| 1074 | path: asToolPath(input.path), | 1376 | path: asToolPath(input.path), |
| 1075 | sourceKey: input.sourceKey, | 1377 | sourceKey: input.sourceKey, |
| 1076 | description: input.definition.description, | 1378 | description: input.definition.description, |
| 1077 | interaction: input.definition.operationType === "query" ? "auto" : "required", | 1379 | interaction: |
| 1380 | input.definition.operationType === "query" ? "auto" : "required", | ||
| 1078 | inputType: presentation.inputType, | 1381 | inputType: presentation.inputType, |
| 1079 | outputType: presentation.outputType, | 1382 | outputType: presentation.outputType, |
| 1080 | ...(input.includeSchemas && presentation.inputSchemaJson | 1383 | ...(input.includeSchemas && presentation.inputSchemaJson |
| @@ -1105,15 +1408,24 @@ const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => · 24 unmodified lines | |||
| 1105 | }), | 1408 | }), |
| 1106 | body: JSON.stringify({ | 1409 | body: JSON.stringify({ |
| 1107 | query: input.query, | 1410 | query: input.query, |
| 1108 | ...(input.variables !== undefined ? { variables: input.variables } : {}), | 1411 | ...(input.variables !== undefined |
| 1109 | ...(input.operationName ? { operationName: input.operationName } : {}), | 1412 | ? { variables: input.variables } |
| 1413 | : {}), | ||
| 1414 | ...(input.operationName | ||
| 1415 | ? { operationName: input.operationName } | ||
| 1416 | : {}), | ||
| 1110 | }), | 1417 | }), |
| 1111 | }), | 1418 | }), |
| 1112 | catch: (cause) => graphqlToolError(`GraphQL request failed for ${input.path}`, cause), | 1419 | catch: (cause) => |
| 1420 | graphqlToolError(`GraphQL request failed for ${input.path}`, cause), | ||
| 1113 | }); | 1421 | }); |
| 1114 | const body = yield* Effect.tryPromise({ | 1422 | const body = yield* Effect.tryPromise({ |
| 1115 | try: () => parseGraphqlResponseBody(response), | 1423 | try: () => parseGraphqlResponseBody(response), |
| 1116 | catch: (cause) => graphqlToolError(`Failed decoding GraphQL response for ${input.path}`, cause), | 1424 | catch: (cause) => |
| 1425 | graphqlToolError( | ||
| 1426 | `Failed decoding GraphQL response for ${input.path}`, | ||
| 1427 | cause, | ||
| 1428 | ), | ||
| 1117 | }); | 1429 | }); |
| 1118 | 1430 | ||
| 1119 | const responseHeaders: Record<string, string> = {}; | 1431 | const responseHeaders: Record<string, string> = {}; |
| @@ -1151,7 +1463,8 @@ const invokeRawGraphqlTool = (input: { · 31 unmodified lines | |||
| 1151 | defaultHeaders: input.defaultHeaders, | 1463 | defaultHeaders: input.defaultHeaders, |
| 1152 | credentialHeaders: input.credentialHeaders, | 1464 | credentialHeaders: input.credentialHeaders, |
| 1153 | query, | 1465 | query, |
| 1154 | variables: record.variables !== undefined ? asRecord(record.variables) : undefined, | 1466 | variables: |
| 1467 | record.variables !== undefined ? asRecord(record.variables) : undefined, | ||
| 1155 | operationName: asString(record.operationName) ?? undefined, | 1468 | operationName: asString(record.operationName) ?? undefined, |
| 1156 | requestHeaders: asStringRecord(record.headers), | 1469 | requestHeaders: asStringRecord(record.headers), |
| 1157 | }); | 1470 | }); |
| @@ -1165,7 +1478,10 @@ const withoutUndefinedEntries = ( · 7 unmodified lines | |||
| 1165 | ); | 1478 | ); |
| 1166 | 1479 | ||
| 1167 | const invokeGraphqlFieldTool = (input: { | 1480 | const invokeGraphqlFieldTool = (input: { |
| 1168 | entry: GraphqlFieldToolManifestEntry; | 1481 | entry: Pick< |
| 1482 | GraphqlFieldToolManifestEntry, | ||
| 1483 | "fieldName" | "operationName" | "operationDocument" | ||
| 1484 | >; | ||
| 1169 | endpoint: string; | 1485 | endpoint: string; |
| 1170 | path: string; | 1486 | path: string; |
| 1171 | defaultHeaders?: Readonly<Record<string, string>>; | 1487 | defaultHeaders?: Readonly<Record<string, string>>; |
| @@ -1218,51 +1534,24 @@ export const createGraphqlToolsFromManifest = (input: { · 46 unmodified lines | |||
| 1218 | manifest: input.manifest, | 1534 | manifest: input.manifest, |
| 1219 | definition, | 1535 | definition, |
| 1220 | }); | 1536 | }); |
| 1221 | const entry = input.manifest.tools.find((tool) => tool.toolId === definition.toolId); | 1537 | const path = input.namespace |
| 1222 | const path = input.namespace ? `${input.namespace}.${definition.toolId}` : definition.toolId; | 1538 | ? `${input.namespace}.${definition.toolId}` |
| 1223 | const inputSchema = presentation.inputSchemaJson | 1539 | : definition.toolId; |
| 1224 | ? (JSON.parse(presentation.inputSchemaJson) as Record<string, unknown>) | ||
| 1225 | : {}; | ||
| 1226 | const metadata: ToolMetadata = { | ||
| 1227 | interaction: "auto", | ||
| 1228 | inputType: presentation.inputType, | ||
| 1229 | outputType: presentation.outputType, | ||
| 1230 | ...(presentation.inputSchemaJson ? { inputSchemaJson: presentation.inputSchemaJson } : {}), | ||
| 1231 | ...(presentation.outputSchemaJson ? { outputSchemaJson: presentation.outputSchemaJson } : {}), | ||
| 1232 | ...(presentation.exampleInputJson ? { exampleInputJson: presentation.exampleInputJson } : {}), | ||
| 1233 | sourceKey: input.sourceKey, | ||
| 1234 | providerKind: "graphql", | ||
| 1235 | providerDataJson: presentation.providerDataJson, | ||
| 1236 | }; | ||
| 1237 | 1540 | ||
| 1238 | return [ | 1541 | return [ |
| 1239 | path, | 1542 | path, |
| 1240 | toTool({ | 1543 | createGraphqlToolFromPersistedOperation({ |
| 1241 | tool: { | 1544 | path, |
| 1242 | description: definition.description, | 1545 | sourceKey: input.sourceKey, |
| 1243 | inputSchema: standardSchemaFromJsonSchema(inputSchema), | 1546 | endpoint, |
| 1244 | execute: (args: unknown) => { | 1547 | description: definition.description, |
| 1245 | if (entry?.kind === "field") { | 1548 | inputSchemaJson: presentation.inputSchemaJson, |
| 1246 | return Effect.runPromise(invokeGraphqlFieldTool({ | 1549 | outputSchemaJson: presentation.outputSchemaJson, |
| 1247 | entry, | 1550 | exampleInputJson: presentation.exampleInputJson, |
| 1248 | endpoint, | 1551 | providerDataJson: presentation.providerDataJson, |
| 1249 | path, | 1552 | schemaRefTable: input.manifest.schemaRefTable, |
| 1250 | defaultHeaders: input.defaultHeaders, | 1553 | defaultHeaders: input.defaultHeaders, |
| 1251 | credentialHeaders: input.credentialHeaders, | 1554 | credentialHeaders: input.credentialHeaders, |
| 1252 | args, | ||
| 1253 | })); | ||
| 1254 | } | ||
| 1255 | |||
| 1256 | return Effect.runPromise(invokeRawGraphqlTool({ | ||
| 1257 | endpoint, | ||
| 1258 | path, | ||
| 1259 | defaultHeaders: input.defaultHeaders, | ||
| 1260 | credentialHeaders: input.credentialHeaders, | ||
| 1261 | args, | ||
| 1262 | })); | ||
| 1263 | }, | ||
| 1264 | }, | ||
| 1265 | metadata, | ||
| 1266 | }), | 1555 | }), |
| 1267 | ] as const; | 1556 | ] as const; |
| 1268 | }), | 1557 | }), |
| @@ -27,9 +27,8 @@ const baseDescriptor: InvocationDescriptor = { · 26 unmodified lines | |||
| 27 | sourceKind: "openapi", | 27 | sourceKind: "openapi", |
| 28 | sourceNamespace: "vercel.api.dns", | 28 | sourceNamespace: "vercel.api.dns", |
| 29 | operationKind: "write", | 29 | operationKind: "write", |
| 30 | httpMethod: "POST", | 30 | interaction: "required", |
| 31 | httpPathTemplate: "/v10/domains/{domain}/records", | 31 | approvalLabel: "POST /v10/domains/{domain}/records", |
| 32 | graphqlOperationType: null, | ||
| 33 | }; | 32 | }; |
| 34 | 33 | ||
| 35 | const basePolicy = ( | 34 | const basePolicy = ( |
| @@ -61,8 +60,8 @@ describe("invocation-policy-engine", () => { · 25 unmodified lines | |||
| 61 | ...baseDescriptor, | 60 | ...baseDescriptor, |
| 62 | toolPath: "vercel.api.dns.getRecords", | 61 | toolPath: "vercel.api.dns.getRecords", |
| 63 | operationKind: "read", | 62 | operationKind: "read", |
| 64 | httpMethod: "GET", | 63 | interaction: "auto", |
| 65 | httpPathTemplate: "/v4/domains/{domain}/records", | 64 | approvalLabel: "GET /v4/domains/{domain}/records", |
| 66 | }, | 65 | }, |
| 67 | args: {}, | 66 | args: {}, |
| 68 | policies: [], | 67 | policies: [], |
| @@ -18,9 +18,8 @@ export type InvocationDescriptor = { · 17 unmodified lines | |||
| 18 | sourceKind: Source["kind"]; | 18 | sourceKind: Source["kind"]; |
| 19 | sourceNamespace: string | null; | 19 | sourceNamespace: string | null; |
| 20 | operationKind: InvocationOperationKind; | 20 | operationKind: InvocationOperationKind; |
| 21 | httpMethod: string | null; | 21 | interaction: "auto" | "required"; |
| 22 | httpPathTemplate: string | null; | 22 | approvalLabel: string | null; |
| 23 | graphqlOperationType: "query" | "mutation" | "subscription" | null; | ||
| 24 | }; | 23 | }; |
| 25 | 24 | ||
| 26 | export type InvocationPolicyContext = { | 25 | export type InvocationPolicyContext = { |
| @@ -223,42 +222,17 @@ const policySpecificity = ( · 196 unmodified lines | |||
| 223 | const defaultDecisionForInvocation = ( | 222 | const defaultDecisionForInvocation = ( |
| 224 | descriptor: InvocationDescriptor, | 223 | descriptor: InvocationDescriptor, |
| 225 | ): InvocationAuthorizationDecision => { | 224 | ): InvocationAuthorizationDecision => { |
| 226 | if (descriptor.sourceKind === "openapi") { | 225 | if (descriptor.interaction === "auto") { |
| 227 | const method = descriptor.httpMethod?.toUpperCase() ?? null; | ||
| 228 | if (method === "GET" || method === "HEAD") { | ||
| 229 | return { | ||
| 230 | kind: "allow", | ||
| 231 | reason: `${method} defaults to allow`, | ||
| 232 | matchedPolicyId: null, | ||
| 233 | }; | ||
| 234 | } | ||
| 235 | |||
| 236 | return { | ||
| 237 | kind: "require_interaction", | ||
| 238 | reason: `${method ?? "unknown HTTP method"} defaults to approval`, | ||
| 239 | matchedPolicyId: null, | ||
| 240 | }; | ||
| 241 | } | ||
| 242 | |||
| 243 | if (descriptor.sourceKind === "graphql") { | ||
| 244 | if (descriptor.graphqlOperationType === "query") { | ||
| 245 | return { | ||
| 246 | kind: "allow", | ||
| 247 | reason: "GraphQL query defaults to allow", | ||
| 248 | matchedPolicyId: null, | ||
| 249 | }; | ||
| 250 | } | ||
| 251 | |||
| 252 | return { | 226 | return { |
| 253 | kind: "require_interaction", | 227 | kind: "allow", |
| 254 | reason: `${descriptor.graphqlOperationType ?? "Unknown GraphQL operation"} defaults to approval`, | 228 | reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to allow`, |
| 255 | matchedPolicyId: null, | 229 | matchedPolicyId: null, |
| 256 | }; | 230 | }; |
| 257 | } | 231 | } |
| 258 | 232 | ||
| 259 | return { | 233 | return { |
| 260 | kind: "allow", | 234 | kind: "require_interaction", |
| 261 | reason: "No invocation-specific approval required by default", | 235 | reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to approval`, |
| 262 | matchedPolicyId: null, | 236 | matchedPolicyId: null, |
| 263 | }; | 237 | }; |
| 264 | }; | 238 | }; |
| @@ -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 | }; | ||
| @@ -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; | ||
| @@ -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 | }; | ||
| @@ -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 | }; | ||
| @@ -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 | }; | ||
| @@ -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 | }); | ||
| @@ -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 | }; | ||
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | type Source, | 7 | type Source, |
| 8 | } from "#schema"; | 8 | } from "#schema"; |
| 9 | 9 | ||
| 10 | import { resolveSourceAuthMaterial } from "./tool-artifacts"; | 10 | import { resolveSourceAuthMaterial } from "./source-auth-material"; |
| 11 | 11 | ||
| 12 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ | 12 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 13 | id: SourceIdSchema.make("src_tool_artifacts"), | 13 | id: SourceIdSchema.make("src_tool_artifacts"), |
| @@ -23,6 +23,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines | |||
| 23 | headers: null, | 23 | headers: null, |
| 24 | specUrl: "https://api.github.com/openapi.json", | 24 | specUrl: "https://api.github.com/openapi.json", |
| 25 | defaultHeaders: null, | 25 | defaultHeaders: null, |
| 26 | importAuthPolicy: "reuse_runtime", | ||
| 27 | importAuth: { kind: "none" }, | ||
| 26 | auth: { kind: "none" }, | 28 | auth: { kind: "none" }, |
| 27 | sourceHash: null, | 29 | sourceHash: null, |
| 28 | lastError: null, | 30 | lastError: null, |
| @@ -31,7 +33,7 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 2 unmodified lines | |||
| 31 | ...overrides, | 33 | ...overrides, |
| 32 | }); | 34 | }); |
| 33 | 35 | ||
| 34 | describe("tool-artifacts", () => { | 36 | describe("source-auth-material", () => { |
| 35 | describe("resolveSourceAuthMaterial", () => { | 37 | describe("resolveSourceAuthMaterial", () => { |
| 36 | it("returns empty headers for auth.kind none", async () => { | 38 | it("returns empty headers for auth.kind none", async () => { |
| 37 | const auth = await Effect.runPromise(resolveSourceAuthMaterial({ | 39 | const auth = await Effect.runPromise(resolveSourceAuthMaterial({ |
| @@ -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 | }); | ||
| @@ -8,7 +8,6 @@ import { · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | import { | 9 | import { |
| 10 | createTerminalSourceAuthSessionPatch, | 10 | createTerminalSourceAuthSessionPatch, |
| 11 | shouldPromptForHttpCredentialSetup, | ||
| 12 | } from "./source-auth-service"; | 11 | } from "./source-auth-service"; |
| 13 | 12 | ||
| 14 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ | 13 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ |
| @@ -25,6 +24,8 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 10 unmodified lines | |||
| 25 | headers: null, | 24 | headers: null, |
| 26 | specUrl: "https://example.com/openapi.json", | 25 | specUrl: "https://example.com/openapi.json", |
| 27 | defaultHeaders: null, | 26 | defaultHeaders: null, |
| 27 | importAuthPolicy: "reuse_runtime", | ||
| 28 | importAuth: { kind: "none" }, | ||
| 28 | auth, | 29 | auth, |
| 29 | sourceHash: null, | 30 | sourceHash: null, |
| 30 | lastError: null, | 31 | lastError: null, |
| @@ -88,64 +89,4 @@ describe("source-auth-service", () => { · 57 unmodified lines | |||
| 88 | updatedAt: 456, | 89 | updatedAt: 456, |
| 89 | }); | 90 | }); |
| 90 | }); | 91 | }); |
| 91 | |||
| 92 | it("reuses existing non-none HTTP source auth without prompting again", () => { | ||
| 93 | expect( | ||
| 94 | shouldPromptForHttpCredentialSetup({ | ||
| 95 | existing: makeExistingOpenApiSource({ | ||
| 96 | kind: "bearer", | ||
| 97 | headerName: "Authorization", | ||
| 98 | prefix: "Bearer ", | ||
| 99 | token: { | ||
| 100 | providerId: "postgres", | ||
| 101 | handle: "sec_bearer", | ||
| 102 | }, | ||
| 103 | }), | ||
| 104 | }), | ||
| 105 | ).toBe(false); | ||
| 106 | |||
| 107 | expect( | ||
| 108 | shouldPromptForHttpCredentialSetup({ | ||
| 109 | existing: makeExistingOpenApiSource({ | ||
| 110 | kind: "oauth2", | ||
| 111 | headerName: "Authorization", | ||
| 112 | prefix: "Bearer ", | ||
| 113 | accessToken: { | ||
| 114 | providerId: "postgres", | ||
| 115 | handle: "sec_access", | ||
| 116 | }, | ||
| 117 | refreshToken: { | ||
| 118 | providerId: "postgres", | ||
| 119 | handle: "sec_refresh", | ||
| 120 | }, | ||
| 121 | }), | ||
| 122 | }), | ||
| 123 | ).toBe(false); | ||
| 124 | |||
| 125 | expect( | ||
| 126 | shouldPromptForHttpCredentialSetup({ | ||
| 127 | existing: makeExistingOpenApiSource({ | ||
| 128 | kind: "none", | ||
| 129 | }), | ||
| 130 | }), | ||
| 131 | ).toBe(true); | ||
| 132 | |||
| 133 | expect( | ||
| 134 | shouldPromptForHttpCredentialSetup({ | ||
| 135 | existing: { | ||
| 136 | ...makeExistingOpenApiSource({ | ||
| 137 | kind: "bearer", | ||
| 138 | headerName: "Authorization", | ||
| 139 | prefix: "Bearer ", | ||
| 140 | token: { | ||
| 141 | providerId: "postgres", | ||
| 142 | handle: "sec_graphql", | ||
| 143 | }, | ||
| 144 | }), | ||
| 145 | kind: "graphql", | ||
| 146 | specUrl: null, | ||
| 147 | }, | ||
| 148 | }), | ||
| 149 | ).toBe(false); | ||
| 150 | }); | ||
| 151 | }); | 92 | }); |
| @@ -9,11 +9,13 @@ import { · 8 unmodified lines | |||
| 9 | } from "#persistence"; | 9 | } from "#persistence"; |
| 10 | import { | 10 | import { |
| 11 | AccountId, | 11 | AccountId, |
| 12 | type CredentialSlot, | ||
| 12 | ExecutionIdSchema, | 13 | ExecutionIdSchema, |
| 13 | McpSourceAuthSessionDataJsonSchema, | 14 | McpSourceAuthSessionDataJsonSchema, |
| 14 | type McpSourceAuthSessionData, | 15 | type McpSourceAuthSessionData, |
| 15 | type SecretMaterialPurpose, | 16 | type SecretMaterialPurpose, |
| 16 | Source, | 17 | Source, |
| 18 | type SourceImportAuthPolicy, | ||
| 17 | SourceAuthSession, | 19 | SourceAuthSession, |
| 18 | SourceAuthSessionIdSchema, | 20 | SourceAuthSessionIdSchema, |
| 19 | SourceIdSchema, | 21 | SourceIdSchema, |
| @@ -41,6 +43,8 @@ import { · 21 unmodified lines | |||
| 41 | createSourceFromPayload, | 43 | createSourceFromPayload, |
| 42 | updateSourceFromPayload, | 44 | updateSourceFromPayload, |
| 43 | } from "./source-definitions"; | 45 | } from "./source-definitions"; |
| 46 | import { hasSourceAdapterFamily } from "./source-adapters"; | ||
| 47 | import { isSourceCredentialRequiredError } from "./source-adapters/shared"; | ||
| 44 | import { | 48 | import { |
| 45 | createDefaultSecretMaterialResolver, | 49 | createDefaultSecretMaterialResolver, |
| 46 | createDefaultSecretMaterialStorer, | 50 | createDefaultSecretMaterialStorer, |
| @@ -48,9 +52,9 @@ import { · 1 unmodified line | |||
| 48 | type StoreSecretMaterial, | 52 | type StoreSecretMaterial, |
| 49 | } from "./secret-material-providers"; | 53 | } from "./secret-material-providers"; |
| 50 | import { | 54 | import { |
| 51 | persistMcpToolArtifactsFromManifest, | 55 | persistMcpRecipeMaterializationFromManifest, |
| 52 | syncSourceToolArtifacts, | 56 | syncSourceMaterialization, |
| 53 | } from "./tool-artifacts"; | 57 | } from "./source-materialization"; |
| 54 | import { | 58 | import { |
| 55 | loadSourceById, | 59 | loadSourceById, |
| 56 | loadSourcesInWorkspace, | 60 | loadSourcesInWorkspace, |
| @@ -255,6 +259,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 198 unmodified lines | |||
| 255 | status: Source["status"]; | 259 | status: Source["status"]; |
| 256 | lastError?: string | null; | 260 | lastError?: string | null; |
| 257 | auth?: Source["auth"]; | 261 | auth?: Source["auth"]; |
| 262 | importAuth?: Source["importAuth"]; | ||
| 258 | }) => | 263 | }) => |
| 259 | Effect.gen(function* () { | 264 | Effect.gen(function* () { |
| 260 | const latest = yield* loadSourceById(rows, { | 265 | const latest = yield* loadSourceById(rows, { |
| @@ -268,6 +273,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 7 unmodified lines | |||
| 268 | status: input.status, | 273 | status: input.status, |
| 269 | lastError: input.lastError ?? null, | 274 | lastError: input.lastError ?? null, |
| 270 | auth: input.auth ?? latest.auth, | 275 | auth: input.auth ?? latest.auth, |
| 276 | importAuth: input.importAuth ?? latest.importAuth, | ||
| 271 | updatedAt: Date.now(), | 277 | updatedAt: Date.now(), |
| 272 | }, { | 278 | }, { |
| 273 | actorAccountId: input.actorAccountId, | 279 | actorAccountId: input.actorAccountId, |
| @@ -282,6 +288,7 @@ export type ExecutorSourceAddResult = · 8 unmodified lines | |||
| 282 | | { | 288 | | { |
| 283 | kind: "credential_required"; | 289 | kind: "credential_required"; |
| 284 | source: Source; | 290 | source: Source; |
| 291 | credentialSlot: CredentialSlot; | ||
| 285 | } | 292 | } |
| 286 | | { | 293 | | { |
| 287 | kind: "oauth_required"; | 294 | kind: "oauth_required"; |
| @@ -332,6 +339,8 @@ export type ExecutorAddSourceInput = · 44 unmodified lines | |||
| 332 | specUrl: string; | 339 | specUrl: string; |
| 333 | name?: string | null; | 340 | name?: string | null; |
| 334 | namespace?: string | null; | 341 | namespace?: string | null; |
| 342 | importAuthPolicy?: SourceImportAuthPolicy | null; | ||
| 343 | importAuth?: ExecutorHttpSourceAuthInput | null; | ||
| 335 | auth?: ExecutorHttpSourceAuthInput | null; | 344 | auth?: ExecutorHttpSourceAuthInput | null; |
| 336 | } | 345 | } |
| 337 | | { | 346 | | { |
| @@ -343,6 +352,8 @@ export type ExecutorAddSourceInput = · 5 unmodified lines | |||
| 343 | endpoint: string; | 352 | endpoint: string; |
| 344 | name?: string | null; | 353 | name?: string | null; |
| 345 | namespace?: string | null; | 354 | namespace?: string | null; |
| 355 | importAuthPolicy?: SourceImportAuthPolicy | null; | ||
| 356 | importAuth?: ExecutorHttpSourceAuthInput | null; | ||
| 346 | auth?: ExecutorHttpSourceAuthInput | null; | 357 | auth?: ExecutorHttpSourceAuthInput | null; |
| 347 | }; | 358 | }; |
| 348 | 359 | ||
| @@ -390,20 +401,6 @@ export type CompleteSourceOAuthSessionResult = { · 41 unmodified lines | |||
| 390 | auth: Extract<Source["auth"], { kind: "oauth2" }>; | 401 | auth: Extract<Source["auth"], { kind: "oauth2" }>; |
| 391 | }; | 402 | }; |
| 392 | 403 | ||
| 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 | |||
| 407 | const materializeSecretRefInput = (input: { | 404 | const materializeSecretRefInput = (input: { |
| 408 | rawValue?: string | null; | 405 | rawValue?: string | null; |
| 409 | ref?: SecretRef | null; | 406 | ref?: SecretRef | null; |
| @@ -436,11 +433,10 @@ const materializeExecutorHttpAuth = (input: { · 26 unmodified lines | |||
| 436 | storeSecretMaterial: StoreSecretMaterial; | 433 | storeSecretMaterial: StoreSecretMaterial; |
| 437 | }): Effect.Effect<Source["auth"], Error, never> => | 434 | }): Effect.Effect<Source["auth"], Error, never> => |
| 438 | Effect.gen(function* () { | 435 | Effect.gen(function* () { |
| 439 | if ( | 436 | const existing = input.existing; |
| 440 | input.auth === undefined | 437 | |
| 441 | && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql") | 438 | if (input.auth === undefined && existing && hasSourceAdapterFamily(existing.kind, "http_api")) { |
| 442 | ) { | 439 | return existing.auth; |
| 443 | return input.existing.auth; | ||
| 444 | } | 440 | } |
| 445 | 441 | ||
| 446 | const auth = input.auth ?? { kind: "none" } satisfies ExecutorHttpSourceAuthInput; | 442 | const auth = input.auth ?? { kind: "none" } satisfies ExecutorHttpSourceAuthInput; |
| @@ -458,10 +454,11 @@ const materializeExecutorHttpAuth = (input: { · 11 unmodified lines | |||
| 458 | if ( | 454 | if ( |
| 459 | token === null | 455 | token === null |
| 460 | && tokenRefInput === null | 456 | && tokenRefInput === null |
| 461 | && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql") | 457 | && existing |
| 462 | && input.existing.auth.kind === "bearer" | 458 | && hasSourceAdapterFamily(existing.kind, "http_api") |
| 459 | && existing.auth.kind === "bearer" | ||
| 463 | ) { | 460 | ) { |
| 464 | return input.existing.auth; | 461 | return existing.auth; |
| 465 | } | 462 | } |
| 466 | 463 | ||
| 467 | const tokenRef = yield* materializeSecretRefInput({ | 464 | const tokenRef = yield* materializeSecretRefInput({ |
| @@ -488,10 +485,11 @@ const materializeExecutorHttpAuth = (input: { · 20 unmodified lines | |||
| 488 | && auth.accessTokenRef == null | 485 | && auth.accessTokenRef == null |
| 489 | && trimOrNull(auth.refreshToken) === null | 486 | && trimOrNull(auth.refreshToken) === null |
| 490 | && auth.refreshTokenRef == null | 487 | && auth.refreshTokenRef == null |
| 491 | && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql") | 488 | && existing |
| 492 | && input.existing.auth.kind === "oauth2" | 489 | && hasSourceAdapterFamily(existing.kind, "http_api") |
| 490 | && existing.auth.kind === "oauth2" | ||
| 493 | ) { | 491 | ) { |
| 494 | return input.existing.auth; | 492 | return existing.auth; |
| 495 | } | 493 | } |
| 496 | 494 | ||
| 497 | const accessTokenRef = yield* materializeSecretRefInput({ | 495 | const accessTokenRef = yield* materializeSecretRefInput({ |
| @@ -520,6 +518,61 @@ const materializeExecutorHttpAuth = (input: { · 22 unmodified lines | |||
| 520 | } satisfies Source["auth"]; | 518 | } satisfies Source["auth"]; |
| 521 | }); | 519 | }); |
| 522 | 520 | ||
| 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 | |||
| 523 | const connectMcpSourceInternal = (input: { | 576 | const connectMcpSourceInternal = (input: { |
| 524 | rows: SqlControlPlaneRows; | 577 | rows: SqlControlPlaneRows; |
| 525 | getLocalServerBaseUrl?: () => string | undefined; | 578 | getLocalServerBaseUrl?: () => string | undefined; |
| @@ -549,7 +602,7 @@ const connectMcpSourceInternal = (input: { · 23 unmodified lines | |||
| 549 | actorAccountId: input.actorAccountId, | 602 | actorAccountId: input.actorAccountId, |
| 550 | }).pipe( | 603 | }).pipe( |
| 551 | Effect.flatMap((source) => | 604 | Effect.flatMap((source) => |
| 552 | source.kind === "mcp" | 605 | hasSourceAdapterFamily(source.kind, "mcp") |
| 553 | ? Effect.succeed(source) | 606 | ? Effect.succeed(source) |
| 554 | : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)), | 607 | : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)), |
| 555 | ), | 608 | ), |
| @@ -560,7 +613,8 @@ const connectMcpSourceInternal = (input: { · 4 unmodified lines | |||
| 560 | Effect.map((sources) => | 613 | Effect.map((sources) => |
| 561 | sources.find( | 614 | sources.find( |
| 562 | (source) => | 615 | (source) => |
| 563 | source.kind === "mcp" && normalizeEndpoint(source.endpoint) === normalizedEndpoint, | 616 | hasSourceAdapterFamily(source.kind, "mcp") |
| 617 | && normalizeEndpoint(source.endpoint) === normalizedEndpoint, | ||
| 564 | ), | 618 | ), |
| 565 | ), | 619 | ), |
| 566 | ) | 620 | ) |
| @@ -593,6 +647,8 @@ const connectMcpSourceInternal = (input: { · 26 unmodified lines | |||
| 593 | transport: chosenTransport, | 647 | transport: chosenTransport, |
| 594 | queryParams: chosenQueryParams, | 648 | queryParams: chosenQueryParams, |
| 595 | headers: chosenHeaders, | 649 | headers: chosenHeaders, |
| 650 | importAuthPolicy: "reuse_runtime", | ||
| 651 | importAuth: { kind: "none" }, | ||
| 596 | auth: { kind: "none" }, | 652 | auth: { kind: "none" }, |
| 597 | lastError: null, | 653 | lastError: null, |
| 598 | }, | 654 | }, |
| @@ -611,6 +667,8 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines | |||
| 611 | transport: chosenTransport, | 667 | transport: chosenTransport, |
| 612 | queryParams: chosenQueryParams, | 668 | queryParams: chosenQueryParams, |
| 613 | headers: chosenHeaders, | 669 | headers: chosenHeaders, |
| 670 | importAuthPolicy: "reuse_runtime", | ||
| 671 | importAuth: { kind: "none" }, | ||
| 614 | auth: { kind: "none" }, | 672 | auth: { kind: "none" }, |
| 615 | }, | 673 | }, |
| 616 | now, | 674 | now, |
| @@ -619,7 +677,7 @@ const connectMcpSourceInternal = (input: { · 2 unmodified lines | |||
| 619 | const persistedDraft = yield* persistSource(input.rows, draftSource, { | 677 | const persistedDraft = yield* persistSource(input.rows, draftSource, { |
| 620 | actorAccountId: input.actorAccountId, | 678 | actorAccountId: input.actorAccountId, |
| 621 | }); | 679 | }); |
| 622 | yield* syncSourceToolArtifacts({ | 680 | yield* syncSourceMaterialization({ |
| 623 | rows: input.rows, | 681 | rows: input.rows, |
| 624 | source: persistedDraft, | 682 | source: persistedDraft, |
| 625 | resolveSecretMaterial: input.resolveSecretMaterial, | 683 | resolveSecretMaterial: input.resolveSecretMaterial, |
| @@ -643,7 +701,7 @@ const connectMcpSourceInternal = (input: { · 17 unmodified lines | |||
| 643 | auth: { kind: "none" }, | 701 | auth: { kind: "none" }, |
| 644 | }); | 702 | }); |
| 645 | const indexed = yield* Effect.either( | 703 | const indexed = yield* Effect.either( |
| 646 | persistMcpToolArtifactsFromManifest({ | 704 | persistMcpRecipeMaterializationFromManifest({ |
| 647 | rows: input.rows, | 705 | rows: input.rows, |
| 648 | source: connected, | 706 | source: connected, |
| 649 | manifestEntries: result.manifest.tools, | 707 | manifestEntries: result.manifest.tools, |
| @@ -704,6 +762,7 @@ const connectMcpSourceInternal = (input: { · 54 unmodified lines | |||
| 704 | workspaceId: input.workspaceId, | 762 | workspaceId: input.workspaceId, |
| 705 | sourceId: authRequiredSource.id, | 763 | sourceId: authRequiredSource.id, |
| 706 | actorAccountId: input.actorAccountId ?? null, | 764 | actorAccountId: input.actorAccountId ?? null, |
| 765 | credentialSlot: "runtime", | ||
| 707 | executionId: input.executionId ?? null, | 766 | executionId: input.executionId ?? null, |
| 708 | interactionId: input.interactionId ?? null, | 767 | interactionId: input.interactionId ?? null, |
| 709 | providerKind: "mcp_oauth", | 768 | providerKind: "mcp_oauth", |
| @@ -780,56 +839,18 @@ const addExecutorHttpSource = (input: { · 70 unmodified lines | |||
| 780 | ?? defaultNamespaceFromName(chosenName); | 839 | ?? defaultNamespaceFromName(chosenName); |
| 781 | const now = Date.now(); | 840 | const now = Date.now(); |
| 782 | 841 | ||
| 783 | if (shouldPromptForHttpCredentialSetup({ | ||
| 784 | existing, | ||
| 785 | auth: input.sourceInput.auth, | ||
| 786 | })) { | ||
| 787 | const draftSource = existing | ||
| 788 | ? yield* updateSourceFromPayload({ | ||
| 789 | source: existing, | ||
| 790 | payload: { | ||
| 791 | name: chosenName, | ||
| 792 | endpoint: normalizedEndpoint, | ||
| 793 | namespace: chosenNamespace, | ||
| 794 | kind: input.sourceInput.kind, | ||
| 795 | status: "auth_required", | ||
| 796 | enabled: true, | ||
| 797 | specUrl: normalizedSpecUrl, | ||
| 798 | auth: { kind: "none" }, | ||
| 799 | lastError: null, | ||
| 800 | }, | ||
| 801 | now, | ||
| 802 | }) | ||
| 803 | : yield* createSourceFromPayload({ | ||
| 804 | workspaceId: input.sourceInput.workspaceId, | ||
| 805 | sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | ||
| 806 | payload: { | ||
| 807 | name: chosenName, | ||
| 808 | kind: input.sourceInput.kind, | ||
| 809 | endpoint: normalizedEndpoint, | ||
| 810 | namespace: chosenNamespace, | ||
| 811 | status: "auth_required", | ||
| 812 | enabled: true, | ||
| 813 | specUrl: normalizedSpecUrl, | ||
| 814 | auth: { kind: "none" }, | ||
| 815 | }, | ||
| 816 | now, | ||
| 817 | }); | ||
| 818 | |||
| 819 | const persistedDraft = yield* persistSource(input.rows, draftSource, { | ||
| 820 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 821 | }); | ||
| 822 | return { | ||
| 823 | kind: "credential_required", | ||
| 824 | source: persistedDraft, | ||
| 825 | } satisfies ExecutorSourceAddResult; | ||
| 826 | } | ||
| 827 | |||
| 828 | const auth = yield* materializeExecutorHttpAuth({ | 842 | const auth = yield* materializeExecutorHttpAuth({ |
| 829 | existing, | 843 | existing, |
| 830 | auth: input.sourceInput.auth, | 844 | auth: input.sourceInput.auth, |
| 831 | storeSecretMaterial: input.storeSecretMaterial, | 845 | storeSecretMaterial: input.storeSecretMaterial, |
| 832 | }); | 846 | }); |
| 847 | const importAuth = yield* materializeExecutorHttpImportAuth({ | ||
| 848 | sourceKind: input.sourceInput.kind, | ||
| 849 | existing, | ||
| 850 | importAuthPolicy: input.sourceInput.importAuthPolicy ?? null, | ||
| 851 | importAuth: input.sourceInput.importAuth ?? null, | ||
| 852 | storeSecretMaterial: input.storeSecretMaterial, | ||
| 853 | }); | ||
| 833 | 854 | ||
| 834 | const draftSource = existing | 855 | const draftSource = existing |
| 835 | ? yield* updateSourceFromPayload({ | 856 | ? yield* updateSourceFromPayload({ |
| @@ -842,6 +863,8 @@ const addExecutorHttpSource = (input: { · 6 unmodified lines | |||
| 842 | status: "probing", | 863 | status: "probing", |
| 843 | enabled: true, | 864 | enabled: true, |
| 844 | specUrl: normalizedSpecUrl, | 865 | specUrl: normalizedSpecUrl, |
| 866 | importAuthPolicy: importAuth.importAuthPolicy, | ||
| 867 | importAuth: importAuth.importAuth, | ||
| 845 | auth, | 868 | auth, |
| 846 | lastError: null, | 869 | lastError: null, |
| 847 | }, | 870 | }, |
| @@ -858,6 +881,8 @@ const addExecutorHttpSource = (input: { · 10 unmodified lines | |||
| 858 | status: "probing", | 881 | status: "probing", |
| 859 | enabled: true, | 882 | enabled: true, |
| 860 | specUrl: normalizedSpecUrl, | 883 | specUrl: normalizedSpecUrl, |
| 884 | importAuthPolicy: importAuth.importAuthPolicy, | ||
| 885 | importAuth: importAuth.importAuth, | ||
| 861 | auth, | 886 | auth, |
| 862 | }, | 887 | }, |
| 863 | now, | 888 | now, |
| @@ -866,8 +891,27 @@ const addExecutorHttpSource = (input: { · 2 unmodified lines | |||
| 866 | const persistedDraft = yield* persistSource(input.rows, draftSource, { | 891 | const persistedDraft = yield* persistSource(input.rows, draftSource, { |
| 867 | actorAccountId: input.sourceInput.actorAccountId, | 892 | actorAccountId: input.sourceInput.actorAccountId, |
| 868 | }); | 893 | }); |
| 894 | |||
| 895 | if (shouldPromptForExecutorHttpRuntimeCredentialSetup({ | ||
| 896 | existing, | ||
| 897 | explicitAuthProvided: input.sourceInput.auth !== undefined, | ||
| 898 | auth: persistedDraft.auth, | ||
| 899 | })) { | ||
| 900 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { | ||
| 901 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 902 | status: "auth_required", | ||
| 903 | lastError: null, | ||
| 904 | }); | ||
| 905 | |||
| 906 | return { | ||
| 907 | kind: "credential_required", | ||
| 908 | source: authRequiredSource, | ||
| 909 | credentialSlot: "runtime", | ||
| 910 | } satisfies ExecutorSourceAddResult; | ||
| 911 | } | ||
| 912 | |||
| 869 | const synced = yield* Effect.either( | 913 | const synced = yield* Effect.either( |
| 870 | syncSourceToolArtifacts({ | 914 | syncSourceMaterialization({ |
| 871 | rows: input.rows, | 915 | rows: input.rows, |
| 872 | source: { | 916 | source: { |
| 873 | ...persistedDraft, | 917 | ...persistedDraft, |
| @@ -879,13 +923,27 @@ const addExecutorHttpSource = (input: { · 5 unmodified lines | |||
| 879 | 923 | ||
| 880 | return yield* Either.match(synced, { | 924 | return yield* Either.match(synced, { |
| 881 | onLeft: (error) => | 925 | onLeft: (error) => |
| 882 | updateSourceStatus(input.rows, persistedDraft, { | 926 | isSourceCredentialRequiredError(error) |
| 883 | actorAccountId: input.sourceInput.actorAccountId, | 927 | ? updateSourceStatus(input.rows, persistedDraft, { |
| 884 | status: "error", | 928 | actorAccountId: input.sourceInput.actorAccountId, |
| 885 | lastError: error.message, | 929 | status: "auth_required", |
| 886 | }).pipe( | 930 | lastError: null, |
| 887 | Effect.zipRight(Effect.fail(error)), | 931 | }).pipe( |
| 888 | ), | 932 | Effect.map((source) => |
| 933 | ({ | ||
| 934 | kind: "credential_required", | ||
| 935 | source, | ||
| 936 | credentialSlot: error.slot, | ||
| 937 | } satisfies ExecutorSourceAddResult) | ||
| 938 | ), | ||
| 939 | ) | ||
| 940 | : updateSourceStatus(input.rows, persistedDraft, { | ||
| 941 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 942 | status: "error", | ||
| 943 | lastError: error.message, | ||
| 944 | }).pipe( | ||
| 945 | Effect.zipRight(Effect.fail(error)), | ||
| 946 | ), | ||
| 889 | onRight: () => | 947 | onRight: () => |
| 890 | updateSourceStatus(input.rows, persistedDraft, { | 948 | updateSourceStatus(input.rows, persistedDraft, { |
| 891 | actorAccountId: input.sourceInput.actorAccountId, | 949 | actorAccountId: input.sourceInput.actorAccountId, |
| @@ -972,10 +1030,13 @@ export const createRuntimeSourceAuthService = (input: { · 80 unmodified lines | |||
| 972 | }), | 1030 | }), |
| 973 | 1031 | ||
| 974 | addExecutorSource: (sourceInput, options) => | 1032 | addExecutorSource: (sourceInput, options) => |
| 975 | sourceInput.kind === "openapi" || sourceInput.kind === "graphql" | 1033 | hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") |
| 976 | ? addExecutorHttpSource({ | 1034 | ? addExecutorHttpSource({ |
| 977 | rows: input.rows, | 1035 | rows: input.rows, |
| 978 | sourceInput, | 1036 | sourceInput: sourceInput as Extract< |
| 1037 | ExecutorAddSourceInput, | ||
| 1038 | { kind: "openapi" | "graphql" } | ||
| 1039 | >, | ||
| 979 | storeSecretMaterial, | 1040 | storeSecretMaterial, |
| 980 | resolveSecretMaterial, | 1041 | resolveSecretMaterial, |
| 981 | }) | 1042 | }) |
| @@ -1043,6 +1104,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines | |||
| 1043 | workspaceId: oauthInput.workspaceId, | 1104 | workspaceId: oauthInput.workspaceId, |
| 1044 | sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`), | 1105 | sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`), |
| 1045 | actorAccountId: oauthInput.actorAccountId ?? null, | 1106 | actorAccountId: oauthInput.actorAccountId ?? null, |
| 1107 | credentialSlot: "runtime", | ||
| 1046 | executionId: null, | 1108 | executionId: null, |
| 1047 | interactionId: null, | 1109 | interactionId: null, |
| 1048 | providerKind: "mcp_oauth", | 1110 | providerKind: "mcp_oauth", |
| @@ -1257,7 +1319,7 @@ export const createRuntimeSourceAuthService = (input: { · 208 unmodified lines | |||
| 1257 | status: "error", | 1319 | status: "error", |
| 1258 | lastError: reason, | 1320 | lastError: reason, |
| 1259 | }); | 1321 | }); |
| 1260 | yield* syncSourceToolArtifacts({ | 1322 | yield* syncSourceMaterialization({ |
| 1261 | rows: input.rows, | 1323 | rows: input.rows, |
| 1262 | source: failedSource, | 1324 | source: failedSource, |
| 1263 | resolveSecretMaterial, | 1325 | resolveSecretMaterial, |
| @@ -1328,7 +1390,7 @@ export const createRuntimeSourceAuthService = (input: { · 64 unmodified lines | |||
| 1328 | }, | 1390 | }, |
| 1329 | }); | 1391 | }); |
| 1330 | const indexed = yield* Effect.either( | 1392 | const indexed = yield* Effect.either( |
| 1331 | syncSourceToolArtifacts({ | 1393 | syncSourceMaterialization({ |
| 1332 | rows: input.rows, | 1394 | rows: input.rows, |
| 1333 | source: connectedSource, | 1395 | source: connectedSource, |
| 1334 | resolveSecretMaterial, | 1396 | resolveSecretMaterial, |
| @@ -1410,6 +1472,7 @@ export const ExecutorAddSourceResultSchema = Schema.Union( · 75 unmodified lines | |||
| 1410 | Schema.Struct({ | 1472 | Schema.Struct({ |
| 1411 | kind: Schema.Literal("credential_required"), | 1473 | kind: Schema.Literal("credential_required"), |
| 1412 | source: SourceSchema, | 1474 | source: SourceSchema, |
| 1475 | credentialSlot: Schema.Literal("runtime", "import"), | ||
| 1413 | }), | 1476 | }), |
| 1414 | Schema.Struct({ | 1477 | Schema.Struct({ |
| 1415 | kind: Schema.Literal("oauth_required"), | 1478 | kind: Schema.Literal("oauth_required"), |
| @@ -16,7 +16,7 @@ import { · 15 unmodified lines | |||
| 16 | stableSourceRecipeRevisionId, | 16 | stableSourceRecipeRevisionId, |
| 17 | updateSourceFromPayload, | 17 | updateSourceFromPayload, |
| 18 | } from "./source-definitions"; | 18 | } from "./source-definitions"; |
| 19 | import { namespaceFromSourceName } from "./tool-artifacts"; | 19 | import { namespaceFromSourceName } from "./source-names"; |
| 20 | 20 | ||
| 21 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ | 21 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 22 | id: SourceIdSchema.make("src_source_definitions"), | 22 | id: SourceIdSchema.make("src_source_definitions"), |
| @@ -32,6 +32,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines | |||
| 32 | headers: null, | 32 | headers: null, |
| 33 | specUrl: "https://api.github.com/openapi.json", | 33 | specUrl: "https://api.github.com/openapi.json", |
| 34 | defaultHeaders: null, | 34 | defaultHeaders: null, |
| 35 | importAuthPolicy: "reuse_runtime", | ||
| 36 | importAuth: { kind: "none" }, | ||
| 35 | auth: { kind: "none" }, | 37 | auth: { kind: "none" }, |
| 36 | sourceHash: null, | 38 | sourceHash: null, |
| 37 | lastError: null, | 39 | lastError: null, |
| @@ -276,7 +278,7 @@ describe("source-definitions", () => { · 238 unmodified lines | |||
| 276 | }, | 278 | }, |
| 277 | } as never, | 279 | } as never, |
| 278 | now: 1234, | 280 | now: 1234, |
| 279 | }))).rejects.toThrow("internal sources cannot define OpenAPI settings"); | 281 | }))).rejects.toThrow("internal sources cannot define HTTP source settings"); |
| 280 | }); | 282 | }); |
| 281 | 283 | ||
| 282 | it("normalizes new auth during updates", async () => { | 284 | it("normalizes new auth during updates", async () => { |
| @@ -313,9 +315,12 @@ describe("source-definitions", () => { · 30 unmodified lines | |||
| 313 | describe("storage roundtrip", () => { | 315 | describe("storage roundtrip", () => { |
| 314 | it("roundtrips bearer auth and serialized maps", async () => { | 316 | it("roundtrips bearer auth and serialized maps", async () => { |
| 315 | const source = makeSource({ | 317 | const source = makeSource({ |
| 318 | kind: "mcp", | ||
| 319 | specUrl: null, | ||
| 320 | defaultHeaders: null, | ||
| 321 | transport: "auto", | ||
| 316 | queryParams: { page: "1" }, | 322 | queryParams: { page: "1" }, |
| 317 | headers: { "x-api-key": "secret" }, | 323 | headers: { "x-api-key": "secret" }, |
| 318 | defaultHeaders: { accept: "application/json" }, | ||
| 319 | auth: { | 324 | auth: { |
| 320 | kind: "bearer", | 325 | kind: "bearer", |
| 321 | headerName: "Authorization", | 326 | headerName: "Authorization", |
| @@ -330,21 +335,25 @@ describe("source-definitions", () => { · 8 unmodified lines | |||
| 330 | const recipeRevisionId = stableSourceRecipeRevisionId(source); | 335 | const recipeRevisionId = stableSourceRecipeRevisionId(source); |
| 331 | const existingCredentialId = CredentialIdSchema.make("cred_existing"); | 336 | const existingCredentialId = CredentialIdSchema.make("cred_existing"); |
| 332 | 337 | ||
| 333 | const { sourceRecord, credential } = splitSourceForStorage({ | 338 | const { sourceRecord, runtimeCredential } = splitSourceForStorage({ |
| 334 | source, | 339 | source, |
| 335 | recipeId, | 340 | recipeId, |
| 336 | recipeRevisionId, | 341 | recipeRevisionId, |
| 337 | existingCredentialId, | 342 | existingRuntimeCredentialId: existingCredentialId, |
| 338 | }); | 343 | }); |
| 339 | 344 | ||
| 340 | expect(credential?.id).toBe(existingCredentialId); | 345 | expect(runtimeCredential?.id).toBe(existingCredentialId); |
| 341 | expect(sourceRecord.queryParamsJson).toBe(JSON.stringify(source.queryParams)); | 346 | expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({ |
| 342 | expect(sourceRecord.headersJson).toBe(JSON.stringify(source.headers)); | 347 | adapterKey: "mcp", |
| 343 | expect(sourceRecord.defaultHeadersJson).toBe(JSON.stringify(source.defaultHeaders)); | 348 | transport: source.transport, |
| 349 | queryParams: source.queryParams, | ||
| 350 | headers: source.headers, | ||
| 351 | }); | ||
| 344 | 352 | ||
| 345 | const projected = await Effect.runPromise(projectSourceFromStorage({ | 353 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 346 | sourceRecord, | 354 | sourceRecord, |
| 347 | credential: credential ?? null, | 355 | runtimeCredential: runtimeCredential ?? null, |
| 356 | importCredential: null, | ||
| 348 | })); | 357 | })); |
| 349 | 358 | ||
| 350 | expect(projected).toEqual(source); | 359 | expect(projected).toEqual(source); |
| @@ -389,14 +398,15 @@ describe("source-definitions", () => { · 38 unmodified lines | |||
| 389 | }); | 398 | }); |
| 390 | 399 | ||
| 391 | for (const source of [withRefresh, withoutRefresh]) { | 400 | for (const source of [withRefresh, withoutRefresh]) { |
| 392 | const { sourceRecord, credential } = splitSourceForStorage({ | 401 | const { sourceRecord, runtimeCredential } = splitSourceForStorage({ |
| 393 | source, | 402 | source, |
| 394 | recipeId: stableSourceRecipeId(source), | 403 | recipeId: stableSourceRecipeId(source), |
| 395 | recipeRevisionId: stableSourceRecipeRevisionId(source), | 404 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 396 | }); | 405 | }); |
| 397 | const projected = await Effect.runPromise(projectSourceFromStorage({ | 406 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 398 | sourceRecord, | 407 | sourceRecord, |
| 399 | credential: credential ?? null, | 408 | runtimeCredential: runtimeCredential ?? null, |
| 409 | importCredential: null, | ||
| 400 | })); | 410 | })); |
| 401 | 411 | ||
| 402 | expect(projected).toEqual(source); | 412 | expect(projected).toEqual(source); |
| @@ -407,17 +417,18 @@ describe("source-definitions", () => { · 4 unmodified lines | |||
| 407 | const source = makeSource({ | 417 | const source = makeSource({ |
| 408 | auth: { kind: "none" }, | 418 | auth: { kind: "none" }, |
| 409 | }); | 419 | }); |
| 410 | const { sourceRecord, credential } = splitSourceForStorage({ | 420 | const { sourceRecord, runtimeCredential } = splitSourceForStorage({ |
| 411 | source, | 421 | source, |
| 412 | recipeId: stableSourceRecipeId(source), | 422 | recipeId: stableSourceRecipeId(source), |
| 413 | recipeRevisionId: stableSourceRecipeRevisionId(source), | 423 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 414 | }); | 424 | }); |
| 415 | 425 | ||
| 416 | expect(credential).toBeNull(); | 426 | expect(runtimeCredential).toBeNull(); |
| 417 | 427 | ||
| 418 | const projected = await Effect.runPromise(projectSourceFromStorage({ | 428 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 419 | sourceRecord, | 429 | sourceRecord, |
| 420 | credential: null, | 430 | runtimeCredential: null, |
| 431 | importCredential: null, | ||
| 421 | })); | 432 | })); |
| 422 | 433 | ||
| 423 | expect(projected.auth).toEqual({ kind: "none" }); | 434 | expect(projected.auth).toEqual({ kind: "none" }); |
| @@ -7,8 +7,10 @@ import type { · 6 unmodified lines | |||
| 7 | import type { | 7 | import type { |
| 8 | AccountId, | 8 | AccountId, |
| 9 | Credential, | 9 | Credential, |
| 10 | CredentialSlot, | ||
| 10 | Source, | 11 | Source, |
| 11 | SourceAuth, | 12 | SourceAuth, |
| 13 | SourceImportAuthPolicy, | ||
| 12 | SourceRecipeId, | 14 | SourceRecipeId, |
| 13 | SourceRecipeImporterKind, | 15 | SourceRecipeImporterKind, |
| 14 | SourceRecipeKind, | 16 | SourceRecipeKind, |
| @@ -25,16 +27,8 @@ import { · 10 unmodified lines | |||
| 25 | SourceRecipeRevisionIdSchema, | 27 | SourceRecipeRevisionIdSchema, |
| 26 | } from "#schema"; | 28 | } from "#schema"; |
| 27 | import * as Effect from "effect/Effect"; | 29 | import * as Effect from "effect/Effect"; |
| 28 | import * as Schema from "effect/Schema"; | 30 | |
| 29 | 31 | import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters"; | |
| 30 | const decodeStringMap = Schema.decodeUnknown( | ||
| 31 | Schema.NullOr( | ||
| 32 | Schema.Record({ | ||
| 33 | key: Schema.String, | ||
| 34 | value: Schema.String, | ||
| 35 | }), | ||
| 36 | ), | ||
| 37 | ); | ||
| 38 | 32 | ||
| 39 | const trimOrNull = (value: string | null | undefined): string | null => { | 33 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 40 | if (value === null || value === undefined) { | 34 | if (value === null || value === undefined) { |
| @@ -45,134 +39,22 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines | |||
| 45 | return trimmed.length > 0 ? trimmed : null; | 39 | return trimmed.length > 0 ? trimmed : null; |
| 46 | }; | 40 | }; |
| 47 | 41 | ||
| 48 | const serializeStringMap = (value: StringMap | null): string | null => | 42 | type SourceRecipeSourceConfig = Record<string, unknown>; |
| 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 | ); | ||
| 71 | 43 | ||
| 72 | type SourceRecipeSourceConfig = | 44 | const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig => |
| 73 | | { | 45 | getSourceAdapterForSource(source).sourceConfigFromSource(source); |
| 74 | kind: "mcp"; | ||
| 75 | endpoint: string; | ||
| 76 | transport: Source["transport"]; | ||
| 77 | queryParams: Source["queryParams"]; | ||
| 78 | headers: Source["headers"]; | ||
| 79 | } | ||
| 80 | | { | ||
| 81 | kind: "openapi"; | ||
| 82 | endpoint: string; | ||
| 83 | specUrl: string; | ||
| 84 | defaultHeaders: Source["defaultHeaders"]; | ||
| 85 | } | ||
| 86 | | { | ||
| 87 | kind: "graphql"; | ||
| 88 | endpoint: string; | ||
| 89 | defaultHeaders: Source["defaultHeaders"]; | ||
| 90 | } | ||
| 91 | | { | ||
| 92 | kind: "internal"; | ||
| 93 | endpoint: string; | ||
| 94 | }; | ||
| 95 | |||
| 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 | }; | ||
| 129 | 46 | ||
| 130 | const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => { | 47 | const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => { |
| 131 | if (source.kind === "mcp") { | 48 | const adapter = getSourceAdapterForSource(source); |
| 132 | return "mcp_recipe"; | 49 | return adapter.family; |
| 133 | } | ||
| 134 | |||
| 135 | if (source.kind === "graphql") { | ||
| 136 | return "graphql_recipe"; | ||
| 137 | } | ||
| 138 | |||
| 139 | if (source.kind === "openapi") { | ||
| 140 | return "http_recipe"; | ||
| 141 | } | ||
| 142 | |||
| 143 | return "internal_recipe"; | ||
| 144 | }; | 50 | }; |
| 145 | 51 | ||
| 146 | const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => { | 52 | const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => { |
| 147 | if (source.kind === "mcp") { | 53 | return getSourceAdapterForSource(source).key; |
| 148 | return "mcp_manifest"; | ||
| 149 | } | ||
| 150 | |||
| 151 | if (source.kind === "graphql") { | ||
| 152 | return "graphql_introspection"; | ||
| 153 | } | ||
| 154 | |||
| 155 | if (source.kind === "openapi") { | ||
| 156 | return "openapi"; | ||
| 157 | } | ||
| 158 | |||
| 159 | return "internal_manifest"; | ||
| 160 | }; | 54 | }; |
| 161 | 55 | ||
| 162 | const sourceRecipeProviderKeyFromSource = (source: Source): string => { | 56 | const sourceRecipeProviderKeyFromSource = (source: Source): string => { |
| 163 | if (source.kind === "mcp") { | 57 | return getSourceAdapterForSource(source).providerKey; |
| 164 | return "generic_mcp"; | ||
| 165 | } | ||
| 166 | |||
| 167 | if (source.kind === "graphql") { | ||
| 168 | return "generic_graphql"; | ||
| 169 | } | ||
| 170 | |||
| 171 | if (source.kind === "openapi") { | ||
| 172 | return "generic_http"; | ||
| 173 | } | ||
| 174 | |||
| 175 | return "generic_internal"; | ||
| 176 | }; | 58 | }; |
| 177 | 59 | ||
| 178 | const stableHash = (value: string): string => | 60 | const stableHash = (value: string): string => |
| @@ -260,58 +142,108 @@ const normalizeAuth = ( · 81 unmodified lines | |||
| 260 | } satisfies SourceAuth; | 142 | } satisfies SourceAuth; |
| 261 | }); | 143 | }); |
| 262 | 144 | ||
| 263 | const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, never> => | 145 | const normalizeImportAuthPolicy = ( |
| 264 | Effect.gen(function* () { | 146 | sourceKind: Source["kind"], |
| 265 | if (source.kind === "mcp") { | 147 | policy: SourceImportAuthPolicy | undefined, |
| 266 | if (source.specUrl !== null) { | 148 | ): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy; |
| 267 | return yield* Effect.fail(new Error("MCP sources cannot define specUrl")); | ||
| 268 | } | ||
| 269 | return source; | ||
| 270 | } | ||
| 271 | 149 | ||
| 272 | if (source.kind === "openapi") { | 150 | const credentialFromAuth = (input: { |
| 273 | if (trimOrNull(source.specUrl) === null) { | 151 | source: Source; |
| 274 | return yield* Effect.fail(new Error("OpenAPI sources require specUrl")); | 152 | auth: SourceAuth; |
| 275 | } | 153 | slot: CredentialSlot; |
| 276 | 154 | actorAccountId?: AccountId | null; | |
| 277 | if (source.transport !== null || source.queryParams !== null || source.headers !== null) { | 155 | existingCredentialId?: Credential["id"] | null; |
| 278 | return yield* Effect.fail( | 156 | }): Credential | null => { |
| 279 | new Error("OpenAPI sources cannot define MCP transport settings"), | 157 | if (input.auth.kind === "none") { |
| 280 | ); | 158 | return null; |
| 281 | } | 159 | } |
| 282 | 160 | ||
| 283 | return source; | 161 | const credentialId = input.existingCredentialId |
| 284 | } | 162 | ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`); |
| 285 | 163 | ||
| 286 | if (source.kind === "graphql") { | 164 | return { |
| 287 | if (source.transport !== null || source.queryParams !== null || source.headers !== null) { | 165 | id: credentialId, |
| 288 | return yield* Effect.fail( | 166 | workspaceId: input.source.workspaceId, |
| 289 | new Error("GraphQL sources cannot define MCP transport settings"), | 167 | sourceId: input.source.id, |
| 290 | ); | 168 | actorAccountId: input.actorAccountId ?? null, |
| 291 | } | 169 | slot: input.slot, |
| 170 | authKind: input.auth.kind, | ||
| 171 | authHeaderName: input.auth.headerName, | ||
| 172 | authPrefix: input.auth.prefix, | ||
| 173 | tokenProviderId: | ||
| 174 | input.auth.kind === "bearer" | ||
| 175 | ? input.auth.token.providerId | ||
| 176 | : input.auth.accessToken.providerId, | ||
| 177 | tokenHandle: | ||
| 178 | input.auth.kind === "bearer" | ||
| 179 | ? input.auth.token.handle | ||
| 180 | : input.auth.accessToken.handle, | ||
| 181 | refreshTokenProviderId: | ||
| 182 | input.auth.kind === "oauth2" && input.auth.refreshToken !== null | ||
| 183 | ? input.auth.refreshToken.providerId | ||
| 184 | : null, | ||
| 185 | refreshTokenHandle: | ||
| 186 | input.auth.kind === "oauth2" && input.auth.refreshToken !== null | ||
| 187 | ? input.auth.refreshToken.handle | ||
| 188 | : null, | ||
| 189 | createdAt: input.source.createdAt, | ||
| 190 | updatedAt: input.source.updatedAt, | ||
| 191 | } satisfies Credential; | ||
| 192 | }; | ||
| 292 | 193 | ||
| 293 | if (source.specUrl !== null) { | 194 | const authFromCredential = (credential: Credential | null): SourceAuth => { |
| 294 | return yield* Effect.fail(new Error("GraphQL sources cannot define specUrl")); | 195 | if (credential === null) { |
| 295 | } | 196 | return { kind: "none" }; |
| 197 | } | ||
| 296 | 198 | ||
| 297 | return source; | 199 | if (credential.authKind === "bearer") { |
| 298 | } | 200 | return { |
| 201 | kind: "bearer", | ||
| 202 | headerName: credential.authHeaderName, | ||
| 203 | prefix: credential.authPrefix, | ||
| 204 | token: { | ||
| 205 | providerId: credential.tokenProviderId, | ||
| 206 | handle: credential.tokenHandle, | ||
| 207 | }, | ||
| 208 | }; | ||
| 209 | } | ||
| 299 | 210 | ||
| 300 | if (source.transport !== null || source.queryParams !== null || source.headers !== null) { | 211 | return { |
| 301 | return yield* Effect.fail( | 212 | kind: "oauth2", |
| 302 | new Error(`${source.kind} sources cannot define MCP transport settings`), | 213 | headerName: credential.authHeaderName, |
| 303 | ); | 214 | prefix: credential.authPrefix, |
| 304 | } | 215 | accessToken: { |
| 216 | providerId: credential.tokenProviderId, | ||
| 217 | handle: credential.tokenHandle, | ||
| 218 | }, | ||
| 219 | refreshToken: | ||
| 220 | credential.refreshTokenProviderId !== null | ||
| 221 | && credential.refreshTokenHandle !== null | ||
| 222 | ? { | ||
| 223 | providerId: credential.refreshTokenProviderId, | ||
| 224 | handle: credential.refreshTokenHandle, | ||
| 225 | } | ||
| 226 | : null, | ||
| 227 | }; | ||
| 228 | }; | ||
| 305 | 229 | ||
| 306 | if (source.specUrl !== null || source.defaultHeaders !== null) { | 230 | const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> => |
| 231 | Effect.gen(function* () { | ||
| 232 | if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") { | ||
| 307 | return yield* Effect.fail( | 233 | return yield* Effect.fail( |
| 308 | new Error(`${source.kind} sources cannot define OpenAPI settings`), | 234 | new Error("importAuth must be none unless importAuthPolicy is separate"), |
| 309 | ); | 235 | ); |
| 310 | } | 236 | } |
| 311 | 237 | ||
| 312 | return source; | 238 | return source; |
| 313 | }); | 239 | }); |
| 314 | 240 | ||
| 241 | const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, never> => | ||
| 242 | Effect.flatMap( | ||
| 243 | validateSourceImportAuth(source), | ||
| 244 | (validated) => getSourceAdapterForSource(validated).validateSource(validated), | ||
| 245 | ); | ||
| 246 | |||
| 315 | export const createSourceFromPayload = (input: { | 247 | export const createSourceFromPayload = (input: { |
| 316 | workspaceId: WorkspaceId; | 248 | workspaceId: WorkspaceId; |
| 317 | sourceId: Source["id"]; | 249 | sourceId: Source["id"]; |
| @@ -320,6 +252,11 @@ export const createSourceFromPayload = (input: { · 2 unmodified lines | |||
| 320 | }): Effect.Effect<Source, Error, never> => | 252 | }): Effect.Effect<Source, Error, never> => |
| 321 | Effect.gen(function* () { | 253 | Effect.gen(function* () { |
| 322 | const auth = yield* normalizeAuth(input.payload.auth); | 254 | const auth = yield* normalizeAuth(input.payload.auth); |
| 255 | const importAuth = yield* normalizeAuth(input.payload.importAuth); | ||
| 256 | const importAuthPolicy = normalizeImportAuthPolicy( | ||
| 257 | input.payload.kind, | ||
| 258 | input.payload.importAuthPolicy, | ||
| 259 | ); | ||
| 323 | 260 | ||
| 324 | return yield* validateSourceByKind({ | 261 | return yield* validateSourceByKind({ |
| 325 | id: input.sourceId, | 262 | id: input.sourceId, |
| @@ -335,6 +272,8 @@ export const createSourceFromPayload = (input: { · 9 unmodified lines | |||
| 335 | headers: input.payload.headers ?? null, | 272 | headers: input.payload.headers ?? null, |
| 336 | specUrl: trimOrNull(input.payload.specUrl), | 273 | specUrl: trimOrNull(input.payload.specUrl), |
| 337 | defaultHeaders: input.payload.defaultHeaders ?? null, | 274 | defaultHeaders: input.payload.defaultHeaders ?? null, |
| 275 | importAuthPolicy, | ||
| 276 | importAuth, | ||
| 338 | auth, | 277 | auth, |
| 339 | sourceHash: trimOrNull(input.payload.sourceHash), | 278 | sourceHash: trimOrNull(input.payload.sourceHash), |
| 340 | lastError: trimOrNull(input.payload.lastError), | 279 | lastError: trimOrNull(input.payload.lastError), |
| @@ -352,6 +291,13 @@ export const updateSourceFromPayload = (input: { · 11 unmodified lines | |||
| 352 | const nextAuth = input.payload.auth === undefined | 291 | const nextAuth = input.payload.auth === undefined |
| 353 | ? input.source.auth | 292 | ? input.source.auth |
| 354 | : yield* normalizeAuth(input.payload.auth); | 293 | : yield* normalizeAuth(input.payload.auth); |
| 294 | const nextImportAuth = input.payload.importAuth === undefined | ||
| 295 | ? input.source.importAuth | ||
| 296 | : yield* normalizeAuth(input.payload.importAuth); | ||
| 297 | const nextImportAuthPolicy = normalizeImportAuthPolicy( | ||
| 298 | input.payload.kind ?? input.source.kind, | ||
| 299 | input.payload.importAuthPolicy ?? input.source.importAuthPolicy, | ||
| 300 | ); | ||
| 355 | 301 | ||
| 356 | return yield* validateSourceByKind({ | 302 | return yield* validateSourceByKind({ |
| 357 | ...input.source, | 303 | ...input.source, |
| @@ -381,6 +327,8 @@ export const updateSourceFromPayload = (input: { · 23 unmodified lines | |||
| 381 | defaultHeaders: input.payload.defaultHeaders !== undefined | 327 | defaultHeaders: input.payload.defaultHeaders !== undefined |
| 382 | ? input.payload.defaultHeaders | 328 | ? input.payload.defaultHeaders |
| 383 | : input.source.defaultHeaders, | 329 | : input.source.defaultHeaders, |
| 330 | importAuthPolicy: nextImportAuthPolicy, | ||
| 331 | importAuth: nextImportAuth, | ||
| 384 | auth: nextAuth, | 332 | auth: nextAuth, |
| 385 | sourceHash: input.payload.sourceHash !== undefined | 333 | sourceHash: input.payload.sourceHash !== undefined |
| 386 | ? trimOrNull(input.payload.sourceHash) | 334 | ? trimOrNull(input.payload.sourceHash) |
| @@ -416,6 +364,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 29 unmodified lines | |||
| 416 | revisionNumber: number; | 364 | revisionNumber: number; |
| 417 | manifestJson?: string | null; | 365 | manifestJson?: string | null; |
| 418 | manifestHash?: string | null; | 366 | manifestHash?: string | null; |
| 367 | materializationHash?: string | null; | ||
| 419 | }): StoredSourceRecipeRevisionRecord => ({ | 368 | }): StoredSourceRecipeRevisionRecord => ({ |
| 420 | id: | 369 | id: |
| 421 | input.recipeRevisionId | 370 | input.recipeRevisionId |
| @@ -425,6 +374,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 3 unmodified lines | |||
| 425 | sourceConfigJson: sourceConfigSignature(input.source), | 374 | sourceConfigJson: sourceConfigSignature(input.source), |
| 426 | manifestJson: input.manifestJson ?? null, | 375 | manifestJson: input.manifestJson ?? null, |
| 427 | manifestHash: input.manifestHash ?? null, | 376 | manifestHash: input.manifestHash ?? null, |
| 377 | materializationHash: input.materializationHash ?? null, | ||
| 428 | createdAt: input.source.createdAt, | 378 | createdAt: input.source.createdAt, |
| 429 | updatedAt: input.source.updatedAt, | 379 | updatedAt: input.source.updatedAt, |
| 430 | }); | 380 | }); |
| @@ -434,10 +384,12 @@ export const splitSourceForStorage = (input: { · 3 unmodified lines | |||
| 434 | recipeId: SourceRecipeId; | 384 | recipeId: SourceRecipeId; |
| 435 | recipeRevisionId: SourceRecipeRevisionId; | 385 | recipeRevisionId: SourceRecipeRevisionId; |
| 436 | actorAccountId?: AccountId | null; | 386 | actorAccountId?: AccountId | null; |
| 437 | existingCredentialId?: Credential["id"] | null; | 387 | existingRuntimeCredentialId?: Credential["id"] | null; |
| 388 | existingImportCredentialId?: Credential["id"] | null; | ||
| 438 | }): { | 389 | }): { |
| 439 | sourceRecord: StoredSourceRecord; | 390 | sourceRecord: StoredSourceRecord; |
| 440 | credential: Credential | null; | 391 | runtimeCredential: Credential | null; |
| 392 | importCredential: Credential | null; | ||
| 441 | } => { | 393 | } => { |
| 442 | const sourceRecord: StoredSourceRecord = { | 394 | const sourceRecord: StoredSourceRecord = { |
| 443 | id: input.source.id, | 395 | id: input.source.id, |
| @@ -450,113 +402,46 @@ export const splitSourceForStorage = (input: { · 6 unmodified lines | |||
| 450 | status: input.source.status, | 402 | status: input.source.status, |
| 451 | enabled: input.source.enabled, | 403 | enabled: input.source.enabled, |
| 452 | namespace: input.source.namespace, | 404 | namespace: input.source.namespace, |
| 453 | bindingConfigJson: null, | 405 | importAuthPolicy: input.source.importAuthPolicy, |
| 454 | transport: input.source.transport, | 406 | bindingConfigJson: getSourceAdapterForSource(input.source).serializeBindingConfig(input.source), |
| 455 | queryParamsJson: serializeStringMap(input.source.queryParams), | ||
| 456 | headersJson: serializeStringMap(input.source.headers), | ||
| 457 | specUrl: input.source.specUrl, | ||
| 458 | defaultHeadersJson: serializeStringMap(input.source.defaultHeaders), | ||
| 459 | sourceHash: input.source.sourceHash, | 407 | sourceHash: input.source.sourceHash, |
| 460 | sourceDocumentText: null, | ||
| 461 | lastError: input.source.lastError, | 408 | lastError: input.source.lastError, |
| 462 | createdAt: input.source.createdAt, | 409 | createdAt: input.source.createdAt, |
| 463 | updatedAt: input.source.updatedAt, | 410 | updatedAt: input.source.updatedAt, |
| 464 | }; | 411 | }; |
| 465 | 412 | ||
| 466 | if (input.source.auth.kind === "none") { | ||
| 467 | return { | ||
| 468 | sourceRecord, | ||
| 469 | credential: null, | ||
| 470 | }; | ||
| 471 | } | ||
| 472 | |||
| 473 | const credentialId = input.existingCredentialId | ||
| 474 | ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`); | ||
| 475 | |||
| 476 | const credential: Credential = { | ||
| 477 | id: credentialId, | ||
| 478 | workspaceId: input.source.workspaceId, | ||
| 479 | sourceId: input.source.id, | ||
| 480 | actorAccountId: input.actorAccountId ?? null, | ||
| 481 | authKind: input.source.auth.kind, | ||
| 482 | authHeaderName: input.source.auth.headerName, | ||
| 483 | authPrefix: input.source.auth.prefix, | ||
| 484 | tokenProviderId: | ||
| 485 | input.source.auth.kind === "bearer" | ||
| 486 | ? input.source.auth.token.providerId | ||
| 487 | : input.source.auth.accessToken.providerId, | ||
| 488 | tokenHandle: | ||
| 489 | input.source.auth.kind === "bearer" | ||
| 490 | ? input.source.auth.token.handle | ||
| 491 | : input.source.auth.accessToken.handle, | ||
| 492 | refreshTokenProviderId: | ||
| 493 | input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null | ||
| 494 | ? input.source.auth.refreshToken.providerId | ||
| 495 | : null, | ||
| 496 | refreshTokenHandle: | ||
| 497 | input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null | ||
| 498 | ? input.source.auth.refreshToken.handle | ||
| 499 | : null, | ||
| 500 | createdAt: input.source.createdAt, | ||
| 501 | updatedAt: input.source.updatedAt, | ||
| 502 | }; | ||
| 503 | |||
| 504 | return { | 413 | return { |
| 505 | sourceRecord, | 414 | sourceRecord, |
| 506 | credential, | 415 | runtimeCredential: credentialFromAuth({ |
| 416 | source: input.source, | ||
| 417 | auth: input.source.auth, | ||
| 418 | slot: "runtime", | ||
| 419 | actorAccountId: input.actorAccountId, | ||
| 420 | existingCredentialId: input.existingRuntimeCredentialId, | ||
| 421 | }), | ||
| 422 | importCredential: input.source.importAuthPolicy === "separate" | ||
| 423 | ? credentialFromAuth({ | ||
| 424 | source: input.source, | ||
| 425 | auth: input.source.importAuth, | ||
| 426 | slot: "import", | ||
| 427 | actorAccountId: input.actorAccountId, | ||
| 428 | existingCredentialId: input.existingImportCredentialId, | ||
| 429 | }) | ||
| 430 | : null, | ||
| 507 | }; | 431 | }; |
| 508 | }; | 432 | }; |
| 509 | 433 | ||
| 510 | export const projectSourceFromStorage = (input: { | 434 | export const projectSourceFromStorage = (input: { |
| 511 | sourceRecord: StoredSourceRecord; | 435 | sourceRecord: StoredSourceRecord; |
| 512 | credential: Credential | null; | 436 | runtimeCredential: Credential | null; |
| 437 | importCredential: Credential | null; | ||
| 513 | }): Effect.Effect<Source, Error, never> => | 438 | }): Effect.Effect<Source, Error, never> => |
| 514 | Effect.gen(function* () { | 439 | Effect.gen(function* () { |
| 515 | const queryParams = yield* parseStringMapJson( | 440 | const adapter = getSourceAdapter(input.sourceRecord.kind); |
| 516 | `queryParamsJson for ${input.sourceRecord.id}`, | 441 | const bindingState = yield* adapter.deserializeBindingConfig({ |
| 517 | input.sourceRecord.queryParamsJson, | 442 | id: input.sourceRecord.id, |
| 518 | ); | 443 | bindingConfigJson: input.sourceRecord.bindingConfigJson, |
| 519 | const headers = yield* parseStringMapJson( | 444 | }); |
| 520 | `headersJson for ${input.sourceRecord.id}`, | ||
| 521 | input.sourceRecord.headersJson, | ||
| 522 | ); | ||
| 523 | const defaultHeaders = yield* parseStringMapJson( | ||
| 524 | `defaultHeadersJson for ${input.sourceRecord.id}`, | ||
| 525 | input.sourceRecord.defaultHeadersJson, | ||
| 526 | ); | ||
| 527 | |||
| 528 | let auth: SourceAuth = { kind: "none" }; | ||
| 529 | if (input.credential !== null) { | ||
| 530 | if (input.credential.authKind === "bearer") { | ||
| 531 | auth = { | ||
| 532 | kind: "bearer", | ||
| 533 | headerName: input.credential.authHeaderName, | ||
| 534 | prefix: input.credential.authPrefix, | ||
| 535 | token: { | ||
| 536 | providerId: input.credential.tokenProviderId, | ||
| 537 | handle: input.credential.tokenHandle, | ||
| 538 | }, | ||
| 539 | }; | ||
| 540 | } else { | ||
| 541 | auth = { | ||
| 542 | kind: "oauth2", | ||
| 543 | headerName: input.credential.authHeaderName, | ||
| 544 | prefix: input.credential.authPrefix, | ||
| 545 | accessToken: { | ||
| 546 | providerId: input.credential.tokenProviderId, | ||
| 547 | handle: input.credential.tokenHandle, | ||
| 548 | }, | ||
| 549 | refreshToken: | ||
| 550 | input.credential.refreshTokenProviderId !== null | ||
| 551 | && input.credential.refreshTokenHandle !== null | ||
| 552 | ? { | ||
| 553 | providerId: input.credential.refreshTokenProviderId, | ||
| 554 | handle: input.credential.refreshTokenHandle, | ||
| 555 | } | ||
| 556 | : null, | ||
| 557 | }; | ||
| 558 | } | ||
| 559 | } | ||
| 560 | 445 | ||
| 561 | return { | 446 | return { |
| 562 | id: input.sourceRecord.id, | 447 | id: input.sourceRecord.id, |
| @@ -567,12 +452,17 @@ export const projectSourceFromStorage = (input: { · 4 unmodified lines | |||
| 567 | status: input.sourceRecord.status, | 452 | status: input.sourceRecord.status, |
| 568 | enabled: input.sourceRecord.enabled, | 453 | enabled: input.sourceRecord.enabled, |
| 569 | namespace: input.sourceRecord.namespace, | 454 | namespace: input.sourceRecord.namespace, |
| 570 | transport: input.sourceRecord.transport, | 455 | transport: bindingState.transport, |
| 571 | queryParams, | 456 | queryParams: bindingState.queryParams, |
| 572 | headers, | 457 | headers: bindingState.headers, |
| 573 | specUrl: input.sourceRecord.specUrl, | 458 | specUrl: bindingState.specUrl, |
| 574 | defaultHeaders, | 459 | defaultHeaders: bindingState.defaultHeaders, |
| 575 | auth, | 460 | importAuthPolicy: input.sourceRecord.importAuthPolicy, |
| 461 | importAuth: | ||
| 462 | input.sourceRecord.importAuthPolicy === "separate" | ||
| 463 | ? authFromCredential(input.importCredential) | ||
| 464 | : { kind: "none" }, | ||
| 465 | auth: authFromCredential(input.runtimeCredential), | ||
| 576 | sourceHash: input.sourceRecord.sourceHash, | 466 | sourceHash: input.sourceRecord.sourceHash, |
| 577 | lastError: input.sourceRecord.lastError, | 467 | lastError: input.sourceRecord.lastError, |
| 578 | createdAt: input.sourceRecord.createdAt, | 468 | createdAt: input.sourceRecord.createdAt, |
| @@ -588,18 +478,29 @@ export const projectSourcesFromStorage = (input: { · 9 unmodified lines | |||
| 588 | sourceRecords: ReadonlyArray<StoredSourceRecord>; | 478 | sourceRecords: ReadonlyArray<StoredSourceRecord>; |
| 589 | credentials: ReadonlyArray<Credential>; | 479 | credentials: ReadonlyArray<Credential>; |
| 590 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { | 480 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { |
| 591 | const credentialsBySourceId = new Map<string, Credential>(); | 481 | const credentialsBySourceId = new Map<string, { |
| 482 | runtime: Credential | null; | ||
| 483 | import: Credential | null; | ||
| 484 | }>(); | ||
| 592 | 485 | ||
| 593 | for (const credential of input.credentials) { | 486 | for (const credential of input.credentials) { |
| 594 | const existing = credentialsBySourceId.get(credential.sourceId); | 487 | const existing = credentialsBySourceId.get(credential.sourceId) ?? { |
| 595 | if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) { | 488 | runtime: null, |
| 596 | credentialsBySourceId.set(credential.sourceId, credential); | 489 | import: null, |
| 490 | }; | ||
| 491 | const current = credential.slot === "runtime" ? existing.runtime : existing.import; | ||
| 492 | if (current === null || (current.actorAccountId === null && credential.actorAccountId !== null)) { | ||
| 493 | credentialsBySourceId.set(credential.sourceId, { | ||
| 494 | ...existing, | ||
| 495 | [credential.slot]: credential, | ||
| 496 | }); | ||
| 597 | } | 497 | } |
| 598 | } | 498 | } |
| 599 | 499 | ||
| 600 | return Effect.forEach(input.sourceRecords, (sourceRecord) => | 500 | return Effect.forEach(input.sourceRecords, (sourceRecord) => |
| 601 | projectSourceFromStorage({ | 501 | projectSourceFromStorage({ |
| 602 | sourceRecord, | 502 | sourceRecord, |
| 603 | credential: credentialsBySourceId.get(sourceRecord.id) ?? null, | 503 | runtimeCredential: credentialsBySourceId.get(sourceRecord.id)?.runtime ?? null, |
| 504 | importCredential: credentialsBySourceId.get(sourceRecord.id)?.import ?? null, | ||
| 604 | })); | 505 | })); |
| 605 | }; | 506 | }; |
| @@ -19,7 +19,7 @@ import { · 18 unmodified lines | |||
| 19 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 20 | import { getIntrospectionQuery } from "graphql"; | 20 | import { getIntrospectionQuery } from "graphql"; |
| 21 | import { startMcpOAuthAuthorization } from "./mcp-oauth"; | 21 | import { startMcpOAuthAuthorization } from "./mcp-oauth"; |
| 22 | import { namespaceFromSourceName } from "./tool-artifacts"; | 22 | import { namespaceFromSourceName } from "./source-names"; |
| 23 | 23 | ||
| 24 | const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery(); | 24 | const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery(); |
| 25 | 25 | ||
| @@ -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 | }); | ||
| @@ -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"; | ||
| 9 | import type { | 1 | import type { |
| 10 | Source, | 2 | Source, |
| 11 | SourceId, | 3 | SourceId, |
| @@ -13,10 +5,14 @@ import type { · 1 unmodified line | |||
| 13 | SourceInspectionDiscoverPayload, | 5 | SourceInspectionDiscoverPayload, |
| 14 | SourceInspectionDiscoverResult, | 6 | SourceInspectionDiscoverResult, |
| 15 | SourceInspectionDiscoverResultItem, | 7 | SourceInspectionDiscoverResultItem, |
| 8 | SourceInspectionSchemaBundle, | ||
| 9 | SourceInspectionToolListItem, | ||
| 16 | SourceInspectionToolDetail, | 10 | SourceInspectionToolDetail, |
| 17 | SourceInspectionToolSummary, | 11 | SourceInspectionToolSummary, |
| 12 | StoredSourceRecipeOperationRecord, | ||
| 18 | WorkspaceId, | 13 | WorkspaceId, |
| 19 | } from "#schema"; | 14 | } from "#schema"; |
| 15 | import { SourceRecipeSchemaBundleIdSchema } from "#schema"; | ||
| 20 | import { | 16 | import { |
| 21 | ControlPlaneNotFoundError, | 17 | ControlPlaneNotFoundError, |
| 22 | ControlPlaneStorageError, | 18 | ControlPlaneStorageError, |
| @@ -25,21 +21,18 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | |||
| 25 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 26 | 22 | ||
| 27 | import { operationErrors } from "./operation-errors"; | 23 | import { operationErrors } from "./operation-errors"; |
| 28 | import { formatJsonIfNeeded, formatWithPrettier } from "./prettier-format"; | 24 | import { formatWithPrettier } from "./prettier-format"; |
| 29 | import { | 25 | import { |
| 30 | buildGraphqlToolPresentation, | 26 | getSourceAdapterForOperation, |
| 31 | compileGraphqlToolDefinitions, | 27 | } from "./source-adapters"; |
| 32 | type GraphqlToolDefinition, | ||
| 33 | type GraphqlToolManifest, | ||
| 34 | } from "./graphql-tools"; | ||
| 35 | import { | 28 | import { |
| 36 | loadSourceRecipe, | 29 | recipeToolMetadata, |
| 37 | recipePrimaryDocumentText, | ||
| 38 | recipeToolPath, | 30 | recipeToolPath, |
| 39 | type LoadedSourceRecipe, | ||
| 40 | } from "./source-recipes-runtime"; | 31 | } from "./source-recipes-runtime"; |
| 32 | import type { SourceAdapterPersistedOperationMetadata } from "./source-adapters/types"; | ||
| 33 | import { namespaceFromSourceName } from "./source-names"; | ||
| 34 | import { loadSourceById } from "./source-store"; | ||
| 41 | import { ControlPlaneStore } from "./store"; | 35 | import { ControlPlaneStore } from "./store"; |
| 42 | import { namespaceFromSourceName } from "./tool-artifacts"; | ||
| 43 | 36 | ||
| 44 | const sourceInspectOps = { | 37 | const sourceInspectOps = { |
| 45 | bundle: operationErrors("sources.inspect.bundle"), | 38 | bundle: operationErrors("sources.inspect.bundle"), |
| @@ -47,8 +40,6 @@ const sourceInspectOps = { · 1 unmodified line | |||
| 47 | discover: operationErrors("sources.inspect.discover"), | 40 | discover: operationErrors("sources.inspect.discover"), |
| 48 | } as const; | 41 | } as const; |
| 49 | 42 | ||
| 50 | const asPrettyJson = (value: unknown): string => JSON.stringify(value, null, 2); | ||
| 51 | |||
| 52 | const tokenize = (value: string): Array<string> => | 43 | const tokenize = (value: string): Array<string> => |
| 53 | value | 44 | value |
| 54 | .trim() | 45 | .trim() |
| @@ -56,26 +47,10 @@ const tokenize = (value: string): Array<string> => · 1 unmodified line | |||
| 56 | .split(/[^a-z0-9]+/) | 47 | .split(/[^a-z0-9]+/) |
| 57 | .filter(Boolean); | 48 | .filter(Boolean); |
| 58 | 49 | ||
| 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 | |||
| 76 | type InspectionToolRecord = { | 50 | type InspectionToolRecord = { |
| 77 | summary: SourceInspectionToolSummary; | 51 | operation: StoredSourceRecipeOperationRecord; |
| 78 | detail: SourceInspectionToolDetail; | 52 | metadata: SourceAdapterPersistedOperationMetadata; |
| 53 | listItem: SourceInspectionToolListItem; | ||
| 79 | searchText: string; | 54 | searchText: string; |
| 80 | }; | 55 | }; |
| 81 | 56 | ||
| @@ -83,9 +58,7 @@ type ResolvedSourceInspection = { · 1 unmodified line | |||
| 83 | source: Source; | 58 | source: Source; |
| 84 | namespace: string; | 59 | namespace: string; |
| 85 | pipelineKind: SourceInspection["pipelineKind"]; | 60 | pipelineKind: SourceInspection["pipelineKind"]; |
| 86 | rawDocumentText: string | null; | 61 | schemaBundleId: string | null; |
| 87 | manifestJson: string | null; | ||
| 88 | definitionsJson: string | null; | ||
| 89 | tools: ReadonlyArray<InspectionToolRecord>; | 62 | tools: ReadonlyArray<InspectionToolRecord>; |
| 90 | }; | 63 | }; |
| 91 | 64 | ||
| @@ -111,41 +84,26 @@ const formatToolSummary = (summary: SourceInspectionToolSummary) => · 19 unmodified lines | |||
| 111 | } satisfies SourceInspectionToolSummary)), | 84 | } satisfies SourceInspectionToolSummary)), |
| 112 | ); | 85 | ); |
| 113 | 86 | ||
| 114 | const formatInspectionToolRecord = (record: InspectionToolRecord) => | 87 | const formatInspectionToolDetail = (detail: SourceInspectionToolDetail) => |
| 115 | Effect.gen(function* () { | 88 | Effect.gen(function* () { |
| 116 | const summary = yield* formatToolSummary(record.summary); | 89 | const summary = yield* formatToolSummary(detail.summary); |
| 117 | const detailFields = yield* Effect.all({ | 90 | const detailFields = yield* Effect.all({ |
| 118 | definitionJson: formatOptionalJson(record.detail.definitionJson), | 91 | definitionJson: formatOptionalJson(detail.definitionJson), |
| 119 | documentationJson: formatOptionalJson(record.detail.documentationJson), | 92 | documentationJson: formatOptionalJson(detail.documentationJson), |
| 120 | providerDataJson: formatOptionalJson(record.detail.providerDataJson), | 93 | providerDataJson: formatOptionalJson(detail.providerDataJson), |
| 121 | inputSchemaJson: formatOptionalJson(record.detail.inputSchemaJson), | 94 | inputSchemaJson: formatOptionalJson(detail.inputSchemaJson), |
| 122 | outputSchemaJson: formatOptionalJson(record.detail.outputSchemaJson), | 95 | outputSchemaJson: formatOptionalJson(detail.outputSchemaJson), |
| 123 | exampleInputJson: formatOptionalJson(record.detail.exampleInputJson), | 96 | exampleInputJson: formatOptionalJson(detail.exampleInputJson), |
| 124 | exampleOutputJson: formatOptionalJson(record.detail.exampleOutputJson), | 97 | exampleOutputJson: formatOptionalJson(detail.exampleOutputJson), |
| 125 | }); | 98 | }); |
| 126 | 99 | ||
| 127 | return { | 100 | return { |
| 101 | ...detail, | ||
| 102 | ...detailFields, | ||
| 128 | summary, | 103 | summary, |
| 129 | detail: { | 104 | } satisfies SourceInspectionToolDetail; |
| 130 | ...record.detail, | ||
| 131 | ...detailFields, | ||
| 132 | summary, | ||
| 133 | }, | ||
| 134 | searchText: record.searchText, | ||
| 135 | } satisfies InspectionToolRecord; | ||
| 136 | }); | ||
| 137 | |||
| 138 | const loadStoredDocumentText = (recipe: LoadedSourceRecipe) => { | ||
| 139 | const rawDocumentText = recipePrimaryDocumentText({ | ||
| 140 | source: recipe.source, | ||
| 141 | documents: recipe.documents, | ||
| 142 | }); | 105 | }); |
| 143 | 106 | ||
| 144 | return rawDocumentText === null | ||
| 145 | ? Effect.succeed<string | null>(null) | ||
| 146 | : Effect.promise(() => formatJsonIfNeeded(rawDocumentText)); | ||
| 147 | }; | ||
| 148 | |||
| 149 | const loadSourceRecipeRecord = (input: { | 107 | const loadSourceRecipeRecord = (input: { |
| 150 | workspaceId: WorkspaceId; | 108 | workspaceId: WorkspaceId; |
| 151 | sourceId: SourceId; | 109 | sourceId: SourceId; |
| @@ -164,316 +122,146 @@ const loadSourceRecipeRecord = (input: { · 12 unmodified lines | |||
| 164 | ); | 122 | ); |
| 165 | } | 123 | } |
| 166 | 124 | ||
| 167 | const recipe = yield* loadSourceRecipe({ | 125 | const source = yield* loadSourceById(store, { |
| 168 | rows: store, | ||
| 169 | workspaceId: input.workspaceId, | 126 | workspaceId: input.workspaceId, |
| 170 | sourceId: input.sourceId, | 127 | sourceId: input.sourceId, |
| 171 | }).pipe( | 128 | }).pipe( |
| 172 | Effect.mapError((cause) => | 129 | Effect.mapError((cause) => |
| 173 | sourceInspectOps.bundle.unknownStorage( | 130 | sourceInspectOps.bundle.unknownStorage( |
| 174 | cause, | 131 | cause, |
| 175 | "Failed loading source recipe", | 132 | "Failed loading source", |
| 176 | ), | 133 | ), |
| 177 | ), | 134 | ), |
| 178 | ); | 135 | ); |
| 136 | const operations = yield* sourceInspectOps.bundle.child("operations").mapStorage( | ||
| 137 | store.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), | ||
| 138 | ); | ||
| 139 | const schemaBundles = yield* sourceInspectOps.bundle.child("schema_bundles").mapStorage( | ||
| 140 | store.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | ||
| 141 | ); | ||
| 179 | 142 | ||
| 180 | return { | 143 | return { |
| 181 | store, | 144 | source, |
| 182 | sourceRecord: sourceRecord.value, | 145 | operations, |
| 183 | recipe, | 146 | schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id |
| 147 | ?? schemaBundles[0]?.id | ||
| 148 | ?? null, | ||
| 184 | }; | 149 | }; |
| 185 | }), | 150 | }), |
| 186 | ); | 151 | ); |
| 187 | 152 | ||
| 188 | const persistedToolSummaryFromRecipeOperation = (input: { | 153 | const inspectionToolListItemFromOperation = (input: { |
| 189 | source: Source; | 154 | source: Source; |
| 190 | operation: LoadedSourceRecipe["operations"][number]; | 155 | operation: StoredSourceRecipeOperationRecord; |
| 191 | }): SourceInspectionToolSummary => ({ | 156 | metadata: SourceAdapterPersistedOperationMetadata; |
| 157 | }): SourceInspectionToolListItem => ({ | ||
| 192 | path: recipeToolPath({ | 158 | path: recipeToolPath({ |
| 193 | source: input.source, | 159 | source: input.source, |
| 194 | operation: input.operation, | 160 | operation: input.operation, |
| 195 | }), | 161 | }), |
| 196 | sourceKey: input.source.id, | 162 | method: input.metadata.method, |
| 197 | ...(input.operation.title ? { title: input.operation.title } : {}), | ||
| 198 | ...(input.operation.description ? { description: input.operation.description } : {}), | ||
| 199 | providerKind: input.operation.providerKind, | ||
| 200 | toolId: input.operation.toolId, | ||
| 201 | rawToolId: input.operation.openApiRawToolId, | ||
| 202 | operationId: input.operation.graphqlOperationName ?? input.operation.openApiOperationId, | ||
| 203 | group: null, | ||
| 204 | leaf: null, | ||
| 205 | tags: input.operation.openApiTagsJson | ||
| 206 | ? ((JSON.parse(input.operation.openApiTagsJson) as Array<string>) ?? []) | ||
| 207 | : [], | ||
| 208 | method: input.operation.openApiMethod, | ||
| 209 | pathTemplate: input.operation.openApiPathTemplate, | ||
| 210 | ...(input.operation.inputSchemaJson | ||
| 211 | ? { | ||
| 212 | inputType: typeSignatureFromSchemaJson( | ||
| 213 | input.operation.inputSchemaJson, | ||
| 214 | "unknown", | ||
| 215 | Infinity, | ||
| 216 | ), | ||
| 217 | } | ||
| 218 | : {}), | ||
| 219 | ...(input.operation.providerKind === "openapi" | ||
| 220 | ? { | ||
| 221 | outputType: openApiOutputTypeSignatureFromSchemaJson( | ||
| 222 | input.operation.outputSchemaJson ?? undefined, | ||
| 223 | Infinity, | ||
| 224 | ), | ||
| 225 | } | ||
| 226 | : input.operation.outputSchemaJson | ||
| 227 | ? { | ||
| 228 | outputType: typeSignatureFromSchemaJson( | ||
| 229 | input.operation.outputSchemaJson, | ||
| 230 | "unknown", | ||
| 231 | Infinity, | ||
| 232 | ), | ||
| 233 | } | ||
| 234 | : {}), | ||
| 235 | }); | 163 | }); |
| 236 | 164 | ||
| 237 | const openApiToolRecord = (input: { | 165 | const persistedToolSummaryFromRecipeOperation = (input: { |
| 238 | source: Source; | 166 | source: Source; |
| 239 | namespace: string; | 167 | operation: StoredSourceRecipeOperationRecord; |
| 240 | manifest: OpenApiToolManifest; | 168 | metadata: SourceAdapterPersistedOperationMetadata; |
| 241 | definition: OpenApiToolDefinition; | 169 | includeTypes: boolean; |
| 242 | }): InspectionToolRecord => { | 170 | }): SourceInspectionToolSummary => { |
| 243 | const presentation = buildOpenApiToolPresentation({ | 171 | const path = inspectionToolListItemFromOperation(input).path; |
| 244 | manifest: input.manifest, | 172 | const descriptor = input.includeTypes |
| 245 | definition: input.definition, | 173 | ? getSourceAdapterForOperation(input.operation).createToolDescriptor({ |
| 246 | }); | 174 | source: input.source, |
| 247 | const path = `${input.namespace}.${input.definition.toolId}`; | 175 | operation: input.operation, |
| 248 | const summary: SourceInspectionToolSummary = { | 176 | path, |
| 249 | path, | 177 | includeSchemas: false, |
| 250 | sourceKey: input.source.id, | 178 | }) |
| 251 | title: input.definition.name, | 179 | : null; |
| 252 | description: input.definition.description, | ||
| 253 | providerKind: "openapi", | ||
| 254 | toolId: input.definition.toolId, | ||
| 255 | rawToolId: input.definition.rawToolId, | ||
| 256 | operationId: input.definition.operationId ?? null, | ||
| 257 | group: input.definition.group, | ||
| 258 | leaf: input.definition.leaf, | ||
| 259 | tags: [...input.definition.tags], | ||
| 260 | method: input.definition.method, | ||
| 261 | pathTemplate: input.definition.path, | ||
| 262 | inputType: presentation.inputType, | ||
| 263 | outputType: presentation.outputType, | ||
| 264 | }; | ||
| 265 | 180 | ||
| 266 | return { | 181 | return { |
| 267 | summary, | 182 | path, |
| 268 | detail: { | 183 | sourceKey: input.source.id, |
| 269 | summary, | 184 | ...(input.operation.title ? { title: input.operation.title } : {}), |
| 270 | definitionJson: asPrettyJson(input.definition), | 185 | ...(input.operation.description ? { description: input.operation.description } : {}), |
| 271 | documentationJson: input.definition.documentation | 186 | providerKind: input.operation.providerKind, |
| 272 | ? asPrettyJson(input.definition.documentation) | 187 | toolId: input.operation.toolId, |
| 273 | : null, | 188 | rawToolId: input.metadata.rawToolId, |
| 274 | providerDataJson: presentation.providerDataJson, | 189 | operationId: input.metadata.operationId, |
| 275 | inputSchemaJson: presentation.inputSchemaJson ?? null, | 190 | group: input.metadata.group, |
| 276 | outputSchemaJson: presentation.outputSchemaJson ?? null, | 191 | leaf: input.metadata.leaf, |
| 277 | exampleInputJson: presentation.exampleInputJson ?? null, | 192 | tags: [...input.metadata.tags], |
| 278 | exampleOutputJson: presentation.exampleOutputJson ?? null, | 193 | method: input.metadata.method, |
| 279 | }, | 194 | pathTemplate: input.metadata.pathTemplate, |
| 280 | searchText: searchTextFromSummary(summary), | 195 | ...(descriptor?.inputType ? { inputType: descriptor.inputType } : {}), |
| 196 | ...(descriptor?.outputType ? { outputType: descriptor.outputType } : {}), | ||
| 281 | }; | 197 | }; |
| 282 | }; | 198 | }; |
| 283 | 199 | ||
| 284 | const graphqlToolRecord = (input: { | 200 | const inspectionToolDetailFromOperation = (input: { |
| 285 | source: Source; | 201 | source: Source; |
| 286 | namespace: string; | 202 | operation: StoredSourceRecipeOperationRecord; |
| 287 | manifest: GraphqlToolManifest; | 203 | metadata: SourceAdapterPersistedOperationMetadata; |
| 288 | definition: GraphqlToolDefinition; | 204 | schemaBundleId: string | null; |
| 289 | }): InspectionToolRecord => { | 205 | }): SourceInspectionToolDetail => { |
| 290 | const presentation = buildGraphqlToolPresentation({ | 206 | const summary = persistedToolSummaryFromRecipeOperation({ |
| 291 | manifest: input.manifest, | 207 | source: input.source, |
| 292 | definition: input.definition, | 208 | operation: input.operation, |
| 209 | metadata: input.metadata, | ||
| 210 | includeTypes: true, | ||
| 293 | }); | 211 | }); |
| 294 | const path = `${input.namespace}.${input.definition.toolId}`; | ||
| 295 | const summary: SourceInspectionToolSummary = { | ||
| 296 | path, | ||
| 297 | sourceKey: input.source.id, | ||
| 298 | title: input.definition.name, | ||
| 299 | description: input.definition.description, | ||
| 300 | providerKind: "graphql", | ||
| 301 | toolId: input.definition.toolId, | ||
| 302 | rawToolId: input.definition.rawToolId, | ||
| 303 | operationId: input.definition.operationName, | ||
| 304 | group: input.definition.group, | ||
| 305 | leaf: input.definition.leaf, | ||
| 306 | tags: [], | ||
| 307 | method: null, | ||
| 308 | pathTemplate: null, | ||
| 309 | inputType: presentation.inputType, | ||
| 310 | outputType: presentation.outputType, | ||
| 311 | }; | ||
| 312 | 212 | ||
| 313 | return { | 213 | return { |
| 314 | summary, | 214 | summary, |
| 315 | detail: { | 215 | definitionJson: null, |
| 316 | summary, | 216 | documentationJson: null, |
| 317 | definitionJson: asPrettyJson(input.definition), | 217 | providerDataJson: input.operation.providerDataJson, |
| 318 | documentationJson: null, | 218 | inputSchemaJson: input.operation.inputSchemaJson, |
| 319 | providerDataJson: presentation.providerDataJson, | 219 | outputSchemaJson: input.operation.outputSchemaJson, |
| 320 | inputSchemaJson: presentation.inputSchemaJson ?? null, | 220 | schemaBundleId: input.schemaBundleId, |
| 321 | outputSchemaJson: presentation.outputSchemaJson ?? null, | 221 | exampleInputJson: null, |
| 322 | exampleInputJson: presentation.exampleInputJson ?? null, | 222 | exampleOutputJson: null, |
| 323 | exampleOutputJson: null, | 223 | } satisfies SourceInspectionToolDetail; |
| 324 | }, | ||
| 325 | searchText: searchTextFromSummary(summary), | ||
| 326 | }; | ||
| 327 | }; | 224 | }; |
| 328 | 225 | ||
| 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 | |||
| 443 | const resolveSourceInspection = (input: { | 226 | const resolveSourceInspection = (input: { |
| 444 | workspaceId: WorkspaceId; | 227 | workspaceId: WorkspaceId; |
| 445 | sourceId: SourceId; | 228 | sourceId: SourceId; |
| 446 | }) => | 229 | }) => |
| 447 | Effect.gen(function* () { | 230 | Effect.gen(function* () { |
| 448 | const { recipe } = yield* loadSourceRecipeRecord(input); | 231 | const { source, operations, schemaBundleId } = yield* loadSourceRecipeRecord(input); |
| 449 | 232 | const namespace = source.namespace ?? namespaceFromSourceName(source.name); | |
| 450 | if (recipe.source.kind === "openapi" && recipe.manifest !== null) { | ||
| 451 | return yield* loadOpenApiInspection({ | ||
| 452 | recipe, | ||
| 453 | }).pipe( | ||
| 454 | Effect.catchAll(() => | ||
| 455 | loadPersistedInspection({ | ||
| 456 | recipe, | ||
| 457 | }), | ||
| 458 | ), | ||
| 459 | ); | ||
| 460 | } | ||
| 461 | |||
| 462 | if (recipe.source.kind === "graphql" && recipe.manifest !== null) { | ||
| 463 | return yield* loadGraphqlInspection({ | ||
| 464 | recipe, | ||
| 465 | }).pipe( | ||
| 466 | Effect.catchAll(() => | ||
| 467 | loadPersistedInspection({ | ||
| 468 | recipe, | ||
| 469 | }), | ||
| 470 | ), | ||
| 471 | ); | ||
| 472 | } | ||
| 473 | 233 | ||
| 474 | return yield* loadPersistedInspection({ | 234 | return { |
| 475 | recipe, | 235 | source, |
| 476 | }); | 236 | namespace, |
| 237 | pipelineKind: "persisted", | ||
| 238 | schemaBundleId, | ||
| 239 | tools: yield* Effect.forEach(operations, (operation) => | ||
| 240 | Effect.gen(function* () { | ||
| 241 | const path = recipeToolPath({ | ||
| 242 | source, | ||
| 243 | operation, | ||
| 244 | }); | ||
| 245 | const metadata = yield* recipeToolMetadata({ | ||
| 246 | source, | ||
| 247 | operation, | ||
| 248 | path, | ||
| 249 | }); | ||
| 250 | const listItem = inspectionToolListItemFromOperation({ | ||
| 251 | source, | ||
| 252 | operation, | ||
| 253 | metadata, | ||
| 254 | }); | ||
| 255 | |||
| 256 | return { | ||
| 257 | operation, | ||
| 258 | metadata, | ||
| 259 | listItem, | ||
| 260 | searchText: metadata.searchText, | ||
| 261 | } satisfies InspectionToolRecord; | ||
| 262 | }) | ||
| 263 | ), | ||
| 264 | } satisfies ResolvedSourceInspection; | ||
| 477 | }); | 265 | }); |
| 478 | 266 | ||
| 479 | const scoreTool = (input: { | 267 | const scoreTool = (input: { |
| @@ -482,15 +270,12 @@ const scoreTool = (input: { · 2 unmodified lines | |||
| 482 | }): SourceInspectionDiscoverResultItem | null => { | 270 | }): SourceInspectionDiscoverResultItem | null => { |
| 483 | let score = 0; | 271 | let score = 0; |
| 484 | const reasons: Array<string> = []; | 272 | const reasons: Array<string> = []; |
| 485 | const pathTokens = tokenize(input.tool.summary.path); | 273 | const pathTokens = tokenize(input.tool.listItem.path); |
| 486 | const titleTokens = tokenize(input.tool.summary.title ?? ""); | 274 | const titleTokens = tokenize(input.tool.operation.title ?? ""); |
| 487 | const descriptionTokens = tokenize(input.tool.summary.description ?? ""); | 275 | const descriptionTokens = tokenize(input.tool.operation.description ?? ""); |
| 488 | const tagTokens = input.tool.summary.tags.flatMap(tokenize); | 276 | const tagTokens = input.tool.metadata.tags.flatMap(tokenize); |
| 489 | const typeTokens = tokenize( | ||
| 490 | `${input.tool.summary.inputType ?? ""} ${input.tool.summary.outputType ?? ""}`, | ||
| 491 | ); | ||
| 492 | const methodPathTokens = tokenize( | 277 | const methodPathTokens = tokenize( |
| 493 | `${input.tool.summary.method ?? ""} ${input.tool.summary.pathTemplate ?? ""}`, | 278 | `${input.tool.listItem.method ?? ""} ${input.tool.metadata.pathTemplate ?? ""}`, |
| 494 | ); | 279 | ); |
| 495 | 280 | ||
| 496 | for (const token of input.queryTokens) { | 281 | for (const token of input.queryTokens) { |
| @@ -514,11 +299,6 @@ const scoreTool = (input: { · 17 unmodified lines | |||
| 514 | reasons.push(`method/path matches ${token} (+6)`); | 299 | reasons.push(`method/path matches ${token} (+6)`); |
| 515 | continue; | 300 | continue; |
| 516 | } | 301 | } |
| 517 | if (typeTokens.includes(token)) { | ||
| 518 | score += 4; | ||
| 519 | reasons.push(`type signature matches ${token} (+4)`); | ||
| 520 | continue; | ||
| 521 | } | ||
| 522 | if (descriptionTokens.includes(token) || input.tool.searchText.includes(token)) { | 302 | if (descriptionTokens.includes(token) || input.tool.searchText.includes(token)) { |
| 523 | score += 2; | 303 | score += 2; |
| 524 | reasons.push(`description/text matches ${token} (+2)`); | 304 | reasons.push(`description/text matches ${token} (+2)`); |
| @@ -530,16 +310,10 @@ const scoreTool = (input: { · 5 unmodified lines | |||
| 530 | } | 310 | } |
| 531 | 311 | ||
| 532 | return { | 312 | return { |
| 533 | path: input.tool.summary.path, | 313 | path: input.tool.listItem.path, |
| 534 | score, | 314 | score, |
| 535 | ...(input.tool.summary.description | 315 | ...(input.tool.operation.description |
| 536 | ? { description: input.tool.summary.description } | 316 | ? { description: input.tool.operation.description } |
| 537 | : {}), | ||
| 538 | ...(input.tool.summary.inputType | ||
| 539 | ? { inputType: input.tool.summary.inputType } | ||
| 540 | : {}), | ||
| 541 | ...(input.tool.summary.outputType | ||
| 542 | ? { outputType: input.tool.summary.outputType } | ||
| 543 | : {}), | 317 | : {}), |
| 544 | reasons, | 318 | reasons, |
| 545 | } satisfies SourceInspectionDiscoverResultItem; | 319 | } satisfies SourceInspectionDiscoverResultItem; |
| @@ -575,10 +349,7 @@ export const getSourceInspection = (input: { · 29 unmodified lines | |||
| 575 | namespace: inspection.namespace, | 349 | namespace: inspection.namespace, |
| 576 | pipelineKind: inspection.pipelineKind, | 350 | pipelineKind: inspection.pipelineKind, |
| 577 | toolCount: inspection.tools.length, | 351 | toolCount: inspection.tools.length, |
| 578 | rawDocumentText: inspection.rawDocumentText, | 352 | tools: inspection.tools.map((tool) => tool.listItem), |
| 579 | manifestJson: inspection.manifestJson, | ||
| 580 | definitionsJson: inspection.definitionsJson, | ||
| 581 | tools: inspection.tools.map((tool) => tool.summary), | ||
| 582 | } satisfies SourceInspection; | 353 | } satisfies SourceInspection; |
| 583 | }).pipe( | 354 | }).pipe( |
| 584 | Effect.mapError((cause) => | 355 | Effect.mapError((cause) => |
| @@ -599,7 +370,7 @@ export const getSourceInspectionToolDetail = (input: { · 14 unmodified lines | |||
| 599 | workspaceId: input.workspaceId, | 370 | workspaceId: input.workspaceId, |
| 600 | sourceId: input.sourceId, | 371 | sourceId: input.sourceId, |
| 601 | }); | 372 | }); |
| 602 | const tool = inspection.tools.find((candidate) => candidate.summary.path === input.toolPath); | 373 | const tool = inspection.tools.find((candidate) => candidate.listItem.path === input.toolPath); |
| 603 | 374 | ||
| 604 | if (!tool) { | 375 | if (!tool) { |
| 605 | return yield* Effect.fail( | 376 | return yield* Effect.fail( |
| @@ -610,7 +381,12 @@ export const getSourceInspectionToolDetail = (input: { · 4 unmodified lines | |||
| 610 | ); | 381 | ); |
| 611 | } | 382 | } |
| 612 | 383 | ||
| 613 | return tool.detail; | 384 | return yield* formatInspectionToolDetail(inspectionToolDetailFromOperation({ |
| 385 | source: inspection.source, | ||
| 386 | operation: tool.operation, | ||
| 387 | metadata: tool.metadata, | ||
| 388 | schemaBundleId: inspection.schemaBundleId, | ||
| 389 | })); | ||
| 614 | }).pipe( | 390 | }).pipe( |
| 615 | Effect.mapError((cause) => | 391 | Effect.mapError((cause) => |
| 616 | mapInspectionError( | 392 | mapInspectionError( |
| @@ -620,6 +396,58 @@ export const getSourceInspectionToolDetail = (input: { · 3 unmodified lines | |||
| 620 | )), | 396 | )), |
| 621 | ); | 397 | ); |
| 622 | 398 | ||
| 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 | |||
| 623 | export const discoverSourceInspectionTools = (input: { | 451 | export const discoverSourceInspectionTools = (input: { |
| 624 | workspaceId: WorkspaceId; | 452 | workspaceId: WorkspaceId; |
| 625 | sourceId: SourceId; | 453 | sourceId: SourceId; |
| @@ -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); | ||
| @@ -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 | }; | ||
| @@ -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 | }); | ||
| @@ -38,6 +38,19 @@ const makePersistence = () => · 37 unmodified lines | |||
| 38 | }), | 38 | }), |
| 39 | ); | 39 | ); |
| 40 | 40 | ||
| 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 | |||
| 41 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ | 54 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 42 | id: SourceIdSchema.make("src_runtime_recipe"), | 55 | id: SourceIdSchema.make("src_runtime_recipe"), |
| 43 | workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"), | 56 | workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"), |
| @@ -52,6 +65,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 8 unmodified lines | |||
| 52 | headers: null, | 65 | headers: null, |
| 53 | specUrl: "https://api.github.com/openapi.json", | 66 | specUrl: "https://api.github.com/openapi.json", |
| 54 | defaultHeaders: null, | 67 | defaultHeaders: null, |
| 68 | importAuthPolicy: "reuse_runtime", | ||
| 69 | importAuth: { kind: "none" }, | ||
| 55 | auth: { kind: "none" }, | 70 | auth: { kind: "none" }, |
| 56 | sourceHash: null, | 71 | sourceHash: null, |
| 57 | lastError: null, | 72 | lastError: null, |
| @@ -61,8 +76,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 3 unmodified lines | |||
| 61 | }); | 76 | }); |
| 62 | 77 | ||
| 63 | const makeOperation = ( | 78 | const makeOperation = ( |
| 64 | overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }>> = {}, | 79 | overrides: Partial<StoredSourceRecipeOperationRecord> = {}, |
| 65 | ): Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }> => ({ | 80 | ): StoredSourceRecipeOperationRecord => ({ |
| 66 | id: "src_recipe_op_runtime", | 81 | id: "src_recipe_op_runtime", |
| 67 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), | 82 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), |
| 68 | operationKey: "getRepo", | 83 | operationKey: "getRepo", |
| @@ -85,25 +100,30 @@ const makeOperation = ( · 16 unmodified lines | |||
| 85 | providerKind: "openapi", | 100 | providerKind: "openapi", |
| 86 | providerDataJson: JSON.stringify({ | 101 | providerDataJson: JSON.stringify({ |
| 87 | kind: "openapi", | 102 | kind: "openapi", |
| 103 | toolId: "getRepo", | ||
| 104 | rawToolId: "repos_getRepo", | ||
| 105 | operationId: "repos.getRepo", | ||
| 106 | group: "repos", | ||
| 107 | leaf: "getRepo", | ||
| 108 | tags: ["repos"], | ||
| 109 | method: "get", | ||
| 110 | path: "/repos/{owner}/{repo}", | ||
| 111 | operationHash: "hash", | ||
| 112 | invocation: { | ||
| 113 | method: "get", | ||
| 114 | pathTemplate: "/repos/{owner}/{repo}", | ||
| 115 | parameters: [], | ||
| 116 | requestBody: null, | ||
| 117 | }, | ||
| 88 | }), | 118 | }), |
| 89 | mcpToolName: null, | ||
| 90 | openApiMethod: "get", | ||
| 91 | openApiPathTemplate: "/repos/{owner}/{repo}", | ||
| 92 | openApiOperationHash: "hash", | ||
| 93 | openApiRawToolId: "repos_getRepo", | ||
| 94 | openApiOperationId: "repos.getRepo", | ||
| 95 | openApiTagsJson: JSON.stringify(["repos"]), | ||
| 96 | openApiRequestBodyRequired: null, | ||
| 97 | graphqlOperationType: null, | ||
| 98 | graphqlOperationName: null, | ||
| 99 | createdAt: 1000, | 119 | createdAt: 1000, |
| 100 | updatedAt: 1000, | 120 | updatedAt: 1000, |
| 101 | ...overrides, | 121 | ...overrides, |
| 102 | }); | 122 | }); |
| 103 | 123 | ||
| 104 | const makeGraphqlOperation = ( | 124 | const makeGraphqlOperation = ( |
| 105 | overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }>> = {}, | 125 | overrides: Partial<StoredSourceRecipeOperationRecord> = {}, |
| 106 | ): Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }> => ({ | 126 | ): StoredSourceRecipeOperationRecord => ({ |
| 107 | id: "src_recipe_op_graphql_runtime", | 127 | id: "src_recipe_op_graphql_runtime", |
| 108 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), | 128 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), |
| 109 | operationKey: "viewer", | 129 | operationKey: "viewer", |
| @@ -126,25 +146,27 @@ const makeGraphqlOperation = ( · 16 unmodified lines | |||
| 126 | providerKind: "graphql", | 146 | providerKind: "graphql", |
| 127 | providerDataJson: JSON.stringify({ | 147 | providerDataJson: JSON.stringify({ |
| 128 | kind: "graphql", | 148 | kind: "graphql", |
| 149 | toolKind: "field", | ||
| 150 | toolId: "viewer", | ||
| 151 | rawToolId: "viewer", | ||
| 152 | group: "query", | ||
| 153 | leaf: "viewer", | ||
| 154 | fieldName: "viewer", | ||
| 155 | operationType: "query", | ||
| 156 | operationName: "viewer", | ||
| 157 | operationDocument: "query Viewer { viewer { login } }", | ||
| 158 | queryTypeName: "Query", | ||
| 159 | mutationTypeName: null, | ||
| 160 | subscriptionTypeName: null, | ||
| 129 | }), | 161 | }), |
| 130 | mcpToolName: null, | ||
| 131 | openApiMethod: null, | ||
| 132 | openApiPathTemplate: null, | ||
| 133 | openApiOperationHash: null, | ||
| 134 | openApiRawToolId: null, | ||
| 135 | openApiOperationId: null, | ||
| 136 | openApiTagsJson: null, | ||
| 137 | openApiRequestBodyRequired: null, | ||
| 138 | graphqlOperationType: "query", | ||
| 139 | graphqlOperationName: "viewer", | ||
| 140 | createdAt: 1000, | 162 | createdAt: 1000, |
| 141 | updatedAt: 1000, | 163 | updatedAt: 1000, |
| 142 | ...overrides, | 164 | ...overrides, |
| 143 | }); | 165 | }); |
| 144 | 166 | ||
| 145 | const makeMcpOperation = ( | 167 | const makeMcpOperation = ( |
| 146 | overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }>> = {}, | 168 | overrides: Partial<StoredSourceRecipeOperationRecord> = {}, |
| 147 | ): Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }> => ({ | 169 | ): StoredSourceRecipeOperationRecord => ({ |
| 148 | id: "src_recipe_op_mcp_runtime", | 170 | id: "src_recipe_op_mcp_runtime", |
| 149 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), | 171 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), |
| 150 | operationKey: "echo", | 172 | operationKey: "echo", |
| @@ -159,17 +181,10 @@ const makeMcpOperation = ( · 8 unmodified lines | |||
| 159 | providerKind: "mcp", | 181 | providerKind: "mcp", |
| 160 | providerDataJson: JSON.stringify({ | 182 | providerDataJson: JSON.stringify({ |
| 161 | kind: "mcp", | 183 | kind: "mcp", |
| 184 | toolId: "echo", | ||
| 185 | toolName: "echo", | ||
| 186 | description: "Echo a value", | ||
| 162 | }), | 187 | }), |
| 163 | mcpToolName: "echo", | ||
| 164 | openApiMethod: null, | ||
| 165 | openApiPathTemplate: null, | ||
| 166 | openApiOperationHash: null, | ||
| 167 | openApiRawToolId: null, | ||
| 168 | openApiOperationId: null, | ||
| 169 | openApiTagsJson: null, | ||
| 170 | openApiRequestBodyRequired: null, | ||
| 171 | graphqlOperationType: null, | ||
| 172 | graphqlOperationName: null, | ||
| 173 | createdAt: 1000, | 188 | createdAt: 1000, |
| 174 | updatedAt: 1000, | 189 | updatedAt: 1000, |
| 175 | ...overrides, | 190 | ...overrides, |
| @@ -248,19 +263,14 @@ describe("source-recipes-runtime", () => { · 72 unmodified lines | |||
| 248 | expect(recipeToolSearchNamespace({ | 263 | expect(recipeToolSearchNamespace({ |
| 249 | source: graphqlSource, | 264 | source: graphqlSource, |
| 250 | path: "ignored.path", | 265 | path: "ignored.path", |
| 251 | operation: makeGraphqlOperation({ | 266 | operation: makeGraphqlOperation(), |
| 252 | graphqlOperationType: "query", | ||
| 253 | graphqlOperationName: "viewer", | ||
| 254 | }), | ||
| 255 | })).toBe("issues"); | 267 | })).toBe("issues"); |
| 256 | }); | 268 | }); |
| 257 | 269 | ||
| 258 | it("computes interaction modes and descriptor fields correctly", () => { | 270 | it("computes interaction modes and descriptor fields correctly", () => { |
| 259 | expect(recipeToolDescriptor({ | 271 | expect(recipeToolDescriptor({ |
| 260 | source: makeSource(), | 272 | source: makeSource(), |
| 261 | operation: makeOperation({ | 273 | operation: makeOperation(), |
| 262 | openApiMethod: "get", | ||
| 263 | }), | ||
| 264 | path: "github.getRepo", | 274 | path: "github.getRepo", |
| 265 | includeSchemas: true, | 275 | includeSchemas: true, |
| 266 | }).interaction).toBe("auto"); | 276 | }).interaction).toBe("auto"); |
| @@ -268,7 +278,7 @@ describe("source-recipes-runtime", () => { · 1 unmodified line | |||
| 268 | expect(recipeToolDescriptor({ | 278 | expect(recipeToolDescriptor({ |
| 269 | source: makeSource(), | 279 | source: makeSource(), |
| 270 | operation: makeOperation({ | 280 | operation: makeOperation({ |
| 271 | openApiMethod: "delete", | 281 | operationKind: "delete", |
| 272 | }), | 282 | }), |
| 273 | path: "github.deleteRepo", | 283 | path: "github.deleteRepo", |
| 274 | includeSchemas: true, | 284 | includeSchemas: true, |
| @@ -280,10 +290,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | |||
| 280 | endpoint: "https://example.com/graphql", | 290 | endpoint: "https://example.com/graphql", |
| 281 | specUrl: null, | 291 | specUrl: null, |
| 282 | }), | 292 | }), |
| 283 | operation: makeGraphqlOperation({ | 293 | operation: makeGraphqlOperation(), |
| 284 | graphqlOperationType: "query", | ||
| 285 | graphqlOperationName: "viewer", | ||
| 286 | }), | ||
| 287 | path: "graphql.viewer", | 294 | path: "graphql.viewer", |
| 288 | includeSchemas: true, | 295 | includeSchemas: true, |
| 289 | }).interaction).toBe("auto"); | 296 | }).interaction).toBe("auto"); |
| @@ -295,8 +302,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | |||
| 295 | specUrl: null, | 302 | specUrl: null, |
| 296 | }), | 303 | }), |
| 297 | operation: makeGraphqlOperation({ | 304 | operation: makeGraphqlOperation({ |
| 298 | graphqlOperationType: "mutation", | 305 | operationKind: "write", |
| 299 | graphqlOperationName: "createIssue", | ||
| 300 | }), | 306 | }), |
| 301 | path: "graphql.createIssue", | 307 | path: "graphql.createIssue", |
| 302 | includeSchemas: true, | 308 | includeSchemas: true, |
| @@ -309,9 +315,7 @@ describe("source-recipes-runtime", () => { · 6 unmodified lines | |||
| 309 | specUrl: null, | 315 | specUrl: null, |
| 310 | transport: "streamable-http", | 316 | transport: "streamable-http", |
| 311 | }), | 317 | }), |
| 312 | operation: makeMcpOperation({ | 318 | operation: makeMcpOperation(), |
| 313 | mcpToolName: "echo", | ||
| 314 | }), | ||
| 315 | path: "mcp.echo", | 319 | path: "mcp.echo", |
| 316 | includeSchemas: true, | 320 | includeSchemas: true, |
| 317 | }).interaction).toBe("auto"); | 321 | }).interaction).toBe("auto"); |
| @@ -349,14 +353,9 @@ describe("source-recipes-runtime", () => { · 31 unmodified lines | |||
| 349 | status: "connected", | 353 | status: "connected", |
| 350 | enabled: true, | 354 | enabled: true, |
| 351 | namespace: "github", | 355 | namespace: "github", |
| 352 | bindingConfigJson: null, | 356 | importAuthPolicy: "reuse_runtime", |
| 353 | transport: null, | 357 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), |
| 354 | queryParamsJson: null, | ||
| 355 | headersJson: null, | ||
| 356 | specUrl: "https://api.github.com/openapi.json", | ||
| 357 | defaultHeadersJson: null, | ||
| 358 | sourceHash: null, | 358 | sourceHash: null, |
| 359 | sourceDocumentText: null, | ||
| 360 | lastError: null, | 359 | lastError: null, |
| 361 | createdAt: 1000, | 360 | createdAt: 1000, |
| 362 | updatedAt: 1000, | 361 | updatedAt: 1000, |
| @@ -368,30 +367,36 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | |||
| 368 | sourceConfigJson: "{}", | 367 | sourceConfigJson: "{}", |
| 369 | manifestJson: null, | 368 | manifestJson: null, |
| 370 | manifestHash: null, | 369 | manifestHash: null, |
| 370 | materializationHash: null, | ||
| 371 | createdAt: 1000, | 371 | createdAt: 1000, |
| 372 | updatedAt: 1000, | 372 | updatedAt: 1000, |
| 373 | }, | 373 | }, |
| 374 | documents: [], | 374 | documents: [], |
| 375 | schemaBundles: [], | ||
| 375 | operations: [makeOperation({ | 376 | operations: [makeOperation({ |
| 376 | searchText: "", | 377 | searchText: "", |
| 377 | })], | 378 | })], |
| 378 | manifest: null, | 379 | manifest: null, |
| 379 | }; | 380 | }; |
| 380 | 381 | ||
| 381 | const expanded = expandRecipeTools({ | 382 | const expanded = Effect.runSync( |
| 382 | recipes: [recipe], | 383 | expandRecipeTools({ |
| 383 | includeSchemas: false, | 384 | recipes: [recipe], |
| 384 | }); | 385 | includeSchemas: false, |
| 386 | }), | ||
| 387 | ); | ||
| 385 | expect(expanded).toHaveLength(1); | 388 | expect(expanded).toHaveLength(1); |
| 386 | expect(expanded[0]?.searchText).toBe("github.getrepo github.getrepo github api"); | 389 | expect(expanded[0]?.searchText).toBe( |
| 390 | "github.getrepo github.getrepo github api github.getrepo getrepo get repo read a repository repos_getrepo repos.getrepo get /repos/{owner}/{repo} repos getrepo repos", | ||
| 391 | ); | ||
| 387 | 392 | ||
| 388 | expect(expandRecipeTools({ | 393 | expect(Effect.runSync(expandRecipeTools({ |
| 389 | recipes: [{ | 394 | recipes: [{ |
| 390 | ...recipe, | 395 | ...recipe, |
| 391 | operations: [], | 396 | operations: [], |
| 392 | }], | 397 | }], |
| 393 | includeSchemas: false, | 398 | includeSchemas: false, |
| 394 | })).toEqual([]); | 399 | }))).toEqual([]); |
| 395 | }); | 400 | }); |
| 396 | }); | 401 | }); |
| 397 | 402 | ||
| @@ -440,7 +445,6 @@ describe("source-recipes-runtime", () => { · 42 unmodified lines | |||
| 440 | const manifest = await Effect.runPromise(extractOpenApiManifest("GitHub", openApiDocument)); | 445 | const manifest = await Effect.runPromise(extractOpenApiManifest("GitHub", openApiDocument)); |
| 441 | const definition = compileOpenApiToolDefinitions(manifest)[0]!; | 446 | const definition = compileOpenApiToolDefinitions(manifest)[0]!; |
| 442 | const presentation = buildOpenApiToolPresentation({ | 447 | const presentation = buildOpenApiToolPresentation({ |
| 443 | manifest, | ||
| 444 | definition, | 448 | definition, |
| 445 | }); | 449 | }); |
| 446 | 450 | ||
| @@ -452,7 +456,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | |||
| 452 | }); | 456 | }); |
| 453 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | 457 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ |
| 454 | id: recipeId, | 458 | id: recipeId, |
| 455 | kind: "http_recipe", | 459 | kind: "http_api", |
| 456 | importerKind: "openapi", | 460 | importerKind: "openapi", |
| 457 | providerKey: "generic_http", | 461 | providerKey: "generic_http", |
| 458 | name: "GitHub", | 462 | name: "GitHub", |
| @@ -474,6 +478,7 @@ describe("source-recipes-runtime", () => { · 15 unmodified lines | |||
| 474 | }), | 478 | }), |
| 475 | manifestJson: JSON.stringify(manifest), | 479 | manifestJson: JSON.stringify(manifest), |
| 476 | manifestHash: manifest.sourceHash, | 480 | manifestHash: manifest.sourceHash, |
| 481 | materializationHash: manifest.sourceHash, | ||
| 477 | createdAt: 1000, | 482 | createdAt: 1000, |
| 478 | updatedAt: 1000, | 483 | updatedAt: 1000, |
| 479 | })); | 484 | })); |
| @@ -504,13 +509,6 @@ describe("source-recipes-runtime", () => { · 24 unmodified lines | |||
| 504 | inputSchemaJson: presentation.inputSchemaJson ?? null, | 509 | inputSchemaJson: presentation.inputSchemaJson ?? null, |
| 505 | outputSchemaJson: presentation.outputSchemaJson ?? null, | 510 | outputSchemaJson: presentation.outputSchemaJson ?? null, |
| 506 | providerDataJson: presentation.providerDataJson, | 511 | providerDataJson: presentation.providerDataJson, |
| 507 | openApiMethod: definition.method, | ||
| 508 | openApiPathTemplate: definition.path, | ||
| 509 | openApiOperationHash: definition.operationHash, | ||
| 510 | openApiRawToolId: definition.rawToolId, | ||
| 511 | openApiOperationId: definition.operationId ?? null, | ||
| 512 | openApiTagsJson: JSON.stringify(definition.tags), | ||
| 513 | openApiRequestBodyRequired: definition.invocation.requestBody?.required ?? null, | ||
| 514 | })], | 512 | })], |
| 515 | })); | 513 | })); |
| 516 | 514 | ||
| @@ -526,14 +524,9 @@ describe("source-recipes-runtime", () => { · 9 unmodified lines | |||
| 526 | status: "connected", | 524 | status: "connected", |
| 527 | enabled: true, | 525 | enabled: true, |
| 528 | namespace: "github", | 526 | namespace: "github", |
| 529 | bindingConfigJson: null, | 527 | importAuthPolicy: "reuse_runtime", |
| 530 | transport: null, | 528 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), |
| 531 | queryParamsJson: null, | ||
| 532 | headersJson: null, | ||
| 533 | specUrl: "https://api.github.com/openapi.json", | ||
| 534 | defaultHeadersJson: null, | ||
| 535 | sourceHash: manifest.sourceHash, | 529 | sourceHash: manifest.sourceHash, |
| 536 | sourceDocumentText: null, | ||
| 537 | lastError: null, | 530 | lastError: null, |
| 538 | createdAt: 1000 + index, | 531 | createdAt: 1000 + index, |
| 539 | updatedAt: 1000 + index, | 532 | updatedAt: 1000 + index, |
| @@ -573,7 +566,7 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines | |||
| 573 | }); | 566 | }); |
| 574 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | 567 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ |
| 575 | id: recipeId, | 568 | id: recipeId, |
| 576 | kind: "graphql_recipe", | 569 | kind: "http_api", |
| 577 | importerKind: "graphql_introspection", | 570 | importerKind: "graphql_introspection", |
| 578 | providerKey: "generic_graphql", | 571 | providerKey: "generic_graphql", |
| 579 | name: "GraphQL Demo", | 572 | name: "GraphQL Demo", |
| @@ -594,6 +587,7 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines | |||
| 594 | }), | 587 | }), |
| 595 | manifestJson: null, | 588 | manifestJson: null, |
| 596 | manifestHash: null, | 589 | manifestHash: null, |
| 590 | materializationHash: null, | ||
| 597 | createdAt: 1000, | 591 | createdAt: 1000, |
| 598 | updatedAt: 1000, | 592 | updatedAt: 1000, |
| 599 | })); | 593 | })); |
| @@ -608,14 +602,9 @@ describe("source-recipes-runtime", () => { · 8 unmodified lines | |||
| 608 | status: "connected", | 602 | status: "connected", |
| 609 | enabled: true, | 603 | enabled: true, |
| 610 | namespace: "graphql", | 604 | namespace: "graphql", |
| 611 | bindingConfigJson: null, | 605 | importAuthPolicy: "reuse_runtime", |
| 612 | transport: null, | 606 | bindingConfigJson: graphqlBindingConfigJson(), |
| 613 | queryParamsJson: null, | ||
| 614 | headersJson: null, | ||
| 615 | specUrl: null, | ||
| 616 | defaultHeadersJson: null, | ||
| 617 | sourceHash: null, | 607 | sourceHash: null, |
| 618 | sourceDocumentText: null, | ||
| 619 | lastError: null, | 608 | lastError: null, |
| 620 | createdAt: 1000, | 609 | createdAt: 1000, |
| 621 | updatedAt: 1000, | 610 | updatedAt: 1000, |
| @@ -659,7 +648,7 @@ describe("source-recipes-runtime", () => { · 37 unmodified lines | |||
| 659 | }); | 648 | }); |
| 660 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ | 649 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ |
| 661 | id: recipeId, | 650 | id: recipeId, |
| 662 | kind: "http_recipe", | 651 | kind: "http_api", |
| 663 | importerKind: "openapi", | 652 | importerKind: "openapi", |
| 664 | providerKey: "generic_http", | 653 | providerKey: "generic_http", |
| 665 | name: "Broken GitHub", | 654 | name: "Broken GitHub", |
| @@ -680,14 +669,9 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines | |||
| 680 | status: "connected", | 669 | status: "connected", |
| 681 | enabled: true, | 670 | enabled: true, |
| 682 | namespace: "github", | 671 | namespace: "github", |
| 683 | bindingConfigJson: null, | 672 | importAuthPolicy: "reuse_runtime", |
| 684 | transport: null, | 673 | bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), |
| 685 | queryParamsJson: null, | ||
| 686 | headersJson: null, | ||
| 687 | specUrl: "https://api.github.com/openapi.json", | ||
| 688 | defaultHeadersJson: null, | ||
| 689 | sourceHash: null, | 674 | sourceHash: null, |
| 690 | sourceDocumentText: null, | ||
| 691 | lastError: null, | 675 | lastError: null, |
| 692 | createdAt: 1000, | 676 | createdAt: 1000, |
| 693 | updatedAt: 1000, | 677 | updatedAt: 1000, |
| @@ -711,6 +695,7 @@ describe("source-recipes-runtime", () => { · 17 unmodified lines | |||
| 711 | }), | 695 | }), |
| 712 | manifestJson: "{bad-json", | 696 | manifestJson: "{bad-json", |
| 713 | manifestHash: null, | 697 | manifestHash: null, |
| 698 | materializationHash: null, | ||
| 714 | createdAt: 1000, | 699 | createdAt: 1000, |
| 715 | updatedAt: 1000, | 700 | updatedAt: 1000, |
| 716 | })); | 701 | })); |
| @@ -1,15 +1,6 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type ToolDescriptor, | 2 | type ToolDescriptor, |
| 3 | type ToolPath, | ||
| 4 | typeSignatureFromSchemaJson, | ||
| 5 | } from "@executor/codemode-core"; | 3 | } 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"; | ||
| 13 | import type { SqlControlPlaneRows } from "#persistence"; | 4 | import type { SqlControlPlaneRows } from "#persistence"; |
| 14 | import type { | 5 | import type { |
| 15 | AccountId, | 6 | AccountId, |
| @@ -17,29 +8,30 @@ import type { · 1 unmodified line | |||
| 17 | StoredSourceRecord, | 8 | StoredSourceRecord, |
| 18 | StoredSourceRecipeDocumentRecord, | 9 | StoredSourceRecipeDocumentRecord, |
| 19 | StoredSourceRecipeOperationRecord, | 10 | StoredSourceRecipeOperationRecord, |
| 11 | StoredSourceRecipeSchemaBundleRecord, | ||
| 20 | StoredSourceRecipeRevisionRecord, | 12 | StoredSourceRecipeRevisionRecord, |
| 21 | WorkspaceId, | 13 | WorkspaceId, |
| 22 | } from "#schema"; | 14 | } from "#schema"; |
| 23 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 24 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 25 | 17 | ||
| 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"; | ||
| 27 | import { loadSourceById, loadSourcesInWorkspace } from "./source-store"; | 25 | import { loadSourceById, loadSourcesInWorkspace } from "./source-store"; |
| 28 | import { namespaceFromSourceName } from "./tool-artifacts"; | ||
| 29 | |||
| 30 | type RecipeManifest = | ||
| 31 | | OpenApiToolManifest | ||
| 32 | | GraphqlToolManifest | ||
| 33 | | McpToolManifest | ||
| 34 | | null; | ||
| 35 | 26 | ||
| 36 | const asToolPath = (value: string): ToolPath => value as ToolPath; | 27 | type RecipeManifest = unknown | null; |
| 37 | 28 | ||
| 38 | export type LoadedSourceRecipe = { | 29 | export type LoadedSourceRecipe = { |
| 39 | source: Source; | 30 | source: Source; |
| 40 | sourceRecord: StoredSourceRecord; | 31 | sourceRecord: StoredSourceRecord; |
| 41 | revision: StoredSourceRecipeRevisionRecord; | 32 | revision: StoredSourceRecipeRevisionRecord; |
| 42 | documents: readonly StoredSourceRecipeDocumentRecord[]; | 33 | documents: readonly StoredSourceRecipeDocumentRecord[]; |
| 34 | schemaBundles: readonly StoredSourceRecipeSchemaBundleRecord[]; | ||
| 43 | operations: readonly StoredSourceRecipeOperationRecord[]; | 35 | operations: readonly StoredSourceRecipeOperationRecord[]; |
| 44 | manifest: RecipeManifest; | 36 | manifest: RecipeManifest; |
| 45 | }; | 37 | }; |
| @@ -52,58 +44,31 @@ export type LoadedSourceRecipeTool = { · 6 unmodified lines | |||
| 52 | sourceRecord: StoredSourceRecord; | 44 | sourceRecord: StoredSourceRecord; |
| 53 | revision: StoredSourceRecipeRevisionRecord; | 45 | revision: StoredSourceRecipeRevisionRecord; |
| 54 | operation: StoredSourceRecipeOperationRecord; | 46 | operation: StoredSourceRecipeOperationRecord; |
| 47 | metadata: SourceAdapterPersistedOperationMetadata; | ||
| 48 | schemaBundleId: string | null; | ||
| 55 | manifest: RecipeManifest; | 49 | manifest: RecipeManifest; |
| 56 | descriptor: ToolDescriptor; | 50 | descriptor: ToolDescriptor; |
| 57 | }; | 51 | }; |
| 58 | 52 | ||
| 59 | const parseJson = <T>(input: { | 53 | export type LoadedSourceRecipeToolIndexEntry = { |
| 60 | label: string; | 54 | path: string; |
| 61 | value: string | null; | 55 | searchNamespace: string; |
| 62 | }): Effect.Effect<T | null, Error, never> => | 56 | searchText: string; |
| 63 | input.value === null | 57 | source: Source; |
| 64 | ? Effect.succeed<T | null>(null) | 58 | sourceRecord: StoredSourceRecord; |
| 65 | : Effect.try({ | 59 | operation: StoredSourceRecipeOperationRecord; |
| 66 | try: () => { | 60 | metadata: SourceAdapterPersistedOperationMetadata; |
| 67 | const value = input.value!; | 61 | schemaBundleId: string | null; |
| 68 | return JSON.parse(value) as T; | 62 | descriptor: ToolDescriptor; |
| 69 | }, | 63 | }; |
| 70 | catch: (cause) => | ||
| 71 | cause instanceof Error | ||
| 72 | ? new Error(`Invalid ${input.label}: ${cause.message}`) | ||
| 73 | : new Error(`Invalid ${input.label}: ${String(cause)}`), | ||
| 74 | }); | ||
| 75 | 64 | ||
| 76 | const parseManifestForRecipe = (input: { | 65 | const parseManifestForRecipe = (input: { |
| 77 | source: Source; | 66 | source: Source; |
| 78 | revision: StoredSourceRecipeRevisionRecord; | 67 | revision: StoredSourceRecipeRevisionRecord; |
| 79 | }): Effect.Effect<RecipeManifest, Error, never> => | 68 | }): Effect.Effect<RecipeManifest, Error, never> => |
| 80 | Effect.gen(function* () { | 69 | getSourceAdapterForSource(input.source).parseManifest({ |
| 81 | if (input.revision.manifestJson === null) { | 70 | source: input.source, |
| 82 | return null; | 71 | manifestJson: input.revision.manifestJson, |
| 83 | } | ||
| 84 | |||
| 85 | if (input.source.kind === "openapi") { | ||
| 86 | return yield* parseJson<OpenApiToolManifest>({ | ||
| 87 | label: `OpenAPI manifest for ${input.source.id}`, | ||
| 88 | value: input.revision.manifestJson, | ||
| 89 | }); | ||
| 90 | } | ||
| 91 | |||
| 92 | if (input.source.kind === "graphql") { | ||
| 93 | return yield* parseJson<GraphqlToolManifest>({ | ||
| 94 | label: `GraphQL manifest for ${input.source.id}`, | ||
| 95 | value: input.revision.manifestJson, | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | |||
| 99 | if (input.source.kind === "mcp") { | ||
| 100 | return yield* parseJson<McpToolManifest>({ | ||
| 101 | label: `MCP manifest for ${input.source.id}`, | ||
| 102 | value: input.revision.manifestJson, | ||
| 103 | }); | ||
| 104 | } | ||
| 105 | |||
| 106 | return null; | ||
| 107 | }); | 72 | }); |
| 108 | 73 | ||
| 109 | const catalogNamespaceFromPath = (path: string): string => { | 74 | const catalogNamespaceFromPath = (path: string): string => { |
| @@ -124,74 +89,34 @@ export const recipeToolSearchNamespace = (input: { · 14 unmodified lines | |||
| 124 | path: string; | 89 | path: string; |
| 125 | operation: StoredSourceRecipeOperationRecord; | 90 | operation: StoredSourceRecipeOperationRecord; |
| 126 | }): string => | 91 | }): string => |
| 127 | input.operation.providerKind === "graphql" | 92 | getSourceAdapterForOperation(input.operation).searchNamespace?.({ |
| 128 | ? (input.source.namespace ?? namespaceFromSourceName(input.source.name)) | 93 | source: input.source, |
| 129 | : catalogNamespaceFromPath(input.path); | 94 | path: input.path, |
| 95 | operation: input.operation, | ||
| 96 | }) | ||
| 97 | ?? catalogNamespaceFromPath(input.path); | ||
| 130 | 98 | ||
| 131 | export const recipeToolDescriptor = (input: { | 99 | export const recipeToolDescriptor = (input: { |
| 132 | source: Source; | 100 | source: Source; |
| 133 | operation: StoredSourceRecipeOperationRecord; | 101 | operation: StoredSourceRecipeOperationRecord; |
| 134 | path: string; | 102 | path: string; |
| 103 | schemaBundleId?: string | null; | ||
| 135 | includeSchemas: boolean; | 104 | includeSchemas: boolean; |
| 136 | }): ToolDescriptor => { | 105 | }): ToolDescriptor => |
| 137 | const interaction = | 106 | getSourceAdapterForOperation(input.operation).createToolDescriptor(input); |
| 138 | input.operation.providerKind === "openapi" | 107 | |
| 139 | ? ( | 108 | export const recipeToolMetadata = (input: { |
| 140 | input.operation.openApiMethod?.toUpperCase() === "GET" | 109 | source: Source; |
| 141 | || input.operation.openApiMethod?.toUpperCase() === "HEAD" | 110 | operation: StoredSourceRecipeOperationRecord; |
| 142 | ? "auto" | 111 | path: string; |
| 143 | : "required" | 112 | }): Effect.Effect<SourceAdapterPersistedOperationMetadata, Error, never> => |
| 144 | ) | 113 | getSourceAdapterForOperation(input.operation).describePersistedOperation(input); |
| 145 | : input.operation.providerKind === "graphql" | ||
| 146 | ? input.operation.graphqlOperationType === "query" | ||
| 147 | ? "auto" | ||
| 148 | : "required" | ||
| 149 | : "auto"; | ||
| 150 | |||
| 151 | return { | ||
| 152 | path: asToolPath(input.path), | ||
| 153 | sourceKey: input.source.id, | ||
| 154 | description: input.operation.description ?? input.operation.title ?? undefined, | ||
| 155 | interaction, | ||
| 156 | inputType: typeSignatureFromSchemaJson( | ||
| 157 | input.operation.inputSchemaJson ?? undefined, | ||
| 158 | "unknown", | ||
| 159 | 320, | ||
| 160 | ), | ||
| 161 | outputType: | ||
| 162 | input.operation.providerKind === "openapi" | ||
| 163 | ? openApiOutputTypeSignatureFromSchemaJson( | ||
| 164 | input.operation.outputSchemaJson ?? undefined, | ||
| 165 | 320, | ||
| 166 | ) | ||
| 167 | : typeSignatureFromSchemaJson( | ||
| 168 | input.operation.outputSchemaJson ?? undefined, | ||
| 169 | "unknown", | ||
| 170 | 320, | ||
| 171 | ), | ||
| 172 | inputSchemaJson: input.includeSchemas ? input.operation.inputSchemaJson ?? undefined : undefined, | ||
| 173 | outputSchemaJson: input.includeSchemas ? input.operation.outputSchemaJson ?? undefined : undefined, | ||
| 174 | ...(input.operation.providerKind | ||
| 175 | ? { providerKind: input.operation.providerKind } | ||
| 176 | : {}), | ||
| 177 | ...(input.operation.providerDataJson | ||
| 178 | ? { providerDataJson: input.operation.providerDataJson } | ||
| 179 | : {}), | ||
| 180 | }; | ||
| 181 | }; | ||
| 182 | 114 | ||
| 183 | const sourceRecipeDocumentForSource = (input: { | 115 | const sourceRecipeDocumentForSource = (input: { |
| 184 | source: Source; | 116 | source: Source; |
| 185 | documents: readonly StoredSourceRecipeDocumentRecord[]; | 117 | documents: readonly StoredSourceRecipeDocumentRecord[]; |
| 186 | }): StoredSourceRecipeDocumentRecord | null => { | 118 | }): StoredSourceRecipeDocumentRecord | null => { |
| 187 | const preferredKind = | 119 | const preferredKind = getSourceAdapterForSource(input.source).primaryDocumentKind; |
| 188 | input.source.kind === "openapi" | ||
| 189 | ? "openapi" | ||
| 190 | : input.source.kind === "graphql" | ||
| 191 | ? "graphql_introspection" | ||
| 192 | : input.source.kind === "mcp" | ||
| 193 | ? "mcp_manifest" | ||
| 194 | : null; | ||
| 195 | 120 | ||
| 196 | if (preferredKind === null) { | 121 | if (preferredKind === null) { |
| 197 | return null; | 122 | return null; |
| @@ -206,6 +131,25 @@ export const recipePrimaryDocumentText = (input: { · 8 unmodified lines | |||
| 206 | }): string | null => | 131 | }): string | null => |
| 207 | sourceRecipeDocumentForSource(input)?.contentText ?? null; | 132 | sourceRecipeDocumentForSource(input)?.contentText ?? null; |
| 208 | 133 | ||
| 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 | |||
| 209 | export const loadWorkspaceSourceRecipes = (input: { | 153 | export const loadWorkspaceSourceRecipes = (input: { |
| 210 | rows: SqlControlPlaneRows; | 154 | rows: SqlControlPlaneRows; |
| 211 | workspaceId: WorkspaceId; | 155 | workspaceId: WorkspaceId; |
| @@ -224,12 +168,19 @@ export const loadWorkspaceSourceRecipes = (input: { · 12 unmodified lines | |||
| 224 | const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds); | 168 | const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds); |
| 225 | const revisionById = new Map(revisions.map((revision) => [revision.id, revision])); | 169 | const revisionById = new Map(revisions.map((revision) => [revision.id, revision])); |
| 226 | const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds); | 170 | const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds); |
| 171 | const schemaBundles = yield* input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds); | ||
| 227 | const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>(); | 172 | const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>(); |
| 228 | for (const document of documents) { | 173 | for (const document of documents) { |
| 229 | const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? []; | 174 | const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? []; |
| 230 | existing.push(document); | 175 | existing.push(document); |
| 231 | documentsByRevisionId.set(document.recipeRevisionId, existing); | 176 | documentsByRevisionId.set(document.recipeRevisionId, existing); |
| 232 | } | 177 | } |
| 178 | const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | ||
| 179 | for (const schemaBundle of schemaBundles) { | ||
| 180 | const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | ||
| 181 | existing.push(schemaBundle); | ||
| 182 | schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | ||
| 183 | } | ||
| 233 | const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds); | 184 | const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds); |
| 234 | const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>(); | 185 | const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>(); |
| 235 | for (const operation of operations) { | 186 | for (const operation of operations) { |
| @@ -264,6 +215,7 @@ export const loadWorkspaceSourceRecipes = (input: { · 28 unmodified lines | |||
| 264 | sourceRecord, | 215 | sourceRecord, |
| 265 | revision, | 216 | revision, |
| 266 | documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | 217 | documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], |
| 218 | schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | ||
| 267 | operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | 219 | operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], |
| 268 | manifest, | 220 | manifest, |
| 269 | } satisfies LoadedSourceRecipe; | 221 | } satisfies LoadedSourceRecipe; |
| @@ -299,8 +251,9 @@ export const loadSourceRecipe = (input: { · 29 unmodified lines | |||
| 299 | new Error(`Recipe revision missing for source ${input.sourceId}`), | 251 | new Error(`Recipe revision missing for source ${input.sourceId}`), |
| 300 | ); | 252 | ); |
| 301 | } | 253 | } |
| 302 | const [documents, operations, manifest] = yield* Effect.all([ | 254 | const [documents, schemaBundles, operations, manifest] = yield* Effect.all([ |
| 303 | input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId), | 255 | input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId), |
| 256 | input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | ||
| 304 | input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), | 257 | input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), |
| 305 | parseManifestForRecipe({ | 258 | parseManifestForRecipe({ |
| 306 | source, | 259 | source, |
| @@ -313,6 +266,7 @@ export const loadSourceRecipe = (input: { · 6 unmodified lines | |||
| 313 | sourceRecord: sourceRecord.value, | 266 | sourceRecord: sourceRecord.value, |
| 314 | revision: revision.value, | 267 | revision: revision.value, |
| 315 | documents, | 268 | documents, |
| 269 | schemaBundles, | ||
| 316 | operations, | 270 | operations, |
| 317 | manifest, | 271 | manifest, |
| 318 | } satisfies LoadedSourceRecipe; | 272 | } satisfies LoadedSourceRecipe; |
| @@ -321,18 +275,245 @@ export const loadSourceRecipe = (input: { · 2 unmodified lines | |||
| 321 | export const expandRecipeTools = (input: { | 275 | export const expandRecipeTools = (input: { |
| 322 | recipes: readonly LoadedSourceRecipe[]; | 276 | recipes: readonly LoadedSourceRecipe[]; |
| 323 | includeSchemas: boolean; | 277 | includeSchemas: boolean; |
| 324 | }): readonly LoadedSourceRecipeTool[] => | 278 | }): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> => |
| 325 | input.recipes.flatMap((recipe) => | 279 | Effect.map( |
| 326 | recipe.operations.map((operation) => { | 280 | Effect.forEach(input.recipes, (recipe) => |
| 281 | Effect.forEach(recipe.operations, (operation) => | ||
| 282 | Effect.gen(function* () { | ||
| 283 | const path = recipeToolPath({ | ||
| 284 | source: recipe.source, | ||
| 285 | operation, | ||
| 286 | }); | ||
| 287 | const searchNamespace = recipeToolSearchNamespace({ | ||
| 288 | source: recipe.source, | ||
| 289 | path, | ||
| 290 | operation, | ||
| 291 | }); | ||
| 292 | const schemaBundleId = primarySchemaBundleForRevision({ | ||
| 293 | source: recipe.source, | ||
| 294 | schemaBundles: recipe.schemaBundles, | ||
| 295 | })?.id ?? null; | ||
| 296 | const metadata = yield* recipeToolMetadata({ | ||
| 297 | source: recipe.source, | ||
| 298 | operation, | ||
| 299 | path, | ||
| 300 | }); | ||
| 301 | |||
| 302 | return { | ||
| 303 | path, | ||
| 304 | searchNamespace, | ||
| 305 | searchText: [ | ||
| 306 | path, | ||
| 307 | searchNamespace, | ||
| 308 | recipe.source.name, | ||
| 309 | metadata.searchText, | ||
| 310 | ] | ||
| 311 | .filter((part) => part.length > 0) | ||
| 312 | .join(" ") | ||
| 313 | .toLowerCase(), | ||
| 314 | source: recipe.source, | ||
| 315 | sourceRecord: recipe.sourceRecord, | ||
| 316 | revision: recipe.revision, | ||
| 317 | operation, | ||
| 318 | metadata, | ||
| 319 | schemaBundleId, | ||
| 320 | manifest: recipe.manifest, | ||
| 321 | descriptor: recipeToolDescriptor({ | ||
| 322 | source: recipe.source, | ||
| 323 | operation, | ||
| 324 | path, | ||
| 325 | schemaBundleId, | ||
| 326 | includeSchemas: input.includeSchemas, | ||
| 327 | }), | ||
| 328 | } satisfies LoadedSourceRecipeTool; | ||
| 329 | }) | ||
| 330 | ), | ||
| 331 | ), | ||
| 332 | (recipes) => recipes.flat(), | ||
| 333 | ); | ||
| 334 | |||
| 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 | |||
| 327 | const path = recipeToolPath({ | 498 | const path = recipeToolPath({ |
| 328 | source: recipe.source, | 499 | source: candidate.source, |
| 329 | operation, | 500 | operation, |
| 330 | }); | 501 | }); |
| 331 | const searchNamespace = recipeToolSearchNamespace({ | 502 | const searchNamespace = recipeToolSearchNamespace({ |
| 332 | source: recipe.source, | 503 | source: candidate.source, |
| 333 | path, | 504 | path, |
| 334 | operation, | 505 | operation, |
| 335 | }); | 506 | }); |
| 507 | const schemaBundleId = primarySchemaBundleForRevision({ | ||
| 508 | source: candidate.source, | ||
| 509 | schemaBundles: | ||
| 510 | schemaBundlesByRevisionId.get(candidate.sourceRecord.recipeRevisionId) ?? [], | ||
| 511 | })?.id ?? null; | ||
| 512 | const metadata = yield* recipeToolMetadata({ | ||
| 513 | source: candidate.source, | ||
| 514 | operation, | ||
| 515 | path, | ||
| 516 | }); | ||
| 336 | 517 | ||
| 337 | return { | 518 | return { |
| 338 | path, | 519 | path, |
| @@ -340,23 +521,26 @@ export const expandRecipeTools = (input: { · 1 unmodified line | |||
| 340 | searchText: [ | 521 | searchText: [ |
| 341 | path, | 522 | path, |
| 342 | searchNamespace, | 523 | searchNamespace, |
| 343 | recipe.source.name, | 524 | candidate.source.name, |
| 344 | operation.searchText, | 525 | metadata.searchText, |
| 345 | ] | 526 | ] |
| 346 | .filter((part) => part.length > 0) | 527 | .filter((part) => part.length > 0) |
| 347 | .join(" ") | 528 | .join(" ") |
| 348 | .toLowerCase(), | 529 | .toLowerCase(), |
| 349 | source: recipe.source, | 530 | source: candidate.source, |
| 350 | sourceRecord: recipe.sourceRecord, | 531 | sourceRecord: candidate.sourceRecord, |
| 351 | revision: recipe.revision, | ||
| 352 | operation, | 532 | operation, |
| 353 | manifest: recipe.manifest, | 533 | metadata, |
| 534 | schemaBundleId, | ||
| 354 | descriptor: recipeToolDescriptor({ | 535 | descriptor: recipeToolDescriptor({ |
| 355 | source: recipe.source, | 536 | source: candidate.source, |
| 356 | operation, | 537 | operation, |
| 357 | path, | 538 | path, |
| 539 | schemaBundleId, | ||
| 358 | includeSchemas: input.includeSchemas, | 540 | includeSchemas: input.includeSchemas, |
| 359 | }), | 541 | }), |
| 360 | } satisfies LoadedSourceRecipeTool; | 542 | } satisfies LoadedSourceRecipeToolIndexEntry; |
| 361 | }) | 543 | } |
| 362 | ); | 544 | |
| 545 | return null; | ||
| 546 | }); | ||
| @@ -62,6 +62,8 @@ const makeOpenApiSource = (input: { · 61 unmodified lines | |||
| 62 | headers: null, | 62 | headers: null, |
| 63 | specUrl: input.specUrl ?? "https://example.com/openapi.json", | 63 | specUrl: input.specUrl ?? "https://example.com/openapi.json", |
| 64 | defaultHeaders: null, | 64 | defaultHeaders: null, |
| 65 | importAuthPolicy: "reuse_runtime", | ||
| 66 | importAuth: { kind: "none" }, | ||
| 65 | auth: input.auth, | 67 | auth: input.auth, |
| 66 | sourceHash: null, | 68 | sourceHash: null, |
| 67 | lastError: null, | 69 | lastError: null, |
| @@ -142,6 +144,7 @@ describe("source-store", () => { · 74 unmodified lines | |||
| 142 | executionId: null, | 144 | executionId: null, |
| 143 | interactionId: null, | 145 | interactionId: null, |
| 144 | providerKind: "mcp_oauth", | 146 | providerKind: "mcp_oauth", |
| 147 | credentialSlot: "runtime", | ||
| 145 | status: "pending", | 148 | status: "pending", |
| 146 | state: "state_source_store", | 149 | state: "state_source_store", |
| 147 | sessionDataJson: encodeSessionDataJson({ | 150 | sessionDataJson: encodeSessionDataJson({ |
| @@ -341,17 +344,24 @@ describe("source-store", () => { · 193 unmodified lines | |||
| 341 | inputSchemaJson: null, | 344 | inputSchemaJson: null, |
| 342 | outputSchemaJson: null, | 345 | outputSchemaJson: null, |
| 343 | providerKind: "openapi", | 346 | providerKind: "openapi", |
| 344 | providerDataJson: null, | 347 | providerDataJson: JSON.stringify({ |
| 345 | mcpToolName: null, | 348 | kind: "openapi", |
| 346 | openApiMethod: "get", | 349 | toolId: "getRepo", |
| 347 | openApiPathTemplate: "/repos/{owner}/{repo}", | 350 | rawToolId: "repos_getRepo", |
| 348 | openApiOperationHash: "hash_recipe_rewrite", | 351 | operationId: "repos.getRepo", |
| 349 | openApiRawToolId: "repos_getRepo", | 352 | group: "repos", |
| 350 | openApiOperationId: "repos.getRepo", | 353 | leaf: "getRepo", |
| 351 | openApiTagsJson: JSON.stringify(["repos"]), | 354 | tags: ["repos"], |
| 352 | openApiRequestBodyRequired: null, | 355 | method: "get", |
| 353 | graphqlOperationType: null, | 356 | path: "/repos/{owner}/{repo}", |
| 354 | graphqlOperationName: null, | 357 | operationHash: "hash_recipe_rewrite", |
| 358 | invocation: { | ||
| 359 | method: "get", | ||
| 360 | pathTemplate: "/repos/{owner}/{repo}", | ||
| 361 | parameters: [], | ||
| 362 | requestBody: null, | ||
| 363 | }, | ||
| 364 | }), | ||
| 355 | createdAt: now, | 365 | createdAt: now, |
| 356 | updatedAt: now, | 366 | updatedAt: now, |
| 357 | }], | 367 | }], |
| @@ -465,17 +475,24 @@ describe("source-store", () => { · 107 unmodified lines | |||
| 465 | inputSchemaJson: null, | 475 | inputSchemaJson: null, |
| 466 | outputSchemaJson: null, | 476 | outputSchemaJson: null, |
| 467 | providerKind: "openapi", | 477 | providerKind: "openapi", |
| 468 | providerDataJson: null, | 478 | providerDataJson: JSON.stringify({ |
| 469 | mcpToolName: null, | 479 | kind: "openapi", |
| 470 | openApiMethod: "get", | 480 | toolId: "getRepo", |
| 471 | openApiPathTemplate: "/repos/{owner}/{repo}", | 481 | rawToolId: "repos_getRepo", |
| 472 | openApiOperationHash: "hash_shared_recipe", | 482 | operationId: "repos.getRepo", |
| 473 | openApiRawToolId: "repos_getRepo", | 483 | group: "repos", |
| 474 | openApiOperationId: "repos.getRepo", | 484 | leaf: "getRepo", |
| 475 | openApiTagsJson: JSON.stringify(["repos"]), | 485 | tags: ["repos"], |
| 476 | openApiRequestBodyRequired: null, | 486 | method: "get", |
| 477 | graphqlOperationType: null, | 487 | path: "/repos/{owner}/{repo}", |
| 478 | graphqlOperationName: null, | 488 | operationHash: "hash_shared_recipe", |
| 489 | invocation: { | ||
| 490 | method: "get", | ||
| 491 | pathTemplate: "/repos/{owner}/{repo}", | ||
| 492 | parameters: [], | ||
| 493 | requestBody: null, | ||
| 494 | }, | ||
| 495 | }), | ||
| 479 | createdAt: now, | 496 | createdAt: now, |
| 480 | updatedAt: now, | 497 | updatedAt: now, |
| 481 | }], | 498 | }], |
| @@ -1,6 +1,7 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | AccountId, | 2 | AccountId, |
| 3 | Credential, | 3 | Credential, |
| 4 | CredentialSlot, | ||
| 4 | SecretRef, | 5 | SecretRef, |
| 5 | Source, | 6 | Source, |
| 6 | SourceRecipeId, | 7 | SourceRecipeId, |
| @@ -64,23 +65,29 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 57 unmodified lines | |||
| 64 | const selectPreferredCredential = (input: { | 65 | const selectPreferredCredential = (input: { |
| 65 | credentials: ReadonlyArray<Credential>; | 66 | credentials: ReadonlyArray<Credential>; |
| 66 | actorAccountId?: AccountId | null; | 67 | actorAccountId?: AccountId | null; |
| 68 | slot: CredentialSlot; | ||
| 67 | }): Credential | null => { | 69 | }): Credential | null => { |
| 70 | const matchingSlot = input.credentials.filter((credential) => credential.slot === input.slot); | ||
| 71 | |||
| 68 | if (input.actorAccountId !== undefined) { | 72 | if (input.actorAccountId !== undefined) { |
| 69 | const exact = input.credentials.find((credential) => credential.actorAccountId === input.actorAccountId); | 73 | const exact = matchingSlot.find((credential) => credential.actorAccountId === input.actorAccountId); |
| 70 | if (exact) { | 74 | if (exact) { |
| 71 | return exact; | 75 | return exact; |
| 72 | } | 76 | } |
| 73 | } | 77 | } |
| 74 | 78 | ||
| 75 | return input.credentials.find((credential) => credential.actorAccountId === null) ?? null; | 79 | return matchingSlot.find((credential) => credential.actorAccountId === null) ?? null; |
| 76 | }; | 80 | }; |
| 77 | 81 | ||
| 78 | const selectExactCredential = (input: { | 82 | const selectExactCredential = (input: { |
| 79 | credentials: ReadonlyArray<Credential>; | 83 | credentials: ReadonlyArray<Credential>; |
| 80 | actorAccountId?: AccountId | null; | 84 | actorAccountId?: AccountId | null; |
| 85 | slot: CredentialSlot; | ||
| 81 | }): Credential | null => | 86 | }): Credential | null => |
| 82 | input.credentials.find( | 87 | input.credentials.find( |
| 83 | (credential) => credential.actorAccountId === (input.actorAccountId ?? null), | 88 | (credential) => |
| 89 | credential.slot === input.slot | ||
| 90 | && credential.actorAccountId === (input.actorAccountId ?? null), | ||
| 84 | ) ?? null; | 91 | ) ?? null; |
| 85 | 92 | ||
| 86 | export const loadSourcesInWorkspace = ( | 93 | export const loadSourcesInWorkspace = ( |
| @@ -98,8 +105,16 @@ export const loadSourcesInWorkspace = ( · 11 unmodified lines | |||
| 98 | const preferred = selectPreferredCredential({ | 105 | const preferred = selectPreferredCredential({ |
| 99 | credentials: matches, | 106 | credentials: matches, |
| 100 | actorAccountId: options.actorAccountId, | 107 | actorAccountId: options.actorAccountId, |
| 108 | slot: "runtime", | ||
| 109 | }); | ||
| 110 | const preferredImport = selectPreferredCredential({ | ||
| 111 | credentials: matches, | ||
| 112 | actorAccountId: options.actorAccountId, | ||
| 113 | slot: "import", | ||
| 101 | }); | 114 | }); |
| 102 | return preferred ? [preferred] : []; | 115 | return [preferred, preferredImport].filter( |
| 116 | (credential): credential is Credential => credential !== null, | ||
| 117 | ); | ||
| 103 | }); | 118 | }); |
| 104 | 119 | ||
| 105 | return yield* projectSourcesFromStorage({ | 120 | return yield* projectSourcesFromStorage({ |
| @@ -132,11 +147,18 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 26 unmodified lines | |||
| 132 | const credential = selectPreferredCredential({ | 147 | const credential = selectPreferredCredential({ |
| 133 | credentials, | 148 | credentials, |
| 134 | actorAccountId: input.actorAccountId, | 149 | actorAccountId: input.actorAccountId, |
| 150 | slot: "runtime", | ||
| 151 | }); | ||
| 152 | const importCredential = selectPreferredCredential({ | ||
| 153 | credentials, | ||
| 154 | actorAccountId: input.actorAccountId, | ||
| 155 | slot: "import", | ||
| 135 | }); | 156 | }); |
| 136 | 157 | ||
| 137 | return yield* projectSourceFromStorage({ | 158 | return yield* projectSourceFromStorage({ |
| 138 | sourceRecord: sourceRecord.value, | 159 | sourceRecord: sourceRecord.value, |
| 139 | credential, | 160 | runtimeCredential: credential, |
| 161 | importCredential, | ||
| 140 | }); | 162 | }); |
| 141 | }); | 163 | }); |
| 142 | 164 | ||
| @@ -178,6 +200,7 @@ const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { · 35 unmodified lines | |||
| 178 | ); | 200 | ); |
| 179 | if (revisionReferenceCount === 0) { | 201 | if (revisionReferenceCount === 0) { |
| 180 | yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId); | 202 | yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId); |
| 203 | yield* rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId); | ||
| 181 | yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId); | 204 | yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId); |
| 182 | } | 205 | } |
| 183 | 206 | ||
| @@ -192,6 +215,7 @@ const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { · 8 unmodified lines | |||
| 192 | (recipeRevision) => | 215 | (recipeRevision) => |
| 193 | Effect.all([ | 216 | Effect.all([ |
| 194 | rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id), | 217 | rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id), |
| 218 | rows.sourceRecipeSchemaBundles.removeByRevisionId(recipeRevision.id), | ||
| 195 | rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id), | 219 | rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id), |
| 196 | ]), | 220 | ]), |
| 197 | { discard: true }, | 221 | { discard: true }, |
| @@ -248,10 +272,18 @@ export const persistSource = ( · 50 unmodified lines | |||
| 248 | const existingCredential = selectExactCredential({ | 272 | const existingCredential = selectExactCredential({ |
| 249 | credentials: existingCredentials, | 273 | credentials: existingCredentials, |
| 250 | actorAccountId: options.actorAccountId, | 274 | actorAccountId: options.actorAccountId, |
| 275 | slot: "runtime", | ||
| 276 | }); | ||
| 277 | const existingImportCredential = selectExactCredential({ | ||
| 278 | credentials: existingCredentials, | ||
| 279 | actorAccountId: options.actorAccountId, | ||
| 280 | slot: "import", | ||
| 251 | }); | 281 | }); |
| 252 | 282 | ||
| 253 | const nextRecipeId = stableSourceRecipeId(source); | 283 | const nextRecipeId = stableSourceRecipeId(source); |
| 254 | const nextRecipeRevisionId = stableSourceRecipeRevisionId(source); | 284 | const nextRecipeRevisionId = Option.isSome(existing) && existing.value.recipeId === nextRecipeId |
| 285 | ? existing.value.recipeRevisionId | ||
| 286 | : stableSourceRecipeRevisionId(source); | ||
| 255 | const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId); | 287 | const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId); |
| 256 | const nextRevision = createSourceRecipeRevisionRecord({ | 288 | const nextRevision = createSourceRecipeRevisionRecord({ |
| 257 | source, | 289 | source, |
| @@ -266,6 +298,9 @@ export const persistSource = ( · 8 unmodified lines | |||
| 266 | manifestHash: Option.isSome(existingTargetRevision) | 298 | manifestHash: Option.isSome(existingTargetRevision) |
| 267 | ? existingTargetRevision.value.manifestHash | 299 | ? existingTargetRevision.value.manifestHash |
| 268 | : null, | 300 | : null, |
| 301 | materializationHash: Option.isSome(existingTargetRevision) | ||
| 302 | ? existingTargetRevision.value.materializationHash | ||
| 303 | : null, | ||
| 269 | }); | 304 | }); |
| 270 | 305 | ||
| 271 | const nextRecipe = createSourceRecipeRecord({ | 306 | const nextRecipe = createSourceRecipeRecord({ |
| @@ -274,12 +309,13 @@ export const persistSource = ( · 2 unmodified lines | |||
| 274 | latestRevisionId: nextRevision.id, | 309 | latestRevisionId: nextRevision.id, |
| 275 | }); | 310 | }); |
| 276 | 311 | ||
| 277 | const { sourceRecord, credential } = splitSourceForStorage({ | 312 | const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({ |
| 278 | source, | 313 | source, |
| 279 | recipeId: nextRecipe.id, | 314 | recipeId: nextRecipe.id, |
| 280 | recipeRevisionId: nextRevision.id, | 315 | recipeRevisionId: nextRevision.id, |
| 281 | actorAccountId: options.actorAccountId, | 316 | actorAccountId: options.actorAccountId, |
| 282 | existingCredentialId: existingCredential?.id ?? null, | 317 | existingRuntimeCredentialId: existingCredential?.id ?? null, |
| 318 | existingImportCredentialId: existingImportCredential?.id ?? null, | ||
| 283 | }); | 319 | }); |
| 284 | 320 | ||
| 285 | if (Option.isNone(existing)) { | 321 | if (Option.isNone(existing)) { |
| @@ -310,19 +346,36 @@ export const persistSource = ( · 24 unmodified lines | |||
| 310 | }); | 346 | }); |
| 311 | } | 347 | } |
| 312 | 348 | ||
| 313 | if (credential === null) { | 349 | if (runtimeCredential === null) { |
| 314 | yield* rows.credentials.removeByWorkspaceSourceAndActor({ | 350 | yield* rows.credentials.removeByWorkspaceSourceAndActor({ |
| 315 | workspaceId: source.workspaceId, | 351 | workspaceId: source.workspaceId, |
| 316 | sourceId: source.id, | 352 | sourceId: source.id, |
| 317 | actorAccountId: options.actorAccountId ?? null, | 353 | actorAccountId: options.actorAccountId ?? null, |
| 354 | slot: "runtime", | ||
| 318 | }); | 355 | }); |
| 319 | } else { | 356 | } else { |
| 320 | yield* rows.credentials.upsert(credential); | 357 | yield* rows.credentials.upsert(runtimeCredential); |
| 321 | } | 358 | } |
| 322 | 359 | ||
| 323 | yield* cleanupCredentialSecretRefs(rows, { | 360 | yield* cleanupCredentialSecretRefs(rows, { |
| 324 | previous: existingCredential ?? null, | 361 | previous: existingCredential ?? null, |
| 325 | next: credential, | 362 | next: runtimeCredential, |
| 363 | }); | ||
| 364 | |||
| 365 | if (importCredential === null) { | ||
| 366 | yield* rows.credentials.removeByWorkspaceSourceAndActor({ | ||
| 367 | workspaceId: source.workspaceId, | ||
| 368 | sourceId: source.id, | ||
| 369 | actorAccountId: options.actorAccountId ?? null, | ||
| 370 | slot: "import", | ||
| 371 | }); | ||
| 372 | } else { | ||
| 373 | yield* rows.credentials.upsert(importCredential); | ||
| 374 | } | ||
| 375 | |||
| 376 | yield* cleanupCredentialSecretRefs(rows, { | ||
| 377 | previous: existingImportCredential ?? null, | ||
| 378 | next: importCredential, | ||
| 326 | }); | 379 | }); |
| 327 | 380 | ||
| 328 | return source; | 381 | return source; |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | createSourceFromPayload, | 18 | createSourceFromPayload, |
| 19 | updateSourceFromPayload, | 19 | updateSourceFromPayload, |
| 20 | } from "./source-definitions"; | 20 | } from "./source-definitions"; |
| 21 | import { getSourceAdapterForSource } from "./source-adapters"; | ||
| 21 | import { | 22 | import { |
| 22 | mapPersistenceError, | 23 | mapPersistenceError, |
| 23 | } from "./operations-shared"; | 24 | } from "./operations-shared"; |
| @@ -26,7 +27,7 @@ import { · 2 unmodified lines | |||
| 26 | } from "./operation-errors"; | 27 | } from "./operation-errors"; |
| 27 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | 28 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; |
| 28 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; | 29 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; |
| 29 | import { syncSourceToolArtifacts } from "./tool-artifacts"; | 30 | import { syncSourceMaterialization } from "./source-materialization"; |
| 30 | import { | 31 | import { |
| 31 | loadSourceById, | 32 | loadSourceById, |
| 32 | loadSourcesInWorkspace, | 33 | loadSourcesInWorkspace, |
| @@ -43,12 +44,7 @@ const sourceOps = { · 10 unmodified lines | |||
| 43 | } as const; | 44 | } as const; |
| 44 | 45 | ||
| 45 | const shouldAutoProbeSource = (source: Source): boolean => | 46 | const shouldAutoProbeSource = (source: Source): boolean => |
| 46 | source.enabled | 47 | getSourceAdapterForSource(source).shouldAutoProbe(source); |
| 47 | && ( | ||
| 48 | (source.kind === "openapi" && !!source.specUrl) | ||
| 49 | || source.kind === "graphql" | ||
| 50 | ) | ||
| 51 | && (source.status === "draft" || source.status === "probing"); | ||
| 52 | 48 | ||
| 53 | const syncArtifactsForSource = (input: { | 49 | const syncArtifactsForSource = (input: { |
| 54 | store: ControlPlaneStoreShape; | 50 | store: ControlPlaneStoreShape; |
| @@ -73,7 +69,7 @@ const syncArtifactsForSource = (input: { · 18 unmodified lines | |||
| 73 | : input.source; | 69 | : input.source; |
| 74 | 70 | ||
| 75 | const synced = yield* Effect.either( | 71 | const synced = yield* Effect.either( |
| 76 | syncSourceToolArtifacts({ | 72 | syncSourceMaterialization({ |
| 77 | rows: input.store, | 73 | rows: input.store, |
| 78 | source: sourceForSync, | 74 | source: sourceForSync, |
| 79 | resolveSecretMaterial, | 75 | resolveSecretMaterial, |
| @@ -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); | ||
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | |||
| 36 | ExecutionIdSchema, | 36 | ExecutionIdSchema, |
| 37 | SecretMaterialIdSchema, | 37 | SecretMaterialIdSchema, |
| 38 | SourceIdSchema, | 38 | SourceIdSchema, |
| 39 | SourceRecipeSchemaBundleIdSchema, | ||
| 39 | WorkspaceIdSchema, | 40 | WorkspaceIdSchema, |
| 40 | type Source, | 41 | type Source, |
| 41 | } from "#schema"; | 42 | } from "#schema"; |
| @@ -60,7 +61,7 @@ import { · 18 unmodified lines | |||
| 60 | } from "./source-auth-service"; | 61 | } from "./source-auth-service"; |
| 61 | import { createLiveExecutionManager } from "./live-execution"; | 62 | import { createLiveExecutionManager } from "./live-execution"; |
| 62 | import { persistSource } from "./source-store"; | 63 | import { persistSource } from "./source-store"; |
| 63 | import { persistMcpToolArtifactsFromManifest } from "./tool-artifacts"; | 64 | import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization"; |
| 64 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; | 65 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; |
| 65 | 66 | ||
| 66 | type CountedMcpServer = { | 67 | type CountedMcpServer = { |
| @@ -85,7 +86,9 @@ type GraphqlServer = { · 18 unmodified lines | |||
| 85 | close: () => Promise<void>; | 86 | close: () => Promise<void>; |
| 86 | }; | 87 | }; |
| 87 | 88 | ||
| 88 | const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string => | 89 | const normalizeSearchText = ( |
| 90 | ...parts: ReadonlyArray<string | null | undefined> | ||
| 91 | ): string => | ||
| 89 | parts | 92 | parts |
| 90 | .flatMap((part) => (part ? [part.trim()] : [])) | 93 | .flatMap((part) => (part ? [part.trim()] : [])) |
| 91 | .filter(Boolean) | 94 | .filter(Boolean) |
| @@ -263,7 +266,10 @@ const GraphqlMutationType = new GraphQLObjectType({ · 171 unmodified lines | |||
| 263 | type: new GraphQLNonNull(GraphqlCreateIssueInputType), | 266 | type: new GraphQLNonNull(GraphqlCreateIssueInputType), |
| 264 | }, | 267 | }, |
| 265 | }, | 268 | }, |
| 266 | resolve: (_source, args: { input: { title: string; teamId?: string | null } }) => ({ | 269 | resolve: ( |
| 270 | _source, | ||
| 271 | args: { input: { title: string; teamId?: string | null } }, | ||
| 272 | ) => ({ | ||
| 267 | id: "iss_created", | 273 | id: "iss_created", |
| 268 | identifier: "ENG-999", | 274 | identifier: "ENG-999", |
| 269 | title: args.input.title, | 275 | title: args.input.title, |
| @@ -285,17 +291,21 @@ const testGraphqlSchema = new GraphQLSchema({ · 15 unmodified lines | |||
| 285 | const closeScope = (scope: Scope.CloseableScope) => | 291 | const closeScope = (scope: Scope.CloseableScope) => |
| 286 | Scope.close(scope, Exit.void).pipe(Effect.orDie); | 292 | Scope.close(scope, Exit.void).pipe(Effect.orDie); |
| 287 | 293 | ||
| 288 | const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | 294 | const makePersistence: Effect.Effect< |
| 289 | Effect.acquireRelease( | 295 | SqlControlPlanePersistence, |
| 296 | unknown, | ||
| 297 | Scope.Scope | ||
| 298 | > = Effect.acquireRelease( | ||
| 290 | createSqlControlPlanePersistence({ | 299 | createSqlControlPlanePersistence({ |
| 291 | localDataDir: ":memory:", | 300 | localDataDir: ":memory:", |
| 292 | }), | 301 | }), |
| 293 | (persistence) => | 302 | (persistence) => |
| 294 | Effect.tryPromise({ | 303 | Effect.tryPromise({ |
| 295 | try: () => persistence.close(), | 304 | try: () => persistence.close(), |
| 296 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | 305 | catch: (cause) => |
| 306 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 297 | }).pipe(Effect.orDie), | 307 | }).pipe(Effect.orDie), |
| 298 | ); | 308 | ); |
| 299 | 309 | ||
| 300 | const makeCountedMcpServer = Effect.acquireRelease( | 310 | const makeCountedMcpServer = Effect.acquireRelease( |
| 301 | Effect.promise<CountedMcpServer>( | 311 | Effect.promise<CountedMcpServer>( |
| @@ -425,37 +435,38 @@ const makeCountedMcpServer = Effect.acquireRelease( · 123 unmodified lines | |||
| 425 | const ownerParam = HttpApiSchema.param("owner", Schema.String); | 435 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 426 | const repoParam = HttpApiSchema.param("repo", Schema.String); | 436 | const repoParam = HttpApiSchema.param("repo", Schema.String); |
| 427 | 437 | ||
| 428 | class GeneratedReposApi extends HttpApiGroup.make("repos") | 438 | class GeneratedReposApi extends HttpApiGroup.make("repos").add( |
| 429 | .add( | 439 | HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`.addSuccess( |
| 430 | HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}` | 440 | Schema.Struct({ |
| 431 | .addSuccess( | 441 | ok: Schema.Boolean, |
| 432 | Schema.Struct({ | 442 | full_name: Schema.String, |
| 433 | ok: Schema.Boolean, | 443 | }), |
| 434 | full_name: Schema.String, | 444 | ), |
| 435 | }), | 445 | ) {} |
| 436 | ), | ||
| 437 | ) {} | ||
| 438 | 446 | ||
| 439 | class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {} | 447 | class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {} |
| 440 | 448 | ||
| 441 | const makeOpenApiSpecServer = Effect.acquireRelease( | 449 | const makeOpenApiSpecServer = Effect.acquireRelease( |
| 442 | Effect.gen(function* () { | 450 | Effect.gen(function* () { |
| 443 | const seenAuthHeaders: OpenApiSpecServer["seenAuthHeaders"] = []; | 451 | const seenAuthHeaders: OpenApiSpecServer["seenAuthHeaders"] = []; |
| 444 | const handlersLayer = HttpApiBuilder.group(GeneratedApi, "repos", (handlers) => | 452 | const handlersLayer = HttpApiBuilder.group( |
| 445 | handlers.handle("getRepo", ({ path, request }) => | 453 | GeneratedApi, |
| 446 | Effect.sync(() => { | 454 | "repos", |
| 447 | seenAuthHeaders.push( | 455 | (handlers) => |
| 448 | typeof request.headers.authorization === "string" | 456 | handlers.handle("getRepo", ({ path, request }) => |
| 449 | ? request.headers.authorization | 457 | Effect.sync(() => { |
| 450 | : null, | 458 | seenAuthHeaders.push( |
| 451 | ); | 459 | typeof request.headers.authorization === "string" |
| 460 | ? request.headers.authorization | ||
| 461 | : null, | ||
| 462 | ); | ||
| 452 | 463 | ||
| 453 | return { | 464 | return { |
| 454 | ok: true, | 465 | ok: true, |
| 455 | full_name: `${path.owner}/${path.repo}`, | 466 | full_name: `${path.owner}/${path.repo}`, |
| 456 | }; | 467 | }; |
| 457 | }) | 468 | }), |
| 458 | ) | 469 | ), |
| 459 | ); | 470 | ); |
| 460 | const apiLayer = HttpApiBuilder.api(GeneratedApi).pipe( | 471 | const apiLayer = HttpApiBuilder.api(GeneratedApi).pipe( |
| 461 | Layer.provide(handlersLayer), | 472 | Layer.provide(handlersLayer), |
| @@ -468,9 +479,8 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 6 unmodified lines | |||
| 468 | const scope = yield* Scope.make(); | 479 | const scope = yield* Scope.make(); |
| 469 | const context = yield* Layer.buildWithScope(serverLayer, scope).pipe( | 480 | const context = yield* Layer.buildWithScope(serverLayer, scope).pipe( |
| 470 | Effect.catchAll((error) => | 481 | Effect.catchAll((error) => |
| 471 | closeScope(scope).pipe( | 482 | closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))), |
| 472 | Effect.zipRight(Effect.fail(error)), | 483 | ), |
| 473 | )), | ||
| 474 | ); | 484 | ); |
| 475 | const server = Context.get(context, HttpServer.HttpServer); | 485 | const server = Context.get(context, HttpServer.HttpServer); |
| 476 | const baseUrl = HttpServer.formatAddress(server.address); | 486 | const baseUrl = HttpServer.formatAddress(server.address); |
| @@ -485,8 +495,9 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 8 unmodified lines | |||
| 485 | (server) => | 495 | (server) => |
| 486 | Effect.tryPromise({ | 496 | Effect.tryPromise({ |
| 487 | try: () => server.close(), | 497 | try: () => server.close(), |
| 488 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | 498 | catch: (cause) => |
| 489 | }).pipe(Effect.orDie), | 499 | cause instanceof Error ? cause : new Error(String(cause)), |
| 500 | }).pipe(Effect.orDie), | ||
| 490 | ); | 501 | ); |
| 491 | 502 | ||
| 492 | const makeGraphqlServer = Effect.acquireRelease( | 503 | const makeGraphqlServer = Effect.acquireRelease( |
| @@ -518,7 +529,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 25 unmodified lines | |||
| 518 | 529 | ||
| 519 | const result = await graphql({ | 530 | const result = await graphql({ |
| 520 | schema: testGraphqlSchema, | 531 | schema: testGraphqlSchema, |
| 521 | source: typeof parsedBody.query === "string" ? parsedBody.query : "", | 532 | source: |
| 533 | typeof parsedBody.query === "string" ? parsedBody.query : "", | ||
| 522 | variableValues: | 534 | variableValues: |
| 523 | parsedBody.variables && typeof parsedBody.variables === "object" | 535 | parsedBody.variables && typeof parsedBody.variables === "object" |
| 524 | ? (parsedBody.variables as Record<string, unknown>) | 536 | ? (parsedBody.variables as Record<string, unknown>) |
| @@ -561,7 +573,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 36 unmodified lines | |||
| 561 | (server) => | 573 | (server) => |
| 562 | Effect.tryPromise({ | 574 | Effect.tryPromise({ |
| 563 | try: () => server.close(), | 575 | try: () => server.close(), |
| 564 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | 576 | catch: (cause) => |
| 577 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 565 | }).pipe(Effect.orDie), | 578 | }).pipe(Effect.orDie), |
| 566 | ); | 579 | ); |
| 567 | 580 | ||
| @@ -587,6 +600,8 @@ const persistConnectedEchoTool = (input: { · 19 unmodified lines | |||
| 587 | headers: null, | 600 | headers: null, |
| 588 | specUrl: null, | 601 | specUrl: null, |
| 589 | defaultHeaders: null, | 602 | defaultHeaders: null, |
| 603 | importAuthPolicy: "reuse_runtime", | ||
| 604 | importAuth: { kind: "none" }, | ||
| 590 | auth: { kind: "none" }, | 605 | auth: { kind: "none" }, |
| 591 | sourceHash: null, | 606 | sourceHash: null, |
| 592 | lastError: null, | 607 | lastError: null, |
| @@ -595,24 +610,26 @@ const persistConnectedEchoTool = (input: { · 2 unmodified lines | |||
| 595 | }; | 610 | }; |
| 596 | 611 | ||
| 597 | yield* persistSource(input.persistence.rows, source); | 612 | yield* persistSource(input.persistence.rows, source); |
| 598 | yield* persistMcpToolArtifactsFromManifest({ | 613 | yield* persistMcpRecipeMaterializationFromManifest({ |
| 599 | rows: input.persistence.rows, | 614 | rows: input.persistence.rows, |
| 600 | source, | 615 | source, |
| 601 | manifestEntries: [{ | 616 | manifestEntries: [ |
| 602 | toolId: "echo", | 617 | { |
| 603 | toolName: "echo", | 618 | toolId: "echo", |
| 604 | description: "Echo the provided string", | 619 | toolName: "echo", |
| 605 | inputSchemaJson: JSON.stringify({ | 620 | description: "Echo the provided string", |
| 606 | type: "object", | 621 | inputSchemaJson: JSON.stringify({ |
| 607 | properties: { | 622 | type: "object", |
| 608 | value: { | 623 | properties: { |
| 609 | type: "string", | 624 | value: { |
| 625 | type: "string", | ||
| 626 | }, | ||
| 610 | }, | 627 | }, |
| 611 | }, | 628 | required: ["value"], |
| 612 | required: ["value"], | 629 | additionalProperties: false, |
| 613 | additionalProperties: false, | 630 | }), |
| 614 | }), | 631 | }, |
| 615 | }], | 632 | ], |
| 616 | }); | 633 | }); |
| 617 | }); | 634 | }); |
| 618 | 635 | ||
| @@ -635,31 +652,52 @@ const persistConnectedGithubOpenApiSource = (input: { · 16 unmodified lines | |||
| 635 | version: "1.0.0", | 652 | version: "1.0.0", |
| 636 | }, | 653 | }, |
| 637 | paths: { | 654 | paths: { |
| 638 | "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}": { | 655 | "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}": |
| 639 | get: { | 656 | { |
| 640 | operationId: "actions/get-environment-secret", | 657 | get: { |
| 641 | tags: ["actions"], | 658 | operationId: "actions/get-environment-secret", |
| 642 | summary: "Get an environment secret", | 659 | tags: ["actions"], |
| 643 | description: | 660 | summary: "Get an environment secret", |
| 644 | "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.", | 661 | description: |
| 645 | parameters: [ | 662 | "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.", |
| 646 | { name: "owner", in: "path", required: true, schema: { type: "string" } }, | 663 | parameters: [ |
| 647 | { name: "repo", in: "path", required: true, schema: { type: "string" } }, | 664 | { |
| 648 | { name: "environment_name", in: "path", required: true, schema: { type: "string" } }, | 665 | name: "owner", |
| 649 | { name: "secret_name", in: "path", required: true, schema: { type: "string" } }, | 666 | in: "path", |
| 650 | ], | 667 | required: true, |
| 651 | responses: { | 668 | schema: { type: "string" }, |
| 652 | 200: { | 669 | }, |
| 653 | description: "ok", | 670 | { |
| 654 | content: { | 671 | name: "repo", |
| 655 | "application/json": { | 672 | in: "path", |
| 656 | schema: { type: "object" }, | 673 | required: true, |
| 674 | schema: { type: "string" }, | ||
| 675 | }, | ||
| 676 | { | ||
| 677 | name: "environment_name", | ||
| 678 | in: "path", | ||
| 679 | required: true, | ||
| 680 | schema: { type: "string" }, | ||
| 681 | }, | ||
| 682 | { | ||
| 683 | name: "secret_name", | ||
| 684 | in: "path", | ||
| 685 | required: true, | ||
| 686 | schema: { type: "string" }, | ||
| 687 | }, | ||
| 688 | ], | ||
| 689 | responses: { | ||
| 690 | 200: { | ||
| 691 | description: "ok", | ||
| 692 | content: { | ||
| 693 | "application/json": { | ||
| 694 | schema: { $ref: "#/components/schemas/Repository" }, | ||
| 695 | }, | ||
| 657 | }, | 696 | }, |
| 658 | }, | 697 | }, |
| 659 | }, | 698 | }, |
| 660 | }, | 699 | }, |
| 661 | }, | 700 | }, |
| 662 | }, | ||
| 663 | "/orgs/{org}/actions/runners/{runner_id}": { | 701 | "/orgs/{org}/actions/runners/{runner_id}": { |
| 664 | get: { | 702 | get: { |
| 665 | operationId: "actions/get-self-hosted-runner-for-org", | 703 | operationId: "actions/get-self-hosted-runner-for-org", |
| @@ -668,8 +706,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 2 unmodified lines | |||
| 668 | description: | 706 | description: |
| 669 | "Gets a specific self-hosted runner configured in an organization. Authenticated users must have admin access to the organization to use this endpoint.", | 707 | "Gets a specific self-hosted runner configured in an organization. Authenticated users must have admin access to the organization to use this endpoint.", |
| 670 | parameters: [ | 708 | parameters: [ |
| 671 | { name: "org", in: "path", required: true, schema: { type: "string" } }, | 709 | { |
| 672 | { name: "runner_id", in: "path", required: true, schema: { type: "integer" } }, | 710 | name: "org", |
| 711 | in: "path", | ||
| 712 | required: true, | ||
| 713 | schema: { type: "string" }, | ||
| 714 | }, | ||
| 715 | { | ||
| 716 | name: "runner_id", | ||
| 717 | in: "path", | ||
| 718 | required: true, | ||
| 719 | schema: { type: "integer" }, | ||
| 720 | }, | ||
| 673 | ], | 721 | ], |
| 674 | responses: { | 722 | responses: { |
| 675 | 200: { | 723 | 200: { |
| @@ -690,8 +738,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 14 unmodified lines | |||
| 690 | summary: "Get a repository", | 738 | summary: "Get a repository", |
| 691 | description: "Gets a repository by owner and name.", | 739 | description: "Gets a repository by owner and name.", |
| 692 | parameters: [ | 740 | parameters: [ |
| 693 | { name: "owner", in: "path", required: true, schema: { type: "string" } }, | 741 | { |
| 694 | { name: "repo", in: "path", required: true, schema: { type: "string" } }, | 742 | name: "owner", |
| 743 | in: "path", | ||
| 744 | required: true, | ||
| 745 | schema: { type: "string" }, | ||
| 746 | }, | ||
| 747 | { | ||
| 748 | name: "repo", | ||
| 749 | in: "path", | ||
| 750 | required: true, | ||
| 751 | schema: { type: "string" }, | ||
| 752 | }, | ||
| 695 | ], | 753 | ], |
| 696 | responses: { | 754 | responses: { |
| 697 | 200: { | 755 | 200: { |
| @@ -725,6 +783,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 27 unmodified lines | |||
| 725 | }, | 783 | }, |
| 726 | }, | 784 | }, |
| 727 | }, | 785 | }, |
| 786 | components: { | ||
| 787 | schemas: { | ||
| 788 | Repository: { | ||
| 789 | type: "object", | ||
| 790 | properties: { | ||
| 791 | full_name: { type: "string" }, | ||
| 792 | private: { type: "boolean" }, | ||
| 793 | }, | ||
| 794 | required: ["full_name"], | ||
| 795 | }, | ||
| 796 | }, | ||
| 797 | }, | ||
| 728 | }); | 798 | }); |
| 729 | 799 | ||
| 730 | const source: Source = { | 800 | const source: Source = { |
| @@ -741,6 +811,8 @@ const persistConnectedGithubOpenApiSource = (input: { · 10 unmodified lines | |||
| 741 | headers: null, | 811 | headers: null, |
| 742 | specUrl: "https://example.com/github-openapi.json", | 812 | specUrl: "https://example.com/github-openapi.json", |
| 743 | defaultHeaders: null, | 813 | defaultHeaders: null, |
| 814 | importAuthPolicy: "reuse_runtime", | ||
| 815 | importAuth: { kind: "none" }, | ||
| 744 | auth: input.auth | 816 | auth: input.auth |
| 745 | ? { | 817 | ? { |
| 746 | kind: "bearer", | 818 | kind: "bearer", |
| @@ -769,12 +841,15 @@ const persistConnectedGithubOpenApiSource = (input: { · 22 unmodified lines | |||
| 769 | ), | 841 | ), |
| 770 | ); | 842 | ); |
| 771 | const definitions = compileOpenApiToolDefinitions(manifest); | 843 | const definitions = compileOpenApiToolDefinitions(manifest); |
| 772 | const sourceRecord = yield* input.persistence.rows.sources.getByWorkspaceAndId( | 844 | const sourceRecord = |
| 773 | input.workspaceId, | 845 | yield* input.persistence.rows.sources.getByWorkspaceAndId( |
| 774 | input.sourceId, | 846 | input.workspaceId, |
| 775 | ); | 847 | input.sourceId, |
| 848 | ); | ||
| 776 | if (Option.isNone(sourceRecord)) { | 849 | if (Option.isNone(sourceRecord)) { |
| 777 | return yield* Effect.fail(new Error(`Missing stored source ${input.sourceId}`)); | 850 | return yield* Effect.fail( |
| 851 | new Error(`Missing stored source ${input.sourceId}`), | ||
| 852 | ); | ||
| 778 | } | 853 | } |
| 779 | 854 | ||
| 780 | yield* input.persistence.rows.sourceRecipeRevisions.update( | 855 | yield* input.persistence.rows.sourceRecipeRevisions.update( |
| @@ -787,23 +862,54 @@ const persistConnectedGithubOpenApiSource = (input: { · 6 unmodified lines | |||
| 787 | ); | 862 | ); |
| 788 | yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ | 863 | yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ |
| 789 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | 864 | recipeRevisionId: sourceRecord.value.recipeRevisionId, |
| 790 | documents: [{ | 865 | documents: [ |
| 791 | id: `src_recipe_doc_${randomUUID()}`, | 866 | { |
| 792 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | 867 | id: `src_recipe_doc_${randomUUID()}`, |
| 793 | documentKind: "openapi", | 868 | recipeRevisionId: sourceRecord.value.recipeRevisionId, |
| 794 | documentKey: source.specUrl ?? source.endpoint, | 869 | documentKind: "openapi", |
| 795 | contentText: openApiDocumentText, | 870 | documentKey: source.specUrl ?? source.endpoint, |
| 796 | contentHash: manifest.sourceHash, | 871 | contentText: openApiDocumentText, |
| 797 | fetchedAt: now, | 872 | contentHash: manifest.sourceHash, |
| 798 | createdAt: now, | 873 | fetchedAt: now, |
| 799 | updatedAt: now, | 874 | createdAt: now, |
| 800 | }], | 875 | updatedAt: now, |
| 876 | }, | ||
| 877 | ], | ||
| 878 | }); | ||
| 879 | const refEntries = Object.entries(manifest.refHintTable ?? {}); | ||
| 880 | yield* input.persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | ||
| 881 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | ||
| 882 | bundles: | ||
| 883 | refEntries.length > 0 | ||
| 884 | ? [ | ||
| 885 | { | ||
| 886 | id: SourceRecipeSchemaBundleIdSchema.make( | ||
| 887 | `src_recipe_bundle_${randomUUID()}`, | ||
| 888 | ), | ||
| 889 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | ||
| 890 | bundleKind: "json_schema_ref_map", | ||
| 891 | refsJson: JSON.stringify( | ||
| 892 | Object.fromEntries( | ||
| 893 | refEntries.map(([ref, rawValue]) => { | ||
| 894 | try { | ||
| 895 | return [ref, JSON.parse(rawValue) as unknown]; | ||
| 896 | } catch { | ||
| 897 | return [ref, rawValue]; | ||
| 898 | } | ||
| 899 | }), | ||
| 900 | ), | ||
| 901 | ), | ||
| 902 | contentHash: manifest.sourceHash, | ||
| 903 | createdAt: now, | ||
| 904 | updatedAt: now, | ||
| 905 | }, | ||
| 906 | ] | ||
| 907 | : [], | ||
| 801 | }); | 908 | }); |
| 802 | yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({ | 909 | yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({ |
| 803 | recipeRevisionId: sourceRecord.value.recipeRevisionId, | 910 | recipeRevisionId: sourceRecord.value.recipeRevisionId, |
| 804 | operations: definitions.map((definition) => { | 911 | operations: definitions.map((definition) => { |
| 805 | const presentation = buildOpenApiToolPresentation({ | 912 | const presentation = buildOpenApiToolPresentation({ |
| 806 | manifest, | ||
| 807 | definition, | 913 | definition, |
| 808 | }); | 914 | }); |
| 809 | const method = definition.method.toUpperCase(); | 915 | const method = definition.method.toUpperCase(); |
| @@ -838,25 +944,19 @@ const persistConnectedGithubOpenApiSource = (input: { · 28 unmodified lines | |||
| 838 | outputSchemaJson: presentation.outputSchemaJson ?? null, | 944 | outputSchemaJson: presentation.outputSchemaJson ?? null, |
| 839 | providerKind: "openapi", | 945 | providerKind: "openapi", |
| 840 | providerDataJson: presentation.providerDataJson, | 946 | providerDataJson: presentation.providerDataJson, |
| 841 | mcpToolName: null, | ||
| 842 | openApiMethod: definition.method, | ||
| 843 | openApiPathTemplate: definition.path, | ||
| 844 | openApiOperationHash: definition.operationHash, | ||
| 845 | openApiRawToolId: definition.rawToolId, | ||
| 846 | openApiOperationId: definition.operationId ?? null, | ||
| 847 | openApiTagsJson: JSON.stringify(definition.tags), | ||
| 848 | openApiRequestBodyRequired: definition.invocation.requestBody?.required ?? null, | ||
| 849 | graphqlOperationType: null, | ||
| 850 | graphqlOperationName: null, | ||
| 851 | createdAt: now, | 947 | createdAt: now, |
| 852 | updatedAt: now, | 948 | updatedAt: now, |
| 853 | }; | 949 | }; |
| 854 | }), | 950 | }), |
| 855 | }); | 951 | }); |
| 856 | yield* input.persistence.rows.sources.update(input.workspaceId, input.sourceId, { | 952 | yield* input.persistence.rows.sources.update( |
| 857 | sourceHash: manifest.sourceHash, | 953 | input.workspaceId, |
| 858 | updatedAt: now, | 954 | input.sourceId, |
| 859 | }); | 955 | { |
| 956 | sourceHash: manifest.sourceHash, | ||
| 957 | updatedAt: now, | ||
| 958 | }, | ||
| 959 | ); | ||
| 860 | 960 | ||
| 861 | return; | 961 | return; |
| 862 | }); | 962 | }); |
| @@ -866,17 +966,24 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 3 unmodified lines | |||
| 866 | rows: persistence.rows, | 966 | rows: persistence.rows, |
| 867 | sourceAuthService: { | 967 | sourceAuthService: { |
| 868 | getLocalServerBaseUrl: () => null, | 968 | getLocalServerBaseUrl: () => null, |
| 869 | storeSecretMaterial: () => Effect.fail(new Error("not implemented in test")), | 969 | storeSecretMaterial: () => |
| 970 | Effect.fail(new Error("not implemented in test")), | ||
| 870 | getSourceById: () => Effect.fail(new Error("not implemented in test")), | 971 | getSourceById: () => Effect.fail(new Error("not implemented in test")), |
| 871 | addExecutorSource: () => Effect.fail(new Error("not implemented in test")), | 972 | addExecutorSource: () => |
| 973 | Effect.fail(new Error("not implemented in test")), | ||
| 872 | connectMcpSource: () => Effect.fail(new Error("not implemented in test")), | 974 | connectMcpSource: () => Effect.fail(new Error("not implemented in test")), |
| 873 | startSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | 975 | startSourceOAuthSession: () => |
| 874 | completeSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | 976 | Effect.fail(new Error("not implemented in test")), |
| 875 | completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")), | 977 | completeSourceOAuthSession: () => |
| 978 | Effect.fail(new Error("not implemented in test")), | ||
| 979 | completeSourceCredentialSetup: () => | ||
| 980 | Effect.fail(new Error("not implemented in test")), | ||
| 876 | } as RuntimeSourceAuthService, | 981 | } as RuntimeSourceAuthService, |
| 877 | }); | 982 | }); |
| 878 | 983 | ||
| 879 | const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) => | 984 | const makeResolverWithLiveSourceAuth = ( |
| 985 | persistence: SqlControlPlanePersistence, | ||
| 986 | ) => | ||
| 880 | createWorkspaceExecutionEnvironmentResolver({ | 987 | createWorkspaceExecutionEnvironmentResolver({ |
| 881 | rows: persistence.rows, | 988 | rows: persistence.rows, |
| 882 | sourceAuthService: createRuntimeSourceAuthService({ | 989 | sourceAuthService: createRuntimeSourceAuthService({ |
| @@ -902,7 +1009,8 @@ describe("workspace-execution-environment", () => { · 19 unmodified lines | |||
| 902 | 1009 | ||
| 903 | yield* Effect.tryPromise({ | 1010 | yield* Effect.tryPromise({ |
| 904 | try: () => server.close(), | 1011 | try: () => server.close(), |
| 905 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | 1012 | catch: (cause) => |
| 1013 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 906 | }).pipe(Effect.orDie); | 1014 | }).pipe(Effect.orDie); |
| 907 | 1015 | ||
| 908 | const resolveEnvironment = makeResolver(persistence); | 1016 | const resolveEnvironment = makeResolver(persistence); |
| @@ -931,52 +1039,58 @@ describe("workspace-execution-environment", () => { · 22 unmodified lines | |||
| 931 | }), | 1039 | }), |
| 932 | ); | 1040 | ); |
| 933 | 1041 | ||
| 934 | it.scoped("prefers namespace and path matches over noisy GitHub boilerplate", () => | 1042 | it.scoped( |
| 935 | Effect.gen(function* () { | 1043 | "prefers namespace and path matches over noisy GitHub boilerplate", |
| 936 | const persistence = yield* makePersistence; | 1044 | () => |
| 937 | const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); | 1045 | Effect.gen(function* () { |
| 938 | const sourceId = SourceIdSchema.make("src_github_discovery"); | 1046 | const persistence = yield* makePersistence; |
| 939 | yield* persistConnectedGithubOpenApiSource({ | 1047 | const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); |
| 940 | persistence, | 1048 | const sourceId = SourceIdSchema.make("src_github_discovery"); |
| 941 | workspaceId, | 1049 | yield* persistConnectedGithubOpenApiSource({ |
| 942 | sourceId, | 1050 | persistence, |
| 943 | }); | 1051 | workspaceId, |
| 1052 | sourceId, | ||
| 1053 | }); | ||
| 944 | 1054 | ||
| 945 | const resolveEnvironment = makeResolver(persistence); | 1055 | const resolveEnvironment = makeResolver(persistence); |
| 946 | const environment = yield* resolveEnvironment({ | 1056 | const environment = yield* resolveEnvironment({ |
| 947 | workspaceId, | 1057 | workspaceId, |
| 948 | accountId: AccountIdSchema.make("acc_github_discovery"), | 1058 | accountId: AccountIdSchema.make("acc_github_discovery"), |
| 949 | executionId: ExecutionIdSchema.make("exec_github_discovery"), | 1059 | executionId: ExecutionIdSchema.make("exec_github_discovery"), |
| 950 | }); | 1060 | }); |
| 951 | 1061 | ||
| 952 | const firstQuery = (yield* environment.toolInvoker.invoke({ | 1062 | const firstQuery = (yield* environment.toolInvoker.invoke({ |
| 953 | path: "discover", | 1063 | path: "discover", |
| 954 | args: { | 1064 | args: { |
| 955 | query: "current authenticated user profile", | 1065 | query: "current authenticated user profile", |
| 956 | limit: 5, | 1066 | limit: 5, |
| 957 | }, | 1067 | }, |
| 958 | })) as { | 1068 | })) as { |
| 959 | bestPath: string | null; | 1069 | bestPath: string | null; |
| 960 | results: Array<{ path: string }>; | 1070 | results: Array<{ path: string }>; |
| 961 | }; | 1071 | }; |
| 962 | 1072 | ||
| 963 | expect(firstQuery.bestPath).toBe("github.users.getAuthenticated"); | 1073 | expect(firstQuery.bestPath).toBe("github.users.getAuthenticated"); |
| 964 | expect(firstQuery.results[0]?.path).toBe("github.users.getAuthenticated"); | 1074 | expect(firstQuery.results[0]?.path).toBe( |
| 1075 | "github.users.getAuthenticated", | ||
| 1076 | ); | ||
| 965 | 1077 | ||
| 966 | const secondQuery = (yield* environment.toolInvoker.invoke({ | 1078 | const secondQuery = (yield* environment.toolInvoker.invoke({ |
| 967 | path: "discover", | 1079 | path: "discover", |
| 968 | args: { | 1080 | args: { |
| 969 | query: "users get myself current user profile", | 1081 | query: "users get myself current user profile", |
| 970 | limit: 5, | 1082 | limit: 5, |
| 971 | }, | 1083 | }, |
| 972 | })) as { | 1084 | })) as { |
| 973 | bestPath: string | null; | 1085 | bestPath: string | null; |
| 974 | results: Array<{ path: string }>; | 1086 | results: Array<{ path: string }>; |
| 975 | }; | 1087 | }; |
| 976 | 1088 | ||
| 977 | expect(secondQuery.bestPath).toBe("github.users.getAuthenticated"); | 1089 | expect(secondQuery.bestPath).toBe("github.users.getAuthenticated"); |
| 978 | expect(secondQuery.results[0]?.path).toBe("github.users.getAuthenticated"); | 1090 | expect(secondQuery.results[0]?.path).toBe( |
| 979 | }), | 1091 | "github.users.getAuthenticated", |
| 1092 | ); | ||
| 1093 | }), | ||
| 980 | ); | 1094 | ); |
| 981 | 1095 | ||
| 982 | it.scoped("invokes persisted MCP tools on demand without re-listing", () => | 1096 | it.scoped("invokes persisted MCP tools on demand without re-listing", () => |
| @@ -1014,338 +1128,381 @@ describe("workspace-execution-environment", () => { · 31 unmodified lines | |||
| 1014 | }), | 1128 | }), |
| 1015 | ); | 1129 | ); |
| 1016 | 1130 | ||
| 1017 | it.scoped("adds an OpenAPI source through executor.sources.add with elicited credentials", () => | 1131 | it.scoped( |
| 1018 | Effect.gen(function* () { | 1132 | "adds an OpenAPI source through executor.sources.add with elicited credentials", |
| 1019 | const specServer = yield* makeOpenApiSpecServer; | 1133 | () => |
| 1020 | const persistence = yield* makePersistence; | 1134 | Effect.gen(function* () { |
| 1021 | const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); | 1135 | const specServer = yield* makeOpenApiSpecServer; |
| 1022 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | 1136 | const persistence = yield* makePersistence; |
| 1023 | const now = Date.now(); | 1137 | const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); |
| 1024 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( | 1138 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); |
| 1025 | "sec_test_openapi_bearer", | 1139 | const now = Date.now(); |
| 1026 | ); | 1140 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( |
| 1027 | yield* persistence.rows.secretMaterials.upsert({ | 1141 | "sec_test_openapi_bearer", |
| 1028 | id: tokenSecretMaterialId, | 1142 | ); |
| 1029 | name: null, | 1143 | yield* persistence.rows.secretMaterials.upsert({ |
| 1030 | purpose: "auth_material", | 1144 | id: tokenSecretMaterialId, |
| 1031 | value: "ghp_test_token", | 1145 | name: null, |
| 1032 | createdAt: now, | 1146 | purpose: "auth_material", |
| 1033 | updatedAt: now, | 1147 | value: "ghp_test_token", |
| 1034 | }); | 1148 | createdAt: now, |
| 1035 | let capturedElicitation: | 1149 | updatedAt: now, |
| 1036 | | { | 1150 | }); |
| 1037 | mode?: string; | 1151 | let capturedElicitation: { |
| 1038 | url?: string; | 1152 | mode?: string; |
| 1039 | elicitationId?: string; | 1153 | url?: string; |
| 1040 | } | 1154 | elicitationId?: string; |
| 1041 | | null = null; | 1155 | } | null = null; |
| 1042 | 1156 | ||
| 1043 | const environment = yield* resolveEnvironment({ | 1157 | const environment = yield* resolveEnvironment({ |
| 1044 | workspaceId, | 1158 | workspaceId, |
| 1045 | accountId: AccountIdSchema.make("acc_add_openapi"), | 1159 | accountId: AccountIdSchema.make("acc_add_openapi"), |
| 1046 | executionId: ExecutionIdSchema.make("exec_add_openapi"), | 1160 | executionId: ExecutionIdSchema.make("exec_add_openapi"), |
| 1047 | onElicitation: (request) => | 1161 | onElicitation: (request) => |
| 1048 | Effect.sync(() => { | 1162 | Effect.sync(() => { |
| 1049 | capturedElicitation = request.elicitation as { | 1163 | capturedElicitation = request.elicitation as { |
| 1050 | mode?: string; | 1164 | mode?: string; |
| 1051 | url?: string; | 1165 | url?: string; |
| 1052 | elicitationId?: string; | 1166 | elicitationId?: string; |
| 1053 | }; | 1167 | }; |
| 1054 | 1168 | ||
| 1055 | return { | 1169 | return { |
| 1056 | action: "accept" as const, | 1170 | action: "accept" as const, |
| 1057 | content: { | 1171 | content: { |
| 1058 | authKind: "bearer", | 1172 | authKind: "bearer", |
| 1059 | tokenRef: { | 1173 | tokenRef: { |
| 1060 | providerId: "postgres", | 1174 | providerId: "postgres", |
| 1061 | handle: tokenSecretMaterialId, | 1175 | handle: tokenSecretMaterialId, |
| 1176 | }, | ||
| 1062 | }, | 1177 | }, |
| 1063 | }, | 1178 | }; |
| 1064 | }; | 1179 | }), |
| 1065 | }), | 1180 | }); |
| 1066 | }); | ||
| 1067 | 1181 | ||
| 1068 | const added = (yield* environment.toolInvoker.invoke({ | 1182 | const added = (yield* environment.toolInvoker.invoke({ |
| 1069 | path: "executor.sources.add", | 1183 | path: "executor.sources.add", |
| 1070 | args: { | 1184 | args: { |
| 1071 | kind: "openapi", | 1185 | kind: "openapi", |
| 1072 | endpoint: specServer.baseUrl, | 1186 | endpoint: specServer.baseUrl, |
| 1073 | specUrl: specServer.specUrl, | 1187 | specUrl: specServer.specUrl, |
| 1074 | name: "GitHub", | 1188 | name: "GitHub", |
| 1075 | namespace: "github", | 1189 | namespace: "github", |
| 1076 | }, | 1190 | }, |
| 1077 | context: { | 1191 | context: { |
| 1078 | runId: "exec_add_openapi", | 1192 | runId: "exec_add_openapi", |
| 1079 | }, | 1193 | }, |
| 1080 | })) as { | 1194 | })) as { |
| 1081 | id: SourceId; | 1195 | id: SourceId; |
| 1082 | kind: string; | ||
| 1083 | status: string; | ||
| 1084 | auth: { | ||
| 1085 | kind: string; | 1196 | kind: string; |
| 1086 | token?: { | 1197 | status: string; |
| 1087 | providerId: string; | 1198 | auth: { |
| 1199 | kind: string; | ||
| 1200 | token?: { | ||
| 1201 | providerId: string; | ||
| 1202 | }; | ||
| 1088 | }; | 1203 | }; |
| 1089 | }; | 1204 | }; |
| 1090 | }; | ||
| 1091 | 1205 | ||
| 1092 | expect(added.kind).toBe("openapi"); | 1206 | expect(added.kind).toBe("openapi"); |
| 1093 | expect(added.status).toBe("connected"); | 1207 | expect(added.status).toBe("connected"); |
| 1094 | expect(added.auth.kind).toBe("bearer"); | 1208 | expect(added.auth.kind).toBe("bearer"); |
| 1095 | expect(added.auth.token?.providerId).toBe("postgres"); | 1209 | expect(added.auth.token?.providerId).toBe("postgres"); |
| 1096 | expect(capturedElicitation).toMatchObject({ | 1210 | expect(capturedElicitation).toMatchObject({ |
| 1097 | mode: "url", | 1211 | mode: "url", |
| 1098 | url: expect.stringContaining("/v1/workspaces/"), | 1212 | url: expect.stringContaining("/v1/workspaces/"), |
| 1099 | elicitationId: expect.stringContaining("executor.sources.add:"), | 1213 | elicitationId: expect.stringContaining("executor.sources.add:"), |
| 1100 | }); | 1214 | }); |
| 1101 | expect((capturedElicitation as { url?: string } | null)?.url).toContain( | 1215 | expect((capturedElicitation as { url?: string } | null)?.url).toContain( |
| 1102 | encodeURIComponent("exec_add_openapi:executor.sources.add:"), | 1216 | encodeURIComponent("exec_add_openapi:executor.sources.add:"), |
| 1103 | ); | ||
| 1104 | |||
| 1105 | const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId( | ||
| 1106 | workspaceId, | ||
| 1107 | added.id, | ||
| 1108 | ); | ||
| 1109 | expect(Option.isSome(storedSource)).toBe(true); | ||
| 1110 | if (Option.isSome(storedSource)) { | ||
| 1111 | const documents = yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | ||
| 1112 | storedSource.value.recipeRevisionId, | ||
| 1113 | ); | 1217 | ); |
| 1114 | expect(documents[0]?.contentText).toContain('"operationId":"repos.getRepo"'); | ||
| 1115 | } | ||
| 1116 | 1218 | ||
| 1117 | const freshEnvironment = yield* resolveEnvironment({ | 1219 | const storedSource = |
| 1118 | workspaceId, | 1220 | yield* persistence.rows.sources.getByWorkspaceAndId( |
| 1119 | accountId: AccountIdSchema.make("acc_add_openapi"), | 1221 | workspaceId, |
| 1120 | executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"), | 1222 | added.id, |
| 1121 | }); | 1223 | ); |
| 1224 | expect(Option.isSome(storedSource)).toBe(true); | ||
| 1225 | if (Option.isSome(storedSource)) { | ||
| 1226 | const documents = | ||
| 1227 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | ||
| 1228 | storedSource.value.recipeRevisionId, | ||
| 1229 | ); | ||
| 1230 | expect(documents[0]?.contentText).toContain( | ||
| 1231 | '"operationId":"repos.getRepo"', | ||
| 1232 | ); | ||
| 1233 | } | ||
| 1122 | 1234 | ||
| 1123 | const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | 1235 | const freshEnvironment = yield* resolveEnvironment({ |
| 1124 | path: "discover", | 1236 | workspaceId, |
| 1125 | args: { | 1237 | accountId: AccountIdSchema.make("acc_add_openapi"), |
| 1126 | query: "github repo", | 1238 | executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"), |
| 1127 | limit: 5, | 1239 | }); |
| 1128 | }, | ||
| 1129 | })) as { | ||
| 1130 | bestPath: string | null; | ||
| 1131 | }; | ||
| 1132 | 1240 | ||
| 1133 | expect(discovered.bestPath).toBe("github.repos.getRepo"); | 1241 | const discovered = (yield* freshEnvironment.toolInvoker.invoke({ |
| 1242 | path: "discover", | ||
| 1243 | args: { | ||
| 1244 | query: "github repo", | ||
| 1245 | limit: 5, | ||
| 1246 | }, | ||
| 1247 | })) as { | ||
| 1248 | bestPath: string | null; | ||
| 1249 | }; | ||
| 1134 | 1250 | ||
| 1135 | const invoked = (yield* freshEnvironment.toolInvoker.invoke({ | 1251 | expect(discovered.bestPath).toBe("github.repos.getRepo"); |
| 1136 | path: "github.repos.getRepo", | ||
| 1137 | args: { | ||
| 1138 | owner: "vercel", | ||
| 1139 | repo: "ai", | ||
| 1140 | }, | ||
| 1141 | })) as { | ||
| 1142 | ok: boolean; | ||
| 1143 | full_name: string; | ||
| 1144 | }; | ||
| 1145 | 1252 | ||
| 1146 | expect(invoked).toEqual({ | 1253 | const invoked = (yield* freshEnvironment.toolInvoker.invoke({ |
| 1147 | ok: true, | 1254 | path: "github.repos.getRepo", |
| 1148 | full_name: "vercel/ai", | 1255 | args: { |
| 1149 | }); | 1256 | owner: "vercel", |
| 1150 | expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]); | 1257 | repo: "ai", |
| 1151 | }), | 1258 | }, |
| 1259 | })) as { | ||
| 1260 | ok: boolean; | ||
| 1261 | full_name: string; | ||
| 1262 | }; | ||
| 1263 | |||
| 1264 | expect(invoked).toEqual({ | ||
| 1265 | ok: true, | ||
| 1266 | full_name: "vercel/ai", | ||
| 1267 | }); | ||
| 1268 | expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]); | ||
| 1269 | }), | ||
| 1152 | ); | 1270 | ); |
| 1153 | 1271 | ||
| 1154 | it.scoped("adds a GraphQL source through executor.sources.add with elicited credentials", () => | 1272 | it.scoped( |
| 1155 | Effect.gen(function* () { | 1273 | "adds a GraphQL source through executor.sources.add with elicited credentials", |
| 1156 | const graphqlServer = yield* makeGraphqlServer; | 1274 | () => |
| 1157 | const persistence = yield* makePersistence; | 1275 | Effect.gen(function* () { |
| 1158 | const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); | 1276 | const graphqlServer = yield* makeGraphqlServer; |
| 1159 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | 1277 | const persistence = yield* makePersistence; |
| 1160 | const now = Date.now(); | 1278 | const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); |
| 1161 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( | 1279 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); |
| 1162 | "sec_test_graphql_bearer", | 1280 | const now = Date.now(); |
| 1163 | ); | 1281 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( |
| 1164 | yield* persistence.rows.secretMaterials.upsert({ | 1282 | "sec_test_graphql_bearer", |
| 1165 | id: tokenSecretMaterialId, | 1283 | ); |
| 1166 | name: null, | 1284 | yield* persistence.rows.secretMaterials.upsert({ |
| 1167 | purpose: "auth_material", | 1285 | id: tokenSecretMaterialId, |
| 1168 | value: "linear_test_token", | 1286 | name: null, |
| 1169 | createdAt: now, | 1287 | purpose: "auth_material", |
| 1170 | updatedAt: now, | 1288 | value: "linear_test_token", |
| 1171 | }); | 1289 | createdAt: now, |
| 1290 | updatedAt: now, | ||
| 1291 | }); | ||
| 1172 | 1292 | ||
| 1173 | const environment = yield* resolveEnvironment({ | 1293 | const environment = yield* resolveEnvironment({ |
| 1174 | workspaceId, | 1294 | workspaceId, |
| 1175 | accountId: AccountIdSchema.make("acc_add_graphql"), | 1295 | accountId: AccountIdSchema.make("acc_add_graphql"), |
| 1176 | executionId: ExecutionIdSchema.make("exec_add_graphql"), | 1296 | executionId: ExecutionIdSchema.make("exec_add_graphql"), |
| 1177 | onElicitation: () => | 1297 | onElicitation: () => |
| 1178 | Effect.succeed({ | 1298 | Effect.succeed({ |
| 1179 | action: "accept" as const, | 1299 | action: "accept" as const, |
| 1180 | content: { | 1300 | content: { |
| 1181 | authKind: "bearer", | 1301 | authKind: "bearer", |
| 1182 | tokenRef: { | 1302 | tokenRef: { |
| 1183 | providerId: "postgres", | 1303 | providerId: "postgres", |
| 1184 | handle: tokenSecretMaterialId, | 1304 | handle: tokenSecretMaterialId, |
| 1305 | }, | ||
| 1185 | }, | 1306 | }, |
| 1186 | }, | 1307 | }), |
| 1187 | }), | 1308 | }); |
| 1188 | }); | ||
| 1189 | 1309 | ||
| 1190 | const added = (yield* environment.toolInvoker.invoke({ | 1310 | const added = (yield* environment.toolInvoker.invoke({ |
| 1191 | path: "executor.sources.add", | 1311 | path: "executor.sources.add", |
| 1192 | args: { | 1312 | args: { |
| 1193 | kind: "graphql", | 1313 | kind: "graphql", |
| 1194 | endpoint: graphqlServer.endpoint, | 1314 | endpoint: graphqlServer.endpoint, |
| 1195 | name: "Linear", | 1315 | name: "Linear", |
| 1196 | namespace: "linear", | 1316 | namespace: "linear", |
| 1197 | }, | 1317 | }, |
| 1198 | context: { | 1318 | context: { |
| 1199 | runId: "exec_add_graphql", | 1319 | runId: "exec_add_graphql", |
| 1200 | }, | 1320 | }, |
| 1201 | })) as { | 1321 | })) as { |
| 1202 | id: SourceId; | 1322 | id: SourceId; |
| 1203 | kind: string; | ||
| 1204 | status: string; | ||
| 1205 | auth: { | ||
| 1206 | kind: string; | 1323 | kind: string; |
| 1207 | token?: { | 1324 | status: string; |
| 1208 | providerId: string; | 1325 | auth: { |
| 1326 | kind: string; | ||
| 1327 | token?: { | ||
| 1328 | providerId: string; | ||
| 1329 | }; | ||
| 1209 | }; | 1330 | }; |
| 1210 | }; | 1331 | }; |
| 1211 | }; | ||
| 1212 | 1332 | ||
| 1213 | expect(added.kind).toBe("graphql"); | 1333 | expect(added.kind).toBe("graphql"); |
| 1214 | expect(added.status).toBe("connected"); | 1334 | expect(added.status).toBe("connected"); |
| 1215 | expect(added.auth.kind).toBe("bearer"); | 1335 | expect(added.auth.kind).toBe("bearer"); |
| 1216 | expect(added.auth.token?.providerId).toBe("postgres"); | 1336 | expect(added.auth.token?.providerId).toBe("postgres"); |
| 1217 | 1337 | ||
| 1218 | const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId( | 1338 | const storedSource = |
| 1219 | workspaceId, | 1339 | yield* persistence.rows.sources.getByWorkspaceAndId( |
| 1220 | added.id, | 1340 | workspaceId, |
| 1221 | ); | 1341 | added.id, |
| 1222 | expect(Option.isSome(storedSource)).toBe(true); | 1342 | ); |
| 1223 | if (Option.isSome(storedSource)) { | 1343 | expect(Option.isSome(storedSource)).toBe(true); |
| 1224 | const documents = yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | 1344 | if (Option.isSome(storedSource)) { |
| 1225 | storedSource.value.recipeRevisionId, | 1345 | const documents = |
| 1346 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | ||
| 1347 | storedSource.value.recipeRevisionId, | ||
| 1348 | ); | ||
| 1349 | expect(documents[0]?.contentText).toContain('"__schema"'); | ||
| 1350 | expect( | ||
| 1351 | (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId( | ||
| 1352 | storedSource.value.recipeRevisionId, | ||
| 1353 | )).length, | ||
| 1354 | ).toBe(1); | ||
| 1355 | } | ||
| 1356 | |||
| 1357 | const connectedSourceRecord = Option.getOrThrow(storedSource); | ||
| 1358 | yield* persistence.rows.sourceRecipeRevisions.update( | ||
| 1359 | connectedSourceRecord.recipeRevisionId, | ||
| 1360 | { | ||
| 1361 | manifestJson: null, | ||
| 1362 | updatedAt: Date.now(), | ||
| 1363 | }, | ||
| 1226 | ); | 1364 | ); |
| 1227 | expect(documents[0]?.contentText).toContain('"__schema"'); | ||
| 1228 | } | ||
| 1229 | 1365 | ||
| 1230 | const freshEnvironment = yield* resolveEnvironment({ | 1366 | const freshEnvironment = yield* resolveEnvironment({ |
| 1231 | workspaceId, | 1367 | workspaceId, |
| 1232 | accountId: AccountIdSchema.make("acc_add_graphql"), | 1368 | accountId: AccountIdSchema.make("acc_add_graphql"), |
| 1233 | executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), | 1369 | executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), |
| 1234 | onElicitation: () => | 1370 | onElicitation: () => |
| 1235 | Effect.succeed({ | 1371 | Effect.succeed({ |
| 1236 | action: "accept" as const, | 1372 | action: "accept" as const, |
| 1237 | }), | 1373 | }), |
| 1238 | }); | 1374 | }); |
| 1239 | 1375 | ||
| 1240 | const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | 1376 | const discovered = (yield* freshEnvironment.toolInvoker.invoke({ |
| 1241 | path: "discover", | 1377 | path: "discover", |
| 1242 | args: { | 1378 | args: { |
| 1243 | query: "linear current user viewer", | 1379 | query: "linear current user viewer", |
| 1244 | limit: 5, | 1380 | limit: 5, |
| 1245 | }, | 1381 | }, |
| 1246 | })) as { | 1382 | })) as { |
| 1247 | bestPath: string | null; | 1383 | bestPath: string | null; |
| 1248 | }; | 1384 | }; |
| 1249 | 1385 | ||
| 1250 | expect(discovered.bestPath).toBe("linear.viewer"); | 1386 | expect(discovered.bestPath).toBe("linear.viewer"); |
| 1251 | 1387 | ||
| 1252 | const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke({ | 1388 | const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke( |
| 1253 | path: "describe.tool", | 1389 | { |
| 1254 | args: { | 1390 | path: "describe.tool", |
| 1255 | path: "linear.createIssue", | 1391 | args: { |
| 1256 | includeSchemas: true, | 1392 | path: "linear.createIssue", |
| 1257 | }, | 1393 | includeSchemas: true, |
| 1258 | })) as { | 1394 | }, |
| 1259 | path: string; | 1395 | }, |
| 1260 | inputType?: string; | 1396 | )) as { |
| 1261 | inputSchemaJson?: string; | 1397 | path: string; |
| 1262 | } | null; | 1398 | inputType?: string; |
| 1263 | 1399 | inputSchemaJson?: string; | |
| 1264 | expect(describeCreateIssue?.path).toBe("linear.createIssue"); | 1400 | schemaBundleId?: string; |
| 1265 | expect(describeCreateIssue?.inputType).toContain("input"); | 1401 | } | null; |
| 1266 | expect(describeCreateIssue?.inputType).toContain("title"); | 1402 | |
| 1267 | 1403 | expect(describeCreateIssue?.path).toBe("linear.createIssue"); | |
| 1268 | const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({ | 1404 | expect(describeCreateIssue?.inputType).toContain("input"); |
| 1269 | path: "linear.viewer", | 1405 | expect(describeCreateIssue?.inputType).toContain("CreateIssueInput"); |
| 1270 | args: {}, | 1406 | expect(describeCreateIssue?.inputSchemaJson).toContain("$ref"); |
| 1271 | })) as { | 1407 | expect(describeCreateIssue?.schemaBundleId).toBeDefined(); |
| 1272 | data: { | 1408 | |
| 1273 | id: string; | 1409 | const graphqlSchemaBundle = (yield* freshEnvironment.toolInvoker.invoke( |
| 1274 | name: string; | 1410 | { |
| 1275 | __typename: string; | 1411 | path: "describe.schemaBundle", |
| 1412 | args: { | ||
| 1413 | id: describeCreateIssue?.schemaBundleId, | ||
| 1414 | }, | ||
| 1415 | }, | ||
| 1416 | )) as { | ||
| 1417 | kind: string; | ||
| 1418 | refsJson: string; | ||
| 1419 | } | null; | ||
| 1420 | |||
| 1421 | expect(graphqlSchemaBundle?.kind).toBe("json_schema_ref_map"); | ||
| 1422 | expect(graphqlSchemaBundle?.refsJson).toContain("CreateIssueInput"); | ||
| 1423 | expect(graphqlSchemaBundle?.refsJson).toContain('"title"'); | ||
| 1424 | |||
| 1425 | const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1426 | path: "linear.viewer", | ||
| 1427 | args: {}, | ||
| 1428 | })) as { | ||
| 1429 | data: { | ||
| 1430 | id: string; | ||
| 1431 | name: string; | ||
| 1432 | __typename: string; | ||
| 1433 | }; | ||
| 1434 | errors: unknown[]; | ||
| 1276 | }; | 1435 | }; |
| 1277 | errors: unknown[]; | ||
| 1278 | }; | ||
| 1279 | 1436 | ||
| 1280 | expect(viewerResult).toMatchObject({ | 1437 | expect(viewerResult).toMatchObject({ |
| 1281 | data: { | 1438 | data: { |
| 1282 | id: "usr_123", | 1439 | id: "usr_123", |
| 1283 | name: "Rhys", | 1440 | name: "Rhys", |
| 1284 | __typename: "User", | 1441 | __typename: "User", |
| 1285 | }, | 1442 | }, |
| 1286 | errors: [], | 1443 | errors: [], |
| 1287 | }); | 1444 | }); |
| 1288 | 1445 | ||
| 1289 | const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({ | 1446 | const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({ |
| 1290 | path: "linear.createIssue", | 1447 | path: "linear.createIssue", |
| 1291 | args: { | 1448 | args: { |
| 1292 | input: { | 1449 | input: { |
| 1293 | title: "Ship GraphQL field tools", | 1450 | title: "Ship GraphQL field tools", |
| 1451 | }, | ||
| 1294 | }, | 1452 | }, |
| 1295 | }, | 1453 | })) as { |
| 1296 | })) as { | 1454 | data: { |
| 1297 | data: { | 1455 | id: string; |
| 1298 | id: string; | 1456 | identifier: string; |
| 1299 | identifier: string; | 1457 | title: string; |
| 1300 | title: string; | 1458 | __typename: string; |
| 1301 | __typename: string; | 1459 | }; |
| 1460 | errors: unknown[]; | ||
| 1302 | }; | 1461 | }; |
| 1303 | errors: unknown[]; | ||
| 1304 | }; | ||
| 1305 | 1462 | ||
| 1306 | expect(createIssueResult).toMatchObject({ | 1463 | expect(createIssueResult).toMatchObject({ |
| 1307 | data: { | ||
| 1308 | id: "iss_created", | ||
| 1309 | identifier: "ENG-999", | ||
| 1310 | title: "Ship GraphQL field tools", | ||
| 1311 | __typename: "Issue", | ||
| 1312 | }, | ||
| 1313 | errors: [], | ||
| 1314 | }); | ||
| 1315 | |||
| 1316 | const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1317 | path: "linear.request", | ||
| 1318 | args: { | ||
| 1319 | query: "query Viewer { viewer { id name } }", | ||
| 1320 | }, | ||
| 1321 | })) as { | ||
| 1322 | status: number; | ||
| 1323 | body: { | ||
| 1324 | data: { | 1464 | data: { |
| 1325 | viewer: { | 1465 | id: "iss_created", |
| 1326 | id: string; | 1466 | identifier: "ENG-999", |
| 1327 | name: string; | 1467 | title: "Ship GraphQL field tools", |
| 1468 | __typename: "Issue", | ||
| 1469 | }, | ||
| 1470 | errors: [], | ||
| 1471 | }); | ||
| 1472 | |||
| 1473 | const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({ | ||
| 1474 | path: "linear.request", | ||
| 1475 | args: { | ||
| 1476 | query: "query Viewer { viewer { id name } }", | ||
| 1477 | }, | ||
| 1478 | })) as { | ||
| 1479 | status: number; | ||
| 1480 | body: { | ||
| 1481 | data: { | ||
| 1482 | viewer: { | ||
| 1483 | id: string; | ||
| 1484 | name: string; | ||
| 1485 | }; | ||
| 1328 | }; | 1486 | }; |
| 1329 | }; | 1487 | }; |
| 1330 | }; | 1488 | }; |
| 1331 | }; | ||
| 1332 | 1489 | ||
| 1333 | expect(rawRequestResult.status).toBe(200); | 1490 | expect(rawRequestResult.status).toBe(200); |
| 1334 | expect(rawRequestResult.body).toEqual({ | 1491 | expect(rawRequestResult.body).toEqual({ |
| 1335 | data: { | 1492 | data: { |
| 1336 | viewer: { | 1493 | viewer: { |
| 1337 | id: "usr_123", | 1494 | id: "usr_123", |
| 1338 | name: "Rhys", | 1495 | name: "Rhys", |
| 1496 | }, | ||
| 1339 | }, | 1497 | }, |
| 1340 | }, | 1498 | }); |
| 1341 | }); | 1499 | expect(graphqlServer.seenAuthHeaders).toEqual([ |
| 1342 | expect(graphqlServer.seenAuthHeaders).toEqual([ | 1500 | "Bearer linear_test_token", |
| 1343 | "Bearer linear_test_token", | 1501 | "Bearer linear_test_token", |
| 1344 | "Bearer linear_test_token", | 1502 | "Bearer linear_test_token", |
| 1345 | "Bearer linear_test_token", | 1503 | "Bearer linear_test_token", |
| 1346 | "Bearer linear_test_token", | 1504 | ]); |
| 1347 | ]); | 1505 | }), |
| 1348 | }), | ||
| 1349 | ); | 1506 | ); |
| 1350 | 1507 | ||
| 1351 | it.scoped("resolves params-backed source auth from invocation context", () => | 1508 | it.scoped("resolves params-backed source auth from invocation context", () => |
| @@ -1397,44 +1554,105 @@ describe("workspace-execution-environment", () => { · 45 unmodified lines | |||
| 1397 | }), | 1554 | }), |
| 1398 | ); | 1555 | ); |
| 1399 | 1556 | ||
| 1400 | it.scoped("describes executor.sources.add with derived type info and schemas", () => | 1557 | it.scoped( |
| 1401 | Effect.gen(function* () { | 1558 | "describes persisted OpenAPI tools with compact schemas and shared bundle ids", |
| 1402 | const persistence = yield* makePersistence; | 1559 | () => |
| 1403 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | 1560 | Effect.gen(function* () { |
| 1561 | const persistence = yield* makePersistence; | ||
| 1562 | const workspaceId = WorkspaceIdSchema.make( | ||
| 1563 | "ws_openapi_describe_bundle", | ||
| 1564 | ); | ||
| 1565 | const sourceId = SourceIdSchema.make("src_openapi_describe_bundle"); | ||
| 1404 | 1566 | ||
| 1405 | const environment = yield* resolveEnvironment({ | 1567 | yield* persistConnectedGithubOpenApiSource({ |
| 1406 | workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"), | 1568 | persistence, |
| 1407 | accountId: AccountIdSchema.make("acc_describe_add_source"), | 1569 | workspaceId, |
| 1408 | executionId: ExecutionIdSchema.make("exec_describe_add_source"), | 1570 | sourceId, |
| 1409 | }); | 1571 | }); |
| 1410 | 1572 | ||
| 1411 | const described = (yield* environment.toolInvoker.invoke({ | 1573 | const resolveEnvironment = makeResolver(persistence); |
| 1412 | path: "describe.tool", | 1574 | const environment = yield* resolveEnvironment({ |
| 1413 | args: { | 1575 | workspaceId, |
| 1414 | path: "executor.sources.add", | 1576 | accountId: AccountIdSchema.make("acc_openapi_describe_bundle"), |
| 1415 | includeSchemas: true, | 1577 | executionId: ExecutionIdSchema.make("exec_openapi_describe_bundle"), |
| 1416 | }, | 1578 | }); |
| 1417 | })) as { | 1579 | |
| 1418 | path: string; | 1580 | const described = (yield* environment.toolInvoker.invoke({ |
| 1419 | description?: string; | 1581 | path: "describe.tool", |
| 1420 | inputType?: string; | 1582 | args: { |
| 1421 | inputSchemaJson?: string; | 1583 | path: "github.repos.getRepo", |
| 1422 | } | null; | 1584 | includeSchemas: true, |
| 1423 | 1585 | }, | |
| 1424 | expect(described?.path).toBe("executor.sources.add"); | 1586 | })) as { |
| 1425 | expect(described?.description).toContain("Source add input shapes:"); | 1587 | path: string; |
| 1426 | expect(described?.description).toContain("specUrl"); | 1588 | inputSchemaJson?: string; |
| 1427 | expect(described?.description).toContain("credential setup"); | 1589 | outputSchemaJson?: string; |
| 1428 | expect(described?.inputType).toContain("endpoint"); | 1590 | schemaBundleId?: string; |
| 1429 | expect(described?.inputType).not.toContain("auth"); | 1591 | } | null; |
| 1430 | 1592 | ||
| 1431 | const inputSchema = described?.inputSchemaJson | 1593 | expect(described?.path).toBe("github.repos.getRepo"); |
| 1432 | ? JSON.parse(described.inputSchemaJson) as Record<string, unknown> | 1594 | expect(described?.inputSchemaJson).toContain('"owner"'); |
| 1433 | : null; | 1595 | expect(described?.outputSchemaJson).toContain('"type"'); |
| 1434 | expect(inputSchema).not.toBeNull(); | 1596 | expect(described?.schemaBundleId).toBeDefined(); |
| 1435 | expect(JSON.stringify(inputSchema)).toContain("specUrl"); | 1597 | |
| 1436 | expect(JSON.stringify(inputSchema)).toContain("endpoint"); | 1598 | const schemaBundle = (yield* environment.toolInvoker.invoke({ |
| 1437 | expect(JSON.stringify(inputSchema)).not.toContain("\"auth\""); | 1599 | path: "describe.schemaBundle", |
| 1438 | }), | 1600 | args: { |
| 1601 | id: described?.schemaBundleId, | ||
| 1602 | }, | ||
| 1603 | })) as { | ||
| 1604 | id: string; | ||
| 1605 | kind: string; | ||
| 1606 | refsJson: string; | ||
| 1607 | } | null; | ||
| 1608 | |||
| 1609 | expect(schemaBundle?.id).toBe(described?.schemaBundleId); | ||
| 1610 | expect(schemaBundle?.kind).toBe("json_schema_ref_map"); | ||
| 1611 | expect(schemaBundle?.refsJson).toContain("Repository"); | ||
| 1612 | }), | ||
| 1613 | ); | ||
| 1614 | |||
| 1615 | it.scoped( | ||
| 1616 | "describes executor.sources.add with derived type info and schemas", | ||
| 1617 | () => | ||
| 1618 | Effect.gen(function* () { | ||
| 1619 | const persistence = yield* makePersistence; | ||
| 1620 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | ||
| 1621 | |||
| 1622 | const environment = yield* resolveEnvironment({ | ||
| 1623 | workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"), | ||
| 1624 | accountId: AccountIdSchema.make("acc_describe_add_source"), | ||
| 1625 | executionId: ExecutionIdSchema.make("exec_describe_add_source"), | ||
| 1626 | }); | ||
| 1627 | |||
| 1628 | const described = (yield* environment.toolInvoker.invoke({ | ||
| 1629 | path: "describe.tool", | ||
| 1630 | args: { | ||
| 1631 | path: "executor.sources.add", | ||
| 1632 | includeSchemas: true, | ||
| 1633 | }, | ||
| 1634 | })) as { | ||
| 1635 | path: string; | ||
| 1636 | description?: string; | ||
| 1637 | inputType?: string; | ||
| 1638 | inputSchemaJson?: string; | ||
| 1639 | } | null; | ||
| 1640 | |||
| 1641 | expect(described?.path).toBe("executor.sources.add"); | ||
| 1642 | expect(described?.description).toContain("Source add input shapes:"); | ||
| 1643 | expect(described?.description).toContain("specUrl"); | ||
| 1644 | expect(described?.description).toContain("credential setup"); | ||
| 1645 | expect(described?.inputType).toContain("endpoint"); | ||
| 1646 | expect(described?.inputType).toContain("importAuth"); | ||
| 1647 | |||
| 1648 | const inputSchema = described?.inputSchemaJson | ||
| 1649 | ? (JSON.parse(described.inputSchemaJson) as Record<string, unknown>) | ||
| 1650 | : null; | ||
| 1651 | expect(inputSchema).not.toBeNull(); | ||
| 1652 | expect(JSON.stringify(inputSchema)).toContain("specUrl"); | ||
| 1653 | expect(JSON.stringify(inputSchema)).toContain("endpoint"); | ||
| 1654 | expect(JSON.stringify(inputSchema)).toContain('"auth"'); | ||
| 1655 | expect(JSON.stringify(inputSchema)).toContain('"importAuth"'); | ||
| 1656 | }), | ||
| 1439 | ); | 1657 | ); |
| 1440 | }); | 1658 | }); |
| @@ -7,27 +7,19 @@ import { · 6 unmodified lines | |||
| 7 | type ToolInvoker, | 7 | type ToolInvoker, |
| 8 | type ToolNamespace, | 8 | type ToolNamespace, |
| 9 | type ToolPath, | 9 | type ToolPath, |
| 10 | type ToolSchemaBundle, | ||
| 10 | } from "@executor/codemode-core"; | 11 | } from "@executor/codemode-core"; |
| 11 | import { | 12 | import { |
| 12 | createSdkMcpConnector, | 13 | isDenoAvailable, |
| 13 | createMcpToolsFromManifest, | 14 | makeDenoSubprocessExecutor, |
| 14 | type McpToolManifest, | 15 | } from "@executor/runtime-deno-subprocess"; |
| 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"; | ||
| 22 | import { makeSesExecutor } from "@executor/runtime-ses"; | 16 | import { makeSesExecutor } from "@executor/runtime-ses"; |
| 23 | import { | 17 | import { |
| 24 | SqlControlPlaneRowsService, | 18 | SqlControlPlaneRowsService, |
| 25 | type SqlControlPlaneRows, | 19 | type SqlControlPlaneRows, |
| 26 | } from "#persistence"; | 20 | } from "#persistence"; |
| 27 | import type { | 21 | import type { AccountId, Source, SourceRecipeSchemaBundleId } from "#schema"; |
| 28 | AccountId, | 22 | import { SourceRecipeSchemaBundleIdSchema } from "#schema"; |
| 29 | Source, | ||
| 30 | } from "#schema"; | ||
| 31 | import * as Context from "effect/Context"; | 23 | import * as Context from "effect/Context"; |
| 32 | import * as Either from "effect/Either"; | 24 | import * as Either from "effect/Either"; |
| 33 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| @@ -40,28 +32,23 @@ import type { · 6 unmodified lines | |||
| 40 | ResolveExecutionEnvironment, | 32 | ResolveExecutionEnvironment, |
| 41 | } from "./execution-state"; | 33 | } from "./execution-state"; |
| 42 | import { createExecutorToolMap } from "./executor-tools"; | 34 | import { createExecutorToolMap } from "./executor-tools"; |
| 43 | import { | ||
| 44 | createGraphqlToolsFromManifest, | ||
| 45 | type GraphqlToolManifest, | ||
| 46 | } from "./graphql-tools"; | ||
| 47 | import { | 35 | import { |
| 48 | RuntimeSourceAuthServiceTag, | 36 | RuntimeSourceAuthServiceTag, |
| 49 | type RuntimeSourceAuthService, | 37 | type RuntimeSourceAuthService, |
| 50 | } from "./source-auth-service"; | 38 | } from "./source-auth-service"; |
| 51 | import { | 39 | import { |
| 52 | expandRecipeTools, | 40 | loadWorkspaceSourceRecipeToolByPath, |
| 53 | loadWorkspaceSourceRecipes, | 41 | loadWorkspaceSourceRecipeToolIndex, |
| 54 | type LoadedSourceRecipeTool, | 42 | type LoadedSourceRecipeToolIndexEntry, |
| 55 | } from "./source-recipes-runtime"; | 43 | } from "./source-recipes-runtime"; |
| 44 | import { resolveSourceAuthMaterial } from "./source-auth-material"; | ||
| 45 | import { namespaceFromSourceName } from "./source-names"; | ||
| 46 | import { getSourceAdapterForOperation } from "./source-adapters"; | ||
| 56 | import { | 47 | import { |
| 57 | createDefaultSecretMaterialResolver, | 48 | createDefaultSecretMaterialResolver, |
| 58 | type ResolveSecretMaterial, | 49 | type ResolveSecretMaterial, |
| 59 | type SecretMaterialResolveContext, | 50 | type SecretMaterialResolveContext, |
| 60 | } from "./secret-material-providers"; | 51 | } from "./secret-material-providers"; |
| 61 | import { | ||
| 62 | namespaceFromSourceName, | ||
| 63 | resolveSourceAuthMaterial, | ||
| 64 | } from "./tool-artifacts"; | ||
| 65 | import { | 52 | import { |
| 66 | evaluateInvocationPolicy, | 53 | evaluateInvocationPolicy, |
| 67 | type InvocationDescriptor, | 54 | type InvocationDescriptor, |
| @@ -76,7 +63,6 @@ const tokenize = (value: string): string[] => · 8 unmodified lines | |||
| 76 | .split(/[^a-z0-9]+/) | 63 | .split(/[^a-z0-9]+/) |
| 77 | .filter(Boolean); | 64 | .filter(Boolean); |
| 78 | 65 | ||
| 79 | |||
| 80 | const LOW_SIGNAL_QUERY_TOKENS = new Set([ | 66 | const LOW_SIGNAL_QUERY_TOKENS = new Set([ |
| 81 | "a", | 67 | "a", |
| 82 | "an", | 68 | "an", |
| @@ -101,15 +87,15 @@ const LOW_SIGNAL_QUERY_TOKENS = new Set([ · 18 unmodified lines | |||
| 101 | ]); | 87 | ]); |
| 102 | 88 | ||
| 103 | const singularizeToken = (value: string): string => | 89 | const singularizeToken = (value: string): string => |
| 104 | value.length > 3 && value.endsWith("s") | 90 | value.length > 3 && value.endsWith("s") ? value.slice(0, -1) : value; |
| 105 | ? value.slice(0, -1) | ||
| 106 | : value; | ||
| 107 | 91 | ||
| 108 | const tokenEquals = (left: string, right: string): boolean => | 92 | const tokenEquals = (left: string, right: string): boolean => |
| 109 | left === right || singularizeToken(left) === singularizeToken(right); | 93 | left === right || singularizeToken(left) === singularizeToken(right); |
| 110 | 94 | ||
| 111 | const hasTokenMatch = (tokens: readonly string[], queryToken: string): boolean => | 95 | const hasTokenMatch = ( |
| 112 | tokens.some((token) => tokenEquals(token, queryToken)); | 96 | tokens: readonly string[], |
| 97 | queryToken: string, | ||
| 98 | ): boolean => tokens.some((token) => tokenEquals(token, queryToken)); | ||
| 113 | 99 | ||
| 114 | const hasSubstringMatch = (value: string, queryToken: string): boolean => { | 100 | const hasSubstringMatch = (value: string, queryToken: string): boolean => { |
| 115 | if (value.includes(queryToken)) { | 101 | if (value.includes(queryToken)) { |
| @@ -132,7 +118,6 @@ const SecretResolutionContextEnvelopeSchema = Schema.Struct({ · 16 unmodified lines | |||
| 132 | const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither( | 118 | const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither( |
| 133 | SecretResolutionContextEnvelopeSchema, | 119 | SecretResolutionContextEnvelopeSchema, |
| 134 | ); | 120 | ); |
| 135 | |||
| 136 | const toSecretResolutionContext = ( | 121 | const toSecretResolutionContext = ( |
| 137 | value: unknown, | 122 | value: unknown, |
| 138 | ): SecretMaterialResolveContext | undefined => { | 123 | ): SecretMaterialResolveContext | undefined => { |
| @@ -154,37 +139,89 @@ const loadWorkspaceRecipeTools = (input: { · 15 unmodified lines | |||
| 154 | workspaceId: Source["workspaceId"]; | 139 | workspaceId: Source["workspaceId"]; |
| 155 | accountId: AccountId; | 140 | accountId: AccountId; |
| 156 | includeSchemas: boolean; | 141 | includeSchemas: boolean; |
| 157 | }): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> => | 142 | }): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> => |
| 158 | Effect.gen(function* () { | 143 | Effect.map( |
| 159 | const recipes = yield* loadWorkspaceSourceRecipes({ | 144 | loadWorkspaceSourceRecipeToolIndex({ |
| 160 | rows: input.rows, | 145 | rows: input.rows, |
| 161 | workspaceId: input.workspaceId, | 146 | workspaceId: input.workspaceId, |
| 162 | actorAccountId: input.accountId, | 147 | actorAccountId: input.accountId, |
| 163 | }); | ||
| 164 | |||
| 165 | return expandRecipeTools({ | ||
| 166 | recipes: recipes.filter((recipe) => | ||
| 167 | recipe.source.enabled && recipe.source.status === "connected" | ||
| 168 | ), | ||
| 169 | includeSchemas: input.includeSchemas, | 148 | includeSchemas: input.includeSchemas, |
| 170 | }); | 149 | }), |
| 150 | (tools) => | ||
| 151 | tools.filter( | ||
| 152 | (tool) => tool.source.enabled && tool.source.status === "connected", | ||
| 153 | ), | ||
| 154 | ); | ||
| 155 | |||
| 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 | }; | ||
| 171 | }); | 208 | }); |
| 172 | 209 | ||
| 173 | const scoreRecipeTool = ( | 210 | const scoreRecipeTool = ( |
| 174 | queryTokens: readonly string[], | 211 | queryTokens: readonly string[], |
| 175 | tool: LoadedSourceRecipeTool, | 212 | tool: LoadedSourceRecipeToolIndexEntry, |
| 176 | ): number => { | 213 | ): number => { |
| 177 | const pathText = tool.path.toLowerCase(); | 214 | const pathText = tool.path.toLowerCase(); |
| 178 | const namespaceText = tool.searchNamespace.toLowerCase(); | 215 | const namespaceText = tool.searchNamespace.toLowerCase(); |
| 179 | const toolIdText = tool.operation.toolId.toLowerCase(); | 216 | const toolIdText = tool.operation.toolId.toLowerCase(); |
| 180 | const titleText = tool.operation.title?.toLowerCase() ?? ""; | 217 | const titleText = tool.operation.title?.toLowerCase() ?? ""; |
| 181 | const descriptionText = tool.operation.description?.toLowerCase() ?? ""; | 218 | const descriptionText = tool.operation.description?.toLowerCase() ?? ""; |
| 182 | const templateText = tool.operation.openApiPathTemplate?.toLowerCase() ?? ""; | 219 | const templateText = tool.metadata.pathTemplate?.toLowerCase() ?? ""; |
| 183 | 220 | ||
| 184 | const pathTokens = tokenize(`${tool.path} ${tool.operation.toolId}`); | 221 | const pathTokens = tokenize(`${tool.path} ${tool.operation.toolId}`); |
| 185 | const namespaceTokens = tokenize(tool.searchNamespace); | 222 | const namespaceTokens = tokenize(tool.searchNamespace); |
| 186 | const titleTokens = tokenize(tool.operation.title ?? ""); | 223 | const titleTokens = tokenize(tool.operation.title ?? ""); |
| 187 | const templateTokens = tokenize(tool.operation.openApiPathTemplate ?? ""); | 224 | const templateTokens = tokenize(tool.metadata.pathTemplate ?? ""); |
| 188 | 225 | ||
| 189 | let score = 0; | 226 | let score = 0; |
| 190 | let structuralHits = 0; | 227 | let structuralHits = 0; |
| @@ -220,7 +257,10 @@ const scoreRecipeTool = ( · 29 unmodified lines | |||
| 220 | continue; | 257 | continue; |
| 221 | } | 258 | } |
| 222 | 259 | ||
| 223 | if (hasSubstringMatch(pathText, token) || hasSubstringMatch(toolIdText, token)) { | 260 | if ( |
| 261 | hasSubstringMatch(pathText, token) || | ||
| 262 | hasSubstringMatch(toolIdText, token) | ||
| 263 | ) { | ||
| 224 | score += 6 * weight; | 264 | score += 6 * weight; |
| 225 | structuralHits += 1; | 265 | structuralHits += 1; |
| 226 | pathHits += 1; | 266 | pathHits += 1; |
| @@ -234,7 +274,10 @@ const scoreRecipeTool = ( · 7 unmodified lines | |||
| 234 | continue; | 274 | continue; |
| 235 | } | 275 | } |
| 236 | 276 | ||
| 237 | if (hasSubstringMatch(titleText, token) || hasSubstringMatch(templateText, token)) { | 277 | if ( |
| 278 | hasSubstringMatch(titleText, token) || | ||
| 279 | hasSubstringMatch(templateText, token) | ||
| 280 | ) { | ||
| 238 | score += 4 * weight; | 281 | score += 4 * weight; |
| 239 | structuralHits += 1; | 282 | structuralHits += 1; |
| 240 | continue; | 283 | continue; |
| @@ -245,7 +288,9 @@ const scoreRecipeTool = ( · 4 unmodified lines | |||
| 245 | } | 288 | } |
| 246 | } | 289 | } |
| 247 | 290 | ||
| 248 | const strongTokens = queryTokens.filter((token) => queryTokenWeight(token) >= 1); | 291 | const strongTokens = queryTokens.filter( |
| 292 | (token) => queryTokenWeight(token) >= 1, | ||
| 293 | ); | ||
| 249 | if (strongTokens.length >= 2) { | 294 | if (strongTokens.length >= 2) { |
| 250 | for (let index = 0; index < strongTokens.length - 1; index += 1) { | 295 | for (let index = 0; index < strongTokens.length - 1; index += 1) { |
| 251 | const current = strongTokens[index]!; | 296 | const current = strongTokens[index]!; |
| @@ -256,7 +301,12 @@ const scoreRecipeTool = ( · 4 unmodified lines | |||
| 256 | `${current}/${next}`, | 301 | `${current}/${next}`, |
| 257 | ]; | 302 | ]; |
| 258 | 303 | ||
| 259 | if (phrases.some((phrase) => pathText.includes(phrase) || templateText.includes(phrase))) { | 304 | if ( |
| 305 | phrases.some( | ||
| 306 | (phrase) => | ||
| 307 | pathText.includes(phrase) || templateText.includes(phrase), | ||
| 308 | ) | ||
| 309 | ) { | ||
| 260 | score += 10; | 310 | score += 10; |
| 261 | } | 311 | } |
| 262 | } | 312 | } |
| @@ -285,39 +335,29 @@ const approvalSchema = { · 22 unmodified lines | |||
| 285 | additionalProperties: false, | 335 | additionalProperties: false, |
| 286 | } satisfies Record<string, unknown>; | 336 | } satisfies Record<string, unknown>; |
| 287 | 337 | ||
| 288 | const approvalMessageForInvocation = (descriptor: InvocationDescriptor): string => { | 338 | const approvalMessageForInvocation = ( |
| 289 | if (descriptor.httpMethod && descriptor.httpPathTemplate) { | 339 | descriptor: InvocationDescriptor, |
| 290 | return `Allow ${descriptor.httpMethod.toUpperCase()} ${descriptor.httpPathTemplate}?`; | 340 | ): string => { |
| 291 | } | 341 | if (descriptor.approvalLabel) { |
| 292 | 342 | return `Allow ${descriptor.approvalLabel}?`; | |
| 293 | if (descriptor.graphqlOperationType) { | ||
| 294 | return `Allow GraphQL ${descriptor.graphqlOperationType} ${descriptor.toolPath}?`; | ||
| 295 | } | 343 | } |
| 296 | 344 | ||
| 297 | return `Allow tool call: ${descriptor.toolPath}?`; | 345 | return `Allow tool call: ${descriptor.toolPath}?`; |
| 298 | }; | 346 | }; |
| 299 | 347 | ||
| 300 | const toGraphqlInvocationOperationType = ( | ||
| 301 | value: string | null, | ||
| 302 | ): InvocationDescriptor["graphqlOperationType"] => | ||
| 303 | value === "query" || value === "mutation" || value === "subscription" | ||
| 304 | ? value | ||
| 305 | : null; | ||
| 306 | |||
| 307 | const toInvocationDescriptorFromRecipeTool = (input: { | 348 | const toInvocationDescriptorFromRecipeTool = (input: { |
| 308 | tool: LoadedSourceRecipeTool; | 349 | tool: LoadedSourceRecipeToolIndexEntry; |
| 309 | }): InvocationDescriptor => ({ | 350 | }): InvocationDescriptor => ({ |
| 310 | toolPath: input.tool.path, | 351 | toolPath: input.tool.path, |
| 311 | sourceId: input.tool.source.id, | 352 | sourceId: input.tool.source.id, |
| 312 | sourceName: input.tool.source.name, | 353 | sourceName: input.tool.source.name, |
| 313 | sourceKind: input.tool.source.kind, | 354 | sourceKind: input.tool.source.kind, |
| 314 | sourceNamespace: input.tool.source.namespace ?? namespaceFromSourceName(input.tool.source.name), | 355 | sourceNamespace: |
| 356 | input.tool.source.namespace ?? | ||
| 357 | namespaceFromSourceName(input.tool.source.name), | ||
| 315 | operationKind: input.tool.operation.operationKind, | 358 | operationKind: input.tool.operation.operationKind, |
| 316 | httpMethod: input.tool.operation.openApiMethod?.toUpperCase() ?? null, | 359 | interaction: input.tool.metadata.interaction, |
| 317 | httpPathTemplate: input.tool.operation.openApiPathTemplate, | 360 | approvalLabel: input.tool.metadata.approvalLabel, |
| 318 | graphqlOperationType: toGraphqlInvocationOperationType( | ||
| 319 | input.tool.operation.graphqlOperationType, | ||
| 320 | ), | ||
| 321 | }); | 361 | }); |
| 322 | 362 | ||
| 323 | const authorizePersistedToolInvocation = (input: { | 363 | const authorizePersistedToolInvocation = (input: { |
| @@ -328,23 +368,29 @@ const authorizePersistedToolInvocation = (input: { · 4 unmodified lines | |||
| 328 | args: unknown; | 368 | args: unknown; |
| 329 | source: Source; | 369 | source: Source; |
| 330 | context?: Record<string, unknown>; | 370 | context?: Record<string, unknown>; |
| 331 | onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"]; | 371 | onElicitation?: Parameters< |
| 372 | typeof makeToolInvokerFromTools | ||
| 373 | >[0]["onElicitation"]; | ||
| 332 | }): Effect.Effect<void, Error, never> => | 374 | }): Effect.Effect<void, Error, never> => |
| 333 | Effect.gen(function* () { | 375 | Effect.gen(function* () { |
| 334 | const workspace = yield* input.rows.workspaces.getById(input.workspaceId).pipe( | 376 | const workspace = yield* input.rows.workspaces |
| 335 | Effect.mapError((cause) => | 377 | .getById(input.workspaceId) |
| 336 | cause instanceof Error ? cause : new Error(String(cause)), | 378 | .pipe( |
| 337 | ), | ||
| 338 | ); | ||
| 339 | const policies = Option.isSome(workspace) | ||
| 340 | ? yield* input.rows.policies.listForWorkspaceContext({ | ||
| 341 | organizationId: workspace.value.organizationId, | ||
| 342 | workspaceId: input.workspaceId, | ||
| 343 | }).pipe( | ||
| 344 | Effect.mapError((cause) => | 379 | Effect.mapError((cause) => |
| 345 | cause instanceof Error ? cause : new Error(String(cause)), | 380 | cause instanceof Error ? cause : new Error(String(cause)), |
| 346 | ), | 381 | ), |
| 347 | ) | 382 | ); |
| 383 | const policies = Option.isSome(workspace) | ||
| 384 | ? yield* input.rows.policies | ||
| 385 | .listForWorkspaceContext({ | ||
| 386 | organizationId: workspace.value.organizationId, | ||
| 387 | workspaceId: input.workspaceId, | ||
| 388 | }) | ||
| 389 | .pipe( | ||
| 390 | Effect.mapError((cause) => | ||
| 391 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 392 | ), | ||
| 393 | ) | ||
| 348 | : []; | 394 | : []; |
| 349 | 395 | ||
| 350 | const decision = evaluateInvocationPolicy({ | 396 | const decision = evaluateInvocationPolicy({ |
| @@ -358,8 +404,8 @@ const authorizePersistedToolInvocation = (input: { · 7 unmodified lines | |||
| 358 | : ("org_unknown" as never), | 404 | : ("org_unknown" as never), |
| 359 | accountId: input.accountId, | 405 | accountId: input.accountId, |
| 360 | clientId: | 406 | clientId: |
| 361 | typeof input.context?.clientId === "string" | 407 | typeof input.context?.clientId === "string" && |
| 362 | && input.context.clientId.length > 0 | 408 | input.context.clientId.length > 0 |
| 363 | ? input.context.clientId | 409 | ? input.context.clientId |
| 364 | : null, | 410 | : null, |
| 365 | }, | 411 | }, |
| @@ -375,45 +421,52 @@ const authorizePersistedToolInvocation = (input: { · 9 unmodified lines | |||
| 375 | 421 | ||
| 376 | if (!input.onElicitation) { | 422 | if (!input.onElicitation) { |
| 377 | return yield* Effect.fail( | 423 | return yield* Effect.fail( |
| 378 | new Error(`Approval required for ${input.descriptor.toolPath}, but no elicitation-capable host is available`), | 424 | new Error( |
| 425 | `Approval required for ${input.descriptor.toolPath}, but no elicitation-capable host is available`, | ||
| 426 | ), | ||
| 379 | ); | 427 | ); |
| 380 | } | 428 | } |
| 381 | 429 | ||
| 382 | const interactionId = typeof input.context?.callId === "string" && input.context.callId.length > 0 | 430 | const interactionId = |
| 383 | ? `tool_execution_gate:${input.context.callId}` | 431 | typeof input.context?.callId === "string" && |
| 384 | : `tool_execution_gate:${crypto.randomUUID()}`; | 432 | input.context.callId.length > 0 |
| 385 | const response = yield* input.onElicitation({ | 433 | ? `tool_execution_gate:${input.context.callId}` |
| 386 | interactionId, | 434 | : `tool_execution_gate:${crypto.randomUUID()}`; |
| 387 | path: asToolPath(input.descriptor.toolPath), | 435 | const response = yield* input |
| 388 | sourceKey: input.source.id, | 436 | .onElicitation({ |
| 389 | args: input.args, | 437 | interactionId, |
| 390 | context: { | 438 | path: asToolPath(input.descriptor.toolPath), |
| 391 | ...(input.context ?? {}), | 439 | sourceKey: input.source.id, |
| 392 | interactionPurpose: "tool_execution_gate", | 440 | args: input.args, |
| 393 | interactionReason: decision.reason, | 441 | context: { |
| 394 | invocationDescriptor: { | 442 | ...(input.context ?? {}), |
| 395 | operationKind: input.descriptor.operationKind, | 443 | interactionPurpose: "tool_execution_gate", |
| 396 | httpMethod: input.descriptor.httpMethod, | 444 | interactionReason: decision.reason, |
| 397 | httpPathTemplate: input.descriptor.httpPathTemplate, | 445 | invocationDescriptor: { |
| 398 | graphqlOperationType: input.descriptor.graphqlOperationType, | 446 | operationKind: input.descriptor.operationKind, |
| 399 | sourceId: input.source.id, | 447 | interaction: input.descriptor.interaction, |
| 400 | sourceName: input.source.name, | 448 | approvalLabel: input.descriptor.approvalLabel, |
| 449 | sourceId: input.source.id, | ||
| 450 | sourceName: input.source.name, | ||
| 451 | }, | ||
| 401 | }, | 452 | }, |
| 402 | }, | 453 | elicitation: { |
| 403 | elicitation: { | 454 | mode: "form", |
| 404 | mode: "form", | 455 | message: approvalMessageForInvocation(input.descriptor), |
| 405 | message: approvalMessageForInvocation(input.descriptor), | 456 | requestedSchema: approvalSchema, |
| 406 | requestedSchema: approvalSchema, | 457 | }, |
| 407 | }, | 458 | }) |
| 408 | }).pipe( | 459 | .pipe( |
| 409 | Effect.mapError((cause) => | 460 | Effect.mapError((cause) => |
| 410 | cause instanceof Error ? cause : new Error(String(cause)), | 461 | cause instanceof Error ? cause : new Error(String(cause)), |
| 411 | ), | 462 | ), |
| 412 | ); | 463 | ); |
| 413 | 464 | ||
| 414 | if (response.action !== "accept") { | 465 | if (response.action !== "accept") { |
| 415 | return yield* Effect.fail( | 466 | return yield* Effect.fail( |
| 416 | new Error(`Tool invocation not approved for ${input.descriptor.toolPath}`), | 467 | new Error( |
| 468 | `Tool invocation not approved for ${input.descriptor.toolPath}`, | ||
| 469 | ), | ||
| 417 | ); | 470 | ); |
| 418 | } | 471 | } |
| 419 | }); | 472 | }); |
| @@ -450,7 +503,8 @@ const createWorkspaceToolCatalog = (input: { · 30 unmodified lines | |||
| 450 | namespace: namespace.namespace, | 503 | namespace: namespace.namespace, |
| 451 | displayName: namespace.displayName ?? existing?.displayName, | 504 | displayName: namespace.displayName ?? existing?.displayName, |
| 452 | toolCount: | 505 | toolCount: |
| 453 | namespace.toolCount !== undefined || existing?.toolCount === undefined | 506 | namespace.toolCount !== undefined || |
| 507 | existing?.toolCount === undefined | ||
| 454 | ? namespace.toolCount | 508 | ? namespace.toolCount |
| 455 | : existing.toolCount, | 509 | : existing.toolCount, |
| 456 | }); | 510 | }); |
| @@ -478,7 +532,9 @@ const createWorkspaceToolCatalog = (input: { · 21 unmodified lines | |||
| 478 | if (!query) { | 532 | if (!query) { |
| 479 | return true; | 533 | return true; |
| 480 | } | 534 | } |
| 481 | return tokenize(query).every((token) => tool.searchText.includes(token)); | 535 | return tokenize(query).every((token) => |
| 536 | tool.searchText.includes(token), | ||
| 537 | ); | ||
| 482 | }), | 538 | }), |
| 483 | ), | 539 | ), |
| 484 | ), | 540 | ), |
| @@ -490,10 +546,7 @@ const createWorkspaceToolCatalog = (input: { · 5 unmodified lines | |||
| 490 | }), | 546 | }), |
| 491 | ]); | 547 | ]); |
| 492 | 548 | ||
| 493 | return [ | 549 | return [...recipeTools.map((tool) => tool.descriptor), ...executor] |
| 494 | ...recipeTools.map((tool) => tool.descriptor), | ||
| 495 | ...executor, | ||
| 496 | ] | ||
| 497 | .sort((left, right) => left.path.localeCompare(right.path)) | 550 | .sort((left, right) => left.path.localeCompare(right.path)) |
| 498 | .slice(0, limit); | 551 | .slice(0, limit); |
| 499 | }), | 552 | }), |
| @@ -508,13 +561,21 @@ const createWorkspaceToolCatalog = (input: { · 8 unmodified lines | |||
| 508 | return executor; | 561 | return executor; |
| 509 | } | 562 | } |
| 510 | 563 | ||
| 511 | const recipeTools = yield* loadWorkspaceRecipeTools({ | 564 | const recipeTool = yield* loadWorkspaceRecipeToolByPath({ |
| 512 | rows: input.rows, | 565 | rows: input.rows, |
| 513 | workspaceId: input.workspaceId, | 566 | workspaceId: input.workspaceId, |
| 514 | accountId: input.accountId, | 567 | accountId: input.accountId, |
| 568 | path, | ||
| 515 | includeSchemas, | 569 | includeSchemas, |
| 516 | }); | 570 | }); |
| 517 | return recipeTools.find((tool) => tool.path === path)?.descriptor ?? null; | 571 | return recipeTool?.descriptor ?? null; |
| 572 | }), | ||
| 573 | |||
| 574 | getSchemaBundle: ({ id }) => | ||
| 575 | loadWorkspaceSchemaBundle({ | ||
| 576 | rows: input.rows, | ||
| 577 | workspaceId: input.workspaceId, | ||
| 578 | id: SourceRecipeSchemaBundleIdSchema.make(id), | ||
| 518 | }), | 579 | }), |
| 519 | 580 | ||
| 520 | searchTools: ({ query, namespace, limit }) => | 581 | searchTools: ({ query, namespace, limit }) => |
| @@ -528,7 +589,9 @@ const createWorkspaceToolCatalog = (input: { · 7 unmodified lines | |||
| 528 | includeSchemas: false, | 589 | includeSchemas: false, |
| 529 | }).pipe( | 590 | }).pipe( |
| 530 | Effect.map((tools) => | 591 | Effect.map((tools) => |
| 531 | tools.filter((tool) => !namespace || tool.searchNamespace === namespace), | 592 | tools.filter( |
| 593 | (tool) => !namespace || tool.searchNamespace === namespace, | ||
| 594 | ), | ||
| 532 | ), | 595 | ), |
| 533 | ), | 596 | ), |
| 534 | input.executorCatalog.searchTools({ | 597 | input.executorCatalog.searchTools({ |
| @@ -546,8 +609,9 @@ const createWorkspaceToolCatalog = (input: { · 11 unmodified lines | |||
| 546 | .filter((hit) => hit.score > 0); | 609 | .filter((hit) => hit.score > 0); |
| 547 | 610 | ||
| 548 | return [...recipeHits, ...executor] | 611 | return [...recipeHits, ...executor] |
| 549 | .sort((left, right) => | 612 | .sort( |
| 550 | right.score - left.score || left.path.localeCompare(right.path), | 613 | (left, right) => |
| 614 | right.score - left.score || left.path.localeCompare(right.path), | ||
| 551 | ) | 615 | ) |
| 552 | .slice(0, limit); | 616 | .slice(0, limit); |
| 553 | }), | 617 | }), |
| @@ -559,11 +623,13 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines | |||
| 559 | rows: SqlControlPlaneRows; | 623 | rows: SqlControlPlaneRows; |
| 560 | resolveSecretMaterial: ResolveSecretMaterial; | 624 | resolveSecretMaterial: ResolveSecretMaterial; |
| 561 | sourceAuthService: RuntimeSourceAuthService; | 625 | sourceAuthService: RuntimeSourceAuthService; |
| 562 | onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"]; | 626 | onElicitation?: Parameters< |
| 627 | typeof makeToolInvokerFromTools | ||
| 628 | >[0]["onElicitation"]; | ||
| 563 | }): { | 629 | }): { |
| 564 | catalog: ToolCatalog; | 630 | catalog: ToolCatalog; |
| 565 | toolInvoker: ToolInvoker; | 631 | toolInvoker: ToolInvoker; |
| 566 | } => { | 632 | } => { |
| 567 | const executorTools = createExecutorToolMap({ | 633 | const executorTools = createExecutorToolMap({ |
| 568 | workspaceId: input.workspaceId, | 634 | workspaceId: input.workspaceId, |
| 569 | accountId: input.accountId, | 635 | accountId: input.accountId, |
| @@ -596,15 +662,17 @@ const createWorkspaceToolInvoker = (input: { · 26 unmodified lines | |||
| 596 | context?: Record<string, unknown>; | 662 | context?: Record<string, unknown>; |
| 597 | }) => | 663 | }) => |
| 598 | Effect.gen(function* () { | 664 | Effect.gen(function* () { |
| 599 | const recipeTools = yield* loadWorkspaceRecipeTools({ | 665 | const recipeTool = yield* loadWorkspaceRecipeToolByPath({ |
| 600 | rows: input.rows, | 666 | rows: input.rows, |
| 601 | workspaceId: input.workspaceId, | 667 | workspaceId: input.workspaceId, |
| 602 | accountId: input.accountId, | 668 | accountId: input.accountId, |
| 669 | path: invocation.path, | ||
| 603 | includeSchemas: false, | 670 | includeSchemas: false, |
| 604 | }); | 671 | }); |
| 605 | const recipeTool = recipeTools.find((tool) => tool.path === invocation.path); | ||
| 606 | if (!recipeTool) { | 672 | if (!recipeTool) { |
| 607 | return yield* Effect.fail(new Error(`Unknown tool path: ${invocation.path}`)); | 673 | return yield* Effect.fail( |
| 674 | new Error(`Unknown tool path: ${invocation.path}`), | ||
| 675 | ); | ||
| 608 | } | 676 | } |
| 609 | 677 | ||
| 610 | yield* authorizePersistedToolInvocation({ | 678 | yield* authorizePersistedToolInvocation({ |
| @@ -623,94 +691,40 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines | |||
| 623 | resolveSecretMaterial: input.resolveSecretMaterial, | 691 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 624 | context: toSecretResolutionContext(invocation.context), | 692 | context: toSecretResolutionContext(invocation.context), |
| 625 | }); | 693 | }); |
| 626 | 694 | const schemaBundle = recipeTool.schemaBundleId | |
| 627 | if (recipeTool.operation.providerKind === "openapi") { | 695 | ? yield* loadWorkspaceSchemaBundle({ |
| 628 | if (recipeTool.manifest === null) { | 696 | rows: input.rows, |
| 629 | return yield* Effect.fail( | 697 | workspaceId: input.workspaceId, |
| 630 | new Error(`Missing OpenAPI manifest for ${recipeTool.source.id}`), | 698 | id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId), |
| 631 | ); | 699 | }) |
| 632 | } | 700 | : null; |
| 633 | 701 | const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( | |
| 634 | const tools = createOpenApiToolsFromManifest({ | 702 | input.workspaceId, |
| 635 | manifest: recipeTool.manifest as OpenApiToolManifest, | 703 | recipeTool.source.id, |
| 636 | baseUrl: recipeTool.source.endpoint, | 704 | ); |
| 637 | namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name), | 705 | let manifestJson: string | null = null; |
| 638 | sourceKey: recipeTool.source.id, | 706 | if (Option.isSome(sourceRecord)) { |
| 639 | defaultHeaders: recipeTool.source.defaultHeaders ?? {}, | 707 | const revision = yield* input.rows.sourceRecipeRevisions.getById( |
| 640 | credentialHeaders: auth.headers, | 708 | sourceRecord.value.recipeRevisionId, |
| 641 | }); | 709 | ); |
| 642 | 710 | manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null; | |
| 643 | return yield* makeToolInvokerFromTools({ | ||
| 644 | tools, | ||
| 645 | onElicitation: input.onElicitation, | ||
| 646 | }).invoke({ | ||
| 647 | path: invocation.path, | ||
| 648 | args: invocation.args, | ||
| 649 | context: invocation.context, | ||
| 650 | }); | ||
| 651 | } | 711 | } |
| 652 | 712 | ||
| 653 | if (recipeTool.operation.providerKind === "graphql") { | 713 | return yield* getSourceAdapterForOperation(recipeTool.operation) |
| 654 | if (recipeTool.manifest === null) { | 714 | .invokePersistedTool({ |
| 655 | return yield* Effect.fail( | 715 | rows: input.rows, |
| 656 | new Error(`Missing GraphQL manifest for ${recipeTool.source.id}`), | 716 | workspaceId: input.workspaceId, |
| 657 | ); | 717 | accountId: input.accountId, |
| 658 | } | 718 | source: recipeTool.source, |
| 659 | |||
| 660 | const tools = createGraphqlToolsFromManifest({ | ||
| 661 | manifest: recipeTool.manifest as GraphqlToolManifest, | ||
| 662 | endpoint: recipeTool.source.endpoint, | ||
| 663 | namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name), | ||
| 664 | sourceKey: recipeTool.source.id, | ||
| 665 | defaultHeaders: recipeTool.source.defaultHeaders ?? {}, | ||
| 666 | credentialHeaders: auth.headers, | ||
| 667 | }); | ||
| 668 | |||
| 669 | return yield* makeToolInvokerFromTools({ | ||
| 670 | tools, | ||
| 671 | onElicitation: input.onElicitation, | ||
| 672 | }).invoke({ | ||
| 673 | path: invocation.path, | 719 | path: invocation.path, |
| 720 | operation: recipeTool.operation, | ||
| 721 | schemaBundle, | ||
| 722 | manifestJson, | ||
| 723 | auth, | ||
| 674 | args: invocation.args, | 724 | args: invocation.args, |
| 675 | context: invocation.context, | 725 | context: invocation.context, |
| 676 | }); | ||
| 677 | } | ||
| 678 | |||
| 679 | if (recipeTool.operation.providerKind === "mcp") { | ||
| 680 | if (recipeTool.manifest === null) { | ||
| 681 | return yield* Effect.fail( | ||
| 682 | new Error(`Missing MCP manifest for ${recipeTool.source.id}`), | ||
| 683 | ); | ||
| 684 | } | ||
| 685 | |||
| 686 | const tools = createMcpToolsFromManifest({ | ||
| 687 | manifest: recipeTool.manifest as McpToolManifest, | ||
| 688 | connect: createSdkMcpConnector({ | ||
| 689 | endpoint: recipeTool.source.endpoint, | ||
| 690 | transport: recipeTool.source.transport ?? undefined, | ||
| 691 | queryParams: recipeTool.source.queryParams ?? undefined, | ||
| 692 | headers: { | ||
| 693 | ...(recipeTool.source.headers ?? {}), | ||
| 694 | ...auth.headers, | ||
| 695 | }, | ||
| 696 | }), | ||
| 697 | namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name), | ||
| 698 | sourceKey: recipeTool.source.id, | ||
| 699 | }); | ||
| 700 | |||
| 701 | return yield* makeToolInvokerFromTools({ | ||
| 702 | tools, | ||
| 703 | onElicitation: input.onElicitation, | 726 | onElicitation: input.onElicitation, |
| 704 | }).invoke({ | ||
| 705 | path: invocation.path, | ||
| 706 | args: invocation.args, | ||
| 707 | context: invocation.context, | ||
| 708 | }); | 727 | }); |
| 709 | } | ||
| 710 | |||
| 711 | return yield* Effect.fail( | ||
| 712 | new Error(`Unsupported stored tool provider for ${invocation.path}`), | ||
| 713 | ); | ||
| 714 | }); | 728 | }); |
| 715 | 729 | ||
| 716 | return { | 730 | return { |
| @@ -732,8 +746,8 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 15 unmodified lines | |||
| 732 | sourceAuthService: RuntimeSourceAuthService; | 746 | sourceAuthService: RuntimeSourceAuthService; |
| 733 | }): ResolveExecutionEnvironment => { | 747 | }): ResolveExecutionEnvironment => { |
| 734 | const resolveSecretMaterial = | 748 | const resolveSecretMaterial = |
| 735 | input.resolveSecretMaterial | 749 | input.resolveSecretMaterial ?? |
| 736 | ?? createDefaultSecretMaterialResolver({ | 750 | createDefaultSecretMaterialResolver({ |
| 737 | rows: input.rows, | 751 | rows: input.rows, |
| 738 | }); | 752 | }); |
| 739 | 753 | ||
| @@ -765,25 +779,26 @@ export class RuntimeExecutionResolverService extends Context.Tag( · 25 unmodified lines | |||
| 765 | )< | 779 | )< |
| 766 | RuntimeExecutionResolverService, | 780 | RuntimeExecutionResolverService, |
| 767 | ReturnType<typeof createWorkspaceExecutionEnvironmentResolver> | 781 | ReturnType<typeof createWorkspaceExecutionEnvironmentResolver> |
| 768 | >() { | 782 | >() {} |
| 769 | } | 783 | |
| 770 | 784 | export const RuntimeExecutionResolverLive = ( | |
| 771 | export const RuntimeExecutionResolverLive = (input: { | 785 | input: { |
| 772 | executionResolver?: ResolveExecutionEnvironment; | 786 | executionResolver?: ResolveExecutionEnvironment; |
| 773 | resolveSecretMaterial?: ResolveSecretMaterial; | 787 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 774 | } = {}) => | 788 | } = {}, |
| 789 | ) => | ||
| 775 | input.executionResolver | 790 | input.executionResolver |
| 776 | ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver) | 791 | ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver) |
| 777 | : Layer.effect( | 792 | : Layer.effect( |
| 778 | RuntimeExecutionResolverService, | 793 | RuntimeExecutionResolverService, |
| 779 | Effect.gen(function* () { | 794 | Effect.gen(function* () { |
| 780 | const rows = yield* SqlControlPlaneRowsService; | 795 | const rows = yield* SqlControlPlaneRowsService; |
| 781 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | 796 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 782 | 797 | ||
| 783 | return createWorkspaceExecutionEnvironmentResolver({ | 798 | return createWorkspaceExecutionEnvironmentResolver({ |
| 784 | rows, | 799 | rows, |
| 785 | sourceAuthService, | 800 | sourceAuthService, |
| 786 | resolveSecretMaterial: input.resolveSecretMaterial, | 801 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 787 | }); | 802 | }); |
| 788 | }), | 803 | }), |
| 789 | ); | 804 | ); |
| @@ -11,6 +11,9 @@ export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecip · 10 unmodified lines | |||
| 11 | export const SourceRecipeRevisionIdSchema = Schema.String.pipe( | 11 | export const SourceRecipeRevisionIdSchema = Schema.String.pipe( |
| 12 | Schema.brand("SourceRecipeRevisionId"), | 12 | Schema.brand("SourceRecipeRevisionId"), |
| 13 | ); | 13 | ); |
| 14 | export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe( | ||
| 15 | Schema.brand("SourceRecipeSchemaBundleId"), | ||
| 16 | ); | ||
| 14 | export const SourceAuthSessionIdSchema = Schema.String.pipe( | 17 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 15 | Schema.brand("SourceAuthSessionId"), | 18 | Schema.brand("SourceAuthSessionId"), |
| 16 | ); | 19 | ); |
| @@ -37,6 +40,7 @@ export type WorkspaceId = typeof WorkspaceIdSchema.Type; · 20 unmodified lines | |||
| 37 | export type SourceId = typeof SourceIdSchema.Type; | 40 | export type SourceId = typeof SourceIdSchema.Type; |
| 38 | export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; | 41 | export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; |
| 39 | export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; | 42 | export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; |
| 43 | export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type; | ||
| 40 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; | 44 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 41 | export type CredentialId = typeof CredentialIdSchema.Type; | 45 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 42 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; | 46 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; |
| @@ -8,7 +8,6 @@ export * from "./models/source"; · 7 unmodified lines | |||
| 8 | export * from "./models/source-recipe"; | 8 | export * from "./models/source-recipe"; |
| 9 | export * from "./models/credential"; | 9 | export * from "./models/credential"; |
| 10 | export * from "./models/source-oauth-client"; | 10 | export * from "./models/source-oauth-client"; |
| 11 | export * from "./models/tool-artifact"; | ||
| 12 | export * from "./models/secret-material"; | 11 | export * from "./models/secret-material"; |
| 13 | export * from "./models/source-auth-session"; | 12 | export * from "./models/source-auth-session"; |
| 14 | export * from "./models/policy"; | 13 | export * from "./models/policy"; |
| @@ -16,3 +15,4 @@ export * from "./models/local-installation"; · 1 unmodified line | |||
| 16 | export * from "./models/execution"; | 15 | export * from "./models/execution"; |
| 17 | export * from "./models/source-inspection"; | 16 | export * from "./models/source-inspection"; |
| 18 | export * from "./models/source-discovery"; | 17 | export * from "./models/source-discovery"; |
| 18 | export * from "./models/data-migration"; | ||
| @@ -11,12 +11,14 @@ import { · 10 unmodified lines | |||
| 11 | } from "../ids"; | 11 | } from "../ids"; |
| 12 | 12 | ||
| 13 | export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); | 13 | export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); |
| 14 | export const CredentialSlotSchema = Schema.Literal("runtime", "import"); | ||
| 14 | 15 | ||
| 15 | const credentialSchemaOverrides = { | 16 | const credentialSchemaOverrides = { |
| 16 | id: CredentialIdSchema, | 17 | id: CredentialIdSchema, |
| 17 | workspaceId: WorkspaceIdSchema, | 18 | workspaceId: WorkspaceIdSchema, |
| 18 | sourceId: SourceIdSchema, | 19 | sourceId: SourceIdSchema, |
| 19 | actorAccountId: Schema.NullOr(AccountIdSchema), | 20 | actorAccountId: Schema.NullOr(AccountIdSchema), |
| 21 | slot: CredentialSlotSchema, | ||
| 20 | authKind: CredentialAuthKindSchema, | 22 | authKind: CredentialAuthKindSchema, |
| 21 | authHeaderName: Schema.String, | 23 | authHeaderName: Schema.String, |
| 22 | authPrefix: Schema.String, | 24 | authPrefix: Schema.String, |
| @@ -34,4 +36,5 @@ export const CredentialSchema = createSelectSchema( · 11 unmodified lines | |||
| 34 | ); | 36 | ); |
| 35 | 37 | ||
| 36 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; | 38 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; |
| 39 | export type CredentialSlot = typeof CredentialSlotSchema.Type; | ||
| 37 | export type Credential = typeof CredentialSchema.Type; | 40 | export type Credential = typeof CredentialSchema.Type; |
| @@ -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; | ||
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | SourceIdSchema, | 11 | SourceIdSchema, |
| 12 | WorkspaceIdSchema, | 12 | WorkspaceIdSchema, |
| 13 | } from "../ids"; | 13 | } from "../ids"; |
| 14 | import { CredentialSlotSchema } from "./credential"; | ||
| 14 | 15 | ||
| 15 | export const SourceAuthSessionProviderKindSchema = Schema.Literal( | 16 | export const SourceAuthSessionProviderKindSchema = Schema.Literal( |
| 16 | "mcp_oauth", | 17 | "mcp_oauth", |
| @@ -78,6 +79,7 @@ const sourceAuthSessionSchemaOverrides = { · 61 unmodified lines | |||
| 78 | workspaceId: WorkspaceIdSchema, | 79 | workspaceId: WorkspaceIdSchema, |
| 79 | sourceId: SourceIdSchema, | 80 | sourceId: SourceIdSchema, |
| 80 | actorAccountId: Schema.NullOr(AccountIdSchema), | 81 | actorAccountId: Schema.NullOr(AccountIdSchema), |
| 82 | credentialSlot: CredentialSlotSchema, | ||
| 81 | executionId: Schema.NullOr(ExecutionIdSchema), | 83 | executionId: Schema.NullOr(ExecutionIdSchema), |
| 82 | interactionId: Schema.NullOr(ExecutionInteractionIdSchema), | 84 | interactionId: Schema.NullOr(ExecutionInteractionIdSchema), |
| 83 | providerKind: SourceAuthSessionProviderKindSchema, | 85 | providerKind: SourceAuthSessionProviderKindSchema, |
| @@ -1,4 +1,3 @@ | |||
| 1 | import { OpenApiHttpMethodSchema } from "@executor/codemode-openapi"; | ||
| 2 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 3 | 2 | ||
| 4 | import { SourceSchema } from "./source"; | 3 | import { SourceSchema } from "./source"; |
| @@ -21,21 +20,23 @@ export const SourceInspectionToolSummarySchema = Schema.Struct({ · 16 unmodified lines | |||
| 21 | group: Schema.NullOr(Schema.String), | 20 | group: Schema.NullOr(Schema.String), |
| 22 | leaf: Schema.NullOr(Schema.String), | 21 | leaf: Schema.NullOr(Schema.String), |
| 23 | tags: Schema.Array(Schema.String), | 22 | tags: Schema.Array(Schema.String), |
| 24 | method: Schema.NullOr(OpenApiHttpMethodSchema), | 23 | method: Schema.NullOr(Schema.String), |
| 25 | pathTemplate: Schema.NullOr(Schema.String), | 24 | pathTemplate: Schema.NullOr(Schema.String), |
| 26 | inputType: Schema.optional(Schema.String), | 25 | inputType: Schema.optional(Schema.String), |
| 27 | outputType: Schema.optional(Schema.String), | 26 | outputType: Schema.optional(Schema.String), |
| 28 | }); | 27 | }); |
| 29 | 28 | ||
| 29 | export const SourceInspectionToolListItemSchema = Schema.Struct({ | ||
| 30 | path: Schema.String, | ||
| 31 | method: Schema.NullOr(Schema.String), | ||
| 32 | }); | ||
| 33 | |||
| 30 | export const SourceInspectionSchema = Schema.Struct({ | 34 | export const SourceInspectionSchema = Schema.Struct({ |
| 31 | source: SourceSchema, | 35 | source: SourceSchema, |
| 32 | namespace: Schema.String, | 36 | namespace: Schema.String, |
| 33 | pipelineKind: SourceInspectionPipelineKindSchema, | 37 | pipelineKind: SourceInspectionPipelineKindSchema, |
| 34 | toolCount: Schema.Number, | 38 | toolCount: Schema.Number, |
| 35 | rawDocumentText: Schema.NullOr(Schema.String), | 39 | tools: Schema.Array(SourceInspectionToolListItemSchema), |
| 36 | manifestJson: Schema.NullOr(Schema.String), | ||
| 37 | definitionsJson: Schema.NullOr(Schema.String), | ||
| 38 | tools: Schema.Array(SourceInspectionToolSummarySchema), | ||
| 39 | }); | 40 | }); |
| 40 | 41 | ||
| 41 | export const SourceInspectionToolDetailSchema = Schema.Struct({ | 42 | export const SourceInspectionToolDetailSchema = Schema.Struct({ |
| @@ -45,10 +46,18 @@ export const SourceInspectionToolDetailSchema = Schema.Struct({ · 3 unmodified lines | |||
| 45 | providerDataJson: Schema.NullOr(Schema.String), | 46 | providerDataJson: Schema.NullOr(Schema.String), |
| 46 | inputSchemaJson: Schema.NullOr(Schema.String), | 47 | inputSchemaJson: Schema.NullOr(Schema.String), |
| 47 | outputSchemaJson: Schema.NullOr(Schema.String), | 48 | outputSchemaJson: Schema.NullOr(Schema.String), |
| 49 | schemaBundleId: Schema.NullOr(Schema.String), | ||
| 48 | exampleInputJson: Schema.NullOr(Schema.String), | 50 | exampleInputJson: Schema.NullOr(Schema.String), |
| 49 | exampleOutputJson: Schema.NullOr(Schema.String), | 51 | exampleOutputJson: Schema.NullOr(Schema.String), |
| 50 | }); | 52 | }); |
| 51 | 53 | ||
| 54 | export const SourceInspectionSchemaBundleSchema = Schema.Struct({ | ||
| 55 | id: Schema.String, | ||
| 56 | kind: Schema.String, | ||
| 57 | hash: Schema.String, | ||
| 58 | refsJson: Schema.String, | ||
| 59 | }); | ||
| 60 | |||
| 52 | export const SourceInspectionDiscoverPayloadSchema = Schema.Struct({ | 61 | export const SourceInspectionDiscoverPayloadSchema = Schema.Struct({ |
| 53 | query: Schema.String, | 62 | query: Schema.String, |
| 54 | limit: Schema.optional(Schema.Number), | 63 | limit: Schema.optional(Schema.Number), |
| @@ -75,9 +84,13 @@ export type SourceInspectionPipelineKind = · 20 unmodified lines | |||
| 75 | typeof SourceInspectionPipelineKindSchema.Type; | 84 | typeof SourceInspectionPipelineKindSchema.Type; |
| 76 | export type SourceInspectionToolSummary = | 85 | export type SourceInspectionToolSummary = |
| 77 | typeof SourceInspectionToolSummarySchema.Type; | 86 | typeof SourceInspectionToolSummarySchema.Type; |
| 87 | export type SourceInspectionToolListItem = | ||
| 88 | typeof SourceInspectionToolListItemSchema.Type; | ||
| 78 | export type SourceInspection = typeof SourceInspectionSchema.Type; | 89 | export type SourceInspection = typeof SourceInspectionSchema.Type; |
| 79 | export type SourceInspectionToolDetail = | 90 | export type SourceInspectionToolDetail = |
| 80 | typeof SourceInspectionToolDetailSchema.Type; | 91 | typeof SourceInspectionToolDetailSchema.Type; |
| 92 | export type SourceInspectionSchemaBundle = | ||
| 93 | typeof SourceInspectionSchemaBundleSchema.Type; | ||
| 81 | export type SourceInspectionDiscoverPayload = | 94 | export type SourceInspectionDiscoverPayload = |
| 82 | typeof SourceInspectionDiscoverPayloadSchema.Type; | 95 | typeof SourceInspectionDiscoverPayloadSchema.Type; |
| 83 | export type SourceInspectionDiscoverResultItem = | 96 | export type SourceInspectionDiscoverResultItem = |
| @@ -4,6 +4,7 @@ import { Schema } from "effect"; · 3 unmodified lines | |||
| 4 | import { | 4 | import { |
| 5 | sourceRecipeDocumentsTable, | 5 | sourceRecipeDocumentsTable, |
| 6 | sourceRecipeOperationsTable, | 6 | sourceRecipeOperationsTable, |
| 7 | sourceRecipeSchemaBundlesTable, | ||
| 7 | sourceRecipeRevisionsTable, | 8 | sourceRecipeRevisionsTable, |
| 8 | sourceRecipesTable, | 9 | sourceRecipesTable, |
| 9 | } from "../../persistence/schema"; | 10 | } from "../../persistence/schema"; |
| @@ -11,24 +12,16 @@ import { TimestampMsSchema } from "../common"; · 1 unmodified line | |||
| 11 | import { | 12 | import { |
| 12 | SourceRecipeIdSchema, | 13 | SourceRecipeIdSchema, |
| 13 | SourceRecipeRevisionIdSchema, | 14 | SourceRecipeRevisionIdSchema, |
| 15 | SourceRecipeSchemaBundleIdSchema, | ||
| 14 | } from "../ids"; | 16 | } from "../ids"; |
| 15 | 17 | ||
| 16 | export const SourceRecipeKindSchema = Schema.Literal( | 18 | export const SourceRecipeKindSchema = Schema.Literal( |
| 17 | "http_recipe", | 19 | "http_api", |
| 18 | "graphql_recipe", | 20 | "mcp", |
| 19 | "mcp_recipe", | 21 | "internal", |
| 20 | "internal_recipe", | ||
| 21 | ); | 22 | ); |
| 22 | 23 | ||
| 23 | export const SourceRecipeImporterKindSchema = Schema.Literal( | 24 | export const SourceRecipeImporterKindSchema = Schema.String; |
| 24 | "openapi", | ||
| 25 | "google_discovery", | ||
| 26 | "postman_collection", | ||
| 27 | "snippet_bundle", | ||
| 28 | "graphql_introspection", | ||
| 29 | "mcp_manifest", | ||
| 30 | "internal_manifest", | ||
| 31 | ); | ||
| 32 | 25 | ||
| 33 | export const SourceRecipeVisibilitySchema = Schema.Literal( | 26 | export const SourceRecipeVisibilitySchema = Schema.Literal( |
| 34 | "private", | 27 | "private", |
| @@ -37,14 +30,9 @@ export const SourceRecipeVisibilitySchema = Schema.Literal( · 2 unmodified lines | |||
| 37 | "public", | 30 | "public", |
| 38 | ); | 31 | ); |
| 39 | 32 | ||
| 40 | export const SourceRecipeDocumentKindSchema = Schema.Literal( | 33 | export const SourceRecipeDocumentKindSchema = Schema.String; |
| 41 | "google_discovery", | 34 | |
| 42 | "openapi", | 35 | export const SourceRecipeSchemaBundleKindSchema = Schema.String; |
| 43 | "postman_collection", | ||
| 44 | "postman_environment", | ||
| 45 | "graphql_introspection", | ||
| 46 | "mcp_manifest", | ||
| 47 | ); | ||
| 48 | 36 | ||
| 49 | export const SourceRecipeTransportKindSchema = Schema.Literal( | 37 | export const SourceRecipeTransportKindSchema = Schema.Literal( |
| 50 | "http", | 38 | "http", |
| @@ -60,29 +48,7 @@ export const SourceRecipeOperationKindSchema = Schema.Literal( · 9 unmodified lines | |||
| 60 | "unknown", | 48 | "unknown", |
| 61 | ); | 49 | ); |
| 62 | 50 | ||
| 63 | export const SourceRecipeOperationProviderKindSchema = Schema.Literal( | 51 | export const SourceRecipeOperationProviderKindSchema = Schema.String; |
| 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 | ); | ||
| 86 | 52 | ||
| 87 | const recipeRowSchemaOverrides = { | 53 | const recipeRowSchemaOverrides = { |
| 88 | id: SourceRecipeIdSchema, | 54 | id: SourceRecipeIdSchema, |
| @@ -98,6 +64,7 @@ const recipeRevisionRowSchemaOverrides = { · 9 unmodified lines | |||
| 98 | id: SourceRecipeRevisionIdSchema, | 64 | id: SourceRecipeRevisionIdSchema, |
| 99 | recipeId: SourceRecipeIdSchema, | 65 | recipeId: SourceRecipeIdSchema, |
| 100 | revisionNumber: Schema.Number, | 66 | revisionNumber: Schema.Number, |
| 67 | materializationHash: Schema.NullOr(Schema.String), | ||
| 101 | createdAt: TimestampMsSchema, | 68 | createdAt: TimestampMsSchema, |
| 102 | updatedAt: TimestampMsSchema, | 69 | updatedAt: TimestampMsSchema, |
| 103 | } as const; | 70 | } as const; |
| @@ -110,13 +77,19 @@ const recipeDocumentRowSchemaOverrides = { · 6 unmodified lines | |||
| 110 | updatedAt: TimestampMsSchema, | 77 | updatedAt: TimestampMsSchema, |
| 111 | } as const; | 78 | } as const; |
| 112 | 79 | ||
| 80 | const recipeSchemaBundleRowSchemaOverrides = { | ||
| 81 | id: SourceRecipeSchemaBundleIdSchema, | ||
| 82 | recipeRevisionId: SourceRecipeRevisionIdSchema, | ||
| 83 | bundleKind: SourceRecipeSchemaBundleKindSchema, | ||
| 84 | createdAt: TimestampMsSchema, | ||
| 85 | updatedAt: TimestampMsSchema, | ||
| 86 | } as const; | ||
| 87 | |||
| 113 | const sourceRecipeOperationRowSchemaOverrides = { | 88 | const sourceRecipeOperationRowSchemaOverrides = { |
| 114 | recipeRevisionId: SourceRecipeRevisionIdSchema, | 89 | recipeRevisionId: SourceRecipeRevisionIdSchema, |
| 115 | transportKind: SourceRecipeTransportKindSchema, | 90 | transportKind: SourceRecipeTransportKindSchema, |
| 116 | operationKind: SourceRecipeOperationKindSchema, | 91 | operationKind: SourceRecipeOperationKindSchema, |
| 117 | providerKind: SourceRecipeOperationProviderKindSchema, | 92 | providerKind: SourceRecipeOperationProviderKindSchema, |
| 118 | openApiMethod: Schema.NullOr(SourceRecipeOpenApiMethodSchema), | ||
| 119 | graphqlOperationType: Schema.NullOr(SourceRecipeGraphqlOperationTypeSchema), | ||
| 120 | createdAt: TimestampMsSchema, | 93 | createdAt: TimestampMsSchema, |
| 121 | updatedAt: TimestampMsSchema, | 94 | updatedAt: TimestampMsSchema, |
| 122 | } as const; | 95 | } as const; |
| @@ -136,97 +109,16 @@ export const StoredSourceRecipeDocumentRecordSchema = createSelectSchema( · 13 unmodified lines | |||
| 136 | recipeDocumentRowSchemaOverrides, | 109 | recipeDocumentRowSchemaOverrides, |
| 137 | ); | 110 | ); |
| 138 | 111 | ||
| 112 | export const StoredSourceRecipeSchemaBundleRecordSchema = createSelectSchema( | ||
| 113 | sourceRecipeSchemaBundlesTable, | ||
| 114 | recipeSchemaBundleRowSchemaOverrides, | ||
| 115 | ); | ||
| 116 | |||
| 139 | export const StoredSourceRecipeOperationRowSchema = createSelectSchema( | 117 | export const StoredSourceRecipeOperationRowSchema = createSelectSchema( |
| 140 | sourceRecipeOperationsTable, | 118 | sourceRecipeOperationsTable, |
| 141 | sourceRecipeOperationRowSchemaOverrides, | 119 | sourceRecipeOperationRowSchemaOverrides, |
| 142 | ); | 120 | ); |
| 143 | 121 | export const StoredSourceRecipeOperationRecordSchema = StoredSourceRecipeOperationRowSchema.annotations({ | |
| 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({ | ||
| 230 | identifier: "StoredSourceRecipeOperationRecord", | 122 | identifier: "StoredSourceRecipeOperationRecord", |
| 231 | }); | 123 | }); |
| 232 | 124 | ||
| @@ -234,24 +126,17 @@ export type SourceRecipeKind = typeof SourceRecipeKindSchema.Type; · 1 unmodified line | |||
| 234 | export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type; | 126 | export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type; |
| 235 | export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type; | 127 | export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type; |
| 236 | export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type; | 128 | export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type; |
| 129 | export type SourceRecipeSchemaBundleKind = | ||
| 130 | typeof SourceRecipeSchemaBundleKindSchema.Type; | ||
| 237 | export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type; | 131 | export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type; |
| 238 | export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type; | 132 | export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type; |
| 239 | export type SourceRecipeOperationProviderKind = | 133 | export type SourceRecipeOperationProviderKind = |
| 240 | typeof SourceRecipeOperationProviderKindSchema.Type; | 134 | typeof SourceRecipeOperationProviderKindSchema.Type; |
| 241 | export type SourceRecipeOpenApiMethod = typeof SourceRecipeOpenApiMethodSchema.Type; | ||
| 242 | export type SourceRecipeGraphqlOperationType = | ||
| 243 | typeof SourceRecipeGraphqlOperationTypeSchema.Type; | ||
| 244 | export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type; | 135 | export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type; |
| 245 | export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type; | 136 | export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type; |
| 246 | export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type; | 137 | export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type; |
| 138 | export type StoredSourceRecipeSchemaBundleRecord = | ||
| 139 | typeof StoredSourceRecipeSchemaBundleRecordSchema.Type; | ||
| 247 | export type StoredSourceRecipeOperationRow = typeof StoredSourceRecipeOperationRowSchema.Type; | 140 | 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; | ||
| 256 | export type StoredSourceRecipeOperationRecord = | 141 | export type StoredSourceRecipeOperationRecord = |
| 257 | typeof StoredSourceRecipeOperationRecordSchema.Type; | 142 | typeof StoredSourceRecipeOperationRecordSchema.Type; |
| @@ -12,12 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | WorkspaceIdSchema, | 12 | WorkspaceIdSchema, |
| 13 | } from "../ids"; | 13 | } from "../ids"; |
| 14 | 14 | ||
| 15 | export const SourceKindSchema = Schema.Literal( | 15 | export const SourceKindSchema = Schema.String; |
| 16 | "mcp", | ||
| 17 | "openapi", | ||
| 18 | "graphql", | ||
| 19 | "internal", | ||
| 20 | ); | ||
| 21 | 16 | ||
| 22 | export const SourceStatusSchema = Schema.Literal( | 17 | export const SourceStatusSchema = Schema.Literal( |
| 23 | "draft", | 18 | "draft", |
| @@ -33,6 +28,12 @@ export const SourceTransportSchema = Schema.Literal( · 9 unmodified lines | |||
| 33 | "sse", | 28 | "sse", |
| 34 | ); | 29 | ); |
| 35 | 30 | ||
| 31 | export const SourceImportAuthPolicySchema = Schema.Literal( | ||
| 32 | "none", | ||
| 33 | "reuse_runtime", | ||
| 34 | "separate", | ||
| 35 | ); | ||
| 36 | |||
| 36 | export const SecretRefSchema = Schema.Struct({ | 37 | export const SecretRefSchema = Schema.Struct({ |
| 37 | providerId: Schema.String, | 38 | providerId: Schema.String, |
| 38 | handle: Schema.String, | 39 | handle: Schema.String, |
| @@ -69,7 +70,7 @@ const sourceRowSchemaOverrides = { · 30 unmodified lines | |||
| 69 | recipeRevisionId: SourceRecipeRevisionIdSchema, | 70 | recipeRevisionId: SourceRecipeRevisionIdSchema, |
| 70 | kind: SourceKindSchema, | 71 | kind: SourceKindSchema, |
| 71 | status: SourceStatusSchema, | 72 | status: SourceStatusSchema, |
| 72 | transport: Schema.NullOr(SourceTransportSchema), | 73 | importAuthPolicy: SourceImportAuthPolicySchema, |
| 73 | createdAt: TimestampMsSchema, | 74 | createdAt: TimestampMsSchema, |
| 74 | updatedAt: TimestampMsSchema, | 75 | updatedAt: TimestampMsSchema, |
| 75 | } as const; | 76 | } as const; |
| @@ -89,14 +90,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 13 unmodified lines | |||
| 89 | status: SourceStatusSchema, | 90 | status: SourceStatusSchema, |
| 90 | enabled: Schema.Boolean, | 91 | enabled: Schema.Boolean, |
| 91 | namespace: Schema.NullOr(Schema.String), | 92 | namespace: Schema.NullOr(Schema.String), |
| 92 | bindingConfigJson: Schema.NullOr(Schema.String), | 93 | importAuthPolicy: SourceImportAuthPolicySchema, |
| 93 | transport: Schema.NullOr(SourceTransportSchema), | 94 | bindingConfigJson: Schema.String, |
| 94 | queryParamsJson: Schema.NullOr(Schema.String), | ||
| 95 | headersJson: Schema.NullOr(Schema.String), | ||
| 96 | specUrl: Schema.NullOr(Schema.String), | ||
| 97 | defaultHeadersJson: Schema.NullOr(Schema.String), | ||
| 98 | sourceHash: Schema.NullOr(Schema.String), | 95 | sourceHash: Schema.NullOr(Schema.String), |
| 99 | sourceDocumentText: Schema.NullOr(Schema.String), | ||
| 100 | lastError: Schema.NullOr(Schema.String), | 96 | lastError: Schema.NullOr(Schema.String), |
| 101 | createdAt: TimestampMsSchema, | 97 | createdAt: TimestampMsSchema, |
| 102 | updatedAt: TimestampMsSchema, | 98 | updatedAt: TimestampMsSchema, |
| @@ -114,14 +110,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 11 unmodified lines | |||
| 114 | status: row.status, | 110 | status: row.status, |
| 115 | enabled: row.enabled, | 111 | enabled: row.enabled, |
| 116 | namespace: row.namespace, | 112 | namespace: row.namespace, |
| 113 | importAuthPolicy: row.importAuthPolicy, | ||
| 117 | bindingConfigJson: row.bindingConfigJson, | 114 | bindingConfigJson: row.bindingConfigJson, |
| 118 | transport: row.transport, | ||
| 119 | queryParamsJson: row.queryParamsJson, | ||
| 120 | headersJson: row.headersJson, | ||
| 121 | specUrl: row.specUrl, | ||
| 122 | defaultHeadersJson: row.defaultHeadersJson, | ||
| 123 | sourceHash: row.sourceHash, | 115 | sourceHash: row.sourceHash, |
| 124 | sourceDocumentText: row.sourceDocumentText, | ||
| 125 | lastError: row.lastError, | 116 | lastError: row.lastError, |
| 126 | createdAt: row.createdAt, | 117 | createdAt: row.createdAt, |
| 127 | updatedAt: row.updatedAt, | 118 | updatedAt: row.updatedAt, |
| @@ -137,14 +128,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 9 unmodified lines | |||
| 137 | status: source.status, | 128 | status: source.status, |
| 138 | enabled: source.enabled, | 129 | enabled: source.enabled, |
| 139 | namespace: source.namespace, | 130 | namespace: source.namespace, |
| 131 | importAuthPolicy: source.importAuthPolicy, | ||
| 140 | bindingConfigJson: source.bindingConfigJson, | 132 | bindingConfigJson: source.bindingConfigJson, |
| 141 | transport: source.transport, | ||
| 142 | queryParamsJson: source.queryParamsJson, | ||
| 143 | headersJson: source.headersJson, | ||
| 144 | specUrl: source.specUrl, | ||
| 145 | defaultHeadersJson: source.defaultHeadersJson, | ||
| 146 | sourceHash: source.sourceHash, | 133 | sourceHash: source.sourceHash, |
| 147 | sourceDocumentText: source.sourceDocumentText, | ||
| 148 | lastError: source.lastError, | 134 | lastError: source.lastError, |
| 149 | createdAt: source.createdAt, | 135 | createdAt: source.createdAt, |
| 150 | updatedAt: source.updatedAt, | 136 | updatedAt: source.updatedAt, |
| @@ -166,6 +152,8 @@ export const SourceSchema = Schema.Struct({ · 15 unmodified lines | |||
| 166 | headers: Schema.NullOr(StringMapSchema), | 152 | headers: Schema.NullOr(StringMapSchema), |
| 167 | specUrl: Schema.NullOr(Schema.String), | 153 | specUrl: Schema.NullOr(Schema.String), |
| 168 | defaultHeaders: Schema.NullOr(StringMapSchema), | 154 | defaultHeaders: Schema.NullOr(StringMapSchema), |
| 155 | importAuthPolicy: SourceImportAuthPolicySchema, | ||
| 156 | importAuth: SourceAuthSchema, | ||
| 169 | auth: SourceAuthSchema, | 157 | auth: SourceAuthSchema, |
| 170 | sourceHash: Schema.NullOr(Schema.String), | 158 | sourceHash: Schema.NullOr(Schema.String), |
| 171 | lastError: Schema.NullOr(Schema.String), | 159 | lastError: Schema.NullOr(Schema.String), |
| @@ -176,6 +164,7 @@ export const SourceSchema = Schema.Struct({ · 4 unmodified lines | |||
| 176 | export type SourceKind = typeof SourceKindSchema.Type; | 164 | export type SourceKind = typeof SourceKindSchema.Type; |
| 177 | export type SourceStatus = typeof SourceStatusSchema.Type; | 165 | export type SourceStatus = typeof SourceStatusSchema.Type; |
| 178 | export type SourceTransport = typeof SourceTransportSchema.Type; | 166 | export type SourceTransport = typeof SourceTransportSchema.Type; |
| 167 | export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type; | ||
| 179 | export type SecretRef = typeof SecretRefSchema.Type; | 168 | export type SecretRef = typeof SecretRefSchema.Type; |
| 180 | export type SourceAuth = typeof SourceAuthSchema.Type; | 169 | export type SourceAuth = typeof SourceAuthSchema.Type; |
| 181 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; | 170 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; |
| @@ -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; | ||
| @@ -16,10 +16,10 @@ describe("control-plane-schema", () => { · 15 unmodified lines | |||
| 16 | expect(RolePermissions.owner).toContain("policies:manage"); | 16 | expect(RolePermissions.owner).toContain("policies:manage"); |
| 17 | }); | 17 | }); |
| 18 | 18 | ||
| 19 | it("enforces provider-specific source recipe operation shapes", () => { | 19 | it("accepts canonical source recipe operation rows and rejects invalid transport kinds", () => { |
| 20 | const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema); | 20 | const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema); |
| 21 | 21 | ||
| 22 | const openApiRecord = decode({ | 22 | const operationRecord = decode({ |
| 23 | id: "src_recipe_op_1", | 23 | id: "src_recipe_op_1", |
| 24 | recipeRevisionId: "src_recipe_rev_1", | 24 | recipeRevisionId: "src_recipe_rev_1", |
| 25 | operationKey: "getRepo", | 25 | operationKey: "getRepo", |
| @@ -32,28 +32,20 @@ describe("control-plane-schema", () => { · 6 unmodified lines | |||
| 32 | inputSchemaJson: null, | 32 | inputSchemaJson: null, |
| 33 | outputSchemaJson: null, | 33 | outputSchemaJson: null, |
| 34 | providerKind: "openapi", | 34 | providerKind: "openapi", |
| 35 | providerDataJson: null, | 35 | providerDataJson: JSON.stringify({ |
| 36 | mcpToolName: null, | 36 | kind: "openapi", |
| 37 | openApiMethod: "get", | 37 | }), |
| 38 | openApiPathTemplate: "/repos/{owner}/{repo}", | ||
| 39 | openApiOperationHash: "hash", | ||
| 40 | openApiRawToolId: "repos_getRepo", | ||
| 41 | openApiOperationId: "repos.getRepo", | ||
| 42 | openApiTagsJson: "[]", | ||
| 43 | openApiRequestBodyRequired: null, | ||
| 44 | graphqlOperationType: null, | ||
| 45 | graphqlOperationName: null, | ||
| 46 | createdAt: 1, | 38 | createdAt: 1, |
| 47 | updatedAt: 1, | 39 | updatedAt: 1, |
| 48 | }); | 40 | }); |
| 49 | 41 | ||
| 50 | expect(Either.isRight(openApiRecord)).toBe(true); | 42 | expect(Either.isRight(operationRecord)).toBe(true); |
| 51 | 43 | ||
| 52 | const invalidGraphqlRecord = decode({ | 44 | const invalidRecord = decode({ |
| 53 | id: "src_recipe_op_2", | 45 | id: "src_recipe_op_2", |
| 54 | recipeRevisionId: "src_recipe_rev_1", | 46 | recipeRevisionId: "src_recipe_rev_1", |
| 55 | operationKey: "viewer", | 47 | operationKey: "viewer", |
| 56 | transportKind: "graphql", | 48 | transportKind: "ftp", |
| 57 | toolId: "viewer", | 49 | toolId: "viewer", |
| 58 | title: "Viewer", | 50 | title: "Viewer", |
| 59 | description: null, | 51 | description: null, |
| @@ -62,21 +54,13 @@ describe("control-plane-schema", () => { · 2 unmodified lines | |||
| 62 | inputSchemaJson: null, | 54 | inputSchemaJson: null, |
| 63 | outputSchemaJson: null, | 55 | outputSchemaJson: null, |
| 64 | providerKind: "graphql", | 56 | providerKind: "graphql", |
| 65 | providerDataJson: null, | 57 | providerDataJson: JSON.stringify({ |
| 66 | mcpToolName: null, | 58 | kind: "graphql", |
| 67 | openApiMethod: "get", | 59 | }), |
| 68 | openApiPathTemplate: null, | ||
| 69 | openApiOperationHash: null, | ||
| 70 | openApiRawToolId: null, | ||
| 71 | openApiOperationId: null, | ||
| 72 | openApiTagsJson: null, | ||
| 73 | openApiRequestBodyRequired: null, | ||
| 74 | graphqlOperationType: "query", | ||
| 75 | graphqlOperationName: "viewer", | ||
| 76 | createdAt: 1, | 60 | createdAt: 1, |
| 77 | updatedAt: 1, | 61 | updatedAt: 1, |
| 78 | }); | 62 | }); |
| 79 | 63 | ||
| 80 | expect(Either.isLeft(invalidGraphqlRecord)).toBe(true); | 64 | expect(Either.isLeft(invalidRecord)).toBe(true); |
| 81 | }); | 65 | }); |
| 82 | }); | 66 | }); |
| @@ -16,7 +16,6 @@ import { · 15 unmodified lines | |||
| 16 | type SecretRef, | 16 | type SecretRef, |
| 17 | type Source, | 17 | type Source, |
| 18 | type SourceId, | 18 | type SourceId, |
| 19 | type StoredToolArtifactRecord, | ||
| 20 | type WorkspaceId, | 19 | type WorkspaceId, |
| 21 | } from "@executor/control-plane"; | 20 | } from "@executor/control-plane"; |
| 22 | import { | 21 | import { |
| @@ -59,6 +58,29 @@ const catalogNamespaceFromPath = (path: string): string => { · 36 unmodified lines | |||
| 59 | return second ? `${first}.${second}` : first; | 58 | return second ? `${first}.${second}` : first; |
| 60 | }; | 59 | }; |
| 61 | 60 | ||
| 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 | |||
| 62 | const toDescriptor = ( | 84 | const toDescriptor = ( |
| 63 | artifact: StoredToolArtifactRecord, | 85 | artifact: StoredToolArtifactRecord, |
| 64 | includeSchemas: boolean, | 86 | includeSchemas: boolean, |
| @@ -282,15 +304,11 @@ const resolveSourceCallContext = (input: { · 217 unmodified lines | |||
| 282 | 304 | ||
| 283 | const createProviderInvoker = (): ProviderInvoker => ({ | 305 | const createProviderInvoker = (): ProviderInvoker => ({ |
| 284 | async invoke({ source, artifact, args, runtime, context }) { | 306 | async invoke({ source, artifact, args, runtime, context }) { |
| 285 | const invocation = artifact.providerKind === "mcp" | 307 | const invocation = { |
| 286 | ? { | 308 | method: artifact.openApiMethod, |
| 287 | toolName: artifact.mcpToolName ?? artifact.title ?? artifact.toolId, | 309 | pathTemplate: artifact.openApiPathTemplate, |
| 288 | } | 310 | operationHash: artifact.openApiOperationHash, |
| 289 | : { | 311 | }; |
| 290 | method: artifact.openApiMethod, | ||
| 291 | pathTemplate: artifact.openApiPathTemplate, | ||
| 292 | operationHash: artifact.openApiOperationHash, | ||
| 293 | }; | ||
| 294 | 312 | ||
| 295 | return { | 313 | return { |
| 296 | sourceId: source.id, | 314 | sourceId: source.id, |
| @@ -337,6 +355,7 @@ const createWorkspaceToolCatalog = (input: { · 40 unmodified lines | |||
| 337 | path, | 355 | path, |
| 338 | }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null)) | 356 | }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null)) |
| 339 | ), | 357 | ), |
| 358 | getSchemaBundle: () => Effect.succeed(null), | ||
| 340 | searchTools: ({ query, namespace, limit }) => | 359 | searchTools: ({ query, namespace, limit }) => |
| 341 | Effect.promise(() => | 360 | Effect.promise(() => |
| 342 | input.toolStore.list({ | 361 | input.toolStore.list({ |
| @@ -480,6 +499,8 @@ const openApiSource = (input: { · 137 unmodified lines | |||
| 480 | headers: null, | 499 | headers: null, |
| 481 | specUrl: input.specUrl ?? null, | 500 | specUrl: input.specUrl ?? null, |
| 482 | defaultHeaders: null, | 501 | defaultHeaders: null, |
| 502 | importAuthPolicy: "reuse_runtime", | ||
| 503 | importAuth: { kind: "none" }, | ||
| 483 | auth: input.auth, | 504 | auth: input.auth, |
| 484 | sourceHash: null, | 505 | sourceHash: null, |
| 485 | lastError: null, | 506 | lastError: null, |
| @@ -523,7 +544,6 @@ const openApiArtifact = (input: { · 37 unmodified lines | |||
| 523 | inputSchemaJson: input.inputSchemaJson ?? null, | 544 | inputSchemaJson: input.inputSchemaJson ?? null, |
| 524 | outputSchemaJson: input.outputSchemaJson ?? null, | 545 | outputSchemaJson: input.outputSchemaJson ?? null, |
| 525 | providerKind: "openapi", | 546 | providerKind: "openapi", |
| 526 | mcpToolName: null, | ||
| 527 | openApiMethod: input.method, | 547 | openApiMethod: input.method, |
| 528 | openApiPathTemplate: input.pathTemplate, | 548 | openApiPathTemplate: input.pathTemplate, |
| 529 | openApiOperationHash: input.operationHash ?? input.toolId, | 549 | openApiOperationHash: input.operationHash ?? input.toolId, |
| @@ -759,7 +779,8 @@ describe("source runtime", () => { · 229 unmodified lines | |||
| 759 | "Workflow:", | 779 | "Workflow:", |
| 760 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | 780 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 761 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | 781 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 762 | "3) Call selected tools.<path>(input).", | 782 | "3) If details.schemaBundleId is present, fetch it once with tools.describe.schemaBundle({ id }).", |
| 783 | "4) Call selected tools.<path>(input).", | ||
| 763 | "Do not use fetch; use tools.* only.", | 784 | "Do not use fetch; use tools.* only.", |
| 764 | ].join("\n"), | 785 | ].join("\n"), |
| 765 | ); | 786 | ); |
| @@ -828,7 +849,8 @@ describe("source runtime", () => { · 62 unmodified lines | |||
| 828 | "Workflow:", | 849 | "Workflow:", |
| 829 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | 850 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 830 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | 851 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 831 | "3) Call selected tools.<path>(input).", | 852 | "3) If details.schemaBundleId is present, fetch it once with tools.describe.schemaBundle({ id }).", |
| 853 | "4) Call selected tools.<path>(input).", | ||
| 832 | "Do not use fetch; use tools.* only.", | 854 | "Do not use fetch; use tools.* only.", |
| 833 | ].join("\n"), | 855 | ].join("\n"), |
| 834 | ); | 856 | ); |
| @@ -702,4 +702,39 @@ describe("executor-react source hooks", () => { · 701 unmodified lines | |||
| 702 | } | 702 | } |
| 703 | }), | 703 | }), |
| 704 | ); | 704 | ); |
| 705 | |||
| 706 | it.effect("surfaces missing sources as errors instead of staying loading", () => | ||
| 707 | Effect.promise(async () => { | ||
| 708 | const apiServer = await startControlPlaneServer(); | ||
| 709 | setExecutorApiBaseUrl(apiServer.baseUrl); | ||
| 710 | |||
| 711 | try { | ||
| 712 | const harness = await renderSourceHarness("src_missing"); | ||
| 713 | |||
| 714 | try { | ||
| 715 | const missing = await waitForValue( | ||
| 716 | () => harness.current, | ||
| 717 | (value) => | ||
| 718 | isReady(value.sources) | ||
| 719 | && value.sources.data.length === 0 | ||
| 720 | && value.source.status === "error" | ||
| 721 | && value.inspection.status === "error", | ||
| 722 | ); | ||
| 723 | |||
| 724 | expect(missing.source.status).toBe("error"); | ||
| 725 | expect(missing.inspection.status).toBe("error"); | ||
| 726 | if (missing.source.status === "error") { | ||
| 727 | expect(missing.source.error.message).toContain("Source not found"); | ||
| 728 | } | ||
| 729 | if (missing.inspection.status === "error") { | ||
| 730 | expect(missing.inspection.error.message).toContain("Source not found"); | ||
| 731 | } | ||
| 732 | } finally { | ||
| 733 | await harness.unmount(); | ||
| 734 | } | ||
| 735 | } finally { | ||
| 736 | await apiServer.close(); | ||
| 737 | } | ||
| 738 | }), | ||
| 739 | ); | ||
| 705 | }); | 740 | }); |
| @@ -19,6 +19,7 @@ import type { · 18 unmodified lines | |||
| 19 | SourceDiscoveryResult, | 19 | SourceDiscoveryResult, |
| 20 | SourceInspection, | 20 | SourceInspection, |
| 21 | SourceInspectionDiscoverResult, | 21 | SourceInspectionDiscoverResult, |
| 22 | SourceInspectionSchemaBundle, | ||
| 22 | SourceInspectionToolDetail, | 23 | SourceInspectionToolDetail, |
| 23 | StartSourceOAuthPayload, | 24 | StartSourceOAuthPayload, |
| 24 | StartSourceOAuthResult, | 25 | StartSourceOAuthResult, |
| @@ -58,6 +59,13 @@ type SourceToolDetailKeyParts = readonly [ · 33 unmodified lines | |||
| 58 | Source["id"], | 59 | Source["id"], |
| 59 | string | null, | 60 | string | null, |
| 60 | ]; | 61 | ]; |
| 62 | type SourceSchemaBundleKeyParts = readonly [ | ||
| 63 | boolean, | ||
| 64 | Source["workspaceId"], | ||
| 65 | string, | ||
| 66 | Source["id"], | ||
| 67 | string | null, | ||
| 68 | ]; | ||
| 61 | type SourceDiscoveryKeyParts = readonly [ | 69 | type SourceDiscoveryKeyParts = readonly [ |
| 62 | boolean, | 70 | boolean, |
| 63 | Source["workspaceId"], | 71 | Source["workspaceId"], |
| @@ -78,6 +86,7 @@ type ActiveQueryCollections = { · 14 unmodified lines | |||
| 78 | sources: Set<string>; | 86 | sources: Set<string>; |
| 79 | inspections: Set<string>; | 87 | inspections: Set<string>; |
| 80 | toolDetails: Set<string>; | 88 | toolDetails: Set<string>; |
| 89 | schemaBundles: Set<string>; | ||
| 81 | discoveries: Set<string>; | 90 | discoveries: Set<string>; |
| 82 | }; | 91 | }; |
| 83 | 92 | ||
| @@ -153,6 +162,17 @@ const encodeToolDetailKey = ( · 69 unmodified lines | |||
| 153 | ): string => | 162 | ): string => |
| 154 | encodeAtomKey([enabled, workspaceId, accountId, sourceId, toolPath] satisfies SourceToolDetailKeyParts); | 163 | encodeAtomKey([enabled, workspaceId, accountId, sourceId, toolPath] satisfies SourceToolDetailKeyParts); |
| 155 | 164 | ||
| 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 | |||
| 156 | const encodeDiscoveryKey = ( | 176 | const encodeDiscoveryKey = ( |
| 157 | enabled: boolean, | 177 | enabled: boolean, |
| 158 | workspaceId: Source["workspaceId"], | 178 | workspaceId: Source["workspaceId"], |
| @@ -335,6 +355,25 @@ const sourceInspectionToolAtom = Atom.family((key: string) => { · 176 unmodified lines | |||
| 335 | ).pipe(Atom.keepAlive); | 355 | ).pipe(Atom.keepAlive); |
| 336 | }); | 356 | }); |
| 337 | 357 | ||
| 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 | |||
| 338 | const sourceDiscoveryAtom = Atom.family((key: string) => { | 377 | const sourceDiscoveryAtom = Atom.family((key: string) => { |
| 339 | const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key); | 378 | const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key); |
| 340 | 379 | ||
| @@ -496,6 +535,7 @@ const createActiveQueryCollections = (): ActiveQueryCollections => ({ · 155 unmodified lines | |||
| 496 | sources: new Set(), | 535 | sources: new Set(), |
| 497 | inspections: new Set(), | 536 | inspections: new Set(), |
| 498 | toolDetails: new Set(), | 537 | toolDetails: new Set(), |
| 538 | schemaBundles: new Set(), | ||
| 499 | discoveries: new Set(), | 539 | discoveries: new Set(), |
| 500 | }); | 540 | }); |
| 501 | 541 | ||
| @@ -550,6 +590,13 @@ const invalidateTrackedQueries = ( · 48 unmodified lines | |||
| 550 | } | 590 | } |
| 551 | }); | 591 | }); |
| 552 | 592 | ||
| 593 | activeQueries.schemaBundles.forEach((key) => { | ||
| 594 | const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceSchemaBundleKeyParts>(key); | ||
| 595 | if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { | ||
| 596 | registry.refresh(sourceInspectionSchemaBundleAtom(key)); | ||
| 597 | } | ||
| 598 | }); | ||
| 599 | |||
| 553 | activeQueries.discoveries.forEach((key) => { | 600 | activeQueries.discoveries.forEach((key) => { |
| 554 | const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key); | 601 | const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key); |
| 555 | if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { | 602 | if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { |
| @@ -625,6 +672,8 @@ const createOptimisticSource = (input: { · 69 unmodified lines | |||
| 625 | headers: input.payload.headers ?? null, | 672 | headers: input.payload.headers ?? null, |
| 626 | specUrl: input.payload.specUrl ?? null, | 673 | specUrl: input.payload.specUrl ?? null, |
| 627 | defaultHeaders: input.payload.defaultHeaders ?? null, | 674 | defaultHeaders: input.payload.defaultHeaders ?? null, |
| 675 | importAuthPolicy: input.payload.importAuthPolicy ?? "reuse_runtime", | ||
| 676 | importAuth: input.payload.importAuth ?? { kind: "none" }, | ||
| 628 | auth: input.payload.auth ?? { kind: "none" }, | 677 | auth: input.payload.auth ?? { kind: "none" }, |
| 629 | sourceHash: input.payload.sourceHash ?? null, | 678 | sourceHash: input.payload.sourceHash ?? null, |
| 630 | lastError: input.payload.lastError ?? null, | 679 | lastError: input.payload.lastError ?? null, |
| @@ -937,6 +986,27 @@ export const useSourceToolDetail = ( · 306 unmodified lines | |||
| 937 | return workspace.enabled ? detail : pendingLoadable(workspace.workspace); | 986 | return workspace.enabled ? detail : pendingLoadable(workspace.workspace); |
| 938 | }; | 987 | }; |
| 939 | 988 | ||
| 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 | |||
| 940 | export const useSourceDiscovery = (input: { | 1010 | export const useSourceDiscovery = (input: { |
| 941 | sourceId: string; | 1011 | sourceId: string; |
| 942 | query: string; | 1012 | query: string; |