Unified · Split
| @@ -1372,7 +1372,7 @@ describe("local-executor-server", () => { · 1371 unmodified lines | ||
| 1372 | 1372 | const manifestJson = JSON.stringify(manifest); |
| 1373 | 1373 | yield* server.runtime.persistence.rows.sourceRecipes.upsert({ |
| 1374 | 1374 | id: recipeId, |
| 1375 | − kind: "mcp_recipe", | |
| 1375 | + kind: "mcp", | |
| 1376 | 1376 | importerKind: "mcp_manifest", |
| 1377 | 1377 | providerKey: "generic_mcp", |
| 1378 | 1378 | name: "Demo", |
| @@ -1395,6 +1395,7 @@ describe("local-executor-server", () => { · 16 unmodified lines | ||
| 1395 | 1395 | }), |
| 1396 | 1396 | manifestJson, |
| 1397 | 1397 | manifestHash: null, |
| 1398 | + materializationHash: null, | |
| 1398 | 1399 | createdAt: now, |
| 1399 | 1400 | updatedAt: now, |
| 1400 | 1401 | }); |
| @@ -1409,14 +1410,14 @@ describe("local-executor-server", () => { · 8 unmodified lines | ||
| 1409 | 1410 | status: "connected", |
| 1410 | 1411 | enabled: true, |
| 1411 | 1412 | namespace: "demo", |
| 1412 | − transport: "streamable-http", | |
| 1413 | − bindingConfigJson: null, | |
| 1414 | − queryParamsJson: null, | |
| 1415 | − headersJson: null, | |
| 1416 | − specUrl: null, | |
| 1417 | − defaultHeadersJson: null, | |
| 1413 | + importAuthPolicy: "reuse_runtime", | |
| 1414 | + bindingConfigJson: JSON.stringify({ | |
| 1415 | + adapterKey: "mcp", | |
| 1416 | + transport: "streamable-http", | |
| 1417 | + queryParams: null, | |
| 1418 | + headers: null, | |
| 1419 | + }), | |
| 1418 | 1420 | sourceHash: null, |
| 1419 | − sourceDocumentText: null, | |
| 1420 | 1421 | lastError: null, |
| 1421 | 1422 | createdAt: now, |
| 1422 | 1423 | updatedAt: now, |
| @@ -1450,17 +1451,12 @@ describe("local-executor-server", () => { · 27 unmodified lines | ||
| 1450 | 1451 | inputSchemaJson: manifest.tools[0]!.inputSchemaJson ?? null, |
| 1451 | 1452 | outputSchemaJson: null, |
| 1452 | 1453 | providerKind: "mcp", |
| 1453 | − providerDataJson: null, | |
| 1454 | − mcpToolName: "gated_echo", | |
| 1455 | − openApiMethod: null, | |
| 1456 | − openApiPathTemplate: null, | |
| 1457 | − openApiOperationHash: null, | |
| 1458 | − openApiRawToolId: null, | |
| 1459 | − openApiOperationId: null, | |
| 1460 | − openApiTagsJson: null, | |
| 1461 | − openApiRequestBodyRequired: null, | |
| 1462 | − graphqlOperationType: null, | |
| 1463 | − graphqlOperationName: null, | |
| 1454 | + providerDataJson: JSON.stringify({ | |
| 1455 | + kind: "mcp", | |
| 1456 | + toolId: "gated_echo", | |
| 1457 | + toolName: "gated_echo", | |
| 1458 | + description: "Asks for approval before echoing a value", | |
| 1459 | + }), | |
| 1464 | 1460 | createdAt: now, |
| 1465 | 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 | 24 | type SourceRouteSearch = { |
| 25 | − tab: "model" | "discover" | "manifest" | "definitions" | "raw"; | |
| 25 | + tab: "model" | "discover"; | |
| 26 | 26 | tool?: string; |
| 27 | 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 | 33 | // Routes |
| @@ -217,7 +217,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 216 unmodified lines | ||
| 217 | 217 | |
| 218 | 218 | const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({ |
| 219 | 219 | ...defaultConnectForm(), |
| 220 | − kind: template.kind, | |
| 220 | + kind: template.kind as ConnectFormState["kind"], | |
| 221 | 221 | endpoint: template.endpoint, |
| 222 | 222 | specUrl: "specUrl" in template ? template.specUrl : "", |
| 223 | 223 | name: template.name, |
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | import { useState, useEffect, useCallback, useRef, useMemo } from "react"; |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { |
| 4 | + useSource, | |
| 5 | + useSources, | |
| 4 | 6 | useSourceInspection, |
| 7 | + useSourceSchemaBundle, | |
| 5 | 8 | useSourceToolDetail, |
| 6 | 9 | useSourceDiscovery, |
| 7 | − usePrefetchToolDetail, | |
| 8 | 10 | type Loadable, |
| 11 | + type Source, | |
| 9 | 12 | type SourceInspection, |
| 10 | 13 | type SourceInspectionToolDetail, |
| 11 | 14 | type SourceInspectionDiscoverResult, |
| @@ -15,6 +18,7 @@ import { Badge, MethodBadge } from "../components/ui/badge"; · 3 unmodified lines | ||
| 15 | 18 | import { Button } from "../components/ui/button"; |
| 16 | 19 | import { LoadableBlock, EmptyState } from "../components/loadable"; |
| 17 | 20 | import { DocumentPanel } from "../components/document-panel"; |
| 21 | +import { SourceNotFoundState } from "../components/source-not-found-state"; | |
| 18 | 22 | import { |
| 19 | 23 | IconSearch, |
| 20 | 24 | IconChevron, |
| @@ -32,7 +36,7 @@ import { Markdown } from "../components/markdown"; · 11 unmodified lines | ||
| 32 | 36 | // --------------------------------------------------------------------------- |
| 33 | 37 | |
| 34 | 38 | type SourceRouteSearch = { |
| 35 | − tab: "model" | "discover" | "manifest" | "definitions" | "raw"; | |
| 39 | + tab: "model" | "discover"; | |
| 36 | 40 | tool?: string; |
| 37 | 41 | query?: string; |
| 38 | 42 | }; |
| @@ -42,6 +46,122 @@ const visibleTabs: Array<{ id: SourceRouteSearch["tab"]; label: string }> = [ · 3 unmodified lines | ||
| 42 | 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 | 166 | // SourceDetailPage (main export) |
| 47 | 167 | // --------------------------------------------------------------------------- |
| @@ -52,7 +172,13 @@ export function SourceDetailPage(props: { · 4 unmodified lines | ||
| 52 | 172 | navigate: (opts: { search: (prev: SourceRouteSearch) => SourceRouteSearch; replace?: boolean }) => void; |
| 53 | 173 | }) { |
| 54 | 174 | const { sourceId, search, navigate } = props; |
| 175 | + const sources = useSources(); | |
| 176 | + const source = useSource(sourceId); | |
| 55 | 177 | const inspection = useSourceInspection(sourceId); |
| 178 | + const missingSource = | |
| 179 | + listExcludesSource(sources, sourceId) | |
| 180 | + || isSourceNotFoundError(source) | |
| 181 | + || isSourceNotFoundError(inspection); | |
| 56 | 182 | |
| 57 | 183 | const selectedToolPath = |
| 58 | 184 | search.tool |
| @@ -77,6 +203,10 @@ export function SourceDetailPage(props: { · 18 unmodified lines | ||
| 77 | 203 | void navigate({ search: (prev) => ({ ...prev, tool: firstTool }), replace: true }); |
| 78 | 204 | }, [inspection, navigate, search.tab, search.tool]); |
| 79 | 205 | |
| 206 | + if (missingSource) { | |
| 207 | + return <SourceNotFoundState />; | |
| 208 | + } | |
| 209 | + | |
| 80 | 210 | return ( |
| 81 | 211 | <LoadableBlock loadable={inspection} loading="Loading source..."> |
| 82 | 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 | 284 | </div> |
| 170 | 285 | </div> |
| 171 | 286 | ); |
| @@ -191,7 +306,7 @@ function ModelView(props: { · 19 unmodified lines | ||
| 191 | 306 | |
| 192 | 307 | const filteredTools = props.bundle.tools.filter((tool) => { |
| 193 | 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 | 310 | .join(" ") |
| 196 | 311 | .toLowerCase(); |
| 197 | 312 | return terms.every((t) => corpus.includes(t)); |
| @@ -258,7 +373,6 @@ function ModelView(props: { · 60 unmodified lines | ||
| 258 | 373 | onSelectTool={props.onSelectTool} |
| 259 | 374 | search={search} |
| 260 | 375 | isFiltered={terms.length > 0} |
| 261 | − sourceId={props.sourceId} | |
| 262 | 376 | /> |
| 263 | 377 | </div> |
| 264 | 378 | )} |
| @@ -270,7 +384,7 @@ function ModelView(props: { · 5 unmodified lines | ||
| 270 | 384 | <LoadableBlock loadable={props.detail} loading="Loading tool..."> |
| 271 | 385 | {(detail) => |
| 272 | 386 | detail ? ( |
| 273 | − <ToolDetailPanel detail={detail} /> | |
| 387 | + <ToolDetailPanel sourceId={props.sourceId} detail={detail} /> | |
| 274 | 388 | ) : ( |
| 275 | 389 | <EmptyState |
| 276 | 390 | title={props.bundle.toolCount > 0 ? "Select a tool" : "No tools available"} |
| @@ -316,10 +430,8 @@ function ToolTree(props: { · 39 unmodified lines | ||
| 316 | 430 | onSelectTool: (path: string) => void; |
| 317 | 431 | search: string; |
| 318 | 432 | isFiltered: boolean; |
| 319 | − sourceId: string; | |
| 320 | 433 | }) { |
| 321 | 434 | const tree = useMemo(() => buildToolTree(props.tools), [props.tools]); |
| 322 | − const prefetch = usePrefetchToolDetail(); | |
| 323 | 435 | const entries = [...tree.children.values()].sort((a, b) => |
| 324 | 436 | a.segment.localeCompare(b.segment), |
| 325 | 437 | ); |
| @@ -335,8 +447,6 @@ function ToolTree(props: { · 9 unmodified lines | ||
| 335 | 447 | onSelectTool={props.onSelectTool} |
| 336 | 448 | search={props.search} |
| 337 | 449 | defaultOpen={props.isFiltered} |
| 338 | − sourceId={props.sourceId} | |
| 339 | − prefetch={prefetch} | |
| 340 | 450 | /> |
| 341 | 451 | ))} |
| 342 | 452 | </div> |
| @@ -350,10 +460,8 @@ function ToolTreeNodeView(props: { · 7 unmodified lines | ||
| 350 | 460 | onSelectTool: (path: string) => void; |
| 351 | 461 | search: string; |
| 352 | 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 | 465 | const hasChildren = node.children.size > 0; |
| 358 | 466 | const isLeaf = !!node.tool && !hasChildren; |
| 359 | 467 | |
| @@ -383,8 +491,6 @@ function ToolTreeNodeView(props: { · 23 unmodified lines | ||
| 383 | 491 | onSelect={() => onSelectTool(node.tool!.path)} |
| 384 | 492 | search={search} |
| 385 | 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 | 526 | search={search} |
| 421 | 527 | depth={-1} |
| 422 | 528 | className="flex-1 pl-1" |
| 423 | − sourceId={sourceId} | |
| 424 | − prefetch={prefetch} | |
| 425 | 529 | /> |
| 426 | 530 | </div> |
| 427 | 531 | ) : ( |
| @@ -469,8 +573,6 @@ function ToolTreeNodeView(props: { · 41 unmodified lines | ||
| 469 | 573 | onSelectTool={onSelectTool} |
| 470 | 574 | search={search} |
| 471 | 575 | defaultOpen={defaultOpen} |
| 472 | − sourceId={sourceId} | |
| 473 | − prefetch={prefetch} | |
| 474 | 576 | /> |
| 475 | 577 | ))} |
| 476 | 578 | </div> |
| @@ -498,12 +600,9 @@ function ToolListItem(props: { · 21 unmodified lines | ||
| 498 | 600 | search: string; |
| 499 | 601 | depth: number; |
| 500 | 602 | className?: string; |
| 501 | − sourceId: string; | |
| 502 | − prefetch: (sourceId: string, toolPath: string) => () => void; | |
| 503 | 603 | }) { |
| 504 | 604 | const ref = useRef<HTMLButtonElement>(null); |
| 505 | 605 | const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined; |
| 506 | − const prefetchedRef = useRef(false); | |
| 507 | 606 | |
| 508 | 607 | useEffect(() => { |
| 509 | 608 | if (props.active && ref.current) { |
| @@ -511,24 +610,6 @@ function ToolListItem(props: { · 1 unmodified line | ||
| 511 | 610 | } |
| 512 | 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 | 613 | const label = props.depth >= 0 |
| 533 | 614 | ? props.tool.path.split(".").pop() ?? props.tool.path |
| 534 | 615 | : props.tool.path; |
| @@ -560,9 +641,14 @@ function ToolListItem(props: { · 25 unmodified lines | ||
| 560 | 641 | // ToolDetailPanel |
| 561 | 642 | // --------------------------------------------------------------------------- |
| 562 | 643 | |
| 563 | −function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { | |
| 644 | +function ToolDetailPanel(props: { sourceId: string; detail: SourceInspectionToolDetail }) { | |
| 564 | 645 | const { detail } = props; |
| 565 | 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 | 653 | const copy = useCallback((text: string, field: string) => { |
| 568 | 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 | 697 | return ( |
| 575 | 698 | <div className="flex h-full flex-col overflow-hidden"> |
| 576 | 699 | {/* Header */} |
| @@ -613,9 +736,30 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 36 unmodified lines | ||
| 613 | 736 | </div> |
| 614 | 737 | |
| 615 | 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 | 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 /> | |
| 618 | − <DocumentPanel title="Output schema" body={detail.outputSchemaJson} empty="No output schema." compact /> | |
| 761 | + <DocumentPanel title="Input schema" body={resolvedSchemas.inputSchemaJson} empty="No input schema." compact /> | |
| 762 | + <DocumentPanel title="Output schema" body={resolvedSchemas.outputSchemaJson} empty="No output schema." compact /> | |
| 619 | 763 | {detail.exampleInputJson && ( |
| 620 | 764 | <DocumentPanel title="Example request" body={detail.exampleInputJson} empty="" compact /> |
| 621 | 765 | )} |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | useRefreshSecrets, |
| 16 | 16 | useRemoveSource, |
| 17 | 17 | useSecrets, |
| 18 | + useSources, | |
| 18 | 19 | useStartSourceOAuth, |
| 19 | 20 | useSource, |
| 20 | 21 | useUpdateSource, |
| @@ -22,6 +23,7 @@ import { · 1 unmodified line | ||
| 22 | 23 | import { Badge } from "../components/ui/badge"; |
| 23 | 24 | import { Button } from "../components/ui/button"; |
| 24 | 25 | import { LoadableBlock } from "../components/loadable"; |
| 26 | +import { SourceNotFoundState } from "../components/source-not-found-state"; | |
| 25 | 27 | import { SourceFavicon } from "../components/source-favicon"; |
| 26 | 28 | import { |
| 27 | 29 | IconArrowLeft, |
| @@ -56,6 +58,10 @@ type SourceOAuthPopupMessage = · 28 unmodified lines | ||
| 56 | 58 | const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; |
| 57 | 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 | 65 | type TransportValue = "" | NonNullable<Source["transport"]>; |
| 60 | 66 | |
| 61 | 67 | type SourceFormState = { |
| @@ -495,7 +501,16 @@ export function NewSourcePage() { · 433 unmodified lines | ||
| 495 | 501 | } |
| 496 | 502 | |
| 497 | 503 | export function EditSourcePage(props: { sourceId: string }) { |
| 504 | + const sources = useSources(); | |
| 498 | 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 | 515 | return ( |
| 501 | 516 | <LoadableBlock loadable={source} loading="Loading source..."> |
| @@ -111,6 +111,7 @@ export function createToolCatalogFromTools(input: { · 110 unmodified lines | ||
| 111 | 111 | }) |
| 112 | 112 | : null, |
| 113 | 113 | ), |
| 114 | + getSchemaBundle: () => Effect.succeed(null), | |
| 114 | 115 | searchTools: ({ query, namespace, limit }) => { |
| 115 | 116 | const queryTokens = tokenize(query); |
| 116 | 117 | |
| @@ -184,6 +185,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 67 unmodified lines | ||
| 184 | 185 | const describe: DescribePrimitive = { |
| 185 | 186 | tool: ({ path, includeSchemas = false }) => |
| 186 | 187 | catalog.getToolByPath({ path, includeSchemas }), |
| 188 | + schemaBundle: ({ id }) => catalog.getSchemaBundle({ id }), | |
| 187 | 189 | }; |
| 188 | 190 | |
| 189 | 191 | const discover: DiscoverPrimitive = ({ |
| @@ -234,6 +236,7 @@ export function createDiscoveryPrimitivesFromToolCatalog(input: { · 44 unmodified lines | ||
| 234 | 236 | ? { |
| 235 | 237 | inputSchemaJson: descriptor.inputSchemaJson, |
| 236 | 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 | 299 | "Workflow:", |
| 297 | 300 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 298 | 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 | 304 | "Do not use fetch; use tools.* only.", |
| 301 | 305 | ].join("\n"); |
| 302 | 306 | }); |
| @@ -331,6 +331,7 @@ describe("codemode-core", () => { · 330 unmodified lines | ||
| 331 | 331 | ), |
| 332 | 332 | getToolByPath: ({ path }) => |
| 333 | 333 | Effect.succeed(descriptors[path] ?? null), |
| 334 | + getSchemaBundle: () => Effect.succeed(null), | |
| 334 | 335 | searchTools: () => |
| 335 | 336 | Effect.succeed([ |
| 336 | 337 | { path: asToolPath("source.issues.create"), score: 0.93 }, |
| @@ -51,6 +51,7 @@ const catalogToolsOutputSchema = Schema.standardSchemaV1( · 50 unmodified lines | ||
| 51 | 51 | outputType: Schema.optional(Schema.String), |
| 52 | 52 | inputSchemaJson: Schema.optional(Schema.String), |
| 53 | 53 | outputSchemaJson: Schema.optional(Schema.String), |
| 54 | + schemaBundleId: Schema.optional(Schema.String), | |
| 54 | 55 | exampleInputJson: Schema.optional(Schema.String), |
| 55 | 56 | exampleOutputJson: Schema.optional(Schema.String), |
| 56 | 57 | providerKind: Schema.optional(Schema.String), |
| @@ -71,6 +72,23 @@ const describeToolOutputSchema = Schema.standardSchemaV1( · 14 unmodified lines | ||
| 71 | 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 | 92 | const discoverInputSchema = Schema.standardSchemaV1( |
| 75 | 93 | Schema.Struct({ |
| 76 | 94 | query: Schema.String, |
| @@ -88,6 +106,7 @@ const discoverResultItemSchema = Schema.Struct({ · 11 unmodified lines | ||
| 88 | 106 | outputType: Schema.optional(Schema.String), |
| 89 | 107 | inputSchemaJson: Schema.optional(Schema.String), |
| 90 | 108 | outputSchemaJson: Schema.optional(Schema.String), |
| 109 | + schemaBundleId: Schema.optional(Schema.String), | |
| 91 | 110 | }); |
| 92 | 111 | |
| 93 | 112 | const discoverOutputSchema = Schema.standardSchemaV1( |
| @@ -182,6 +201,24 @@ export const createSystemToolMap = ( · 88 unmodified lines | ||
| 182 | 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 | 224 | if (primitives.discover) { |
| @@ -438,6 +438,7 @@ export function toolDescriptorsFromTools(input: { · 437 unmodified lines | ||
| 438 | 438 | metadata?.outputType ?? inferTypeFromSchemaJson(outputSchemaJson, "unknown"), |
| 439 | 439 | inputSchemaJson, |
| 440 | 440 | outputSchemaJson, |
| 441 | + ...(metadata?.schemaBundleId ? { schemaBundleId: metadata.schemaBundleId } : {}), | |
| 441 | 442 | ...(metadata?.exampleInputJson |
| 442 | 443 | ? { exampleInputJson: metadata.exampleInputJson } |
| 443 | 444 | : {}), |
| @@ -81,6 +81,7 @@ export type ToolMetadata = { · 80 unmodified lines | ||
| 81 | 81 | outputType?: string; |
| 82 | 82 | inputSchemaJson?: string; |
| 83 | 83 | outputSchemaJson?: string; |
| 84 | + schemaBundleId?: string; | |
| 84 | 85 | exampleInputJson?: string; |
| 85 | 86 | exampleOutputJson?: string; |
| 86 | 87 | sourceKey?: string; |
| @@ -134,12 +135,20 @@ export type ToolDescriptor = { · 47 unmodified lines | ||
| 134 | 135 | outputType?: string; |
| 135 | 136 | inputSchemaJson?: string; |
| 136 | 137 | outputSchemaJson?: string; |
| 138 | + schemaBundleId?: string; | |
| 137 | 139 | exampleInputJson?: string; |
| 138 | 140 | exampleOutputJson?: string; |
| 139 | 141 | providerKind?: string; |
| 140 | 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 | 152 | export type ToolNamespace = { |
| 144 | 153 | namespace: string; |
| 145 | 154 | displayName?: string; |
| @@ -168,6 +177,10 @@ export interface ToolCatalog { · 22 unmodified lines | ||
| 168 | 177 | includeSchemas: boolean; |
| 169 | 178 | }): Effect.Effect<ToolDescriptor | null, unknown>; |
| 170 | 179 | |
| 180 | + getSchemaBundle(input: { | |
| 181 | + id: string; | |
| 182 | + }): Effect.Effect<ToolSchemaBundle | null, unknown>; | |
| 183 | + | |
| 171 | 184 | searchTools(input: { |
| 172 | 185 | query: string; |
| 173 | 186 | namespace?: string; |
| @@ -196,6 +209,9 @@ export type DescribePrimitive = { · 22 unmodified lines | ||
| 196 | 209 | path: ToolPath; |
| 197 | 210 | includeSchemas?: boolean; |
| 198 | 211 | }): Effect.Effect<ToolDescriptor | null, unknown>; |
| 212 | + schemaBundle(input: { | |
| 213 | + id: string; | |
| 214 | + }): Effect.Effect<ToolSchemaBundle | null, unknown>; | |
| 199 | 215 | }; |
| 200 | 216 | |
| 201 | 217 | export type DiscoverPrimitive = (input: { |
| @@ -19,6 +19,7 @@ export { · 18 unmodified lines | ||
| 19 | 19 | } from "./openapi-tool-presentation"; |
| 20 | 20 | export { |
| 21 | 21 | OpenApiToolInvocationError, |
| 22 | + createOpenApiToolFromDefinition, | |
| 22 | 23 | createOpenApiToolsFromManifest, |
| 23 | 24 | createOpenApiToolsFromSpec, |
| 24 | 25 | } from "./openapi-tools"; |
| @@ -39,6 +40,9 @@ export { · 14 unmodified lines | ||
| 39 | 40 | OpenApiRequestBodyDocumentationSchema, |
| 40 | 41 | OpenApiResponseDocumentationSchema, |
| 41 | 42 | OpenApiToolDocumentationSchema, |
| 43 | + OpenApiToolProviderDataSchema, | |
| 44 | + OpenApiRefHintTableSchema, | |
| 45 | + OpenApiRefHintValueSchema, | |
| 42 | 46 | type DiscoveryTypingPayload, |
| 43 | 47 | type OpenApiExtractedTool, |
| 44 | 48 | type OpenApiHttpMethod, |
| @@ -49,6 +53,9 @@ export { · 4 unmodified lines | ||
| 49 | 53 | type OpenApiParameterLocation, |
| 50 | 54 | type OpenApiSpecInput, |
| 51 | 55 | type OpenApiToolManifest, |
| 56 | + type OpenApiToolProviderData, | |
| 57 | + type OpenApiRefHintTable, | |
| 58 | + type OpenApiRefHintValue, | |
| 52 | 59 | type OpenApiToolParameter, |
| 53 | 60 | type OpenApiToolRequestBody, |
| 54 | 61 | } from "./openapi-types"; |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | OpenApiHttpMethodSchema, |
| 6 | 6 | OpenApiInvocationPayloadSchema, |
| 7 | 7 | OpenApiToolDocumentationSchema, |
| 8 | + OpenApiToolProviderDataSchema, | |
| 8 | 9 | type OpenApiExtractedTool, |
| 9 | 10 | type OpenApiToolManifest, |
| 10 | 11 | } from "./openapi-types"; |
| @@ -250,5 +251,6 @@ export const openApiProviderDataJsonFromDefinition = ( · 239 unmodified lines | ||
| 250 | 251 | method: definition.method, |
| 251 | 252 | path: definition.path, |
| 252 | 253 | operationHash: definition.operationHash, |
| 254 | + invocation: definition.invocation, | |
| 253 | 255 | documentation: definition.documentation, |
| 254 | − }); | |
| 256 | + } satisfies typeof OpenApiToolProviderDataSchema.Type); | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | type JsonObject = Record<string, unknown>; |
| 2 | +type RefHintValue = string | JsonObject; | |
| 2 | 3 | |
| 3 | 4 | type DiscoveryTypingLike = { |
| 4 | 5 | inputSchemaJson?: string; |
| @@ -18,7 +19,7 @@ const parseJson = (value: string): unknown | null => { · 13 unmodified lines | ||
| 18 | 19 | |
| 19 | 20 | const resolveSchemaNode = ( |
| 20 | 21 | value: unknown, |
| 21 | − refHintTable: Readonly<Record<string, string>>, | |
| 22 | + refHintTable: Readonly<Record<string, RefHintValue>>, | |
| 22 | 23 | parsedHintCache: Map<string, unknown>, |
| 23 | 24 | activeRefs: ReadonlySet<string>, |
| 24 | 25 | ): unknown => { |
| @@ -38,7 +39,12 @@ const resolveSchemaNode = ( · 13 unmodified lines | ||
| 38 | 39 | |
| 39 | 40 | if (resolvedRefValue === undefined) { |
| 40 | 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 | 48 | parsedHintCache.set(ref, resolvedRefValue); |
| 43 | 49 | } |
| 44 | 50 | |
| @@ -88,7 +94,7 @@ const resolveSchemaNode = ( · 43 unmodified lines | ||
| 88 | 94 | |
| 89 | 95 | export const resolveSchemaJsonWithRefHints = ( |
| 90 | 96 | schemaJson: string | undefined, |
| 91 | − refHintTable: Readonly<Record<string, string>> | undefined, | |
| 97 | + refHintTable: Readonly<Record<string, RefHintValue>> | undefined, | |
| 92 | 98 | ): string | null => { |
| 93 | 99 | if (!schemaJson) { |
| 94 | 100 | return null; |
| @@ -119,7 +125,7 @@ export const resolveSchemaJsonWithRefHints = ( · 24 unmodified lines | ||
| 119 | 125 | |
| 120 | 126 | export const resolveTypingSchemasWithRefHints = ( |
| 121 | 127 | typing: DiscoveryTypingLike | undefined, |
| 122 | − refHintTable: Readonly<Record<string, string>> | undefined, | |
| 128 | + refHintTable: Readonly<Record<string, RefHintValue>> | undefined, | |
| 123 | 129 | ): { |
| 124 | 130 | inputSchemaJson: string | null; |
| 125 | 131 | outputSchemaJson: string | null; |
| @@ -1,14 +1,13 @@ | ||
| 1 | 1 | import { typeSignatureFromSchemaJson } from "@executor/codemode-core"; |
| 2 | 2 | |
| 3 | −import { | |
| 4 | − resolveTypingSchemasWithRefHints, | |
| 5 | −} from "./openapi-schema-refs"; | |
| 6 | 3 | import type { |
| 7 | 4 | OpenApiExample, |
| 8 | 5 | OpenApiToolDocumentation, |
| 9 | − OpenApiToolManifest, | |
| 10 | 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 | 12 | const parseJson = (value: string): unknown | undefined => { |
| 14 | 13 | try { |
| @@ -104,15 +103,10 @@ export type OpenApiToolPresentation = { · 89 unmodified lines | ||
| 104 | 103 | }; |
| 105 | 104 | |
| 106 | 105 | export const buildOpenApiToolPresentation = (input: { |
| 107 | − manifest: OpenApiToolManifest; | |
| 108 | 106 | definition: OpenApiToolDefinition; |
| 109 | 107 | }): OpenApiToolPresentation => { |
| 110 | − const resolvedSchemas = resolveTypingSchemasWithRefHints( | |
| 111 | − input.definition.typing, | |
| 112 | − input.manifest.refHintTable, | |
| 113 | − ); | |
| 114 | − const inputSchemaJson = resolvedSchemas.inputSchemaJson ?? undefined; | |
| 115 | − const outputSchemaJson = resolvedSchemas.outputSchemaJson ?? undefined; | |
| 108 | + const inputSchemaJson = input.definition.typing?.inputSchemaJson; | |
| 109 | + const outputSchemaJson = input.definition.typing?.outputSchemaJson; | |
| 116 | 110 | const exampleInputJson = buildExampleInputJson(input.definition.documentation); |
| 117 | 111 | const exampleOutputJson = buildExampleOutputJson(input.definition.documentation); |
| 118 | 112 | |
| @@ -123,19 +117,6 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines | ||
| 123 | 117 | ...(outputSchemaJson ? { outputSchemaJson } : {}), |
| 124 | 118 | ...(exampleInputJson ? { exampleInputJson } : {}), |
| 125 | 119 | ...(exampleOutputJson ? { exampleOutputJson } : {}), |
| 126 | − providerDataJson: JSON.stringify({ | |
| 127 | − kind: "openapi", | |
| 128 | − toolId: input.definition.toolId, | |
| 129 | − rawToolId: input.definition.rawToolId, | |
| 130 | − operationId: input.definition.operationId, | |
| 131 | − group: input.definition.group, | |
| 132 | − leaf: input.definition.leaf, | |
| 133 | − tags: input.definition.tags, | |
| 134 | − versionSegment: input.definition.versionSegment, | |
| 135 | − method: input.definition.method, | |
| 136 | − path: input.definition.path, | |
| 137 | − operationHash: input.definition.operationHash, | |
| 138 | − documentation: input.definition.documentation, | |
| 139 | − }), | |
| 120 | + providerDataJson: openApiProviderDataJsonFromDefinition(input.definition), | |
| 140 | 121 | }; |
| 141 | 122 | }; |
| @@ -19,11 +19,13 @@ import { · 18 unmodified lines | ||
| 19 | 19 | type OpenApiToolDefinition, |
| 20 | 20 | } from "./openapi-definitions"; |
| 21 | 21 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; |
| 22 | +import { resolveSchemaJsonWithRefHints } from "./openapi-schema-refs"; | |
| 22 | 23 | import { |
| 23 | 24 | extractOpenApiManifest, |
| 24 | 25 | type OpenApiExtractionError, |
| 25 | 26 | } from "./openapi-extraction"; |
| 26 | 27 | import { |
| 28 | + type OpenApiRefHintTable, | |
| 27 | 29 | type OpenApiInvocationPayload, |
| 28 | 30 | type OpenApiSpecInput, |
| 29 | 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) => { | |
| 260 | − if (!inputSchemaJson) { | |
| 261 | +const inputSchemaFromTypingJson = (input: { | |
| 262 | + inputSchemaJson: string | undefined; | |
| 263 | + refHintTable?: Readonly<OpenApiRefHintTable>; | |
| 264 | +}) => { | |
| 265 | + const resolvedSchemaJson = resolveSchemaJsonWithRefHints( | |
| 266 | + input.inputSchemaJson, | |
| 267 | + input.refHintTable, | |
| 268 | + ) ?? input.inputSchemaJson; | |
| 269 | + | |
| 270 | + if (!resolvedSchemaJson) { | |
| 261 | 271 | return unknownInputSchema; |
| 262 | 272 | } |
| 263 | 273 | |
| 264 | 274 | try { |
| 265 | − return standardSchemaFromJsonSchema(JSON.parse(inputSchemaJson), { | |
| 275 | + return standardSchemaFromJsonSchema(JSON.parse(resolvedSchemaJson), { | |
| 266 | 276 | vendor: "openapi", |
| 267 | 277 | fallback: unknownInputSchema, |
| 268 | 278 | }); |
| @@ -396,6 +406,97 @@ const buildFetchRequest = (input: { · 127 unmodified lines | ||
| 396 | 406 | const createToolPath = (namespace: string | undefined, definition: OpenApiToolDefinition): string => |
| 397 | 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 | 500 | export const createOpenApiToolsFromManifest = ( |
| 400 | 501 | input: CreateOpenApiToolsFromManifestInput, |
| 401 | 502 | ): ToolMap => { |
| @@ -410,77 +511,15 @@ export const createOpenApiToolsFromManifest = ( · 8 unmodified lines | ||
| 410 | 511 | |
| 411 | 512 | for (const definition of definitions) { |
| 412 | 513 | const toolPath = createToolPath(input.namespace, definition); |
| 413 | − const presentation = buildOpenApiToolPresentation({ | |
| 414 | − manifest: input.manifest, | |
| 514 | + result[toolPath] = createOpenApiToolFromDefinition({ | |
| 415 | 515 | definition, |
| 416 | − }); | |
| 417 | − | |
| 418 | − result[toolPath] = toTool({ | |
| 419 | − tool: { | |
| 420 | − description: definition.description, | |
| 421 | − inputSchema: inputSchemaFromTypingJson( | |
| 422 | − presentation.inputSchemaJson ?? definition.typing?.inputSchemaJson, | |
| 423 | − ), | |
| 424 | − execute: async (args: unknown) => { | |
| 425 | − const decodedArgs = asToolArgs(args); | |
| 426 | − const request = buildFetchRequest({ | |
| 427 | − payload: definition.invocation, | |
| 428 | − args: decodedArgs, | |
| 429 | − baseUrl, | |
| 430 | − defaultHeaders, | |
| 431 | − credentialHeaders, | |
| 432 | − }); | |
| 433 | − | |
| 434 | − return Effect.runPromise( | |
| 435 | − Effect.gen(function* () { | |
| 436 | − const client = yield* HttpClient.HttpClient; | |
| 437 | − let clientRequest = HttpClientRequest.make( | |
| 438 | − request.method as Parameters<typeof HttpClientRequest.make>[0], | |
| 439 | − )(request.url, { | |
| 440 | − headers: request.headers, | |
| 441 | − }); | |
| 442 | − | |
| 443 | − if (request.body !== undefined) { | |
| 444 | − clientRequest = HttpClientRequest.bodyText( | |
| 445 | − clientRequest, | |
| 446 | − request.body, | |
| 447 | − request.headers["content-type"], | |
| 448 | − ); | |
| 449 | − } | |
| 450 | − | |
| 451 | − const response = yield* client.execute(clientRequest).pipe( | |
| 452 | − Effect.mapError((cause) => | |
| 453 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 454 | − ), | |
| 455 | − ); | |
| 456 | − const body = yield* decodeHttpClientResponseBody(response); | |
| 457 | − | |
| 458 | − if (response.status < 200 || response.status >= 300) { | |
| 459 | − throw new OpenApiToolInvocationError({ | |
| 460 | − operation: "http_response", | |
| 461 | − message: `OpenAPI request failed with HTTP ${response.status}`, | |
| 462 | − details: summarizeHttpResponseBody(body), | |
| 463 | − }); | |
| 464 | − } | |
| 465 | − | |
| 466 | − return body; | |
| 467 | − }).pipe( | |
| 468 | − Effect.provide(httpClientLayer), | |
| 469 | − ), | |
| 470 | − ); | |
| 471 | − }, | |
| 472 | − }, | |
| 473 | − metadata: { | |
| 474 | − sourceKey, | |
| 475 | − inputType: presentation.inputType, | |
| 476 | − outputType: presentation.outputType, | |
| 477 | − inputSchemaJson: presentation.inputSchemaJson, | |
| 478 | − outputSchemaJson: presentation.outputSchemaJson, | |
| 479 | − exampleInputJson: presentation.exampleInputJson, | |
| 480 | − exampleOutputJson: presentation.exampleOutputJson, | |
| 481 | − providerKind: "openapi", | |
| 482 | − providerDataJson: presentation.providerDataJson, | |
| 483 | − }, | |
| 516 | + path: toolPath, | |
| 517 | + sourceKey, | |
| 518 | + baseUrl, | |
| 519 | + defaultHeaders, | |
| 520 | + credentialHeaders, | |
| 521 | + refHintTable: input.manifest.refHintTable, | |
| 522 | + httpClientLayer, | |
| 484 | 523 | }); |
| 485 | 524 | } |
| 486 | 525 | |
| @@ -63,6 +63,19 @@ export const DiscoveryTypingPayloadSchema = Schema.Struct({ · 62 unmodified lines | ||
| 63 | 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 | 79 | export const OpenApiExampleSchema = Schema.Struct({ |
| 67 | 80 | valueJson: Schema.String, |
| 68 | 81 | mediaType: Schema.optional(Schema.String), |
| @@ -97,6 +110,22 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 28 unmodified lines | ||
| 97 | 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 | 129 | export const OpenApiExtractedToolSchema = Schema.Struct({ |
| 101 | 130 | toolId: Schema.String, |
| 102 | 131 | operationId: Schema.optional(Schema.String), |
| @@ -129,10 +158,13 @@ export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; · 26 unmodified lines | ||
| 129 | 158 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; |
| 130 | 159 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; |
| 131 | 160 | export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type; |
| 161 | +export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type; | |
| 162 | +export type OpenApiRefHintTable = typeof OpenApiRefHintTableSchema.Type; | |
| 132 | 163 | export type OpenApiExample = typeof OpenApiExampleSchema.Type; |
| 133 | 164 | export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type; |
| 134 | 165 | export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type; |
| 135 | 166 | export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type; |
| 136 | 167 | export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type; |
| 168 | +export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type; | |
| 137 | 169 | export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type; |
| 138 | 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 | 11 | } from "./organizations/api"; |
| 12 | 12 | import { CreatePolicyPayloadSchema } from "./policies/api"; |
| 13 | 13 | import { |
| 14 | + ConnectSourcePayloadSchema, | |
| 14 | 15 | CreateSourcePayloadSchema, |
| 15 | 16 | UpdateSourcePayloadSchema, |
| 16 | 17 | } from "./sources/api"; |
| @@ -56,6 +57,18 @@ describe("control-plane payload schemas", () => { · 39 unmodified lines | ||
| 56 | 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 | 72 | expect( |
| 60 | 73 | Schema.decodeUnknownSync(CreatePolicyPayloadSchema)({ |
| 61 | 74 | resourcePattern: " source.github.* ", |
| @@ -83,5 +96,12 @@ describe("control-plane payload schemas", () => { · 21 unmodified lines | ||
| 83 | 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 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { |
| 3 | 3 | ExecutionInteractionIdSchema, |
| 4 | − SecretRefSchema, | |
| 5 | 4 | SourceAuthSchema, |
| 6 | 5 | SourceAuthSessionIdSchema, |
| 7 | 6 | SourceDiscoveryResultSchema, |
| 8 | 7 | SourceIdSchema, |
| 9 | 8 | SourceInspectionDiscoverPayloadSchema, |
| 10 | 9 | SourceInspectionDiscoverResultSchema, |
| 10 | + SourceInspectionSchemaBundleSchema, | |
| 11 | 11 | SourceInspectionSchema, |
| 12 | 12 | SourceInspectionToolDetailSchema, |
| 13 | + SourceImportAuthPolicySchema, | |
| 13 | 14 | SourceKindSchema, |
| 14 | 15 | SourceProbeAuthSchema, |
| 15 | 16 | SourceSchema, |
| @@ -31,6 +32,10 @@ import { · 15 unmodified lines | ||
| 31 | 32 | OptionalTrimmedNonEmptyStringSchema, |
| 32 | 33 | TrimmedNonEmptyStringSchema, |
| 33 | 34 | } from "../string-schemas"; |
| 35 | +import { | |
| 36 | + ConnectSourcePayloadSchema, | |
| 37 | + type ConnectSourcePayload, | |
| 38 | +} from "../../runtime/source-adapters"; | |
| 34 | 39 | |
| 35 | 40 | const createSourcePayloadRequiredSchema = Schema.Struct({ |
| 36 | 41 | name: TrimmedNonEmptyStringSchema, |
| @@ -47,6 +52,8 @@ const createSourcePayloadOptionalSchema = Schema.Struct({ · 10 unmodified lines | ||
| 47 | 52 | headers: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 48 | 53 | specUrl: Schema.optional(Schema.NullOr(Schema.String)), |
| 49 | 54 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 55 | + importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema), | |
| 56 | + importAuth: Schema.optional(SourceAuthSchema), | |
| 50 | 57 | auth: Schema.optional(SourceAuthSchema), |
| 51 | 58 | sourceHash: Schema.optional(Schema.NullOr(Schema.String)), |
| 52 | 59 | lastError: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -71,6 +78,8 @@ export const UpdateSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines | ||
| 71 | 78 | headers: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 72 | 79 | specUrl: Schema.optional(Schema.NullOr(Schema.String)), |
| 73 | 80 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), |
| 81 | + importAuthPolicy: Schema.optional(SourceImportAuthPolicySchema), | |
| 82 | + importAuth: Schema.optional(SourceAuthSchema), | |
| 74 | 83 | auth: Schema.optional(SourceAuthSchema), |
| 75 | 84 | sourceHash: Schema.optional(Schema.NullOr(Schema.String)), |
| 76 | 85 | lastError: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -81,6 +90,7 @@ export type UpdateSourcePayload = typeof UpdateSourcePayloadSchema.Type; · 4 unmodified lines | ||
| 81 | 90 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 82 | 91 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); |
| 83 | 92 | const toolPathParam = HttpApiSchema.param("toolPath", Schema.String); |
| 93 | +const schemaBundleIdParam = HttpApiSchema.param("schemaBundleId", Schema.String); | |
| 84 | 94 | |
| 85 | 95 | const CredentialPageUrlParamsSchema = Schema.Struct({ |
| 86 | 96 | interactionId: ExecutionInteractionIdSchema, |
| @@ -105,59 +115,6 @@ const DiscoverSourcePayloadSchema = Schema.Struct({ · 18 unmodified lines | ||
| 105 | 115 | |
| 106 | 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 | 118 | const ConnectSourceResultSchema = Schema.Union( |
| 162 | 119 | Schema.Struct({ |
| 163 | 120 | kind: Schema.Literal("connected"), |
| @@ -166,6 +123,7 @@ const ConnectSourceResultSchema = Schema.Union( · 2 unmodified lines | ||
| 166 | 123 | Schema.Struct({ |
| 167 | 124 | kind: Schema.Literal("credential_required"), |
| 168 | 125 | source: SourceSchema, |
| 126 | + credentialSlot: Schema.Literal("runtime", "import"), | |
| 169 | 127 | }), |
| 170 | 128 | Schema.Struct({ |
| 171 | 129 | kind: Schema.Literal("oauth_required"), |
| @@ -183,6 +141,8 @@ export { · 11 unmodified lines | ||
| 183 | 141 | DiscoverSourcePayloadSchema, |
| 184 | 142 | }; |
| 185 | 143 | |
| 144 | +export type { ConnectSourcePayload }; | |
| 145 | + | |
| 186 | 146 | const HtmlSchema = HttpApiSchema.Text({ |
| 187 | 147 | contentType: "text/html", |
| 188 | 148 | }); |
| @@ -292,6 +252,15 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 103 unmodified lines | ||
| 292 | 252 | .addError(ControlPlaneNotFoundError) |
| 293 | 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 | 264 | .add( |
| 296 | 265 | HttpApiEndpoint.post("inspectionDiscover")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection/discover` |
| 297 | 266 | .setPayload(SourceInspectionDiscoverPayloadSchema) |
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | import { |
| 22 | 22 | discoverSourceInspectionTools, |
| 23 | 23 | getSourceInspection, |
| 24 | + getSourceInspectionSchemaBundle, | |
| 24 | 25 | getSourceInspectionToolDetail, |
| 25 | 26 | } from "../../runtime/source-inspection"; |
| 26 | 27 | import { |
| @@ -29,6 +30,7 @@ import { · 2 unmodified lines | ||
| 29 | 30 | submitSourceCredentialInteraction, |
| 30 | 31 | } from "../../runtime/local-operations"; |
| 31 | 32 | import { discoverSource } from "../../runtime/source-discovery"; |
| 33 | +import { hasSourceAdapterFamily } from "../../runtime/source-adapters"; | |
| 32 | 34 | import { RuntimeSourceAuthServiceTag } from "../../runtime/source-auth-service"; |
| 33 | 35 | |
| 34 | 36 | import { |
| @@ -682,7 +684,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 647 unmodified lines | ||
| 682 | 684 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 683 | 685 | const baseUrl = resolveRequestOrigin(request); |
| 684 | 686 | |
| 685 | − if (payload.kind === "mcp") { | |
| 687 | + if (payload.kind === "mcp" && hasSourceAdapterFamily(payload.kind, "mcp")) { | |
| 686 | 688 | return yield* sourceAuthService.connectMcpSource({ |
| 687 | 689 | workspaceId: path.workspaceId, |
| 688 | 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 | 701 | return yield* sourceAuthService.addExecutorSource( |
| 718 | 702 | { |
| 719 | 703 | workspaceId: path.workspaceId, |
| 720 | 704 | actorAccountId: actor.principal.accountId, |
| 721 | 705 | executionId: null, |
| 722 | 706 | interactionId: null, |
| 723 | − kind: "graphql", | |
| 724 | − endpoint: payload.endpoint, | |
| 725 | − name: payload.name, | |
| 726 | − namespace: payload.namespace, | |
| 727 | − auth: payload.auth, | |
| 707 | + ...payload, | |
| 728 | 708 | }, |
| 729 | 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 | 775 | .handle("inspectionDiscover", ({ path, payload }) => |
| 785 | 776 | withWorkspaceRequestActor("sources.inspection_discover", path.workspaceId, () => |
| 786 | 777 | withPolicy(requireReadSources(path.workspaceId))( |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | SourceAuthSessionIdSchema, |
| 12 | 12 | SourceIdSchema, |
| 13 | 13 | SourceRecipeIdSchema, |
| 14 | + SourceRecipeSchemaBundleIdSchema, | |
| 14 | 15 | SourceRecipeRevisionIdSchema, |
| 15 | 16 | WorkspaceIdSchema, |
| 16 | 17 | } from "#schema"; |
| @@ -39,6 +40,35 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 22 unmodified lines | ||
| 39 | 40 | |
| 40 | 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 | 72 | const seedWorkspaceSourceState = (input: { |
| 43 | 73 | persistence: SqlControlPlanePersistence; |
| 44 | 74 | accountId: ReturnType<typeof AccountIdSchema.make>; |
| @@ -68,6 +98,33 @@ const seedWorkspaceSourceState = (input: { · 23 unmodified lines | ||
| 68 | 98 | createdAt: now, |
| 69 | 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 | 128 | yield* input.persistence.rows.sources.insert({ |
| 72 | 129 | id: input.sourceId, |
| 73 | 130 | workspaceId: input.workspaceId, |
| @@ -79,14 +136,9 @@ const seedWorkspaceSourceState = (input: { · 5 unmodified lines | ||
| 79 | 136 | status: "connected", |
| 80 | 137 | enabled: true, |
| 81 | 138 | namespace: "github", |
| 82 | − transport: null, | |
| 83 | − bindingConfigJson: null, | |
| 84 | − queryParamsJson: null, | |
| 85 | − headersJson: null, | |
| 86 | − specUrl: "https://api.github.com/openapi.json", | |
| 87 | − defaultHeadersJson: null, | |
| 139 | + importAuthPolicy: "reuse_runtime", | |
| 140 | + bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 88 | 141 | sourceHash: null, |
| 89 | − sourceDocumentText: null, | |
| 90 | 142 | lastError: null, |
| 91 | 143 | createdAt: now, |
| 92 | 144 | updatedAt: now, |
| @@ -131,6 +183,7 @@ const seedWorkspaceCredentialState = (input: { · 38 unmodified lines | ||
| 131 | 183 | workspaceId: input.workspaceId, |
| 132 | 184 | sourceId: input.sourceId, |
| 133 | 185 | actorAccountId: input.accountId, |
| 186 | + slot: "runtime", | |
| 134 | 187 | authKind: "oauth2", |
| 135 | 188 | authHeaderName: "Authorization", |
| 136 | 189 | authPrefix: "Bearer ", |
| @@ -149,6 +202,7 @@ const seedWorkspaceCredentialState = (input: { · 12 unmodified lines | ||
| 149 | 202 | executionId: null, |
| 150 | 203 | interactionId: null, |
| 151 | 204 | providerKind: "mcp_oauth", |
| 205 | + credentialSlot: "runtime", | |
| 152 | 206 | status: "pending", |
| 153 | 207 | state: `state_${input.workspaceId}`, |
| 154 | 208 | sessionDataJson: encodeSessionDataJson({ |
| @@ -212,14 +266,9 @@ describe("control-plane-persistence-drizzle", () => { · 57 unmodified lines | ||
| 212 | 266 | status: "draft", |
| 213 | 267 | enabled: true, |
| 214 | 268 | namespace: "github", |
| 215 | − transport: null, | |
| 216 | − bindingConfigJson: null, | |
| 217 | − queryParamsJson: null, | |
| 218 | − headersJson: null, | |
| 219 | − specUrl: "https://api.github.com/openapi.json", | |
| 220 | − defaultHeadersJson: null, | |
| 269 | + importAuthPolicy: "reuse_runtime", | |
| 270 | + bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 221 | 271 | sourceHash: null, |
| 222 | − sourceDocumentText: null, | |
| 223 | 272 | lastError: null, |
| 224 | 273 | createdAt: now, |
| 225 | 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 | 442 | it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () => |
| 306 | 443 | Effect.gen(function* () { |
| 307 | 444 | const persistence = yield* makePersistence; |
| @@ -329,6 +466,7 @@ describe("control-plane-persistence-drizzle", () => { · 21 unmodified lines | ||
| 329 | 466 | workspaceId, |
| 330 | 467 | sourceId, |
| 331 | 468 | actorAccountId: null, |
| 469 | + slot: "runtime", | |
| 332 | 470 | authKind: "bearer", |
| 333 | 471 | authHeaderName: "Authorization", |
| 334 | 472 | authPrefix: "Bearer ", |
| @@ -344,6 +482,7 @@ describe("control-plane-persistence-drizzle", () => { · 9 unmodified lines | ||
| 344 | 482 | workspaceId, |
| 345 | 483 | sourceId, |
| 346 | 484 | actorAccountId: null, |
| 485 | + slot: "runtime", | |
| 347 | 486 | authKind: "bearer", |
| 348 | 487 | authHeaderName: "Authorization", |
| 349 | 488 | authPrefix: "Bearer ", |
| @@ -362,6 +501,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines | ||
| 362 | 501 | workspaceId, |
| 363 | 502 | sourceId, |
| 364 | 503 | actorAccountId: null, |
| 504 | + slot: "runtime", | |
| 365 | 505 | authKind: "oauth2", |
| 366 | 506 | authHeaderName: "X-Auth", |
| 367 | 507 | authPrefix: "Token ", |
| @@ -378,6 +518,7 @@ describe("control-plane-persistence-drizzle", () => { · 10 unmodified lines | ||
| 378 | 518 | workspaceId, |
| 379 | 519 | sourceId, |
| 380 | 520 | actorAccountId: accountId, |
| 521 | + slot: "runtime", | |
| 381 | 522 | authKind: "bearer", |
| 382 | 523 | authHeaderName: "Authorization", |
| 383 | 524 | authPrefix: "Bearer ", |
| @@ -418,6 +559,7 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines | ||
| 418 | 559 | workspaceId, |
| 419 | 560 | sourceId, |
| 420 | 561 | actorAccountId: null, |
| 562 | + slot: "runtime", | |
| 421 | 563 | }); |
| 422 | 564 | assertTrue(Option.isSome(nullActorOnly)); |
| 423 | 565 | if (Option.isSome(nullActorOnly)) { |
| @@ -428,6 +570,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines | ||
| 428 | 570 | workspaceId, |
| 429 | 571 | sourceId, |
| 430 | 572 | actorAccountId: AccountIdSchema.make("acc_missing_credentials"), |
| 573 | + slot: "runtime", | |
| 431 | 574 | }); |
| 432 | 575 | expect(Option.isNone(missingActor)).toBe(true); |
| 433 | 576 | }), |
| @@ -440,6 +583,7 @@ describe("control-plane-persistence-drizzle", () => { · 6 unmodified lines | ||
| 440 | 583 | const organizationId = OrganizationIdSchema.make("org_ws_cleanup"); |
| 441 | 584 | const workspaceId = WorkspaceIdSchema.make("ws_cleanup"); |
| 442 | 585 | const sourceId = SourceIdSchema.make("src_ws_cleanup"); |
| 586 | + const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`); | |
| 443 | 587 | |
| 444 | 588 | const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({ |
| 445 | 589 | persistence, |
| @@ -448,17 +592,295 @@ describe("control-plane-persistence-drizzle", () => { · 2 unmodified lines | ||
| 448 | 592 | workspaceId, |
| 449 | 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 | 667 | const removed = yield* persistence.rows.workspaces.removeById(workspaceId); |
| 453 | 668 | expect(removed).toBe(true); |
| 454 | 669 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 455 | 670 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 456 | 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 | 681 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 458 | 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 | 884 | it.scoped("deleting an organization removes workspace credential state and postgres secrets", () => |
| 463 | 885 | Effect.gen(function* () { |
| 464 | 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 | 7 | import { |
| 8 | 8 | createAccountsRepo, |
| 9 | 9 | createCredentialsRepo, |
| 10 | + createDataMigrationsRepo, | |
| 10 | 11 | createExecutionInteractionsRepo, |
| 11 | 12 | createExecutionsRepo, |
| 12 | 13 | createLocalInstallationsRepo, |
| @@ -17,11 +18,11 @@ import { · 4 unmodified lines | ||
| 17 | 18 | createSourceAuthSessionsRepo, |
| 18 | 19 | createSourceOauthClientsRepo, |
| 19 | 20 | createSourceRecipeDocumentsRepo, |
| 21 | + createSourceRecipeSchemaBundlesRepo, | |
| 20 | 22 | createSourceRecipeOperationsRepo, |
| 21 | 23 | createSourceRecipeRevisionsRepo, |
| 22 | 24 | createSourceRecipesRepo, |
| 23 | 25 | createSourcesRepo, |
| 24 | − createToolArtifactsRepo, | |
| 25 | 26 | createWorkspacesRepo, |
| 26 | 27 | } from "./repos"; |
| 27 | 28 | import { drizzleSchema, tableNames, type DrizzleTables } from "./schema"; |
| @@ -33,7 +34,7 @@ import { · 5 unmodified lines | ||
| 33 | 34 | type SqlBackend, |
| 34 | 35 | type SqlRuntime, |
| 35 | 36 | } from "./sql-runtime"; |
| 36 | −import { runPostMigrationRepairs } from "./post-migrations"; | |
| 37 | +import { runDataMigrations } from "./data-migrations"; | |
| 37 | 38 | |
| 38 | 39 | export { tableNames, type DrizzleTables } from "./schema"; |
| 39 | 40 | export { |
| @@ -60,10 +61,11 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 20 unmodified lines | ||
| 60 | 61 | sourceRecipes: createSourceRecipesRepo(client, tables), |
| 61 | 62 | sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables), |
| 62 | 63 | sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables), |
| 64 | + sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables), | |
| 63 | 65 | sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables), |
| 66 | + dataMigrations: createDataMigrationsRepo(client, tables), | |
| 64 | 67 | credentials: createCredentialsRepo(client, tables), |
| 65 | 68 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), |
| 66 | − toolArtifacts: createToolArtifactsRepo(client, tables), | |
| 67 | 69 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 68 | 70 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| 69 | 71 | policies: createPoliciesRepo(client, tables), |
| @@ -126,8 +128,14 @@ const closeRuntimeEffect = (runtime: SqlRuntime) => · 56 unmodified lines | ||
| 126 | 128 | cause instanceof Error ? cause : new Error(String(cause ?? "unknown close error")), |
| 127 | 129 | }).pipe(Effect.orDie); |
| 128 | 130 | |
| 131 | +export type CreateSqlControlPlanePersistenceOptions = | |
| 132 | + & CreateSqlRuntimeOptions | |
| 133 | + & { | |
| 134 | + runDataMigrations?: boolean; | |
| 135 | + }; | |
| 136 | + | |
| 129 | 137 | export const createSqlControlPlanePersistence = ( |
| 130 | − options: CreateSqlRuntimeOptions, | |
| 138 | + options: CreateSqlControlPlanePersistenceOptions, | |
| 131 | 139 | ): Effect.Effect<SqlControlPlanePersistence, SqlPersistenceBootstrapError> => |
| 132 | 140 | Effect.flatMap(createRuntimeEffect(options), (runtime) => |
| 133 | 141 | runMigrationsEffect(runtime, options.migrationsFolder).pipe( |
| @@ -146,10 +154,12 @@ export const createSqlControlPlanePersistence = ( · 12 unmodified lines | ||
| 146 | 154 | } satisfies SqlControlPlanePersistence; |
| 147 | 155 | }), |
| 148 | 156 | Effect.flatMap((persistence) => |
| 149 | − runPostMigrationRepairs(persistence.rows).pipe( | |
| 150 | − Effect.mapError(toBootstrapError), | |
| 151 | − Effect.map(() => persistence), | |
| 152 | − )), | |
| 157 | + options.runDataMigrations === false | |
| 158 | + ? Effect.succeed(persistence) | |
| 159 | + : runDataMigrations(persistence.rows).pipe( | |
| 160 | + Effect.mapError(toBootstrapError), | |
| 161 | + Effect.map(() => persistence), | |
| 162 | + )), | |
| 153 | 163 | Effect.catchAll((error) => |
| 154 | 164 | closeRuntimeEffect(runtime).pipe( |
| 155 | 165 | Effect.zipRight(Effect.fail(error)), |
| @@ -157,7 +167,7 @@ export const createSqlControlPlanePersistence = ( · 1 unmodified line | ||
| 157 | 167 | )); |
| 158 | 168 | |
| 159 | 169 | export const SqlControlPlanePersistenceLive = ( |
| 160 | − options: CreateSqlRuntimeOptions, | |
| 170 | + options: CreateSqlControlPlanePersistenceOptions, | |
| 161 | 171 | ) => |
| 162 | 172 | Layer.scoped( |
| 163 | 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 | 6 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 7 | import { |
| 3 | 8 | AccountIdSchema, |
| @@ -13,29 +18,88 @@ import * as Effect from "effect/Effect"; · 9 unmodified lines | ||
| 13 | 18 | import * as Option from "effect/Option"; |
| 14 | 19 | import * as Schema from "effect/Schema"; |
| 15 | 20 | import * as Scope from "effect/Scope"; |
| 16 | −import { | |
| 17 | − buildSchema, | |
| 18 | − getIntrospectionQuery, | |
| 19 | − graphqlSync, | |
| 20 | −} from "graphql"; | |
| 21 | +import { buildSchema, getIntrospectionQuery, graphqlSync } from "graphql"; | |
| 22 | +import { sql } from "drizzle-orm"; | |
| 21 | 23 | |
| 22 | 24 | import { |
| 23 | 25 | createSqlControlPlanePersistence, |
| 24 | 26 | type SqlControlPlanePersistence, |
| 25 | 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> = | |
| 29 | − Effect.acquireRelease( | |
| 30 | − createSqlControlPlanePersistence({ | |
| 31 | − localDataDir: ":memory:", | |
| 32 | − }), | |
| 33 | − (persistence) => | |
| 34 | − Effect.tryPromise({ | |
| 35 | − try: () => persistence.close(), | |
| 36 | − catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | |
| 37 | − }).pipe(Effect.orDie), | |
| 38 | − ); | |
| 31 | +const openApiBindingConfigJson = (specUrl: string): string => | |
| 32 | + JSON.stringify({ | |
| 33 | + adapterKey: "openapi", | |
| 34 | + specUrl, | |
| 35 | + defaultHeaders: null, | |
| 36 | + }); | |
| 37 | + | |
| 38 | +const graphqlBindingConfigJson = (): string => | |
| 39 | + JSON.stringify({ | |
| 40 | + adapterKey: "graphql", | |
| 41 | + defaultHeaders: null, | |
| 42 | + }); | |
| 43 | + | |
| 44 | +const baseRevisionRecord = (input: { | |
| 45 | + id: ReturnType<typeof SourceRecipeRevisionIdSchema.make>; | |
| 46 | + recipeId: ReturnType<typeof SourceRecipeIdSchema.make>; | |
| 47 | + revisionNumber: number; | |
| 48 | + sourceConfigJson: string; | |
| 49 | + manifestJson?: string | null; | |
| 50 | + manifestHash?: string | null; | |
| 51 | + materializationHash?: string | null; | |
| 52 | + createdAt: number; | |
| 53 | + updatedAt: number; | |
| 54 | +}) => ({ | |
| 55 | + id: input.id, | |
| 56 | + recipeId: input.recipeId, | |
| 57 | + revisionNumber: input.revisionNumber, | |
| 58 | + sourceConfigJson: input.sourceConfigJson, | |
| 59 | + manifestJson: input.manifestJson ?? null, | |
| 60 | + manifestHash: input.manifestHash ?? null, | |
| 61 | + materializationHash: input.materializationHash ?? null, | |
| 62 | + createdAt: input.createdAt, | |
| 63 | + updatedAt: input.updatedAt, | |
| 64 | +}); | |
| 65 | + | |
| 66 | +const drizzleDir = path.resolve( | |
| 67 | + path.dirname(fileURLToPath(import.meta.url)), | |
| 68 | + "../../drizzle", | |
| 69 | +); | |
| 70 | + | |
| 71 | +const copyMigrationsBefore = (cutoffDirectoryName: string): string => { | |
| 72 | + const targetDir = mkdtempSync(path.join(tmpdir(), "executor-cp-migrations-")); | |
| 73 | + for (const directoryName of readdirSync(drizzleDir).sort()) { | |
| 74 | + if (directoryName >= cutoffDirectoryName) { | |
| 75 | + continue; | |
| 76 | + } | |
| 77 | + | |
| 78 | + cpSync( | |
| 79 | + path.join(drizzleDir, directoryName), | |
| 80 | + path.join(targetDir, directoryName), | |
| 81 | + { recursive: true }, | |
| 82 | + ); | |
| 83 | + } | |
| 84 | + | |
| 85 | + return targetDir; | |
| 86 | +}; | |
| 87 | + | |
| 88 | +const makePersistence: Effect.Effect< | |
| 89 | + SqlControlPlanePersistence, | |
| 90 | + unknown, | |
| 91 | + Scope.Scope | |
| 92 | +> = Effect.acquireRelease( | |
| 93 | + createSqlControlPlanePersistence({ | |
| 94 | + localDataDir: ":memory:", | |
| 95 | + }), | |
| 96 | + (persistence) => | |
| 97 | + Effect.tryPromise({ | |
| 98 | + try: () => persistence.close(), | |
| 99 | + catch: (cause) => | |
| 100 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 101 | + }).pipe(Effect.orDie), | |
| 102 | +); | |
| 39 | 103 | |
| 40 | 104 | const seedMigratedSourceRecipe = (input: { |
| 41 | 105 | persistence: SqlControlPlanePersistence; |
| @@ -43,15 +107,21 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line | ||
| 43 | 107 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; |
| 44 | 108 | sourceId: ReturnType<typeof SourceIdSchema.make>; |
| 45 | 109 | documentText: string; |
| 46 | −}): Effect.Effect<{ | |
| 47 | − recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>; | |
| 48 | −}, unknown, never> => | |
| 110 | +}): Effect.Effect< | |
| 111 | + { | |
| 112 | + recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>; | |
| 113 | + }, | |
| 114 | + unknown, | |
| 115 | + never | |
| 116 | +> => | |
| 49 | 117 | Effect.gen(function* () { |
| 50 | 118 | const now = Date.now(); |
| 51 | 119 | const accountId = AccountIdSchema.make(`acc_${input.sourceId}`); |
| 52 | 120 | const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`); |
| 53 | 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 | 126 | yield* input.persistence.rows.organizations.insert({ |
| 57 | 127 | id: organizationId, |
| @@ -72,9 +142,11 @@ const seedMigratedSourceRecipe = (input: { · 14 unmodified lines | ||
| 72 | 142 | }); |
| 73 | 143 | yield* input.persistence.rows.sourceRecipes.upsert({ |
| 74 | 144 | id: recipeId, |
| 75 | − kind: input.kind === "openapi" ? "http_recipe" : "graphql_recipe", | |
| 76 | − importerKind: input.kind === "openapi" ? "openapi" : "graphql_introspection", | |
| 77 | − providerKey: input.kind === "openapi" ? "generic_http" : "generic_graphql", | |
| 145 | + kind: "http_api", | |
| 146 | + importerKind: | |
| 147 | + input.kind === "openapi" ? "openapi" : "graphql_introspection", | |
| 148 | + providerKey: | |
| 149 | + input.kind === "openapi" ? "generic_http" : "generic_graphql", | |
| 78 | 150 | name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo", |
| 79 | 151 | summary: null, |
| 80 | 152 | visibility: "workspace", |
| @@ -82,7 +154,7 @@ const seedMigratedSourceRecipe = (input: { · 1 unmodified line | ||
| 82 | 154 | createdAt: now, |
| 83 | 155 | updatedAt: now, |
| 84 | 156 | }); |
| 85 | − yield* input.persistence.rows.sourceRecipeRevisions.upsert({ | |
| 157 | + yield* input.persistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | |
| 86 | 158 | id: recipeRevisionId, |
| 87 | 159 | recipeId, |
| 88 | 160 | revisionNumber: 1, |
| @@ -102,9 +174,10 @@ const seedMigratedSourceRecipe = (input: { · 13 unmodified lines | ||
| 102 | 174 | ), |
| 103 | 175 | manifestJson: null, |
| 104 | 176 | manifestHash: null, |
| 177 | + materializationHash: null, | |
| 105 | 178 | createdAt: now, |
| 106 | 179 | updatedAt: now, |
| 107 | − }); | |
| 180 | + })); | |
| 108 | 181 | yield* input.persistence.rows.sources.insert({ |
| 109 | 182 | id: input.sourceId, |
| 110 | 183 | workspaceId: input.workspaceId, |
| @@ -119,45 +192,47 @@ const seedMigratedSourceRecipe = (input: { · 8 unmodified lines | ||
| 119 | 192 | status: "connected", |
| 120 | 193 | enabled: true, |
| 121 | 194 | namespace: input.kind === "openapi" ? "github" : "graphql", |
| 122 | − transport: null, | |
| 123 | − bindingConfigJson: null, | |
| 124 | − queryParamsJson: null, | |
| 125 | − headersJson: null, | |
| 126 | − specUrl: input.kind === "openapi" ? "https://api.example.com/openapi.json" : null, | |
| 127 | − defaultHeadersJson: null, | |
| 195 | + importAuthPolicy: "reuse_runtime", | |
| 196 | + bindingConfigJson: | |
| 197 | + input.kind === "openapi" | |
| 198 | + ? openApiBindingConfigJson("https://api.example.com/openapi.json") | |
| 199 | + : graphqlBindingConfigJson(), | |
| 128 | 200 | sourceHash: null, |
| 129 | − sourceDocumentText: null, | |
| 130 | 201 | lastError: null, |
| 131 | 202 | createdAt: now, |
| 132 | 203 | updatedAt: now, |
| 133 | 204 | }); |
| 134 | 205 | yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ |
| 135 | 206 | recipeRevisionId, |
| 136 | − documents: [{ | |
| 137 | − id: `src_recipe_doc_${input.sourceId}`, | |
| 138 | − recipeRevisionId, | |
| 139 | − documentKind: input.kind === "openapi" ? "openapi" : "graphql_introspection", | |
| 140 | − documentKey: | |
| 141 | − input.kind === "openapi" | |
| 142 | − ? "https://api.example.com/openapi.json" | |
| 143 | − : "https://api.example.com/graphql", | |
| 144 | − contentText: input.documentText, | |
| 145 | − contentHash: `hash_${input.sourceId}`, | |
| 146 | − fetchedAt: now, | |
| 147 | − createdAt: now, | |
| 148 | − updatedAt: now, | |
| 149 | − }], | |
| 207 | + documents: [ | |
| 208 | + { | |
| 209 | + id: `src_recipe_doc_${input.sourceId}`, | |
| 210 | + recipeRevisionId, | |
| 211 | + documentKind: | |
| 212 | + input.kind === "openapi" ? "openapi" : "graphql_introspection", | |
| 213 | + documentKey: | |
| 214 | + input.kind === "openapi" | |
| 215 | + ? "https://api.example.com/openapi.json" | |
| 216 | + : "https://api.example.com/graphql", | |
| 217 | + contentText: input.documentText, | |
| 218 | + contentHash: `hash_${input.sourceId}`, | |
| 219 | + fetchedAt: now, | |
| 220 | + createdAt: now, | |
| 221 | + updatedAt: now, | |
| 222 | + }, | |
| 223 | + ], | |
| 150 | 224 | }); |
| 151 | 225 | |
| 152 | 226 | return { recipeRevisionId }; |
| 153 | 227 | }); |
| 154 | 228 | |
| 155 | −describe("post-migrations", () => { | |
| 229 | +describe("data-migrations", () => { | |
| 156 | 230 | it.scoped("repairs migrated OpenAPI recipes from stored documents", () => |
| 157 | 231 | Effect.gen(function* () { |
| 158 | 232 | const persistence = yield* makePersistence; |
| 159 | 233 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi"); |
| 160 | 234 | const sourceId = SourceIdSchema.make("src_post_migration_openapi"); |
| 235 | + yield* persistence.rows.dataMigrations.clearAll(); | |
| 161 | 236 | |
| 162 | 237 | const openApiDocument = JSON.stringify({ |
| 163 | 238 | openapi: "3.0.3", |
| @@ -189,11 +264,7 @@ describe("post-migrations", () => { · 25 unmodified lines | ||
| 189 | 264 | content: { |
| 190 | 265 | "application/json": { |
| 191 | 266 | schema: { |
| 192 | − type: "object", | |
| 193 | − properties: { | |
| 194 | − full_name: { type: "string" }, | |
| 195 | − }, | |
| 196 | − required: ["full_name"], | |
| 267 | + $ref: "#/components/schemas/Repository", | |
| 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 | 289 | const { recipeRevisionId } = yield* seedMigratedSourceRecipe({ |
| @@ -212,13 +294,22 @@ describe("post-migrations", () => { · 4 unmodified lines | ||
| 212 | 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 | 301 | expect(Option.isSome(revision)).toBe(true); |
| 219 | 302 | expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); |
| 220 | − expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length) | |
| 221 | − .toBeGreaterThan(0); | |
| 303 | + expect( | |
| 304 | + (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId( | |
| 305 | + recipeRevisionId, | |
| 306 | + )).length, | |
| 307 | + ).toBe(1); | |
| 308 | + expect( | |
| 309 | + (yield* persistence.rows.sourceRecipeOperations.listByRevisionId( | |
| 310 | + recipeRevisionId, | |
| 311 | + )).length, | |
| 312 | + ).toBeGreaterThan(0); | |
| 222 | 313 | }), |
| 223 | 314 | ); |
| 224 | 315 | |
| @@ -227,6 +318,7 @@ describe("post-migrations", () => { · 2 unmodified lines | ||
| 227 | 318 | const persistence = yield* makePersistence; |
| 228 | 319 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql"); |
| 229 | 320 | const sourceId = SourceIdSchema.make("src_post_migration_graphql"); |
| 321 | + yield* persistence.rows.dataMigrations.clearAll(); | |
| 230 | 322 | const schema = buildSchema(` |
| 231 | 323 | type Query { |
| 232 | 324 | viewer: User! |
| @@ -260,13 +352,22 @@ describe("post-migrations", () => { · 27 unmodified lines | ||
| 260 | 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 | 359 | expect(Option.isSome(revision)).toBe(true); |
| 267 | 360 | expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); |
| 268 | − expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length) | |
| 269 | − .toBeGreaterThan(0); | |
| 361 | + expect( | |
| 362 | + (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId( | |
| 363 | + recipeRevisionId, | |
| 364 | + )).length, | |
| 365 | + ).toBe(1); | |
| 366 | + expect( | |
| 367 | + (yield* persistence.rows.sourceRecipeOperations.listByRevisionId( | |
| 368 | + recipeRevisionId, | |
| 369 | + )).length, | |
| 370 | + ).toBeGreaterThan(0); | |
| 270 | 371 | }), |
| 271 | 372 | ); |
| 272 | 373 | |
| @@ -275,7 +376,10 @@ describe("post-migrations", () => { · 2 unmodified lines | ||
| 275 | 376 | const persistence = yield* makePersistence; |
| 276 | 377 | const workspaceId = WorkspaceIdSchema.make("ws_post_migration_session"); |
| 277 | 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 | 383 | const now = Date.now(); |
| 280 | 384 | |
| 281 | 385 | yield* persistence.rows.sourceAuthSessions.insert({ |
| @@ -283,20 +387,23 @@ describe("post-migrations", () => { · 1 unmodified line | ||
| 283 | 387 | workspaceId, |
| 284 | 388 | sourceId, |
| 285 | 389 | actorAccountId: null, |
| 286 | − executionId: null, | |
| 287 | − interactionId: null, | |
| 288 | − providerKind: "mcp_oauth", | |
| 289 | − status: "pending", | |
| 390 | + executionId: null, | |
| 391 | + interactionId: null, | |
| 392 | + providerKind: "mcp_oauth", | |
| 393 | + credentialSlot: "runtime", | |
| 394 | + status: "pending", | |
| 290 | 395 | state: "state_post_migration_session", |
| 291 | 396 | sessionDataJson: JSON.stringify({ |
| 292 | 397 | kind: "mcp_oauth", |
| 293 | 398 | endpoint: "https://api.github.com", |
| 294 | 399 | redirectUri: "http://127.0.0.1/callback", |
| 295 | 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 | 403 | authorizationServerUrl: "https://github.com/login/oauth", |
| 298 | 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 | 407 | clientInformationJson: '{"client_id":"abc123"}', |
| 301 | 408 | codeVerifier: "verifier", |
| 302 | 409 | authorizationUrl: "https://github.com/login/oauth/authorize", |
| @@ -307,15 +414,16 @@ describe("post-migrations", () => { · 4 unmodified lines | ||
| 307 | 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 | 421 | expect(Option.isSome(session)).toBe(true); |
| 314 | 422 | const repairedSession = session.pipe(Option.getOrNull); |
| 315 | 423 | expect(repairedSession).not.toBeNull(); |
| 316 | − const repaired = Schema.decodeUnknownSync(McpSourceAuthSessionDataJsonSchema)( | |
| 317 | − repairedSession!.sessionDataJson, | |
| 318 | − ); | |
| 424 | + const repaired = Schema.decodeUnknownSync( | |
| 425 | + McpSourceAuthSessionDataJsonSchema, | |
| 426 | + )(repairedSession!.sessionDataJson); | |
| 319 | 427 | expect(repaired.resourceMetadata).toEqual({ |
| 320 | 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 | 90 | .orderBy( |
| 91 | + asc(tables.credentialsTable.slot), | |
| 91 | 92 | asc(tables.credentialsTable.actorAccountId), |
| 92 | 93 | asc(tables.credentialsTable.updatedAt), |
| 93 | 94 | asc(tables.credentialsTable.id), |
| @@ -100,6 +101,7 @@ export const createCredentialsRepo = ( · 6 unmodified lines | ||
| 100 | 101 | workspaceId: Credential["workspaceId"]; |
| 101 | 102 | sourceId: Credential["sourceId"]; |
| 102 | 103 | actorAccountId: Credential["actorAccountId"]; |
| 104 | + slot: Credential["slot"]; | |
| 103 | 105 | }) => |
| 104 | 106 | client.use("rows.credentials.get_by_workspace_source_actor", async (db) => { |
| 105 | 107 | const rows = await db |
| @@ -109,6 +111,7 @@ export const createCredentialsRepo = ( · 3 unmodified lines | ||
| 109 | 111 | and( |
| 110 | 112 | eq(tables.credentialsTable.workspaceId, input.workspaceId), |
| 111 | 113 | eq(tables.credentialsTable.sourceId, input.sourceId), |
| 114 | + eq(tables.credentialsTable.slot, input.slot), | |
| 112 | 115 | input.actorAccountId === null |
| 113 | 116 | ? isNull(tables.credentialsTable.actorAccountId) |
| 114 | 117 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), |
| @@ -134,6 +137,7 @@ export const createCredentialsRepo = ( · 19 unmodified lines | ||
| 134 | 137 | and( |
| 135 | 138 | eq(tables.credentialsTable.workspaceId, credential.workspaceId), |
| 136 | 139 | eq(tables.credentialsTable.sourceId, credential.sourceId), |
| 140 | + eq(tables.credentialsTable.slot, credential.slot), | |
| 137 | 141 | isNull(tables.credentialsTable.actorAccountId), |
| 138 | 142 | ), |
| 139 | 143 | ) |
| @@ -170,6 +174,7 @@ export const createCredentialsRepo = ( · 30 unmodified lines | ||
| 170 | 174 | tables.credentialsTable.workspaceId, |
| 171 | 175 | tables.credentialsTable.sourceId, |
| 172 | 176 | tables.credentialsTable.actorAccountId, |
| 177 | + tables.credentialsTable.slot, | |
| 173 | 178 | ], |
| 174 | 179 | set: { |
| 175 | 180 | ...credentialUpdateSet(credential), |
| @@ -181,6 +186,7 @@ export const createCredentialsRepo = ( · 5 unmodified lines | ||
| 181 | 186 | workspaceId: Credential["workspaceId"]; |
| 182 | 187 | sourceId: Credential["sourceId"]; |
| 183 | 188 | actorAccountId: Credential["actorAccountId"]; |
| 189 | + slot?: Credential["slot"]; | |
| 184 | 190 | }) => |
| 185 | 191 | client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => { |
| 186 | 192 | const deleted = await db |
| @@ -189,6 +195,9 @@ export const createCredentialsRepo = ( · 2 unmodified lines | ||
| 189 | 195 | and( |
| 190 | 196 | eq(tables.credentialsTable.workspaceId, input.workspaceId), |
| 191 | 197 | eq(tables.credentialsTable.sourceId, input.sourceId), |
| 198 | + input.slot === undefined | |
| 199 | + ? undefined | |
| 200 | + : eq(tables.credentialsTable.slot, input.slot), | |
| 192 | 201 | input.actorAccountId === null |
| 193 | 202 | ? isNull(tables.credentialsTable.actorAccountId) |
| 194 | 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 | 6 | export { createSourceRecipesRepo } from "./source-recipes-repo"; |
| 7 | 7 | export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo"; |
| 8 | 8 | export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo"; |
| 9 | +export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo"; | |
| 9 | 10 | export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo"; |
| 11 | +export { createDataMigrationsRepo } from "./data-migrations-repo"; | |
| 10 | 12 | export { createCredentialsRepo } from "./credentials-repo"; |
| 11 | 13 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; |
| 12 | −export { createToolArtifactsRepo } from "./tool-artifacts-repo"; | |
| 13 | 14 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 14 | 15 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| 15 | 16 | export { createPoliciesRepo } from "./policies-repo"; |
| @@ -6,11 +6,12 @@ import { · 5 unmodified lines | ||
| 6 | 6 | } from "#schema"; |
| 7 | 7 | import * as Option from "effect/Option"; |
| 8 | 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 | 11 | import type { DrizzleClient } from "../client"; |
| 12 | 12 | import type { DrizzleTables } from "../schema"; |
| 13 | 13 | import { |
| 14 | + cleanupOrphanedSourceRecipes, | |
| 14 | 15 | firstOption, |
| 15 | 16 | postgresSecretHandlesFromCredentials, |
| 16 | 17 | withoutCreatedAt, |
| @@ -138,8 +139,8 @@ export const createOrganizationsRepo = ( · 121 unmodified lines | ||
| 138 | 139 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| 139 | 140 | const sourceRows = await tx |
| 140 | 141 | .select({ |
| 141 | − sourceId: tables.sourcesTable.sourceId, | |
| 142 | 142 | recipeId: tables.sourcesTable.recipeId, |
| 143 | + recipeRevisionId: tables.sourcesTable.recipeRevisionId, | |
| 143 | 144 | }) |
| 144 | 145 | .from(tables.sourcesTable) |
| 145 | 146 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| @@ -154,28 +155,8 @@ export const createOrganizationsRepo = ( · 8 unmodified lines | ||
| 154 | 155 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); |
| 155 | 156 | |
| 156 | 157 | const executionIds = executionRows.map((execution) => execution.id); |
| 157 | − const sourceIds = sourceRows.map((source) => source.sourceId); | |
| 158 | 158 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 159 | − const recipeRevisionRows = recipeIds.length > 0 | |
| 160 | − ? await tx | |
| 161 | − .select({ id: tables.sourceRecipeRevisionsTable.id }) | |
| 162 | − .from(tables.sourceRecipeRevisionsTable) | |
| 163 | − .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)) | |
| 164 | − : []; | |
| 165 | − const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id); | |
| 166 | − const toolArtifactPaths = sourceIds.length > 0 | |
| 167 | − ? ( | |
| 168 | − await tx | |
| 169 | − .select({ path: tables.toolArtifactsTable.path }) | |
| 170 | − .from(tables.toolArtifactsTable) | |
| 171 | − .where( | |
| 172 | − and( | |
| 173 | − inArray(tables.toolArtifactsTable.workspaceId, workspaceIds), | |
| 174 | − inArray(tables.toolArtifactsTable.sourceId, sourceIds), | |
| 175 | − ), | |
| 176 | − ) | |
| 177 | − ).map((row) => row.path) | |
| 178 | − : []; | |
| 159 | + const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); | |
| 179 | 160 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 180 | 161 | |
| 181 | 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 | 170 | await tx |
| 223 | 171 | .delete(tables.executionsTable) |
| 224 | 172 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| @@ -235,33 +183,16 @@ export const createOrganizationsRepo = ( · 10 unmodified lines | ||
| 235 | 183 | .delete(tables.credentialsTable) |
| 236 | 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 | 186 | await tx |
| 243 | 187 | .delete(tables.sourcesTable) |
| 244 | 188 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| 245 | 189 | |
| 246 | − if (recipeRevisionIds.length > 0) { | |
| 247 | − await tx | |
| 248 | − .delete(tables.sourceRecipeDocumentsTable) | |
| 249 | − .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds)); | |
| 250 | − | |
| 251 | − await tx | |
| 252 | − .delete(tables.sourceRecipeOperationsTable) | |
| 253 | − .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds)); | |
| 254 | − } | |
| 255 | − | |
| 256 | − if (recipeIds.length > 0) { | |
| 257 | − await tx | |
| 258 | − .delete(tables.sourceRecipeRevisionsTable) | |
| 259 | − .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)); | |
| 260 | − | |
| 261 | − await tx | |
| 262 | − .delete(tables.sourceRecipesTable) | |
| 263 | − .where(inArray(tables.sourceRecipesTable.id, recipeIds)); | |
| 264 | − } | |
| 190 | + await cleanupOrphanedSourceRecipes({ | |
| 191 | + tx, | |
| 192 | + tables, | |
| 193 | + candidateRecipeIds: recipeIds, | |
| 194 | + candidateRecipeRevisionIds: recipeRevisionIds, | |
| 195 | + }); | |
| 265 | 196 | |
| 266 | 197 | await tx |
| 267 | 198 | .delete(tables.policiesTable) |
| @@ -1,5 +1,9 @@ | ||
| 1 | +import { inArray } from "drizzle-orm"; | |
| 1 | 2 | import * as Option from "effect/Option"; |
| 2 | 3 | |
| 4 | +import type { DrizzleClient } from "../client"; | |
| 5 | +import type { DrizzleTables } from "../schema"; | |
| 6 | + | |
| 3 | 7 | export const firstOption = <A>(rows: ReadonlyArray<A>): Option.Option<A> => |
| 4 | 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 | 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 | 74 | workspaceId: SourceAuthSession["workspaceId"]; |
| 75 | 75 | sourceId: SourceAuthSession["sourceId"]; |
| 76 | 76 | actorAccountId: SourceAuthSession["actorAccountId"]; |
| 77 | + credentialSlot?: SourceAuthSession["credentialSlot"]; | |
| 77 | 78 | }) => |
| 78 | 79 | client.use( |
| 79 | 80 | "rows.source_auth_sessions.get_pending_by_workspace_source_actor", |
| @@ -85,6 +86,9 @@ export const createSourceAuthSessionsRepo = ( · 5 unmodified lines | ||
| 85 | 86 | and( |
| 86 | 87 | eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId), |
| 87 | 88 | eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId), |
| 89 | + input.credentialSlot === undefined | |
| 90 | + ? undefined | |
| 91 | + : eq(tables.sourceAuthSessionsTable.credentialSlot, input.credentialSlot), | |
| 88 | 92 | input.actorAccountId === null |
| 89 | 93 | ? isNull(tables.sourceAuthSessionsTable.actorAccountId) |
| 90 | 94 | : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId), |
| @@ -7,11 +7,14 @@ import { asc, eq, inArray } from "drizzle-orm"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import type { DrizzleClient } from "../client"; |
| 9 | 9 | import type { DrizzleTables } from "../schema"; |
| 10 | +import { chunkArray } from "./shared"; | |
| 10 | 11 | |
| 11 | 12 | const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync( |
| 12 | 13 | StoredSourceRecipeDocumentRecordSchema, |
| 13 | 14 | ); |
| 14 | 15 | |
| 16 | +const RECIPE_DOCUMENT_INSERT_BATCH_SIZE = 500; | |
| 17 | + | |
| 15 | 18 | export const createSourceRecipeDocumentsRepo = ( |
| 16 | 19 | client: DrizzleClient, |
| 17 | 20 | tables: DrizzleTables, |
| @@ -61,7 +64,12 @@ export const createSourceRecipeDocumentsRepo = ( · 43 unmodified lines | ||
| 61 | 64 | .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId)); |
| 62 | 65 | |
| 63 | 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 | 8 | import type { DrizzleClient } from "../client"; |
| 9 | 9 | import type { DrizzleTables } from "../schema"; |
| 10 | +import { chunkArray } from "./shared"; | |
| 10 | 11 | |
| 11 | 12 | const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync( |
| 12 | 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 | 19 | export const createSourceRecipeOperationsRepo = ( |
| 16 | 20 | client: DrizzleClient, |
| 17 | 21 | tables: DrizzleTables, |
| @@ -61,7 +65,12 @@ export const createSourceRecipeOperationsRepo = ( · 43 unmodified lines | ||
| 61 | 65 | .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId)); |
| 62 | 66 | |
| 63 | 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 | 4 | } from "#schema"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 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 | 9 | import type { DrizzleClient } from "../client"; |
| 10 | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -69,6 +69,42 @@ export const createSourceRecipeRevisionsRepo = ( · 58 unmodified lines | ||
| 69 | 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 | 108 | upsert: (revision: StoredSourceRecipeRevisionRecord) => |
| 73 | 109 | client.use("rows.source_recipe_revisions.upsert", async (db) => { |
| 74 | 110 | await db |
| @@ -108,4 +144,14 @@ export const createSourceRecipeRevisionsRepo = ( · 33 unmodified lines | ||
| 108 | 144 | |
| 109 | 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 | 4 | } from "#schema"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 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 | 9 | import type { DrizzleClient } from "../client"; |
| 10 | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -26,16 +26,9 @@ const toSourceUpdateSet = ( · 15 unmodified lines | ||
| 26 | 26 | if (patch.status !== undefined) set.status = patch.status; |
| 27 | 27 | if (patch.enabled !== undefined) set.enabled = patch.enabled; |
| 28 | 28 | if (patch.namespace !== undefined) set.namespace = patch.namespace; |
| 29 | + if (patch.importAuthPolicy !== undefined) set.importAuthPolicy = patch.importAuthPolicy; | |
| 29 | 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 | 31 | if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash; |
| 38 | − if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText; | |
| 39 | 32 | if (patch.lastError !== undefined) set.lastError = patch.lastError; |
| 40 | 33 | if (patch.updatedAt !== undefined) set.updatedAt = patch.updatedAt; |
| 41 | 34 | |
| @@ -146,66 +139,6 @@ export const createSourcesRepo = ( · 104 unmodified lines | ||
| 146 | 139 | sourceId: StoredSourceRecord["id"], |
| 147 | 140 | ) => |
| 148 | 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 | 142 | const deleted = await tx |
| 210 | 143 | .delete(tables.sourcesTable) |
| 211 | 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 | 1 | import { type Workspace, WorkspaceSchema } from "#schema"; |
| 2 | 2 | import * as Option from "effect/Option"; |
| 3 | 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 | 6 | import type { DrizzleClient } from "../client"; |
| 7 | 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 | 14 | const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); |
| 11 | 15 | |
| @@ -71,14 +75,14 @@ export const createWorkspacesRepo = ( · 59 unmodified lines | ||
| 71 | 75 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| 72 | 76 | const sourceRows = await tx |
| 73 | 77 | .select({ |
| 74 | − sourceId: tables.sourcesTable.sourceId, | |
| 75 | 78 | recipeId: tables.sourcesTable.recipeId, |
| 79 | + recipeRevisionId: tables.sourcesTable.recipeRevisionId, | |
| 76 | 80 | }) |
| 77 | 81 | .from(tables.sourcesTable) |
| 78 | 82 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); |
| 79 | 83 | const executionIds = executionRows.map((execution) => execution.id); |
| 80 | − const sourceIds = sourceRows.map((source) => source.sourceId); | |
| 81 | 84 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 85 | + const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); | |
| 82 | 86 | const credentials = await tx |
| 83 | 87 | .select({ |
| 84 | 88 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| @@ -88,26 +92,6 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | ||
| 88 | 92 | }) |
| 89 | 93 | .from(tables.credentialsTable) |
| 90 | 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 | 95 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 112 | 96 | |
| 113 | 97 | if (executionIds.length > 0) { |
| @@ -116,39 +100,6 @@ export const createWorkspacesRepo = ( · 2 unmodified lines | ||
| 116 | 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 | 103 | await tx |
| 153 | 104 | .delete(tables.executionsTable) |
| 154 | 105 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| @@ -165,33 +116,16 @@ export const createWorkspacesRepo = ( · 10 unmodified lines | ||
| 165 | 116 | .delete(tables.credentialsTable) |
| 166 | 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 | 119 | await tx |
| 173 | 120 | .delete(tables.sourcesTable) |
| 174 | 121 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); |
| 175 | 122 | |
| 176 | − if (recipeRevisionIds.length > 0) { | |
| 177 | − await tx | |
| 178 | − .delete(tables.sourceRecipeDocumentsTable) | |
| 179 | − .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds)); | |
| 180 | − | |
| 181 | − await tx | |
| 182 | − .delete(tables.sourceRecipeOperationsTable) | |
| 183 | − .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds)); | |
| 184 | − } | |
| 185 | − | |
| 186 | − if (recipeIds.length > 0) { | |
| 187 | − await tx | |
| 188 | − .delete(tables.sourceRecipeRevisionsTable) | |
| 189 | − .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)); | |
| 190 | − | |
| 191 | − await tx | |
| 192 | − .delete(tables.sourceRecipesTable) | |
| 193 | − .where(inArray(tables.sourceRecipesTable.id, recipeIds)); | |
| 194 | − } | |
| 123 | + await cleanupOrphanedSourceRecipes({ | |
| 124 | + tx, | |
| 125 | + tables, | |
| 126 | + candidateRecipeIds: recipeIds, | |
| 127 | + candidateRecipeRevisionIds: recipeRevisionIds, | |
| 128 | + }); | |
| 195 | 129 | |
| 196 | 130 | await tx |
| 197 | 131 | .delete(tables.policiesTable) |
| @@ -19,11 +19,9 @@ export const tableNames = { · 18 unmodified lines | ||
| 19 | 19 | sourceRecipes: "source_recipes", |
| 20 | 20 | sourceRecipeRevisions: "source_recipe_revisions", |
| 21 | 21 | sourceRecipeDocuments: "source_recipe_documents", |
| 22 | + sourceRecipeSchemaBundles: "source_recipe_schema_bundles", | |
| 22 | 23 | sourceRecipeOperations: "source_recipe_operations", |
| 23 | − toolArtifacts: "tool_artifacts", | |
| 24 | − toolArtifactParameters: "tool_artifact_parameters", | |
| 25 | − toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types", | |
| 26 | − toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys", | |
| 24 | + dataMigrations: "control_plane_data_migrations", | |
| 27 | 25 | credentials: "workspace_source_credentials", |
| 28 | 26 | workspaceSourceOauthClients: "workspace_source_oauth_clients", |
| 29 | 27 | secretMaterials: "secret_materials", |
| @@ -145,14 +143,9 @@ export const sourcesTable = pgTable( · 115 unmodified lines | ||
| 145 | 143 | status: text("status").notNull(), |
| 146 | 144 | enabled: boolean("enabled").notNull(), |
| 147 | 145 | namespace: text("namespace"), |
| 148 | − bindingConfigJson: text("binding_config_json"), | |
| 149 | − transport: text("transport"), | |
| 150 | − queryParamsJson: text("query_params_json"), | |
| 151 | − headersJson: text("headers_json"), | |
| 152 | − specUrl: text("spec_url"), | |
| 153 | − defaultHeadersJson: text("default_headers_json"), | |
| 146 | + importAuthPolicy: text("import_auth_policy").notNull(), | |
| 147 | + bindingConfigJson: text("binding_config_json").notNull(), | |
| 154 | 148 | sourceHash: text("source_hash"), |
| 155 | − sourceDocumentText: text("source_document_text"), | |
| 156 | 149 | lastError: text("last_error"), |
| 157 | 150 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 158 | 151 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| @@ -173,17 +166,13 @@ export const sourcesTable = pgTable( · 14 unmodified lines | ||
| 173 | 166 | table.sourceId, |
| 174 | 167 | ), |
| 175 | 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 | 169 | check( |
| 181 | 170 | "sources_status_check", |
| 182 | 171 | sql`${table.status} in ('draft', 'probing', 'auth_required', 'connected', 'error')`, |
| 183 | 172 | ), |
| 184 | 173 | check( |
| 185 | − "sources_transport_check", | |
| 186 | − sql`${table.transport} is null or ${table.transport} in ('auto', 'streamable-http', 'sse')`, | |
| 174 | + "sources_import_auth_policy_check", | |
| 175 | + sql`${table.importAuthPolicy} in ('none', 'reuse_runtime', 'separate')`, | |
| 187 | 176 | ), |
| 188 | 177 | ], |
| 189 | 178 | ); |
| @@ -215,19 +204,7 @@ export const sourceRecipesTable = pgTable( · 25 unmodified lines | ||
| 215 | 204 | ), |
| 216 | 205 | check( |
| 217 | 206 | "source_recipes_kind_check", |
| 218 | − sql`${table.kind} in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')`, | |
| 219 | − ), | |
| 220 | − check( | |
| 221 | − "source_recipes_importer_kind_check", | |
| 222 | − sql`${table.importerKind} in ( | |
| 223 | − 'openapi', | |
| 224 | − 'google_discovery', | |
| 225 | − 'postman_collection', | |
| 226 | − 'snippet_bundle', | |
| 227 | − 'graphql_introspection', | |
| 228 | − 'mcp_manifest', | |
| 229 | − 'internal_manifest' | |
| 230 | − )`, | |
| 207 | + sql`${table.kind} in ('http_api', 'mcp', 'internal')`, | |
| 231 | 208 | ), |
| 232 | 209 | check( |
| 233 | 210 | "source_recipes_visibility_check", |
| @@ -245,6 +222,7 @@ export const sourceRecipeRevisionsTable = pgTable( · 11 unmodified lines | ||
| 245 | 222 | sourceConfigJson: text("source_config_json").notNull(), |
| 246 | 223 | manifestJson: text("manifest_json"), |
| 247 | 224 | manifestHash: text("manifest_hash"), |
| 225 | + materializationHash: text("materialization_hash"), | |
| 248 | 226 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 249 | 227 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 250 | 228 | }, |
| @@ -253,7 +231,11 @@ export const sourceRecipeRevisionsTable = pgTable( · 2 unmodified lines | ||
| 253 | 231 | table.recipeId, |
| 254 | 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 | 239 | table.recipeId, |
| 258 | 240 | table.manifestHash, |
| 259 | 241 | ), |
| @@ -289,16 +271,29 @@ export const sourceRecipeDocumentsTable = pgTable( · 29 unmodified lines | ||
| 289 | 271 | table.createdAt, |
| 290 | 272 | table.id, |
| 291 | 273 | ), |
| 292 | − check( | |
| 293 | − "source_recipe_documents_kind_check", | |
| 294 | − sql`${table.documentKind} in ( | |
| 295 | − 'google_discovery', | |
| 296 | − 'openapi', | |
| 297 | − 'postman_collection', | |
| 298 | − 'postman_environment', | |
| 299 | − 'graphql_introspection', | |
| 300 | − 'mcp_manifest' | |
| 301 | − )`, | |
| 274 | + ], | |
| 275 | +); | |
| 276 | + | |
| 277 | +export const sourceRecipeSchemaBundlesTable = pgTable( | |
| 278 | + tableNames.sourceRecipeSchemaBundles, | |
| 279 | + { | |
| 280 | + id: text("id").notNull().primaryKey(), | |
| 281 | + recipeRevisionId: text("recipe_revision_id").notNull(), | |
| 282 | + bundleKind: text("bundle_kind").notNull(), | |
| 283 | + refsJson: text("refs_json").notNull(), | |
| 284 | + contentHash: text("content_hash").notNull(), | |
| 285 | + createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 286 | + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 287 | + }, | |
| 288 | + (table) => [ | |
| 289 | + uniqueIndex("source_recipe_schema_bundles_revision_kind_idx").on( | |
| 290 | + table.recipeRevisionId, | |
| 291 | + table.bundleKind, | |
| 292 | + ), | |
| 293 | + index("source_recipe_schema_bundles_revision_created_idx").on( | |
| 294 | + table.recipeRevisionId, | |
| 295 | + table.createdAt, | |
| 296 | + table.id, | |
| 302 | 297 | ), |
| 303 | 298 | ], |
| 304 | 299 | ); |
| @@ -319,16 +314,6 @@ export const sourceRecipeOperationsTable = pgTable( · 14 unmodified lines | ||
| 319 | 314 | outputSchemaJson: text("output_schema_json"), |
| 320 | 315 | providerKind: text("provider_kind").notNull(), |
| 321 | 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 | 317 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 333 | 318 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 334 | 319 | }, |
| @@ -355,13 +340,17 @@ export const sourceRecipeOperationsTable = pgTable( · 20 unmodified lines | ||
| 355 | 340 | "source_recipe_operations_kind_check", |
| 356 | 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 | 354 | export const credentialsTable = pgTable( |
| 366 | 355 | tableNames.credentials, |
| 367 | 356 | { |
| @@ -369,6 +358,7 @@ export const credentialsTable = pgTable( · 1 unmodified line | ||
| 369 | 358 | workspaceId: text("workspace_id").notNull(), |
| 370 | 359 | sourceId: text("source_id").notNull(), |
| 371 | 360 | actorAccountId: text("actor_account_id"), |
| 361 | + slot: text("slot").notNull(), | |
| 372 | 362 | authKind: text("auth_kind").notNull(), |
| 373 | 363 | authHeaderName: text("auth_header_name").notNull(), |
| 374 | 364 | authPrefix: text("auth_prefix").notNull(), |
| @@ -389,6 +379,7 @@ export const credentialsTable = pgTable( · 14 unmodified lines | ||
| 389 | 379 | table.workspaceId, |
| 390 | 380 | table.sourceId, |
| 391 | 381 | table.actorAccountId, |
| 382 | + table.slot, | |
| 392 | 383 | ), |
| 393 | 384 | index("credentials_workspace_source_idx").on( |
| 394 | 385 | table.workspaceId, |
| @@ -400,6 +391,10 @@ export const credentialsTable = pgTable( · 5 unmodified lines | ||
| 400 | 391 | "credentials_auth_kind_check", |
| 401 | 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 | 429 | export const secretMaterialsTable = pgTable( |
| 563 | 430 | tableNames.secretMaterials, |
| 564 | 431 | { |
| @@ -585,6 +452,7 @@ export const sourceAuthSessionsTable = pgTable( · 20 unmodified lines | ||
| 585 | 452 | workspaceId: text("workspace_id").notNull(), |
| 586 | 453 | sourceId: text("source_id").notNull(), |
| 587 | 454 | actorAccountId: text("actor_account_id"), |
| 455 | + credentialSlot: text("credential_slot").notNull(), | |
| 588 | 456 | executionId: text("execution_id"), |
| 589 | 457 | interactionId: text("interaction_id"), |
| 590 | 458 | providerKind: text("provider_kind").notNull(), |
| @@ -606,6 +474,7 @@ export const sourceAuthSessionsTable = pgTable( · 15 unmodified lines | ||
| 606 | 474 | table.workspaceId, |
| 607 | 475 | table.sourceId, |
| 608 | 476 | table.actorAccountId, |
| 477 | + table.credentialSlot, | |
| 609 | 478 | table.status, |
| 610 | 479 | table.updatedAt, |
| 611 | 480 | table.id, |
| @@ -619,6 +488,10 @@ export const sourceAuthSessionsTable = pgTable( · 7 unmodified lines | ||
| 619 | 488 | "source_auth_sessions_status_check", |
| 620 | 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 | 635 | sourceRecipesTable, |
| 763 | 636 | sourceRecipeRevisionsTable, |
| 764 | 637 | sourceRecipeDocumentsTable, |
| 638 | + sourceRecipeSchemaBundlesTable, | |
| 765 | 639 | sourceRecipeOperationsTable, |
| 640 | + dataMigrationsTable, | |
| 766 | 641 | credentialsTable, |
| 767 | 642 | workspaceSourceOauthClientsTable, |
| 768 | − toolArtifactsTable, | |
| 769 | − toolArtifactParametersTable, | |
| 770 | − toolArtifactRequestBodyContentTypesTable, | |
| 771 | − toolArtifactRefHintKeysTable, | |
| 772 | 643 | secretMaterialsTable, |
| 773 | 644 | sourceAuthSessionsTable, |
| 774 | 645 | policiesTable, |
| @@ -29,6 +29,13 @@ const makeRuntime = Effect.acquireRelease( · 28 unmodified lines | ||
| 29 | 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 | 39 | type OpenApiSpecServer = { |
| 33 | 40 | baseUrl: string; |
| 34 | 41 | specUrl: string; |
| @@ -288,14 +295,9 @@ describe("control-plane-runtime", () => { · 253 unmodified lines | ||
| 288 | 295 | status: "auth_required", |
| 289 | 296 | enabled: true, |
| 290 | 297 | namespace: "github", |
| 291 | − bindingConfigJson: null, | |
| 292 | − transport: null, | |
| 293 | − queryParamsJson: null, | |
| 294 | − headersJson: null, | |
| 295 | − specUrl: "https://example.com/github-openapi.yaml", | |
| 296 | − defaultHeadersJson: null, | |
| 298 | + importAuthPolicy: "reuse_runtime", | |
| 299 | + bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), | |
| 297 | 300 | sourceHash: null, |
| 298 | − sourceDocumentText: null, | |
| 299 | 301 | lastError: null, |
| 300 | 302 | createdAt: now, |
| 301 | 303 | updatedAt: now, |
| @@ -448,14 +450,9 @@ describe("control-plane-runtime", () => { · 146 unmodified lines | ||
| 448 | 450 | status: "auth_required", |
| 449 | 451 | enabled: true, |
| 450 | 452 | namespace: "github", |
| 451 | − bindingConfigJson: null, | |
| 452 | − transport: null, | |
| 453 | − queryParamsJson: null, | |
| 454 | − headersJson: null, | |
| 455 | − specUrl: "https://example.com/github-openapi.yaml", | |
| 456 | − defaultHeadersJson: null, | |
| 453 | + importAuthPolicy: "reuse_runtime", | |
| 454 | + bindingConfigJson: openApiBindingConfigJson("https://example.com/github-openapi.yaml"), | |
| 457 | 455 | sourceHash: null, |
| 458 | − sourceDocumentText: null, | |
| 459 | 456 | lastError: null, |
| 460 | 457 | createdAt: now, |
| 461 | 458 | updatedAt: now, |
| @@ -36,59 +36,20 @@ import { · 35 unmodified lines | ||
| 36 | 36 | deriveSchemaJson, |
| 37 | 37 | deriveSchemaTypeSignature, |
| 38 | 38 | } from "./schema-type-signature"; |
| 39 | +import { | |
| 40 | + ExecutorAddSourceInputSchema, | |
| 41 | + executorAddableSourceAdapters, | |
| 42 | +} from "./source-adapters"; | |
| 39 | 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 | 45 | const ExecutorSourcesAddInputSchema = Schema.standardSchemaV1( |
| 70 | − ExecutorSourcesAddSchema, | |
| 46 | + ExecutorAddSourceInputSchema, | |
| 71 | 47 | ); |
| 72 | 48 | |
| 73 | 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 | 51 | export const EXECUTOR_SOURCES_ADD_INPUT_HINT = deriveSchemaTypeSignature( |
| 91 | − ExecutorSourcesAddInputSchema, | |
| 52 | + ExecutorAddSourceInputSchema, | |
| 92 | 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 | 61 | export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON = JSON.stringify( |
| 101 | − deriveSchemaJson(ExecutorSourcesAddSchema) ?? {}, | |
| 62 | + deriveSchemaJson(ExecutorAddSourceInputSchema) ?? {}, | |
| 102 | 63 | ); |
| 103 | 64 | |
| 104 | 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 | 69 | export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ |
| 109 | 70 | "Source add input shapes:", |
| 110 | − `- MCP: ${EXECUTOR_SOURCES_ADD_MCP_INPUT_SIGNATURE}`, | |
| 111 | − ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.', | |
| 112 | − `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`, | |
| 113 | − " endpoint is the base API URL. specUrl is the OpenAPI document URL.", | |
| 114 | − `- GraphQL: ${EXECUTOR_SOURCES_ADD_GRAPHQL_INPUT_SIGNATURE}`, | |
| 115 | − " endpoint is the GraphQL HTTP endpoint.", | |
| 71 | + ...executorAddableSourceAdapters.flatMap((adapter) => | |
| 72 | + adapter.executorAddInputSchema | |
| 73 | + && adapter.executorAddInputSignatureWidth !== null | |
| 74 | + && adapter.executorAddHelpText | |
| 75 | + ? [ | |
| 76 | + `- ${adapter.displayName}: ${deriveSchemaTypeSignature(adapter.executorAddInputSchema, adapter.executorAddInputSignatureWidth)}`, | |
| 77 | + ...adapter.executorAddHelpText.map((line) => ` ${line}`), | |
| 78 | + ] | |
| 79 | + : [], | |
| 80 | + ), | |
| 116 | 81 | " executor handles the credential setup for you.", |
| 117 | 82 | ] as const; |
| 118 | 83 | |
| @@ -168,6 +133,7 @@ const promptForSourceCredentialSelection = (input: { · 49 unmodified lines | ||
| 168 | 133 | name?: string | null; |
| 169 | 134 | namespace?: string | null; |
| 170 | 135 | }; |
| 136 | + credentialSlot: "runtime" | "import"; | |
| 171 | 137 | source: Source; |
| 172 | 138 | executionId: string; |
| 173 | 139 | interactionId: string; |
| @@ -200,7 +166,10 @@ const promptForSourceCredentialSelection = (input: { · 26 unmodified lines | ||
| 200 | 166 | context: input.invocation, |
| 201 | 167 | elicitation: { |
| 202 | 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 | 173 | url: resolveLocalCredentialUrl({ |
| 205 | 174 | baseUrl: input.localServerBaseUrl, |
| 206 | 175 | workspaceId: input.args.workspaceId, |
| @@ -272,7 +241,7 @@ export const createExecutorToolMap = (input: { · 65 unmodified lines | ||
| 272 | 241 | `executor.sources.add:${crypto.randomUUID()}`, |
| 273 | 242 | ); |
| 274 | 243 | const preparedArgs: ExecutorAddSourceInput = |
| 275 | − args.kind === "openapi" || args.kind === "graphql" | |
| 244 | + args.kind === "openapi" | |
| 276 | 245 | ? { |
| 277 | 246 | ...args, |
| 278 | 247 | workspaceId: input.workspaceId, |
| @@ -280,6 +249,14 @@ export const createExecutorToolMap = (input: { · 1 unmodified line | ||
| 280 | 249 | executionId, |
| 281 | 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 | 261 | kind: args.kind, |
| 285 | 262 | endpoint: args.endpoint, |
| @@ -290,7 +267,7 @@ export const createExecutorToolMap = (input: { · 4 unmodified lines | ||
| 290 | 267 | executionId, |
| 291 | 268 | interactionId, |
| 292 | 269 | }; |
| 293 | − const result = await runEffect( | |
| 270 | + let result = await runEffect( | |
| 294 | 271 | input.sourceAuthService.addExecutorSource( |
| 295 | 272 | preparedArgs, |
| 296 | 273 | context?.onElicitation |
| @@ -313,61 +290,88 @@ export const createExecutorToolMap = (input: { · 16 unmodified lines | ||
| 313 | 290 | } |
| 314 | 291 | |
| 315 | 292 | if (result.kind === "credential_required") { |
| 316 | − const preparedHttpArgs = preparedArgs as Extract< | |
| 293 | + let pendingResult = result; | |
| 294 | + let pendingArgs = preparedArgs as Extract< | |
| 317 | 295 | ExecutorAddSourceInput, |
| 318 | 296 | { kind: "openapi" | "graphql" } |
| 319 | 297 | >; |
| 320 | − const selectedAuth = await runEffect( | |
| 321 | − promptForSourceCredentialSelection({ | |
| 322 | − args: { | |
| 323 | − ...preparedHttpArgs, | |
| 324 | − workspaceId: input.workspaceId, | |
| 325 | − sourceId: result.source.id, | |
| 326 | − }, | |
| 327 | − source: result.source, | |
| 328 | − executionId, | |
| 329 | − interactionId, | |
| 330 | − path: context?.path ?? asToolPath("executor.sources.add"), | |
| 331 | − sourceKey: context?.sourceKey ?? "executor", | |
| 332 | − localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(), | |
| 333 | − metadata: context?.metadata, | |
| 334 | − invocation: context?.invocation, | |
| 335 | − onElicitation: context?.onElicitation, | |
| 336 | − }), | |
| 337 | − ); | |
| 338 | − | |
| 339 | − const completed = await runEffect( | |
| 340 | − input.sourceAuthService.addExecutorSource( | |
| 341 | − { | |
| 342 | − ...preparedHttpArgs, | |
| 343 | − auth: selectedAuth, | |
| 344 | − }, | |
| 345 | − context?.onElicitation | |
| 346 | − ? { | |
| 347 | − mcpDiscoveryElicitation: { | |
| 348 | − onElicitation: context.onElicitation, | |
| 349 | − path: context.path ?? asToolPath("executor.sources.add"), | |
| 350 | − sourceKey: context.sourceKey, | |
| 351 | − args, | |
| 352 | − metadata: context.metadata, | |
| 353 | − invocation: context.invocation, | |
| 354 | − }, | |
| 298 | + | |
| 299 | + while (pendingResult.kind === "credential_required") { | |
| 300 | + const selectedAuth = await runEffect( | |
| 301 | + promptForSourceCredentialSelection({ | |
| 302 | + args: { | |
| 303 | + ...pendingArgs, | |
| 304 | + workspaceId: input.workspaceId, | |
| 305 | + sourceId: pendingResult.source.id, | |
| 306 | + }, | |
| 307 | + credentialSlot: pendingResult.credentialSlot, | |
| 308 | + source: pendingResult.source, | |
| 309 | + executionId, | |
| 310 | + interactionId, | |
| 311 | + path: context?.path ?? asToolPath("executor.sources.add"), | |
| 312 | + sourceKey: context?.sourceKey ?? "executor", | |
| 313 | + localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(), | |
| 314 | + metadata: context?.metadata, | |
| 315 | + invocation: context?.invocation, | |
| 316 | + onElicitation: context?.onElicitation, | |
| 317 | + }), | |
| 318 | + ); | |
| 319 | + | |
| 320 | + pendingArgs = pendingResult.credentialSlot === "import" | |
| 321 | + && pendingArgs.importAuthPolicy === "separate" | |
| 322 | + ? { | |
| 323 | + ...pendingArgs, | |
| 324 | + importAuth: selectedAuth, | |
| 355 | 325 | } |
| 356 | − : undefined, | |
| 357 | − ), | |
| 358 | − ); | |
| 326 | + : { | |
| 327 | + ...pendingArgs, | |
| 328 | + auth: selectedAuth, | |
| 329 | + }; | |
| 330 | + | |
| 331 | + const completed = await runEffect( | |
| 332 | + input.sourceAuthService.addExecutorSource( | |
| 333 | + pendingArgs, | |
| 334 | + context?.onElicitation | |
| 335 | + ? { | |
| 336 | + mcpDiscoveryElicitation: { | |
| 337 | + onElicitation: context.onElicitation, | |
| 338 | + path: context.path ?? asToolPath("executor.sources.add"), | |
| 339 | + sourceKey: context.sourceKey, | |
| 340 | + args, | |
| 341 | + metadata: context.metadata, | |
| 342 | + invocation: context.invocation, | |
| 343 | + }, | |
| 344 | + } | |
| 345 | + : undefined, | |
| 346 | + ), | |
| 347 | + ); | |
| 348 | + | |
| 349 | + if (completed.kind === "connected") { | |
| 350 | + return completed.source; | |
| 351 | + } | |
| 352 | + | |
| 353 | + if (completed.kind === "credential_required") { | |
| 354 | + pendingResult = completed; | |
| 355 | + continue; | |
| 356 | + } | |
| 359 | 357 | |
| 360 | − if (completed.kind === "connected") { | |
| 361 | − return completed.source; | |
| 358 | + result = completed; | |
| 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 | 367 | if (!context?.onElicitation) { |
| 368 | 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 | 375 | const response: ElicitationResponse = await runEffect( |
| 372 | 376 | context.onElicitation({ |
| 373 | 377 | interactionId, |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | typeSignatureFromSchemaJson, |
| 11 | 11 | } from "@executor/codemode-core"; |
| 12 | 12 | import * as Effect from "effect/Effect"; |
| 13 | +import * as Schema from "effect/Schema"; | |
| 13 | 14 | import { |
| 14 | 15 | buildClientSchema, |
| 15 | 16 | getIntrospectionQuery, |
| @@ -25,13 +26,18 @@ import { · 9 unmodified lines | ||
| 25 | 26 | isUnionType, |
| 26 | 27 | type GraphQLArgument, |
| 27 | 28 | type GraphQLField, |
| 29 | + type GraphQLInputObjectType, | |
| 28 | 30 | type GraphQLInputType, |
| 31 | + type GraphQLInterfaceType, | |
| 32 | + type GraphQLNamedType, | |
| 33 | + type GraphQLObjectType, | |
| 29 | 34 | type GraphQLOutputType, |
| 30 | 35 | type GraphQLSchema, |
| 31 | 36 | type IntrospectionQuery, |
| 32 | 37 | } from "graphql"; |
| 33 | 38 | |
| 34 | 39 | type JsonSchema = Record<string, unknown>; |
| 40 | +type GraphqlSchemaRefTable = Record<string, string>; | |
| 35 | 41 | |
| 36 | 42 | type GraphqlToolKind = "request" | "field"; |
| 37 | 43 | |
| @@ -72,6 +78,7 @@ export type GraphqlToolManifest = { · 34 unmodified lines | ||
| 72 | 78 | queryTypeName: string | null; |
| 73 | 79 | mutationTypeName: string | null; |
| 74 | 80 | subscriptionTypeName: string | null; |
| 81 | + schemaRefTable?: GraphqlSchemaRefTable; | |
| 75 | 82 | tools: readonly GraphqlToolManifestEntry[]; |
| 76 | 83 | }; |
| 77 | 84 | |
| @@ -98,6 +105,28 @@ export type GraphqlToolPresentation = { · 20 unmodified lines | ||
| 98 | 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 | 130 | type SelectedGraphqlOutput = { |
| 102 | 131 | selectionSet: string; |
| 103 | 132 | schema: JsonSchema; |
| @@ -218,11 +247,11 @@ const normalizeHttpUrl = (value: string): string => { · 114 unmodified lines | ||
| 218 | 247 | }; |
| 219 | 248 | |
| 220 | 249 | const graphqlToolError = (message: string, cause?: unknown): Error => |
| 221 | − new Error( | |
| 222 | − cause instanceof Error ? `${message}: ${cause.message}` : message, | |
| 223 | − ); | |
| 250 | + new Error(cause instanceof Error ? `${message}: ${cause.message}` : message); | |
| 224 | 251 | |
| 225 | −const parseGraphqlResponseBody = async (response: Response): Promise<unknown> => { | |
| 252 | +const parseGraphqlResponseBody = async ( | |
| 253 | + response: Response, | |
| 254 | +): Promise<unknown> => { | |
| 226 | 255 | const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; |
| 227 | 256 | if (contentType.includes("application/json")) { |
| 228 | 257 | return await response.json(); |
| @@ -271,10 +300,8 @@ const withSchemaDescription = ( · 42 unmodified lines | ||
| 271 | 300 | return trimmed ? { ...schema, description: trimmed } : schema; |
| 272 | 301 | }; |
| 273 | 302 | |
| 274 | −const withSchemaDefault = ( | |
| 275 | − schema: JsonSchema, | |
| 276 | − value: unknown, | |
| 277 | −): JsonSchema => (value !== undefined ? { ...schema, default: value } : schema); | |
| 303 | +const withSchemaDefault = (schema: JsonSchema, value: unknown): JsonSchema => | |
| 304 | + value !== undefined ? { ...schema, default: value } : schema; | |
| 278 | 305 | |
| 279 | 306 | const withSchemaDeprecation = ( |
| 280 | 307 | schema: JsonSchema, |
| @@ -286,7 +313,9 @@ const withSchemaDeprecation = ( · 5 unmodified lines | ||
| 286 | 313 | : schema; |
| 287 | 314 | }; |
| 288 | 315 | |
| 289 | −const introspectionQueryFromDocument = (documentText: string): IntrospectionQuery => { | |
| 316 | +const introspectionQueryFromDocument = ( | |
| 317 | + documentText: string, | |
| 318 | +): IntrospectionQuery => { | |
| 290 | 319 | let parsed: unknown; |
| 291 | 320 | try { |
| 292 | 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 = ( | |
| 420 | − type: GraphQLInputType, | |
| 421 | − depth = 0, | |
| 422 | −): JsonSchema => { | |
| 423 | − if (isNonNullType(type)) { | |
| 424 | − return schemaForGraphqlInputType(type.ofType, depth); | |
| 425 | − } | |
| 448 | +const GRAPHQL_SCHEMA_REF_PREFIX = "#/$defs/graphql"; | |
| 426 | 449 | |
| 427 | − if (isListType(type)) { | |
| 428 | − return { | |
| 429 | − type: "array", | |
| 430 | − items: schemaForGraphqlInputType(type.ofType, depth + 1), | |
| 431 | − }; | |
| 432 | − } | |
| 450 | +const graphqlScalarRef = (name: string): string => | |
| 451 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/scalars/${name}`; | |
| 433 | 452 | |
| 434 | − const namedType = getNamedType(type); | |
| 435 | − if (isScalarType(namedType)) { | |
| 436 | − return scalarInputSchema(namedType.name); | |
| 437 | − } | |
| 453 | +const graphqlEnumRef = (name: string): string => | |
| 454 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/enums/${name}`; | |
| 438 | 455 | |
| 439 | − if (isEnumType(namedType)) { | |
| 440 | − return { | |
| 441 | − type: "string", | |
| 442 | − enum: namedType.getValues().map((value) => value.name), | |
| 443 | − }; | |
| 444 | − } | |
| 456 | +const graphqlInputObjectRef = (name: string): string => | |
| 457 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/input/${name}`; | |
| 445 | 458 | |
| 446 | − if (isInputObjectType(namedType)) { | |
| 447 | − if (depth >= 2) { | |
| 448 | − return { | |
| 459 | +const graphqlOutputTypeRef = (name: string, depth: number): string => | |
| 460 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`; | |
| 461 | + | |
| 462 | +const schemaRef = (ref: string): JsonSchema => ({ | |
| 463 | + $ref: ref, | |
| 464 | +}); | |
| 465 | + | |
| 466 | +const typenameOnlyObjectSchema = (): JsonSchema => ({ | |
| 467 | + type: "object", | |
| 468 | + properties: { | |
| 469 | + __typename: { type: "string" }, | |
| 470 | + }, | |
| 471 | + required: ["__typename"], | |
| 472 | + additionalProperties: false, | |
| 473 | +}); | |
| 474 | + | |
| 475 | +const createGraphqlSchemaRefTableBuilder = () => { | |
| 476 | + const refTable: GraphqlSchemaRefTable = {}; | |
| 477 | + const outputSelectionsByRef = new Map<string, SelectedGraphqlOutput>(); | |
| 478 | + | |
| 479 | + const defineRefSchema = (ref: string, schema: JsonSchema): void => { | |
| 480 | + refTable[ref] = JSON.stringify(schema); | |
| 481 | + }; | |
| 482 | + | |
| 483 | + const ensureScalarRef = (name: string): JsonSchema => { | |
| 484 | + const ref = graphqlScalarRef(name); | |
| 485 | + if (!(ref in refTable)) { | |
| 486 | + defineRefSchema(ref, scalarInputSchema(name)); | |
| 487 | + } | |
| 488 | + | |
| 489 | + return schemaRef(ref); | |
| 490 | + }; | |
| 491 | + | |
| 492 | + const ensureEnumRef = ( | |
| 493 | + name: string, | |
| 494 | + values: readonly string[], | |
| 495 | + ): JsonSchema => { | |
| 496 | + const ref = graphqlEnumRef(name); | |
| 497 | + if (!(ref in refTable)) { | |
| 498 | + defineRefSchema(ref, { | |
| 499 | + type: "string", | |
| 500 | + enum: [...values], | |
| 501 | + }); | |
| 502 | + } | |
| 503 | + | |
| 504 | + return schemaRef(ref); | |
| 505 | + }; | |
| 506 | + | |
| 507 | + const ensureInputObjectRef = ( | |
| 508 | + namedType: GraphQLInputObjectType, | |
| 509 | + ): JsonSchema => { | |
| 510 | + const ref = graphqlInputObjectRef(namedType.name); | |
| 511 | + if (!(ref in refTable)) { | |
| 512 | + defineRefSchema(ref, {}); | |
| 513 | + | |
| 514 | + const fields = Object.values(namedType.getFields()); | |
| 515 | + const properties = Object.fromEntries( | |
| 516 | + fields.map((field) => { | |
| 517 | + const schema = inputSchemaForType(field.type); | |
| 518 | + const enriched = withSchemaDeprecation( | |
| 519 | + withSchemaDefault( | |
| 520 | + withSchemaDescription(schema, field.description), | |
| 521 | + field.defaultValue, | |
| 522 | + ), | |
| 523 | + field.deprecationReason, | |
| 524 | + ); | |
| 525 | + return [field.name, enriched]; | |
| 526 | + }), | |
| 527 | + ); | |
| 528 | + const required = fields | |
| 529 | + .filter( | |
| 530 | + (field) => | |
| 531 | + isNonNullType(field.type) && field.defaultValue === undefined, | |
| 532 | + ) | |
| 533 | + .map((field) => field.name); | |
| 534 | + | |
| 535 | + defineRefSchema(ref, { | |
| 449 | 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()); | |
| 455 | − const properties = Object.fromEntries( | |
| 456 | − fields.map((field) => { | |
| 457 | − const schema = schemaForGraphqlInputType(field.type, depth + 1); | |
| 458 | − const enriched = withSchemaDeprecation( | |
| 459 | − withSchemaDefault( | |
| 460 | − withSchemaDescription(schema, field.description), | |
| 461 | − field.defaultValue, | |
| 462 | − ), | |
| 463 | − field.deprecationReason, | |
| 464 | − ); | |
| 465 | − return [field.name, enriched]; | |
| 466 | − }), | |
| 558 | + const namedType = getNamedType(type); | |
| 559 | + if (isScalarType(namedType)) { | |
| 560 | + return ensureScalarRef(namedType.name); | |
| 561 | + } | |
| 562 | + | |
| 563 | + if (isEnumType(namedType)) { | |
| 564 | + return ensureEnumRef( | |
| 565 | + namedType.name, | |
| 566 | + namedType.getValues().map((value) => value.name), | |
| 567 | + ); | |
| 568 | + } | |
| 569 | + | |
| 570 | + if (isInputObjectType(namedType)) { | |
| 571 | + return ensureInputObjectRef(namedType); | |
| 572 | + } | |
| 573 | + | |
| 574 | + return {}; | |
| 575 | + }; | |
| 576 | + | |
| 577 | + const selectedOutputForNamedType = ( | |
| 578 | + namedType: GraphQLNamedType, | |
| 579 | + depth: number, | |
| 580 | + ): SelectedGraphqlOutput => { | |
| 581 | + if (isScalarType(namedType)) { | |
| 582 | + return { | |
| 583 | + selectionSet: "", | |
| 584 | + schema: ensureScalarRef(namedType.name), | |
| 585 | + }; | |
| 586 | + } | |
| 587 | + | |
| 588 | + if (isEnumType(namedType)) { | |
| 589 | + return { | |
| 590 | + selectionSet: "", | |
| 591 | + schema: ensureEnumRef( | |
| 592 | + namedType.name, | |
| 593 | + namedType.getValues().map((value) => value.name), | |
| 594 | + ), | |
| 595 | + }; | |
| 596 | + } | |
| 597 | + | |
| 598 | + const ref = graphqlOutputTypeRef(namedType.name, depth); | |
| 599 | + const cached = outputSelectionsByRef.get(ref); | |
| 600 | + if (cached) { | |
| 601 | + return cached; | |
| 602 | + } | |
| 603 | + | |
| 604 | + if (isUnionType(namedType)) { | |
| 605 | + const result = { | |
| 606 | + selectionSet: "{ __typename }", | |
| 607 | + schema: schemaRef(ref), | |
| 608 | + } satisfies SelectedGraphqlOutput; | |
| 609 | + outputSelectionsByRef.set(ref, result); | |
| 610 | + defineRefSchema(ref, typenameOnlyObjectSchema()); | |
| 611 | + return result; | |
| 612 | + } | |
| 613 | + | |
| 614 | + if (!isObjectType(namedType) && !isInterfaceType(namedType)) { | |
| 615 | + return { | |
| 616 | + selectionSet: "{ __typename }", | |
| 617 | + schema: typenameOnlyObjectSchema(), | |
| 618 | + }; | |
| 619 | + } | |
| 620 | + | |
| 621 | + const fallback = { | |
| 622 | + selectionSet: "{ __typename }", | |
| 623 | + schema: schemaRef(ref), | |
| 624 | + } satisfies SelectedGraphqlOutput; | |
| 625 | + outputSelectionsByRef.set(ref, fallback); | |
| 626 | + | |
| 627 | + if (depth >= 2) { | |
| 628 | + defineRefSchema(ref, typenameOnlyObjectSchema()); | |
| 629 | + return fallback; | |
| 630 | + } | |
| 631 | + | |
| 632 | + const fields = Object.values(namedType.getFields()).filter( | |
| 633 | + (field) => !field.name.startsWith("__"), | |
| 634 | + ); | |
| 635 | + const leafFields = fields.filter((field) => | |
| 636 | + isLeafType(getNamedType(field.type)), | |
| 637 | + ); | |
| 638 | + const nestedFields = fields.filter( | |
| 639 | + (field) => !isLeafType(getNamedType(field.type)), | |
| 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 | 687 | type: "object", |
| 474 | 688 | properties, |
| 475 | − ...(required.length > 0 ? { required } : {}), | |
| 689 | + required, | |
| 476 | 690 | additionalProperties: false, |
| 477 | − }; | |
| 478 | − } | |
| 691 | + }); | |
| 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 | 730 | const exampleValueForGraphqlInputType = ( |
| @@ -510,14 +757,14 @@ const exampleValueForGraphqlInputType = ( · 26 unmodified lines | ||
| 510 | 757 | const requiredFields = fields.filter( |
| 511 | 758 | (field) => isNonNullType(field.type) && field.defaultValue === undefined, |
| 512 | 759 | ); |
| 513 | − const fieldsForExample = requiredFields.length > 0 | |
| 514 | − ? requiredFields | |
| 515 | − : fields.slice(0, 1); | |
| 760 | + const fieldsForExample = | |
| 761 | + requiredFields.length > 0 ? requiredFields : fields.slice(0, 1); | |
| 516 | 762 | |
| 517 | 763 | return Object.fromEntries( |
| 518 | 764 | fieldsForExample.map((field) => [ |
| 519 | 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 | 772 | return {}; |
| 526 | 773 | }; |
| 527 | 774 | |
| 528 | −const scalarOutputSchema = (name: string): JsonSchema => scalarInputSchema(name); | |
| 529 | − | |
| 530 | −const selectedLeafOutputForType = (type: GraphQLOutputType): SelectedGraphqlOutput => { | |
| 531 | − const namedType = getNamedType(type); | |
| 532 | − | |
| 533 | − if (isScalarType(namedType)) { | |
| 534 | − return { | |
| 535 | − selectionSet: "", | |
| 536 | − schema: scalarOutputSchema(namedType.name), | |
| 537 | − }; | |
| 538 | − } | |
| 539 | − | |
| 540 | − if (isEnumType(namedType)) { | |
| 541 | − return { | |
| 542 | − selectionSet: "", | |
| 543 | − schema: { | |
| 544 | − type: "string", | |
| 545 | − enum: namedType.getValues().map((value) => value.name), | |
| 546 | − }, | |
| 547 | − }; | |
| 548 | − } | |
| 549 | − | |
| 550 | − return { | |
| 551 | − selectionSet: "", | |
| 552 | − schema: {}, | |
| 553 | − }; | |
| 554 | −}; | |
| 555 | − | |
| 556 | −const dedupeFields = <T extends { name: string }>(fields: readonly T[]): T[] => { | |
| 775 | +const dedupeFields = <T extends { name: string }>( | |
| 776 | + fields: readonly T[], | |
| 777 | +): T[] => { | |
| 557 | 778 | const seen = new Set<string>(); |
| 558 | 779 | const deduped: T[] = []; |
| 559 | 780 | |
| @@ -585,155 +806,6 @@ const pickPreferredFields = <T extends { name: string }>(input: { · 25 unmodified lines | ||
| 585 | 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 | 809 | const printGraphqlType = (type: GraphQLInputType): string => { |
| 738 | 810 | if (isNonNullType(type)) { |
| 739 | 811 | return `${printGraphqlType(type.ofType)}!`; |
| @@ -746,10 +818,13 @@ const printGraphqlType = (type: GraphQLInputType): string => { · 6 unmodified lines | ||
| 746 | 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 | 825 | const properties = Object.fromEntries( |
| 751 | 826 | args.map((arg) => { |
| 752 | − const schema = schemaForGraphqlInputType(arg.type); | |
| 827 | + const schema = bundleBuilder.inputSchemaForType(arg.type); | |
| 753 | 828 | const enriched = withSchemaDeprecation( |
| 754 | 829 | withSchemaDefault( |
| 755 | 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 => { | |
| 785 | − const selectedOutput = selectedOutputForType(fieldType); | |
| 859 | +const outputEnvelopeSchemaForField = ( | |
| 860 | + fieldType: GraphQLOutputType, | |
| 861 | + bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>, | |
| 862 | +): JsonSchema => { | |
| 863 | + const selectedOutput = bundleBuilder.selectedOutputForType(fieldType); | |
| 786 | 864 | |
| 787 | 865 | return { |
| 788 | 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 | 885 | if (args.length === 0) { |
| 806 | 886 | return JSON.stringify({}); |
| 807 | 887 | } |
| @@ -809,7 +889,8 @@ const exampleInputJsonForField = (args: readonly GraphQLArgument[]): string | un · 1 unmodified line | ||
| 809 | 889 | const requiredArgs = args.filter( |
| 810 | 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 | 894 | const example = Object.fromEntries( |
| 814 | 895 | argsForExample.map((arg) => [ |
| 815 | 896 | arg.name, |
| @@ -825,8 +906,11 @@ const buildGraphqlFieldOperationDocument = (input: { · 9 unmodified lines | ||
| 825 | 906 | fieldName: string; |
| 826 | 907 | args: readonly GraphQLArgument[]; |
| 827 | 908 | fieldType: GraphQLOutputType; |
| 909 | + bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>; | |
| 828 | 910 | }): { operationName: string; operationDocument: string } => { |
| 829 | − const selectedOutput = selectedOutputForType(input.fieldType); | |
| 911 | + const selectedOutput = input.bundleBuilder.selectedOutputForType( | |
| 912 | + input.fieldType, | |
| 913 | + ); | |
| 830 | 914 | const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`; |
| 831 | 915 | const variableDefinitions = input.args |
| 832 | 916 | .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`) |
| @@ -834,12 +918,14 @@ const buildGraphqlFieldOperationDocument = (input: { · 1 unmodified line | ||
| 834 | 918 | const fieldArguments = input.args |
| 835 | 919 | .map((arg) => `${arg.name}: $${arg.name}`) |
| 836 | 920 | .join(", "); |
| 837 | − const fieldCall = fieldArguments.length > 0 | |
| 838 | − ? `${input.fieldName}(${fieldArguments})` | |
| 839 | − : input.fieldName; | |
| 840 | − const selectionSuffix = selectedOutput.selectionSet.length > 0 | |
| 841 | − ? ` ${selectedOutput.selectionSet}` | |
| 842 | − : ""; | |
| 921 | + const fieldCall = | |
| 922 | + fieldArguments.length > 0 | |
| 923 | + ? `${input.fieldName}(${fieldArguments})` | |
| 924 | + : input.fieldName; | |
| 925 | + const selectionSuffix = | |
| 926 | + selectedOutput.selectionSet.length > 0 | |
| 927 | + ? ` ${selectedOutput.selectionSet}` | |
| 928 | + : ""; | |
| 843 | 929 | |
| 844 | 930 | return { |
| 845 | 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)}`); | |
| 911 | − applyDuplicates((draft) => | |
| 912 | − `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`, | |
| 996 | + applyDuplicates( | |
| 997 | + (draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`, | |
| 998 | + ); | |
| 999 | + applyDuplicates( | |
| 1000 | + (draft) => | |
| 1001 | + `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`, | |
| 913 | 1002 | ); |
| 914 | 1003 | |
| 915 | 1004 | return staged |
| 916 | − .sort((left, right) => | |
| 917 | − left.toolId.localeCompare(right.toolId) | |
| 918 | − || left.fieldName.localeCompare(right.fieldName) | |
| 919 | − || left.operationType.localeCompare(right.operationType), | |
| 1005 | + .sort( | |
| 1006 | + (left, right) => | |
| 1007 | + left.toolId.localeCompare(right.toolId) || | |
| 1008 | + left.fieldName.localeCompare(right.fieldName) || | |
| 1009 | + left.operationType.localeCompare(right.operationType), | |
| 920 | 1010 | ) |
| 921 | 1011 | .map((draft) => ({ ...draft })); |
| 922 | 1012 | }; |
| 923 | 1013 | |
| 924 | 1014 | const fieldToolDraftsFromRootType = (input: { |
| 925 | − rootType: ReturnType<GraphQLSchema["getQueryType"]> | ReturnType<GraphQLSchema["getMutationType"]>; | |
| 1015 | + rootType: | |
| 1016 | + | ReturnType<GraphQLSchema["getQueryType"]> | |
| 1017 | + | ReturnType<GraphQLSchema["getMutationType"]>; | |
| 926 | 1018 | operationType: GraphqlOperationType; |
| 1019 | + bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>; | |
| 927 | 1020 | }): GraphqlFieldToolDraft[] => { |
| 928 | 1021 | if (!input.rootType) { |
| 929 | 1022 | return []; |
| @@ -933,14 +1026,20 @@ const fieldToolDraftsFromRootType = (input: { · 3 unmodified lines | ||
| 933 | 1026 | .filter((field) => !field.name.startsWith("__")) |
| 934 | 1027 | .map((field) => { |
| 935 | 1028 | const leaf = toCamelCase(field.name); |
| 936 | − const outputSchemaJson = JSON.stringify(outputEnvelopeSchemaForField(field.type)); | |
| 937 | − const inputSchemaJson = JSON.stringify(inputSchemaForFieldArguments(field.args)); | |
| 938 | − const { operationName, operationDocument } = buildGraphqlFieldOperationDocument({ | |
| 939 | − operationType: input.operationType, | |
| 940 | − fieldName: field.name, | |
| 941 | − args: field.args, | |
| 942 | − fieldType: field.type, | |
| 943 | − }); | |
| 1029 | + const outputSchemaJson = JSON.stringify( | |
| 1030 | + outputEnvelopeSchemaForField(field.type, input.bundleBuilder), | |
| 1031 | + ); | |
| 1032 | + const inputSchemaJson = JSON.stringify( | |
| 1033 | + inputSchemaForFieldArguments(field.args, input.bundleBuilder), | |
| 1034 | + ); | |
| 1035 | + const { operationName, operationDocument } = | |
| 1036 | + buildGraphqlFieldOperationDocument({ | |
| 1037 | + operationType: input.operationType, | |
| 1038 | + fieldName: field.name, | |
| 1039 | + args: field.args, | |
| 1040 | + fieldType: field.type, | |
| 1041 | + bundleBuilder: input.bundleBuilder, | |
| 1042 | + }); | |
| 944 | 1043 | |
| 945 | 1044 | return { |
| 946 | 1045 | kind: "field", |
| @@ -983,14 +1082,17 @@ export const extractGraphqlManifest = ( · 36 unmodified lines | ||
| 983 | 1082 | const introspection = introspectionQueryFromDocument(documentText); |
| 984 | 1083 | const schema = buildClientSchema(introspection); |
| 985 | 1084 | const sourceHash = createGraphqlSourceHash(documentText); |
| 1085 | + const bundleBuilder = createGraphqlSchemaRefTableBuilder(); | |
| 986 | 1086 | const fieldTools = resolveGraphqlFieldToolIds([ |
| 987 | 1087 | ...fieldToolDraftsFromRootType({ |
| 988 | 1088 | rootType: schema.getQueryType(), |
| 989 | 1089 | operationType: "query", |
| 1090 | + bundleBuilder, | |
| 990 | 1091 | }), |
| 991 | 1092 | ...fieldToolDraftsFromRootType({ |
| 992 | 1093 | rootType: schema.getMutationType(), |
| 993 | 1094 | operationType: "mutation", |
| 1095 | + bundleBuilder, | |
| 994 | 1096 | }), |
| 995 | 1097 | ]); |
| 996 | 1098 | |
| @@ -1000,10 +1102,10 @@ export const extractGraphqlManifest = ( · 3 unmodified lines | ||
| 1000 | 1102 | queryTypeName: schema.getQueryType()?.name ?? null, |
| 1001 | 1103 | mutationTypeName: schema.getMutationType()?.name ?? null, |
| 1002 | 1104 | subscriptionTypeName: schema.getSubscriptionType()?.name ?? null, |
| 1003 | − tools: [ | |
| 1004 | − ...fieldTools, | |
| 1005 | − requestToolManifestEntry(sourceName), | |
| 1006 | − ], | |
| 1105 | + ...(Object.keys(bundleBuilder.refTable).length > 0 | |
| 1106 | + ? { schemaRefTable: bundleBuilder.refTable } | |
| 1107 | + : {}), | |
| 1108 | + tools: [...fieldTools, requestToolManifestEntry(sourceName)], | |
| 1007 | 1109 | } satisfies GraphqlToolManifest; |
| 1008 | 1110 | }, |
| 1009 | 1111 | catch: (cause) => |
| @@ -1024,23 +1126,38 @@ export const compileGraphqlToolDefinitions = ( · 14 unmodified lines | ||
| 1024 | 1126 | operationType: tool.kind === "field" ? tool.operationType : null, |
| 1025 | 1127 | operationName: tool.kind === "field" ? tool.operationName : null, |
| 1026 | 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 | 1135 | export const buildGraphqlToolPresentation = (input: { |
| 1031 | 1136 | manifest: GraphqlToolManifest; |
| 1032 | 1137 | definition: GraphqlToolDefinition; |
| 1033 | 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 | 1142 | const inputSchemaJson = entry?.inputSchemaJson; |
| 1036 | 1143 | const outputSchemaJson = entry?.outputSchemaJson; |
| 1037 | 1144 | |
| 1038 | 1145 | return { |
| 1039 | − inputType: typeSignatureFromSchemaJson(inputSchemaJson, "unknown", Infinity), | |
| 1040 | − outputType: typeSignatureFromSchemaJson(outputSchemaJson, "unknown", Infinity), | |
| 1146 | + inputType: typeSignatureFromSchemaJson( | |
| 1147 | + inputSchemaJson, | |
| 1148 | + "unknown", | |
| 1149 | + Infinity, | |
| 1150 | + ), | |
| 1151 | + outputType: typeSignatureFromSchemaJson( | |
| 1152 | + outputSchemaJson, | |
| 1153 | + "unknown", | |
| 1154 | + Infinity, | |
| 1155 | + ), | |
| 1041 | 1156 | ...(inputSchemaJson ? { inputSchemaJson } : {}), |
| 1042 | 1157 | ...(outputSchemaJson ? { outputSchemaJson } : {}), |
| 1043 | − ...(entry?.exampleInputJson ? { exampleInputJson: entry.exampleInputJson } : {}), | |
| 1158 | + ...(entry?.exampleInputJson | |
| 1159 | + ? { exampleInputJson: entry.exampleInputJson } | |
| 1160 | + : {}), | |
| 1044 | 1161 | providerDataJson: JSON.stringify({ |
| 1045 | 1162 | kind: "graphql", |
| 1046 | 1163 | toolKind: entry?.kind ?? "request", |
| @@ -1051,11 +1168,196 @@ export const buildGraphqlToolPresentation = (input: { · 4 unmodified lines | ||
| 1051 | 1168 | fieldName: input.definition.fieldName, |
| 1052 | 1169 | operationType: input.definition.operationType, |
| 1053 | 1170 | operationName: input.definition.operationName, |
| 1171 | + operationDocument: input.definition.operationDocument, | |
| 1054 | 1172 | queryTypeName: input.manifest.queryTypeName, |
| 1055 | 1173 | mutationTypeName: input.manifest.mutationTypeName, |
| 1056 | 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 | 1363 | export const graphqlToolDescriptorFromDefinition = (input: { |
| @@ -1074,7 +1376,8 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 12 unmodified lines | ||
| 1074 | 1376 | path: asToolPath(input.path), |
| 1075 | 1377 | sourceKey: input.sourceKey, |
| 1076 | 1378 | description: input.definition.description, |
| 1077 | − interaction: input.definition.operationType === "query" ? "auto" : "required", | |
| 1379 | + interaction: | |
| 1380 | + input.definition.operationType === "query" ? "auto" : "required", | |
| 1078 | 1381 | inputType: presentation.inputType, |
| 1079 | 1382 | outputType: presentation.outputType, |
| 1080 | 1383 | ...(input.includeSchemas && presentation.inputSchemaJson |
| @@ -1105,15 +1408,24 @@ const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => · 24 unmodified lines | ||
| 1105 | 1408 | }), |
| 1106 | 1409 | body: JSON.stringify({ |
| 1107 | 1410 | query: input.query, |
| 1108 | − ...(input.variables !== undefined ? { variables: input.variables } : {}), | |
| 1109 | − ...(input.operationName ? { operationName: input.operationName } : {}), | |
| 1411 | + ...(input.variables !== undefined | |
| 1412 | + ? { variables: input.variables } | |
| 1413 | + : {}), | |
| 1414 | + ...(input.operationName | |
| 1415 | + ? { operationName: input.operationName } | |
| 1416 | + : {}), | |
| 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 | 1422 | const body = yield* Effect.tryPromise({ |
| 1115 | 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 | 1431 | const responseHeaders: Record<string, string> = {}; |
| @@ -1151,7 +1463,8 @@ const invokeRawGraphqlTool = (input: { · 31 unmodified lines | ||
| 1151 | 1463 | defaultHeaders: input.defaultHeaders, |
| 1152 | 1464 | credentialHeaders: input.credentialHeaders, |
| 1153 | 1465 | query, |
| 1154 | − variables: record.variables !== undefined ? asRecord(record.variables) : undefined, | |
| 1466 | + variables: | |
| 1467 | + record.variables !== undefined ? asRecord(record.variables) : undefined, | |
| 1155 | 1468 | operationName: asString(record.operationName) ?? undefined, |
| 1156 | 1469 | requestHeaders: asStringRecord(record.headers), |
| 1157 | 1470 | }); |
| @@ -1165,7 +1478,10 @@ const withoutUndefinedEntries = ( · 7 unmodified lines | ||
| 1165 | 1478 | ); |
| 1166 | 1479 | |
| 1167 | 1480 | const invokeGraphqlFieldTool = (input: { |
| 1168 | − entry: GraphqlFieldToolManifestEntry; | |
| 1481 | + entry: Pick< | |
| 1482 | + GraphqlFieldToolManifestEntry, | |
| 1483 | + "fieldName" | "operationName" | "operationDocument" | |
| 1484 | + >; | |
| 1169 | 1485 | endpoint: string; |
| 1170 | 1486 | path: string; |
| 1171 | 1487 | defaultHeaders?: Readonly<Record<string, string>>; |
| @@ -1218,51 +1534,24 @@ export const createGraphqlToolsFromManifest = (input: { · 46 unmodified lines | ||
| 1218 | 1534 | manifest: input.manifest, |
| 1219 | 1535 | definition, |
| 1220 | 1536 | }); |
| 1221 | − const entry = input.manifest.tools.find((tool) => tool.toolId === definition.toolId); | |
| 1222 | − const path = input.namespace ? `${input.namespace}.${definition.toolId}` : definition.toolId; | |
| 1223 | − const inputSchema = presentation.inputSchemaJson | |
| 1224 | − ? (JSON.parse(presentation.inputSchemaJson) as Record<string, unknown>) | |
| 1225 | − : {}; | |
| 1226 | − const metadata: ToolMetadata = { | |
| 1227 | − interaction: "auto", | |
| 1228 | − inputType: presentation.inputType, | |
| 1229 | − outputType: presentation.outputType, | |
| 1230 | − ...(presentation.inputSchemaJson ? { inputSchemaJson: presentation.inputSchemaJson } : {}), | |
| 1231 | − ...(presentation.outputSchemaJson ? { outputSchemaJson: presentation.outputSchemaJson } : {}), | |
| 1232 | − ...(presentation.exampleInputJson ? { exampleInputJson: presentation.exampleInputJson } : {}), | |
| 1233 | − sourceKey: input.sourceKey, | |
| 1234 | − providerKind: "graphql", | |
| 1235 | − providerDataJson: presentation.providerDataJson, | |
| 1236 | − }; | |
| 1537 | + const path = input.namespace | |
| 1538 | + ? `${input.namespace}.${definition.toolId}` | |
| 1539 | + : definition.toolId; | |
| 1237 | 1540 | |
| 1238 | 1541 | return [ |
| 1239 | 1542 | path, |
| 1240 | − toTool({ | |
| 1241 | − tool: { | |
| 1242 | − description: definition.description, | |
| 1243 | − inputSchema: standardSchemaFromJsonSchema(inputSchema), | |
| 1244 | − execute: (args: unknown) => { | |
| 1245 | − if (entry?.kind === "field") { | |
| 1246 | − return Effect.runPromise(invokeGraphqlFieldTool({ | |
| 1247 | − entry, | |
| 1248 | − endpoint, | |
| 1249 | − path, | |
| 1250 | − defaultHeaders: input.defaultHeaders, | |
| 1251 | − credentialHeaders: input.credentialHeaders, | |
| 1252 | − args, | |
| 1253 | − })); | |
| 1254 | − } | |
| 1255 | − | |
| 1256 | − return Effect.runPromise(invokeRawGraphqlTool({ | |
| 1257 | − endpoint, | |
| 1258 | − path, | |
| 1259 | − defaultHeaders: input.defaultHeaders, | |
| 1260 | − credentialHeaders: input.credentialHeaders, | |
| 1261 | − args, | |
| 1262 | − })); | |
| 1263 | − }, | |
| 1264 | − }, | |
| 1265 | − metadata, | |
| 1543 | + createGraphqlToolFromPersistedOperation({ | |
| 1544 | + path, | |
| 1545 | + sourceKey: input.sourceKey, | |
| 1546 | + endpoint, | |
| 1547 | + description: definition.description, | |
| 1548 | + inputSchemaJson: presentation.inputSchemaJson, | |
| 1549 | + outputSchemaJson: presentation.outputSchemaJson, | |
| 1550 | + exampleInputJson: presentation.exampleInputJson, | |
| 1551 | + providerDataJson: presentation.providerDataJson, | |
| 1552 | + schemaRefTable: input.manifest.schemaRefTable, | |
| 1553 | + defaultHeaders: input.defaultHeaders, | |
| 1554 | + credentialHeaders: input.credentialHeaders, | |
| 1266 | 1555 | }), |
| 1267 | 1556 | ] as const; |
| 1268 | 1557 | }), |
| @@ -27,9 +27,8 @@ const baseDescriptor: InvocationDescriptor = { · 26 unmodified lines | ||
| 27 | 27 | sourceKind: "openapi", |
| 28 | 28 | sourceNamespace: "vercel.api.dns", |
| 29 | 29 | operationKind: "write", |
| 30 | − httpMethod: "POST", | |
| 31 | − httpPathTemplate: "/v10/domains/{domain}/records", | |
| 32 | − graphqlOperationType: null, | |
| 30 | + interaction: "required", | |
| 31 | + approvalLabel: "POST /v10/domains/{domain}/records", | |
| 33 | 32 | }; |
| 34 | 33 | |
| 35 | 34 | const basePolicy = ( |
| @@ -61,8 +60,8 @@ describe("invocation-policy-engine", () => { · 25 unmodified lines | ||
| 61 | 60 | ...baseDescriptor, |
| 62 | 61 | toolPath: "vercel.api.dns.getRecords", |
| 63 | 62 | operationKind: "read", |
| 64 | − httpMethod: "GET", | |
| 65 | − httpPathTemplate: "/v4/domains/{domain}/records", | |
| 63 | + interaction: "auto", | |
| 64 | + approvalLabel: "GET /v4/domains/{domain}/records", | |
| 66 | 65 | }, |
| 67 | 66 | args: {}, |
| 68 | 67 | policies: [], |
| @@ -18,9 +18,8 @@ export type InvocationDescriptor = { · 17 unmodified lines | ||
| 18 | 18 | sourceKind: Source["kind"]; |
| 19 | 19 | sourceNamespace: string | null; |
| 20 | 20 | operationKind: InvocationOperationKind; |
| 21 | − httpMethod: string | null; | |
| 22 | − httpPathTemplate: string | null; | |
| 23 | − graphqlOperationType: "query" | "mutation" | "subscription" | null; | |
| 21 | + interaction: "auto" | "required"; | |
| 22 | + approvalLabel: string | null; | |
| 24 | 23 | }; |
| 25 | 24 | |
| 26 | 25 | export type InvocationPolicyContext = { |
| @@ -223,42 +222,17 @@ const policySpecificity = ( · 196 unmodified lines | ||
| 223 | 222 | const defaultDecisionForInvocation = ( |
| 224 | 223 | descriptor: InvocationDescriptor, |
| 225 | 224 | ): InvocationAuthorizationDecision => { |
| 226 | − if (descriptor.sourceKind === "openapi") { | |
| 227 | − const method = descriptor.httpMethod?.toUpperCase() ?? null; | |
| 228 | − if (method === "GET" || method === "HEAD") { | |
| 229 | − return { | |
| 230 | − kind: "allow", | |
| 231 | − reason: `${method} defaults to allow`, | |
| 232 | − matchedPolicyId: null, | |
| 233 | − }; | |
| 234 | − } | |
| 235 | − | |
| 236 | − return { | |
| 237 | − kind: "require_interaction", | |
| 238 | − reason: `${method ?? "unknown HTTP method"} defaults to approval`, | |
| 239 | − matchedPolicyId: null, | |
| 240 | − }; | |
| 241 | − } | |
| 242 | − | |
| 243 | − if (descriptor.sourceKind === "graphql") { | |
| 244 | − if (descriptor.graphqlOperationType === "query") { | |
| 245 | − return { | |
| 246 | − kind: "allow", | |
| 247 | − reason: "GraphQL query defaults to allow", | |
| 248 | − matchedPolicyId: null, | |
| 249 | − }; | |
| 250 | − } | |
| 251 | − | |
| 225 | + if (descriptor.interaction === "auto") { | |
| 252 | 226 | return { |
| 253 | − kind: "require_interaction", | |
| 254 | − reason: `${descriptor.graphqlOperationType ?? "Unknown GraphQL operation"} defaults to approval`, | |
| 227 | + kind: "allow", | |
| 228 | + reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to allow`, | |
| 255 | 229 | matchedPolicyId: null, |
| 256 | 230 | }; |
| 257 | 231 | } |
| 258 | 232 | |
| 259 | 233 | return { |
| 260 | − kind: "allow", | |
| 261 | − reason: "No invocation-specific approval required by default", | |
| 234 | + kind: "require_interaction", | |
| 235 | + reason: `${descriptor.approvalLabel ?? descriptor.toolPath} defaults to approval`, | |
| 262 | 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 | 7 | type Source, |
| 8 | 8 | } from "#schema"; |
| 9 | 9 | |
| 10 | −import { resolveSourceAuthMaterial } from "./tool-artifacts"; | |
| 10 | +import { resolveSourceAuthMaterial } from "./source-auth-material"; | |
| 11 | 11 | |
| 12 | 12 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 13 | 13 | id: SourceIdSchema.make("src_tool_artifacts"), |
| @@ -23,6 +23,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines | ||
| 23 | 23 | headers: null, |
| 24 | 24 | specUrl: "https://api.github.com/openapi.json", |
| 25 | 25 | defaultHeaders: null, |
| 26 | + importAuthPolicy: "reuse_runtime", | |
| 27 | + importAuth: { kind: "none" }, | |
| 26 | 28 | auth: { kind: "none" }, |
| 27 | 29 | sourceHash: null, |
| 28 | 30 | lastError: null, |
| @@ -31,7 +33,7 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 2 unmodified lines | ||
| 31 | 33 | ...overrides, |
| 32 | 34 | }); |
| 33 | 35 | |
| 34 | −describe("tool-artifacts", () => { | |
| 36 | +describe("source-auth-material", () => { | |
| 35 | 37 | describe("resolveSourceAuthMaterial", () => { |
| 36 | 38 | it("returns empty headers for auth.kind none", async () => { |
| 37 | 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 | 9 | import { |
| 10 | 10 | createTerminalSourceAuthSessionPatch, |
| 11 | − shouldPromptForHttpCredentialSetup, | |
| 12 | 11 | } from "./source-auth-service"; |
| 13 | 12 | |
| 14 | 13 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ |
| @@ -25,6 +24,8 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 10 unmodified lines | ||
| 25 | 24 | headers: null, |
| 26 | 25 | specUrl: "https://example.com/openapi.json", |
| 27 | 26 | defaultHeaders: null, |
| 27 | + importAuthPolicy: "reuse_runtime", | |
| 28 | + importAuth: { kind: "none" }, | |
| 28 | 29 | auth, |
| 29 | 30 | sourceHash: null, |
| 30 | 31 | lastError: null, |
| @@ -88,64 +89,4 @@ describe("source-auth-service", () => { · 57 unmodified lines | ||
| 88 | 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 | 9 | } from "#persistence"; |
| 10 | 10 | import { |
| 11 | 11 | AccountId, |
| 12 | + type CredentialSlot, | |
| 12 | 13 | ExecutionIdSchema, |
| 13 | 14 | McpSourceAuthSessionDataJsonSchema, |
| 14 | 15 | type McpSourceAuthSessionData, |
| 15 | 16 | type SecretMaterialPurpose, |
| 16 | 17 | Source, |
| 18 | + type SourceImportAuthPolicy, | |
| 17 | 19 | SourceAuthSession, |
| 18 | 20 | SourceAuthSessionIdSchema, |
| 19 | 21 | SourceIdSchema, |
| @@ -41,6 +43,8 @@ import { · 21 unmodified lines | ||
| 41 | 43 | createSourceFromPayload, |
| 42 | 44 | updateSourceFromPayload, |
| 43 | 45 | } from "./source-definitions"; |
| 46 | +import { hasSourceAdapterFamily } from "./source-adapters"; | |
| 47 | +import { isSourceCredentialRequiredError } from "./source-adapters/shared"; | |
| 44 | 48 | import { |
| 45 | 49 | createDefaultSecretMaterialResolver, |
| 46 | 50 | createDefaultSecretMaterialStorer, |
| @@ -48,9 +52,9 @@ import { · 1 unmodified line | ||
| 48 | 52 | type StoreSecretMaterial, |
| 49 | 53 | } from "./secret-material-providers"; |
| 50 | 54 | import { |
| 51 | − persistMcpToolArtifactsFromManifest, | |
| 52 | − syncSourceToolArtifacts, | |
| 53 | −} from "./tool-artifacts"; | |
| 55 | + persistMcpRecipeMaterializationFromManifest, | |
| 56 | + syncSourceMaterialization, | |
| 57 | +} from "./source-materialization"; | |
| 54 | 58 | import { |
| 55 | 59 | loadSourceById, |
| 56 | 60 | loadSourcesInWorkspace, |
| @@ -255,6 +259,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 198 unmodified lines | ||
| 255 | 259 | status: Source["status"]; |
| 256 | 260 | lastError?: string | null; |
| 257 | 261 | auth?: Source["auth"]; |
| 262 | + importAuth?: Source["importAuth"]; | |
| 258 | 263 | }) => |
| 259 | 264 | Effect.gen(function* () { |
| 260 | 265 | const latest = yield* loadSourceById(rows, { |
| @@ -268,6 +273,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 7 unmodified lines | ||
| 268 | 273 | status: input.status, |
| 269 | 274 | lastError: input.lastError ?? null, |
| 270 | 275 | auth: input.auth ?? latest.auth, |
| 276 | + importAuth: input.importAuth ?? latest.importAuth, | |
| 271 | 277 | updatedAt: Date.now(), |
| 272 | 278 | }, { |
| 273 | 279 | actorAccountId: input.actorAccountId, |
| @@ -282,6 +288,7 @@ export type ExecutorSourceAddResult = · 8 unmodified lines | ||
| 282 | 288 | | { |
| 283 | 289 | kind: "credential_required"; |
| 284 | 290 | source: Source; |
| 291 | + credentialSlot: CredentialSlot; | |
| 285 | 292 | } |
| 286 | 293 | | { |
| 287 | 294 | kind: "oauth_required"; |
| @@ -332,6 +339,8 @@ export type ExecutorAddSourceInput = · 44 unmodified lines | ||
| 332 | 339 | specUrl: string; |
| 333 | 340 | name?: string | null; |
| 334 | 341 | namespace?: string | null; |
| 342 | + importAuthPolicy?: SourceImportAuthPolicy | null; | |
| 343 | + importAuth?: ExecutorHttpSourceAuthInput | null; | |
| 335 | 344 | auth?: ExecutorHttpSourceAuthInput | null; |
| 336 | 345 | } |
| 337 | 346 | | { |
| @@ -343,6 +352,8 @@ export type ExecutorAddSourceInput = · 5 unmodified lines | ||
| 343 | 352 | endpoint: string; |
| 344 | 353 | name?: string | null; |
| 345 | 354 | namespace?: string | null; |
| 355 | + importAuthPolicy?: SourceImportAuthPolicy | null; | |
| 356 | + importAuth?: ExecutorHttpSourceAuthInput | null; | |
| 346 | 357 | auth?: ExecutorHttpSourceAuthInput | null; |
| 347 | 358 | }; |
| 348 | 359 | |
| @@ -390,20 +401,6 @@ export type CompleteSourceOAuthSessionResult = { · 41 unmodified lines | ||
| 390 | 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 | 404 | const materializeSecretRefInput = (input: { |
| 408 | 405 | rawValue?: string | null; |
| 409 | 406 | ref?: SecretRef | null; |
| @@ -436,11 +433,10 @@ const materializeExecutorHttpAuth = (input: { · 26 unmodified lines | ||
| 436 | 433 | storeSecretMaterial: StoreSecretMaterial; |
| 437 | 434 | }): Effect.Effect<Source["auth"], Error, never> => |
| 438 | 435 | Effect.gen(function* () { |
| 439 | − if ( | |
| 440 | − input.auth === undefined | |
| 441 | − && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql") | |
| 442 | − ) { | |
| 443 | − return input.existing.auth; | |
| 436 | + const existing = input.existing; | |
| 437 | + | |
| 438 | + if (input.auth === undefined && existing && hasSourceAdapterFamily(existing.kind, "http_api")) { | |
| 439 | + return existing.auth; | |
| 444 | 440 | } |
| 445 | 441 | |
| 446 | 442 | const auth = input.auth ?? { kind: "none" } satisfies ExecutorHttpSourceAuthInput; |
| @@ -458,10 +454,11 @@ const materializeExecutorHttpAuth = (input: { · 11 unmodified lines | ||
| 458 | 454 | if ( |
| 459 | 455 | token === null |
| 460 | 456 | && tokenRefInput === null |
| 461 | − && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql") | |
| 462 | − && input.existing.auth.kind === "bearer" | |
| 457 | + && existing | |
| 458 | + && hasSourceAdapterFamily(existing.kind, "http_api") | |
| 459 | + && existing.auth.kind === "bearer" | |
| 463 | 460 | ) { |
| 464 | − return input.existing.auth; | |
| 461 | + return existing.auth; | |
| 465 | 462 | } |
| 466 | 463 | |
| 467 | 464 | const tokenRef = yield* materializeSecretRefInput({ |
| @@ -488,10 +485,11 @@ const materializeExecutorHttpAuth = (input: { · 20 unmodified lines | ||
| 488 | 485 | && auth.accessTokenRef == null |
| 489 | 486 | && trimOrNull(auth.refreshToken) === null |
| 490 | 487 | && auth.refreshTokenRef == null |
| 491 | − && (input.existing?.kind === "openapi" || input.existing?.kind === "graphql") | |
| 492 | − && input.existing.auth.kind === "oauth2" | |
| 488 | + && existing | |
| 489 | + && hasSourceAdapterFamily(existing.kind, "http_api") | |
| 490 | + && existing.auth.kind === "oauth2" | |
| 493 | 491 | ) { |
| 494 | − return input.existing.auth; | |
| 492 | + return existing.auth; | |
| 495 | 493 | } |
| 496 | 494 | |
| 497 | 495 | const accessTokenRef = yield* materializeSecretRefInput({ |
| @@ -520,6 +518,61 @@ const materializeExecutorHttpAuth = (input: { · 22 unmodified lines | ||
| 520 | 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 | 576 | const connectMcpSourceInternal = (input: { |
| 524 | 577 | rows: SqlControlPlaneRows; |
| 525 | 578 | getLocalServerBaseUrl?: () => string | undefined; |
| @@ -549,7 +602,7 @@ const connectMcpSourceInternal = (input: { · 23 unmodified lines | ||
| 549 | 602 | actorAccountId: input.actorAccountId, |
| 550 | 603 | }).pipe( |
| 551 | 604 | Effect.flatMap((source) => |
| 552 | − source.kind === "mcp" | |
| 605 | + hasSourceAdapterFamily(source.kind, "mcp") | |
| 553 | 606 | ? Effect.succeed(source) |
| 554 | 607 | : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)), |
| 555 | 608 | ), |
| @@ -560,7 +613,8 @@ const connectMcpSourceInternal = (input: { · 4 unmodified lines | ||
| 560 | 613 | Effect.map((sources) => |
| 561 | 614 | sources.find( |
| 562 | 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 | 647 | transport: chosenTransport, |
| 594 | 648 | queryParams: chosenQueryParams, |
| 595 | 649 | headers: chosenHeaders, |
| 650 | + importAuthPolicy: "reuse_runtime", | |
| 651 | + importAuth: { kind: "none" }, | |
| 596 | 652 | auth: { kind: "none" }, |
| 597 | 653 | lastError: null, |
| 598 | 654 | }, |
| @@ -611,6 +667,8 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines | ||
| 611 | 667 | transport: chosenTransport, |
| 612 | 668 | queryParams: chosenQueryParams, |
| 613 | 669 | headers: chosenHeaders, |
| 670 | + importAuthPolicy: "reuse_runtime", | |
| 671 | + importAuth: { kind: "none" }, | |
| 614 | 672 | auth: { kind: "none" }, |
| 615 | 673 | }, |
| 616 | 674 | now, |
| @@ -619,7 +677,7 @@ const connectMcpSourceInternal = (input: { · 2 unmodified lines | ||
| 619 | 677 | const persistedDraft = yield* persistSource(input.rows, draftSource, { |
| 620 | 678 | actorAccountId: input.actorAccountId, |
| 621 | 679 | }); |
| 622 | − yield* syncSourceToolArtifacts({ | |
| 680 | + yield* syncSourceMaterialization({ | |
| 623 | 681 | rows: input.rows, |
| 624 | 682 | source: persistedDraft, |
| 625 | 683 | resolveSecretMaterial: input.resolveSecretMaterial, |
| @@ -643,7 +701,7 @@ const connectMcpSourceInternal = (input: { · 17 unmodified lines | ||
| 643 | 701 | auth: { kind: "none" }, |
| 644 | 702 | }); |
| 645 | 703 | const indexed = yield* Effect.either( |
| 646 | − persistMcpToolArtifactsFromManifest({ | |
| 704 | + persistMcpRecipeMaterializationFromManifest({ | |
| 647 | 705 | rows: input.rows, |
| 648 | 706 | source: connected, |
| 649 | 707 | manifestEntries: result.manifest.tools, |
| @@ -704,6 +762,7 @@ const connectMcpSourceInternal = (input: { · 54 unmodified lines | ||
| 704 | 762 | workspaceId: input.workspaceId, |
| 705 | 763 | sourceId: authRequiredSource.id, |
| 706 | 764 | actorAccountId: input.actorAccountId ?? null, |
| 765 | + credentialSlot: "runtime", | |
| 707 | 766 | executionId: input.executionId ?? null, |
| 708 | 767 | interactionId: input.interactionId ?? null, |
| 709 | 768 | providerKind: "mcp_oauth", |
| @@ -780,56 +839,18 @@ const addExecutorHttpSource = (input: { · 70 unmodified lines | ||
| 780 | 839 | ?? defaultNamespaceFromName(chosenName); |
| 781 | 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 | 842 | const auth = yield* materializeExecutorHttpAuth({ |
| 829 | 843 | existing, |
| 830 | 844 | auth: input.sourceInput.auth, |
| 831 | 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 | 855 | const draftSource = existing |
| 835 | 856 | ? yield* updateSourceFromPayload({ |
| @@ -842,6 +863,8 @@ const addExecutorHttpSource = (input: { · 6 unmodified lines | ||
| 842 | 863 | status: "probing", |
| 843 | 864 | enabled: true, |
| 844 | 865 | specUrl: normalizedSpecUrl, |
| 866 | + importAuthPolicy: importAuth.importAuthPolicy, | |
| 867 | + importAuth: importAuth.importAuth, | |
| 845 | 868 | auth, |
| 846 | 869 | lastError: null, |
| 847 | 870 | }, |
| @@ -858,6 +881,8 @@ const addExecutorHttpSource = (input: { · 10 unmodified lines | ||
| 858 | 881 | status: "probing", |
| 859 | 882 | enabled: true, |
| 860 | 883 | specUrl: normalizedSpecUrl, |
| 884 | + importAuthPolicy: importAuth.importAuthPolicy, | |
| 885 | + importAuth: importAuth.importAuth, | |
| 861 | 886 | auth, |
| 862 | 887 | }, |
| 863 | 888 | now, |
| @@ -866,8 +891,27 @@ const addExecutorHttpSource = (input: { · 2 unmodified lines | ||
| 866 | 891 | const persistedDraft = yield* persistSource(input.rows, draftSource, { |
| 867 | 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 | 913 | const synced = yield* Effect.either( |
| 870 | − syncSourceToolArtifacts({ | |
| 914 | + syncSourceMaterialization({ | |
| 871 | 915 | rows: input.rows, |
| 872 | 916 | source: { |
| 873 | 917 | ...persistedDraft, |
| @@ -879,13 +923,27 @@ const addExecutorHttpSource = (input: { · 5 unmodified lines | ||
| 879 | 923 | |
| 880 | 924 | return yield* Either.match(synced, { |
| 881 | 925 | onLeft: (error) => |
| 882 | − updateSourceStatus(input.rows, persistedDraft, { | |
| 883 | − actorAccountId: input.sourceInput.actorAccountId, | |
| 884 | − status: "error", | |
| 885 | − lastError: error.message, | |
| 886 | − }).pipe( | |
| 887 | − Effect.zipRight(Effect.fail(error)), | |
| 888 | − ), | |
| 926 | + isSourceCredentialRequiredError(error) | |
| 927 | + ? updateSourceStatus(input.rows, persistedDraft, { | |
| 928 | + actorAccountId: input.sourceInput.actorAccountId, | |
| 929 | + status: "auth_required", | |
| 930 | + lastError: null, | |
| 931 | + }).pipe( | |
| 932 | + Effect.map((source) => | |
| 933 | + ({ | |
| 934 | + kind: "credential_required", | |
| 935 | + source, | |
| 936 | + credentialSlot: error.slot, | |
| 937 | + } satisfies ExecutorSourceAddResult) | |
| 938 | + ), | |
| 939 | + ) | |
| 940 | + : updateSourceStatus(input.rows, persistedDraft, { | |
| 941 | + actorAccountId: input.sourceInput.actorAccountId, | |
| 942 | + status: "error", | |
| 943 | + lastError: error.message, | |
| 944 | + }).pipe( | |
| 945 | + Effect.zipRight(Effect.fail(error)), | |
| 946 | + ), | |
| 889 | 947 | onRight: () => |
| 890 | 948 | updateSourceStatus(input.rows, persistedDraft, { |
| 891 | 949 | actorAccountId: input.sourceInput.actorAccountId, |
| @@ -972,10 +1030,13 @@ export const createRuntimeSourceAuthService = (input: { · 80 unmodified lines | ||
| 972 | 1030 | }), |
| 973 | 1031 | |
| 974 | 1032 | addExecutorSource: (sourceInput, options) => |
| 975 | − sourceInput.kind === "openapi" || sourceInput.kind === "graphql" | |
| 1033 | + hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") | |
| 976 | 1034 | ? addExecutorHttpSource({ |
| 977 | 1035 | rows: input.rows, |
| 978 | − sourceInput, | |
| 1036 | + sourceInput: sourceInput as Extract< | |
| 1037 | + ExecutorAddSourceInput, | |
| 1038 | + { kind: "openapi" | "graphql" } | |
| 1039 | + >, | |
| 979 | 1040 | storeSecretMaterial, |
| 980 | 1041 | resolveSecretMaterial, |
| 981 | 1042 | }) |
| @@ -1043,6 +1104,7 @@ export const createRuntimeSourceAuthService = (input: { · 61 unmodified lines | ||
| 1043 | 1104 | workspaceId: oauthInput.workspaceId, |
| 1044 | 1105 | sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`), |
| 1045 | 1106 | actorAccountId: oauthInput.actorAccountId ?? null, |
| 1107 | + credentialSlot: "runtime", | |
| 1046 | 1108 | executionId: null, |
| 1047 | 1109 | interactionId: null, |
| 1048 | 1110 | providerKind: "mcp_oauth", |
| @@ -1257,7 +1319,7 @@ export const createRuntimeSourceAuthService = (input: { · 208 unmodified lines | ||
| 1257 | 1319 | status: "error", |
| 1258 | 1320 | lastError: reason, |
| 1259 | 1321 | }); |
| 1260 | − yield* syncSourceToolArtifacts({ | |
| 1322 | + yield* syncSourceMaterialization({ | |
| 1261 | 1323 | rows: input.rows, |
| 1262 | 1324 | source: failedSource, |
| 1263 | 1325 | resolveSecretMaterial, |
| @@ -1328,7 +1390,7 @@ export const createRuntimeSourceAuthService = (input: { · 64 unmodified lines | ||
| 1328 | 1390 | }, |
| 1329 | 1391 | }); |
| 1330 | 1392 | const indexed = yield* Effect.either( |
| 1331 | − syncSourceToolArtifacts({ | |
| 1393 | + syncSourceMaterialization({ | |
| 1332 | 1394 | rows: input.rows, |
| 1333 | 1395 | source: connectedSource, |
| 1334 | 1396 | resolveSecretMaterial, |
| @@ -1410,6 +1472,7 @@ export const ExecutorAddSourceResultSchema = Schema.Union( · 75 unmodified lines | ||
| 1410 | 1472 | Schema.Struct({ |
| 1411 | 1473 | kind: Schema.Literal("credential_required"), |
| 1412 | 1474 | source: SourceSchema, |
| 1475 | + credentialSlot: Schema.Literal("runtime", "import"), | |
| 1413 | 1476 | }), |
| 1414 | 1477 | Schema.Struct({ |
| 1415 | 1478 | kind: Schema.Literal("oauth_required"), |
| @@ -16,7 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | stableSourceRecipeRevisionId, |
| 17 | 17 | updateSourceFromPayload, |
| 18 | 18 | } from "./source-definitions"; |
| 19 | −import { namespaceFromSourceName } from "./tool-artifacts"; | |
| 19 | +import { namespaceFromSourceName } from "./source-names"; | |
| 20 | 20 | |
| 21 | 21 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 22 | 22 | id: SourceIdSchema.make("src_source_definitions"), |
| @@ -32,6 +32,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 9 unmodified lines | ||
| 32 | 32 | headers: null, |
| 33 | 33 | specUrl: "https://api.github.com/openapi.json", |
| 34 | 34 | defaultHeaders: null, |
| 35 | + importAuthPolicy: "reuse_runtime", | |
| 36 | + importAuth: { kind: "none" }, | |
| 35 | 37 | auth: { kind: "none" }, |
| 36 | 38 | sourceHash: null, |
| 37 | 39 | lastError: null, |
| @@ -276,7 +278,7 @@ describe("source-definitions", () => { · 238 unmodified lines | ||
| 276 | 278 | }, |
| 277 | 279 | } as never, |
| 278 | 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 | 284 | it("normalizes new auth during updates", async () => { |
| @@ -313,9 +315,12 @@ describe("source-definitions", () => { · 30 unmodified lines | ||
| 313 | 315 | describe("storage roundtrip", () => { |
| 314 | 316 | it("roundtrips bearer auth and serialized maps", async () => { |
| 315 | 317 | const source = makeSource({ |
| 318 | + kind: "mcp", | |
| 319 | + specUrl: null, | |
| 320 | + defaultHeaders: null, | |
| 321 | + transport: "auto", | |
| 316 | 322 | queryParams: { page: "1" }, |
| 317 | 323 | headers: { "x-api-key": "secret" }, |
| 318 | − defaultHeaders: { accept: "application/json" }, | |
| 319 | 324 | auth: { |
| 320 | 325 | kind: "bearer", |
| 321 | 326 | headerName: "Authorization", |
| @@ -330,21 +335,25 @@ describe("source-definitions", () => { · 8 unmodified lines | ||
| 330 | 335 | const recipeRevisionId = stableSourceRecipeRevisionId(source); |
| 331 | 336 | const existingCredentialId = CredentialIdSchema.make("cred_existing"); |
| 332 | 337 | |
| 333 | − const { sourceRecord, credential } = splitSourceForStorage({ | |
| 338 | + const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | |
| 334 | 339 | source, |
| 335 | 340 | recipeId, |
| 336 | 341 | recipeRevisionId, |
| 337 | − existingCredentialId, | |
| 342 | + existingRuntimeCredentialId: existingCredentialId, | |
| 338 | 343 | }); |
| 339 | 344 | |
| 340 | − expect(credential?.id).toBe(existingCredentialId); | |
| 341 | − expect(sourceRecord.queryParamsJson).toBe(JSON.stringify(source.queryParams)); | |
| 342 | − expect(sourceRecord.headersJson).toBe(JSON.stringify(source.headers)); | |
| 343 | − expect(sourceRecord.defaultHeadersJson).toBe(JSON.stringify(source.defaultHeaders)); | |
| 345 | + expect(runtimeCredential?.id).toBe(existingCredentialId); | |
| 346 | + expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({ | |
| 347 | + adapterKey: "mcp", | |
| 348 | + transport: source.transport, | |
| 349 | + queryParams: source.queryParams, | |
| 350 | + headers: source.headers, | |
| 351 | + }); | |
| 344 | 352 | |
| 345 | 353 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 346 | 354 | sourceRecord, |
| 347 | − credential: credential ?? null, | |
| 355 | + runtimeCredential: runtimeCredential ?? null, | |
| 356 | + importCredential: null, | |
| 348 | 357 | })); |
| 349 | 358 | |
| 350 | 359 | expect(projected).toEqual(source); |
| @@ -389,14 +398,15 @@ describe("source-definitions", () => { · 38 unmodified lines | ||
| 389 | 398 | }); |
| 390 | 399 | |
| 391 | 400 | for (const source of [withRefresh, withoutRefresh]) { |
| 392 | − const { sourceRecord, credential } = splitSourceForStorage({ | |
| 401 | + const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | |
| 393 | 402 | source, |
| 394 | 403 | recipeId: stableSourceRecipeId(source), |
| 395 | 404 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 396 | 405 | }); |
| 397 | 406 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 398 | 407 | sourceRecord, |
| 399 | − credential: credential ?? null, | |
| 408 | + runtimeCredential: runtimeCredential ?? null, | |
| 409 | + importCredential: null, | |
| 400 | 410 | })); |
| 401 | 411 | |
| 402 | 412 | expect(projected).toEqual(source); |
| @@ -407,17 +417,18 @@ describe("source-definitions", () => { · 4 unmodified lines | ||
| 407 | 417 | const source = makeSource({ |
| 408 | 418 | auth: { kind: "none" }, |
| 409 | 419 | }); |
| 410 | − const { sourceRecord, credential } = splitSourceForStorage({ | |
| 420 | + const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | |
| 411 | 421 | source, |
| 412 | 422 | recipeId: stableSourceRecipeId(source), |
| 413 | 423 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 414 | 424 | }); |
| 415 | 425 | |
| 416 | − expect(credential).toBeNull(); | |
| 426 | + expect(runtimeCredential).toBeNull(); | |
| 417 | 427 | |
| 418 | 428 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 419 | 429 | sourceRecord, |
| 420 | − credential: null, | |
| 430 | + runtimeCredential: null, | |
| 431 | + importCredential: null, | |
| 421 | 432 | })); |
| 422 | 433 | |
| 423 | 434 | expect(projected.auth).toEqual({ kind: "none" }); |
| @@ -7,8 +7,10 @@ import type { · 6 unmodified lines | ||
| 7 | 7 | import type { |
| 8 | 8 | AccountId, |
| 9 | 9 | Credential, |
| 10 | + CredentialSlot, | |
| 10 | 11 | Source, |
| 11 | 12 | SourceAuth, |
| 13 | + SourceImportAuthPolicy, | |
| 12 | 14 | SourceRecipeId, |
| 13 | 15 | SourceRecipeImporterKind, |
| 14 | 16 | SourceRecipeKind, |
| @@ -25,16 +27,8 @@ import { · 10 unmodified lines | ||
| 25 | 27 | SourceRecipeRevisionIdSchema, |
| 26 | 28 | } from "#schema"; |
| 27 | 29 | import * as Effect from "effect/Effect"; |
| 28 | −import * as Schema from "effect/Schema"; | |
| 29 | − | |
| 30 | −const decodeStringMap = Schema.decodeUnknown( | |
| 31 | − Schema.NullOr( | |
| 32 | − Schema.Record({ | |
| 33 | − key: Schema.String, | |
| 34 | − value: Schema.String, | |
| 35 | − }), | |
| 36 | − ), | |
| 37 | −); | |
| 30 | + | |
| 31 | +import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters"; | |
| 38 | 32 | |
| 39 | 33 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 40 | 34 | if (value === null || value === undefined) { |
| @@ -45,134 +39,22 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 4 unmodified lines | ||
| 45 | 39 | return trimmed.length > 0 ? trimmed : null; |
| 46 | 40 | }; |
| 47 | 41 | |
| 48 | −const serializeStringMap = (value: StringMap | null): string | null => | |
| 49 | − value === null ? null : JSON.stringify(value); | |
| 50 | − | |
| 51 | −const parseStringMapJson = ( | |
| 52 | − fieldName: string, | |
| 53 | − value: string | null, | |
| 54 | −): Effect.Effect<StringMap | null, Error, never> => | |
| 55 | − value === null | |
| 56 | − ? Effect.succeed(null) | |
| 57 | − : Effect.try({ | |
| 58 | − try: () => JSON.parse(value), | |
| 59 | − catch: (cause) => | |
| 60 | − cause instanceof Error | |
| 61 | − ? new Error(`Invalid ${fieldName}: ${cause.message}`) | |
| 62 | − : new Error(`Invalid ${fieldName}: ${String(cause)}`), | |
| 63 | − }).pipe( | |
| 64 | − Effect.flatMap((parsed) => decodeStringMap(parsed)), | |
| 65 | − Effect.mapError((cause) => | |
| 66 | − cause instanceof Error | |
| 67 | − ? new Error(`Invalid ${fieldName}: ${cause.message}`) | |
| 68 | − : new Error(`Invalid ${fieldName}: ${String(cause)}`), | |
| 69 | − ), | |
| 70 | − ); | |
| 42 | +type SourceRecipeSourceConfig = Record<string, unknown>; | |
| 71 | 43 | |
| 72 | −type SourceRecipeSourceConfig = | |
| 73 | − | { | |
| 74 | − kind: "mcp"; | |
| 75 | − endpoint: string; | |
| 76 | − transport: Source["transport"]; | |
| 77 | − queryParams: Source["queryParams"]; | |
| 78 | − headers: Source["headers"]; | |
| 79 | − } | |
| 80 | − | { | |
| 81 | − kind: "openapi"; | |
| 82 | − endpoint: string; | |
| 83 | − specUrl: string; | |
| 84 | − defaultHeaders: Source["defaultHeaders"]; | |
| 85 | − } | |
| 86 | − | { | |
| 87 | − kind: "graphql"; | |
| 88 | − endpoint: string; | |
| 89 | − defaultHeaders: Source["defaultHeaders"]; | |
| 90 | − } | |
| 91 | − | { | |
| 92 | − kind: "internal"; | |
| 93 | − endpoint: string; | |
| 94 | − }; | |
| 95 | − | |
| 96 | −const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig => { | |
| 97 | − if (source.kind === "mcp") { | |
| 98 | − return { | |
| 99 | − kind: "mcp", | |
| 100 | − endpoint: source.endpoint, | |
| 101 | − transport: source.transport, | |
| 102 | − queryParams: source.queryParams, | |
| 103 | − headers: source.headers, | |
| 104 | − }; | |
| 105 | − } | |
| 106 | − | |
| 107 | − if (source.kind === "openapi") { | |
| 108 | − return { | |
| 109 | − kind: "openapi", | |
| 110 | − endpoint: source.endpoint, | |
| 111 | − specUrl: source.specUrl ?? source.endpoint, | |
| 112 | − defaultHeaders: source.defaultHeaders, | |
| 113 | − }; | |
| 114 | − } | |
| 115 | − | |
| 116 | − if (source.kind === "graphql") { | |
| 117 | − return { | |
| 118 | − kind: "graphql", | |
| 119 | − endpoint: source.endpoint, | |
| 120 | − defaultHeaders: source.defaultHeaders, | |
| 121 | − }; | |
| 122 | − } | |
| 123 | − | |
| 124 | − return { | |
| 125 | − kind: "internal", | |
| 126 | − endpoint: source.endpoint, | |
| 127 | − }; | |
| 128 | −}; | |
| 44 | +const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig => | |
| 45 | + getSourceAdapterForSource(source).sourceConfigFromSource(source); | |
| 129 | 46 | |
| 130 | 47 | const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => { |
| 131 | − if (source.kind === "mcp") { | |
| 132 | − return "mcp_recipe"; | |
| 133 | − } | |
| 134 | − | |
| 135 | − if (source.kind === "graphql") { | |
| 136 | − return "graphql_recipe"; | |
| 137 | − } | |
| 138 | − | |
| 139 | − if (source.kind === "openapi") { | |
| 140 | − return "http_recipe"; | |
| 141 | − } | |
| 142 | − | |
| 143 | − return "internal_recipe"; | |
| 48 | + const adapter = getSourceAdapterForSource(source); | |
| 49 | + return adapter.family; | |
| 144 | 50 | }; |
| 145 | 51 | |
| 146 | 52 | const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => { |
| 147 | − if (source.kind === "mcp") { | |
| 148 | − return "mcp_manifest"; | |
| 149 | − } | |
| 150 | − | |
| 151 | − if (source.kind === "graphql") { | |
| 152 | − return "graphql_introspection"; | |
| 153 | − } | |
| 154 | − | |
| 155 | − if (source.kind === "openapi") { | |
| 156 | − return "openapi"; | |
| 157 | − } | |
| 158 | − | |
| 159 | − return "internal_manifest"; | |
| 53 | + return getSourceAdapterForSource(source).key; | |
| 160 | 54 | }; |
| 161 | 55 | |
| 162 | 56 | const sourceRecipeProviderKeyFromSource = (source: Source): string => { |
| 163 | − if (source.kind === "mcp") { | |
| 164 | − return "generic_mcp"; | |
| 165 | − } | |
| 166 | − | |
| 167 | − if (source.kind === "graphql") { | |
| 168 | − return "generic_graphql"; | |
| 169 | − } | |
| 170 | − | |
| 171 | − if (source.kind === "openapi") { | |
| 172 | − return "generic_http"; | |
| 173 | − } | |
| 174 | − | |
| 175 | − return "generic_internal"; | |
| 57 | + return getSourceAdapterForSource(source).providerKey; | |
| 176 | 58 | }; |
| 177 | 59 | |
| 178 | 60 | const stableHash = (value: string): string => |
| @@ -260,58 +142,108 @@ const normalizeAuth = ( · 81 unmodified lines | ||
| 260 | 142 | } satisfies SourceAuth; |
| 261 | 143 | }); |
| 262 | 144 | |
| 263 | −const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, never> => | |
| 264 | − Effect.gen(function* () { | |
| 265 | − if (source.kind === "mcp") { | |
| 266 | − if (source.specUrl !== null) { | |
| 267 | − return yield* Effect.fail(new Error("MCP sources cannot define specUrl")); | |
| 268 | − } | |
| 269 | − return source; | |
| 270 | − } | |
| 145 | +const normalizeImportAuthPolicy = ( | |
| 146 | + sourceKind: Source["kind"], | |
| 147 | + policy: SourceImportAuthPolicy | undefined, | |
| 148 | +): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy; | |
| 271 | 149 | |
| 272 | − if (source.kind === "openapi") { | |
| 273 | − if (trimOrNull(source.specUrl) === null) { | |
| 274 | − return yield* Effect.fail(new Error("OpenAPI sources require specUrl")); | |
| 275 | − } | |
| 276 | − | |
| 277 | − if (source.transport !== null || source.queryParams !== null || source.headers !== null) { | |
| 278 | − return yield* Effect.fail( | |
| 279 | − new Error("OpenAPI sources cannot define MCP transport settings"), | |
| 280 | − ); | |
| 281 | − } | |
| 150 | +const credentialFromAuth = (input: { | |
| 151 | + source: Source; | |
| 152 | + auth: SourceAuth; | |
| 153 | + slot: CredentialSlot; | |
| 154 | + actorAccountId?: AccountId | null; | |
| 155 | + existingCredentialId?: Credential["id"] | null; | |
| 156 | +}): Credential | null => { | |
| 157 | + if (input.auth.kind === "none") { | |
| 158 | + return null; | |
| 159 | + } | |
| 282 | 160 | |
| 283 | − return source; | |
| 284 | − } | |
| 161 | + const credentialId = input.existingCredentialId | |
| 162 | + ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`); | |
| 285 | 163 | |
| 286 | − if (source.kind === "graphql") { | |
| 287 | − if (source.transport !== null || source.queryParams !== null || source.headers !== null) { | |
| 288 | − return yield* Effect.fail( | |
| 289 | − new Error("GraphQL sources cannot define MCP transport settings"), | |
| 290 | − ); | |
| 291 | − } | |
| 164 | + return { | |
| 165 | + id: credentialId, | |
| 166 | + workspaceId: input.source.workspaceId, | |
| 167 | + sourceId: input.source.id, | |
| 168 | + actorAccountId: input.actorAccountId ?? null, | |
| 169 | + slot: input.slot, | |
| 170 | + authKind: input.auth.kind, | |
| 171 | + authHeaderName: input.auth.headerName, | |
| 172 | + authPrefix: input.auth.prefix, | |
| 173 | + tokenProviderId: | |
| 174 | + input.auth.kind === "bearer" | |
| 175 | + ? input.auth.token.providerId | |
| 176 | + : input.auth.accessToken.providerId, | |
| 177 | + tokenHandle: | |
| 178 | + input.auth.kind === "bearer" | |
| 179 | + ? input.auth.token.handle | |
| 180 | + : input.auth.accessToken.handle, | |
| 181 | + refreshTokenProviderId: | |
| 182 | + input.auth.kind === "oauth2" && input.auth.refreshToken !== null | |
| 183 | + ? input.auth.refreshToken.providerId | |
| 184 | + : null, | |
| 185 | + refreshTokenHandle: | |
| 186 | + input.auth.kind === "oauth2" && input.auth.refreshToken !== null | |
| 187 | + ? input.auth.refreshToken.handle | |
| 188 | + : null, | |
| 189 | + createdAt: input.source.createdAt, | |
| 190 | + updatedAt: input.source.updatedAt, | |
| 191 | + } satisfies Credential; | |
| 192 | +}; | |
| 292 | 193 | |
| 293 | − if (source.specUrl !== null) { | |
| 294 | − return yield* Effect.fail(new Error("GraphQL sources cannot define specUrl")); | |
| 295 | − } | |
| 194 | +const authFromCredential = (credential: Credential | null): SourceAuth => { | |
| 195 | + if (credential === null) { | |
| 196 | + return { kind: "none" }; | |
| 197 | + } | |
| 296 | 198 | |
| 297 | − return source; | |
| 298 | − } | |
| 199 | + if (credential.authKind === "bearer") { | |
| 200 | + return { | |
| 201 | + kind: "bearer", | |
| 202 | + headerName: credential.authHeaderName, | |
| 203 | + prefix: credential.authPrefix, | |
| 204 | + token: { | |
| 205 | + providerId: credential.tokenProviderId, | |
| 206 | + handle: credential.tokenHandle, | |
| 207 | + }, | |
| 208 | + }; | |
| 209 | + } | |
| 299 | 210 | |
| 300 | − if (source.transport !== null || source.queryParams !== null || source.headers !== null) { | |
| 301 | − return yield* Effect.fail( | |
| 302 | − new Error(`${source.kind} sources cannot define MCP transport settings`), | |
| 303 | − ); | |
| 304 | − } | |
| 211 | + return { | |
| 212 | + kind: "oauth2", | |
| 213 | + headerName: credential.authHeaderName, | |
| 214 | + prefix: credential.authPrefix, | |
| 215 | + accessToken: { | |
| 216 | + providerId: credential.tokenProviderId, | |
| 217 | + handle: credential.tokenHandle, | |
| 218 | + }, | |
| 219 | + refreshToken: | |
| 220 | + credential.refreshTokenProviderId !== null | |
| 221 | + && credential.refreshTokenHandle !== null | |
| 222 | + ? { | |
| 223 | + providerId: credential.refreshTokenProviderId, | |
| 224 | + handle: credential.refreshTokenHandle, | |
| 225 | + } | |
| 226 | + : null, | |
| 227 | + }; | |
| 228 | +}; | |
| 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 | 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 | 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 | 247 | export const createSourceFromPayload = (input: { |
| 316 | 248 | workspaceId: WorkspaceId; |
| 317 | 249 | sourceId: Source["id"]; |
| @@ -320,6 +252,11 @@ export const createSourceFromPayload = (input: { · 2 unmodified lines | ||
| 320 | 252 | }): Effect.Effect<Source, Error, never> => |
| 321 | 253 | Effect.gen(function* () { |
| 322 | 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 | 261 | return yield* validateSourceByKind({ |
| 325 | 262 | id: input.sourceId, |
| @@ -335,6 +272,8 @@ export const createSourceFromPayload = (input: { · 9 unmodified lines | ||
| 335 | 272 | headers: input.payload.headers ?? null, |
| 336 | 273 | specUrl: trimOrNull(input.payload.specUrl), |
| 337 | 274 | defaultHeaders: input.payload.defaultHeaders ?? null, |
| 275 | + importAuthPolicy, | |
| 276 | + importAuth, | |
| 338 | 277 | auth, |
| 339 | 278 | sourceHash: trimOrNull(input.payload.sourceHash), |
| 340 | 279 | lastError: trimOrNull(input.payload.lastError), |
| @@ -352,6 +291,13 @@ export const updateSourceFromPayload = (input: { · 11 unmodified lines | ||
| 352 | 291 | const nextAuth = input.payload.auth === undefined |
| 353 | 292 | ? input.source.auth |
| 354 | 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 | 302 | return yield* validateSourceByKind({ |
| 357 | 303 | ...input.source, |
| @@ -381,6 +327,8 @@ export const updateSourceFromPayload = (input: { · 23 unmodified lines | ||
| 381 | 327 | defaultHeaders: input.payload.defaultHeaders !== undefined |
| 382 | 328 | ? input.payload.defaultHeaders |
| 383 | 329 | : input.source.defaultHeaders, |
| 330 | + importAuthPolicy: nextImportAuthPolicy, | |
| 331 | + importAuth: nextImportAuth, | |
| 384 | 332 | auth: nextAuth, |
| 385 | 333 | sourceHash: input.payload.sourceHash !== undefined |
| 386 | 334 | ? trimOrNull(input.payload.sourceHash) |
| @@ -416,6 +364,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 29 unmodified lines | ||
| 416 | 364 | revisionNumber: number; |
| 417 | 365 | manifestJson?: string | null; |
| 418 | 366 | manifestHash?: string | null; |
| 367 | + materializationHash?: string | null; | |
| 419 | 368 | }): StoredSourceRecipeRevisionRecord => ({ |
| 420 | 369 | id: |
| 421 | 370 | input.recipeRevisionId |
| @@ -425,6 +374,7 @@ export const createSourceRecipeRevisionRecord = (input: { · 3 unmodified lines | ||
| 425 | 374 | sourceConfigJson: sourceConfigSignature(input.source), |
| 426 | 375 | manifestJson: input.manifestJson ?? null, |
| 427 | 376 | manifestHash: input.manifestHash ?? null, |
| 377 | + materializationHash: input.materializationHash ?? null, | |
| 428 | 378 | createdAt: input.source.createdAt, |
| 429 | 379 | updatedAt: input.source.updatedAt, |
| 430 | 380 | }); |
| @@ -434,10 +384,12 @@ export const splitSourceForStorage = (input: { · 3 unmodified lines | ||
| 434 | 384 | recipeId: SourceRecipeId; |
| 435 | 385 | recipeRevisionId: SourceRecipeRevisionId; |
| 436 | 386 | actorAccountId?: AccountId | null; |
| 437 | − existingCredentialId?: Credential["id"] | null; | |
| 387 | + existingRuntimeCredentialId?: Credential["id"] | null; | |
| 388 | + existingImportCredentialId?: Credential["id"] | null; | |
| 438 | 389 | }): { |
| 439 | 390 | sourceRecord: StoredSourceRecord; |
| 440 | − credential: Credential | null; | |
| 391 | + runtimeCredential: Credential | null; | |
| 392 | + importCredential: Credential | null; | |
| 441 | 393 | } => { |
| 442 | 394 | const sourceRecord: StoredSourceRecord = { |
| 443 | 395 | id: input.source.id, |
| @@ -450,113 +402,46 @@ export const splitSourceForStorage = (input: { · 6 unmodified lines | ||
| 450 | 402 | status: input.source.status, |
| 451 | 403 | enabled: input.source.enabled, |
| 452 | 404 | namespace: input.source.namespace, |
| 453 | − bindingConfigJson: null, | |
| 454 | − transport: input.source.transport, | |
| 455 | − queryParamsJson: serializeStringMap(input.source.queryParams), | |
| 456 | − headersJson: serializeStringMap(input.source.headers), | |
| 457 | − specUrl: input.source.specUrl, | |
| 458 | − defaultHeadersJson: serializeStringMap(input.source.defaultHeaders), | |
| 405 | + importAuthPolicy: input.source.importAuthPolicy, | |
| 406 | + bindingConfigJson: getSourceAdapterForSource(input.source).serializeBindingConfig(input.source), | |
| 459 | 407 | sourceHash: input.source.sourceHash, |
| 460 | − sourceDocumentText: null, | |
| 461 | 408 | lastError: input.source.lastError, |
| 462 | 409 | createdAt: input.source.createdAt, |
| 463 | 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 | 413 | return { |
| 505 | 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 | 434 | export const projectSourceFromStorage = (input: { |
| 511 | 435 | sourceRecord: StoredSourceRecord; |
| 512 | − credential: Credential | null; | |
| 436 | + runtimeCredential: Credential | null; | |
| 437 | + importCredential: Credential | null; | |
| 513 | 438 | }): Effect.Effect<Source, Error, never> => |
| 514 | 439 | Effect.gen(function* () { |
| 515 | − const queryParams = yield* parseStringMapJson( | |
| 516 | − `queryParamsJson for ${input.sourceRecord.id}`, | |
| 517 | − input.sourceRecord.queryParamsJson, | |
| 518 | − ); | |
| 519 | − const headers = yield* parseStringMapJson( | |
| 520 | − `headersJson for ${input.sourceRecord.id}`, | |
| 521 | − input.sourceRecord.headersJson, | |
| 522 | − ); | |
| 523 | − const defaultHeaders = yield* parseStringMapJson( | |
| 524 | − `defaultHeadersJson for ${input.sourceRecord.id}`, | |
| 525 | − input.sourceRecord.defaultHeadersJson, | |
| 526 | − ); | |
| 527 | − | |
| 528 | − let auth: SourceAuth = { kind: "none" }; | |
| 529 | − if (input.credential !== null) { | |
| 530 | − if (input.credential.authKind === "bearer") { | |
| 531 | − auth = { | |
| 532 | − kind: "bearer", | |
| 533 | − headerName: input.credential.authHeaderName, | |
| 534 | − prefix: input.credential.authPrefix, | |
| 535 | − token: { | |
| 536 | − providerId: input.credential.tokenProviderId, | |
| 537 | − handle: input.credential.tokenHandle, | |
| 538 | − }, | |
| 539 | − }; | |
| 540 | − } else { | |
| 541 | − auth = { | |
| 542 | − kind: "oauth2", | |
| 543 | − headerName: input.credential.authHeaderName, | |
| 544 | − prefix: input.credential.authPrefix, | |
| 545 | − accessToken: { | |
| 546 | − providerId: input.credential.tokenProviderId, | |
| 547 | − handle: input.credential.tokenHandle, | |
| 548 | − }, | |
| 549 | − refreshToken: | |
| 550 | − input.credential.refreshTokenProviderId !== null | |
| 551 | − && input.credential.refreshTokenHandle !== null | |
| 552 | − ? { | |
| 553 | − providerId: input.credential.refreshTokenProviderId, | |
| 554 | − handle: input.credential.refreshTokenHandle, | |
| 555 | − } | |
| 556 | − : null, | |
| 557 | − }; | |
| 558 | − } | |
| 559 | − } | |
| 440 | + const adapter = getSourceAdapter(input.sourceRecord.kind); | |
| 441 | + const bindingState = yield* adapter.deserializeBindingConfig({ | |
| 442 | + id: input.sourceRecord.id, | |
| 443 | + bindingConfigJson: input.sourceRecord.bindingConfigJson, | |
| 444 | + }); | |
| 560 | 445 | |
| 561 | 446 | return { |
| 562 | 447 | id: input.sourceRecord.id, |
| @@ -567,12 +452,17 @@ export const projectSourceFromStorage = (input: { · 4 unmodified lines | ||
| 567 | 452 | status: input.sourceRecord.status, |
| 568 | 453 | enabled: input.sourceRecord.enabled, |
| 569 | 454 | namespace: input.sourceRecord.namespace, |
| 570 | − transport: input.sourceRecord.transport, | |
| 571 | − queryParams, | |
| 572 | − headers, | |
| 573 | − specUrl: input.sourceRecord.specUrl, | |
| 574 | − defaultHeaders, | |
| 575 | − auth, | |
| 455 | + transport: bindingState.transport, | |
| 456 | + queryParams: bindingState.queryParams, | |
| 457 | + headers: bindingState.headers, | |
| 458 | + specUrl: bindingState.specUrl, | |
| 459 | + defaultHeaders: bindingState.defaultHeaders, | |
| 460 | + importAuthPolicy: input.sourceRecord.importAuthPolicy, | |
| 461 | + importAuth: | |
| 462 | + input.sourceRecord.importAuthPolicy === "separate" | |
| 463 | + ? authFromCredential(input.importCredential) | |
| 464 | + : { kind: "none" }, | |
| 465 | + auth: authFromCredential(input.runtimeCredential), | |
| 576 | 466 | sourceHash: input.sourceRecord.sourceHash, |
| 577 | 467 | lastError: input.sourceRecord.lastError, |
| 578 | 468 | createdAt: input.sourceRecord.createdAt, |
| @@ -588,18 +478,29 @@ export const projectSourcesFromStorage = (input: { · 9 unmodified lines | ||
| 588 | 478 | sourceRecords: ReadonlyArray<StoredSourceRecord>; |
| 589 | 479 | credentials: ReadonlyArray<Credential>; |
| 590 | 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 | 486 | for (const credential of input.credentials) { |
| 594 | − const existing = credentialsBySourceId.get(credential.sourceId); | |
| 595 | − if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) { | |
| 596 | − credentialsBySourceId.set(credential.sourceId, credential); | |
| 487 | + const existing = credentialsBySourceId.get(credential.sourceId) ?? { | |
| 488 | + runtime: null, | |
| 489 | + import: null, | |
| 490 | + }; | |
| 491 | + const current = credential.slot === "runtime" ? existing.runtime : existing.import; | |
| 492 | + if (current === null || (current.actorAccountId === null && credential.actorAccountId !== null)) { | |
| 493 | + credentialsBySourceId.set(credential.sourceId, { | |
| 494 | + ...existing, | |
| 495 | + [credential.slot]: credential, | |
| 496 | + }); | |
| 597 | 497 | } |
| 598 | 498 | } |
| 599 | 499 | |
| 600 | 500 | return Effect.forEach(input.sourceRecords, (sourceRecord) => |
| 601 | 501 | projectSourceFromStorage({ |
| 602 | 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 | 19 | import * as Effect from "effect/Effect"; |
| 20 | 20 | import { getIntrospectionQuery } from "graphql"; |
| 21 | 21 | import { startMcpOAuthAuthorization } from "./mcp-oauth"; |
| 22 | −import { namespaceFromSourceName } from "./tool-artifacts"; | |
| 22 | +import { namespaceFromSourceName } from "./source-names"; | |
| 23 | 23 | |
| 24 | 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 | 1 | import type { |
| 10 | 2 | Source, |
| 11 | 3 | SourceId, |
| @@ -13,10 +5,14 @@ import type { · 1 unmodified line | ||
| 13 | 5 | SourceInspectionDiscoverPayload, |
| 14 | 6 | SourceInspectionDiscoverResult, |
| 15 | 7 | SourceInspectionDiscoverResultItem, |
| 8 | + SourceInspectionSchemaBundle, | |
| 9 | + SourceInspectionToolListItem, | |
| 16 | 10 | SourceInspectionToolDetail, |
| 17 | 11 | SourceInspectionToolSummary, |
| 12 | + StoredSourceRecipeOperationRecord, | |
| 18 | 13 | WorkspaceId, |
| 19 | 14 | } from "#schema"; |
| 15 | +import { SourceRecipeSchemaBundleIdSchema } from "#schema"; | |
| 20 | 16 | import { |
| 21 | 17 | ControlPlaneNotFoundError, |
| 22 | 18 | ControlPlaneStorageError, |
| @@ -25,21 +21,18 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | ||
| 25 | 21 | import * as Option from "effect/Option"; |
| 26 | 22 | |
| 27 | 23 | import { operationErrors } from "./operation-errors"; |
| 28 | −import { formatJsonIfNeeded, formatWithPrettier } from "./prettier-format"; | |
| 24 | +import { formatWithPrettier } from "./prettier-format"; | |
| 29 | 25 | import { |
| 30 | − buildGraphqlToolPresentation, | |
| 31 | − compileGraphqlToolDefinitions, | |
| 32 | − type GraphqlToolDefinition, | |
| 33 | − type GraphqlToolManifest, | |
| 34 | −} from "./graphql-tools"; | |
| 26 | + getSourceAdapterForOperation, | |
| 27 | +} from "./source-adapters"; | |
| 35 | 28 | import { |
| 36 | − loadSourceRecipe, | |
| 37 | − recipePrimaryDocumentText, | |
| 29 | + recipeToolMetadata, | |
| 38 | 30 | recipeToolPath, |
| 39 | − type LoadedSourceRecipe, | |
| 40 | 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 | 35 | import { ControlPlaneStore } from "./store"; |
| 42 | −import { namespaceFromSourceName } from "./tool-artifacts"; | |
| 43 | 36 | |
| 44 | 37 | const sourceInspectOps = { |
| 45 | 38 | bundle: operationErrors("sources.inspect.bundle"), |
| @@ -47,8 +40,6 @@ const sourceInspectOps = { · 1 unmodified line | ||
| 47 | 40 | discover: operationErrors("sources.inspect.discover"), |
| 48 | 41 | } as const; |
| 49 | 42 | |
| 50 | −const asPrettyJson = (value: unknown): string => JSON.stringify(value, null, 2); | |
| 51 | − | |
| 52 | 43 | const tokenize = (value: string): Array<string> => |
| 53 | 44 | value |
| 54 | 45 | .trim() |
| @@ -56,26 +47,10 @@ const tokenize = (value: string): Array<string> => · 1 unmodified line | ||
| 56 | 47 | .split(/[^a-z0-9]+/) |
| 57 | 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 | 50 | type InspectionToolRecord = { |
| 77 | − summary: SourceInspectionToolSummary; | |
| 78 | − detail: SourceInspectionToolDetail; | |
| 51 | + operation: StoredSourceRecipeOperationRecord; | |
| 52 | + metadata: SourceAdapterPersistedOperationMetadata; | |
| 53 | + listItem: SourceInspectionToolListItem; | |
| 79 | 54 | searchText: string; |
| 80 | 55 | }; |
| 81 | 56 | |
| @@ -83,9 +58,7 @@ type ResolvedSourceInspection = { · 1 unmodified line | ||
| 83 | 58 | source: Source; |
| 84 | 59 | namespace: string; |
| 85 | 60 | pipelineKind: SourceInspection["pipelineKind"]; |
| 86 | − rawDocumentText: string | null; | |
| 87 | − manifestJson: string | null; | |
| 88 | − definitionsJson: string | null; | |
| 61 | + schemaBundleId: string | null; | |
| 89 | 62 | tools: ReadonlyArray<InspectionToolRecord>; |
| 90 | 63 | }; |
| 91 | 64 | |
| @@ -111,41 +84,26 @@ const formatToolSummary = (summary: SourceInspectionToolSummary) => · 19 unmodified lines | ||
| 111 | 84 | } satisfies SourceInspectionToolSummary)), |
| 112 | 85 | ); |
| 113 | 86 | |
| 114 | −const formatInspectionToolRecord = (record: InspectionToolRecord) => | |
| 87 | +const formatInspectionToolDetail = (detail: SourceInspectionToolDetail) => | |
| 115 | 88 | Effect.gen(function* () { |
| 116 | − const summary = yield* formatToolSummary(record.summary); | |
| 89 | + const summary = yield* formatToolSummary(detail.summary); | |
| 117 | 90 | const detailFields = yield* Effect.all({ |
| 118 | − definitionJson: formatOptionalJson(record.detail.definitionJson), | |
| 119 | − documentationJson: formatOptionalJson(record.detail.documentationJson), | |
| 120 | − providerDataJson: formatOptionalJson(record.detail.providerDataJson), | |
| 121 | − inputSchemaJson: formatOptionalJson(record.detail.inputSchemaJson), | |
| 122 | − outputSchemaJson: formatOptionalJson(record.detail.outputSchemaJson), | |
| 123 | − exampleInputJson: formatOptionalJson(record.detail.exampleInputJson), | |
| 124 | − exampleOutputJson: formatOptionalJson(record.detail.exampleOutputJson), | |
| 91 | + definitionJson: formatOptionalJson(detail.definitionJson), | |
| 92 | + documentationJson: formatOptionalJson(detail.documentationJson), | |
| 93 | + providerDataJson: formatOptionalJson(detail.providerDataJson), | |
| 94 | + inputSchemaJson: formatOptionalJson(detail.inputSchemaJson), | |
| 95 | + outputSchemaJson: formatOptionalJson(detail.outputSchemaJson), | |
| 96 | + exampleInputJson: formatOptionalJson(detail.exampleInputJson), | |
| 97 | + exampleOutputJson: formatOptionalJson(detail.exampleOutputJson), | |
| 125 | 98 | }); |
| 126 | 99 | |
| 127 | 100 | return { |
| 101 | + ...detail, | |
| 102 | + ...detailFields, | |
| 128 | 103 | summary, |
| 129 | − detail: { | |
| 130 | − ...record.detail, | |
| 131 | − ...detailFields, | |
| 132 | − summary, | |
| 133 | − }, | |
| 134 | − searchText: record.searchText, | |
| 135 | − } satisfies InspectionToolRecord; | |
| 136 | − }); | |
| 137 | − | |
| 138 | −const loadStoredDocumentText = (recipe: LoadedSourceRecipe) => { | |
| 139 | − const rawDocumentText = recipePrimaryDocumentText({ | |
| 140 | − source: recipe.source, | |
| 141 | − documents: recipe.documents, | |
| 104 | + } satisfies SourceInspectionToolDetail; | |
| 142 | 105 | }); |
| 143 | 106 | |
| 144 | − return rawDocumentText === null | |
| 145 | − ? Effect.succeed<string | null>(null) | |
| 146 | − : Effect.promise(() => formatJsonIfNeeded(rawDocumentText)); | |
| 147 | −}; | |
| 148 | − | |
| 149 | 107 | const loadSourceRecipeRecord = (input: { |
| 150 | 108 | workspaceId: WorkspaceId; |
| 151 | 109 | sourceId: SourceId; |
| @@ -164,316 +122,146 @@ const loadSourceRecipeRecord = (input: { · 12 unmodified lines | ||
| 164 | 122 | ); |
| 165 | 123 | } |
| 166 | 124 | |
| 167 | − const recipe = yield* loadSourceRecipe({ | |
| 168 | − rows: store, | |
| 125 | + const source = yield* loadSourceById(store, { | |
| 169 | 126 | workspaceId: input.workspaceId, |
| 170 | 127 | sourceId: input.sourceId, |
| 171 | 128 | }).pipe( |
| 172 | 129 | Effect.mapError((cause) => |
| 173 | 130 | sourceInspectOps.bundle.unknownStorage( |
| 174 | 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 | 143 | return { |
| 181 | − store, | |
| 182 | − sourceRecord: sourceRecord.value, | |
| 183 | − recipe, | |
| 144 | + source, | |
| 145 | + operations, | |
| 146 | + schemaBundleId: schemaBundles.find((bundle) => bundle.bundleKind === "json_schema_ref_map")?.id | |
| 147 | + ?? schemaBundles[0]?.id | |
| 148 | + ?? null, | |
| 184 | 149 | }; |
| 185 | 150 | }), |
| 186 | 151 | ); |
| 187 | 152 | |
| 188 | −const persistedToolSummaryFromRecipeOperation = (input: { | |
| 153 | +const inspectionToolListItemFromOperation = (input: { | |
| 189 | 154 | source: Source; |
| 190 | − operation: LoadedSourceRecipe["operations"][number]; | |
| 191 | −}): SourceInspectionToolSummary => ({ | |
| 155 | + operation: StoredSourceRecipeOperationRecord; | |
| 156 | + metadata: SourceAdapterPersistedOperationMetadata; | |
| 157 | +}): SourceInspectionToolListItem => ({ | |
| 192 | 158 | path: recipeToolPath({ |
| 193 | 159 | source: input.source, |
| 194 | 160 | operation: input.operation, |
| 195 | 161 | }), |
| 196 | − sourceKey: input.source.id, | |
| 197 | − ...(input.operation.title ? { title: input.operation.title } : {}), | |
| 198 | − ...(input.operation.description ? { description: input.operation.description } : {}), | |
| 199 | − providerKind: input.operation.providerKind, | |
| 200 | − toolId: input.operation.toolId, | |
| 201 | − rawToolId: input.operation.openApiRawToolId, | |
| 202 | − operationId: input.operation.graphqlOperationName ?? input.operation.openApiOperationId, | |
| 203 | − group: null, | |
| 204 | − leaf: null, | |
| 205 | − tags: input.operation.openApiTagsJson | |
| 206 | − ? ((JSON.parse(input.operation.openApiTagsJson) as Array<string>) ?? []) | |
| 207 | − : [], | |
| 208 | − method: input.operation.openApiMethod, | |
| 209 | − pathTemplate: input.operation.openApiPathTemplate, | |
| 210 | − ...(input.operation.inputSchemaJson | |
| 211 | − ? { | |
| 212 | − inputType: typeSignatureFromSchemaJson( | |
| 213 | − input.operation.inputSchemaJson, | |
| 214 | − "unknown", | |
| 215 | − Infinity, | |
| 216 | − ), | |
| 217 | − } | |
| 218 | − : {}), | |
| 219 | − ...(input.operation.providerKind === "openapi" | |
| 220 | − ? { | |
| 221 | − outputType: openApiOutputTypeSignatureFromSchemaJson( | |
| 222 | − input.operation.outputSchemaJson ?? undefined, | |
| 223 | − Infinity, | |
| 224 | − ), | |
| 225 | − } | |
| 226 | − : input.operation.outputSchemaJson | |
| 227 | − ? { | |
| 228 | − outputType: typeSignatureFromSchemaJson( | |
| 229 | − input.operation.outputSchemaJson, | |
| 230 | − "unknown", | |
| 231 | − Infinity, | |
| 232 | − ), | |
| 233 | − } | |
| 234 | − : {}), | |
| 162 | + method: input.metadata.method, | |
| 235 | 163 | }); |
| 236 | 164 | |
| 237 | −const openApiToolRecord = (input: { | |
| 165 | +const persistedToolSummaryFromRecipeOperation = (input: { | |
| 238 | 166 | source: Source; |
| 239 | − namespace: string; | |
| 240 | − manifest: OpenApiToolManifest; | |
| 241 | − definition: OpenApiToolDefinition; | |
| 242 | −}): InspectionToolRecord => { | |
| 243 | − const presentation = buildOpenApiToolPresentation({ | |
| 244 | − manifest: input.manifest, | |
| 245 | − definition: input.definition, | |
| 246 | − }); | |
| 247 | − const path = `${input.namespace}.${input.definition.toolId}`; | |
| 248 | − const summary: SourceInspectionToolSummary = { | |
| 249 | − path, | |
| 250 | − sourceKey: input.source.id, | |
| 251 | − title: input.definition.name, | |
| 252 | − description: input.definition.description, | |
| 253 | − providerKind: "openapi", | |
| 254 | − toolId: input.definition.toolId, | |
| 255 | − rawToolId: input.definition.rawToolId, | |
| 256 | − operationId: input.definition.operationId ?? null, | |
| 257 | − group: input.definition.group, | |
| 258 | − leaf: input.definition.leaf, | |
| 259 | − tags: [...input.definition.tags], | |
| 260 | − method: input.definition.method, | |
| 261 | − pathTemplate: input.definition.path, | |
| 262 | − inputType: presentation.inputType, | |
| 263 | − outputType: presentation.outputType, | |
| 264 | − }; | |
| 167 | + operation: StoredSourceRecipeOperationRecord; | |
| 168 | + metadata: SourceAdapterPersistedOperationMetadata; | |
| 169 | + includeTypes: boolean; | |
| 170 | +}): SourceInspectionToolSummary => { | |
| 171 | + const path = inspectionToolListItemFromOperation(input).path; | |
| 172 | + const descriptor = input.includeTypes | |
| 173 | + ? getSourceAdapterForOperation(input.operation).createToolDescriptor({ | |
| 174 | + source: input.source, | |
| 175 | + operation: input.operation, | |
| 176 | + path, | |
| 177 | + includeSchemas: false, | |
| 178 | + }) | |
| 179 | + : null; | |
| 265 | 180 | |
| 266 | 181 | return { |
| 267 | − summary, | |
| 268 | − detail: { | |
| 269 | − summary, | |
| 270 | − definitionJson: asPrettyJson(input.definition), | |
| 271 | − documentationJson: input.definition.documentation | |
| 272 | − ? asPrettyJson(input.definition.documentation) | |
| 273 | − : null, | |
| 274 | − providerDataJson: presentation.providerDataJson, | |
| 275 | − inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 276 | − outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 277 | − exampleInputJson: presentation.exampleInputJson ?? null, | |
| 278 | − exampleOutputJson: presentation.exampleOutputJson ?? null, | |
| 279 | − }, | |
| 280 | − searchText: searchTextFromSummary(summary), | |
| 182 | + path, | |
| 183 | + sourceKey: input.source.id, | |
| 184 | + ...(input.operation.title ? { title: input.operation.title } : {}), | |
| 185 | + ...(input.operation.description ? { description: input.operation.description } : {}), | |
| 186 | + providerKind: input.operation.providerKind, | |
| 187 | + toolId: input.operation.toolId, | |
| 188 | + rawToolId: input.metadata.rawToolId, | |
| 189 | + operationId: input.metadata.operationId, | |
| 190 | + group: input.metadata.group, | |
| 191 | + leaf: input.metadata.leaf, | |
| 192 | + tags: [...input.metadata.tags], | |
| 193 | + method: input.metadata.method, | |
| 194 | + pathTemplate: input.metadata.pathTemplate, | |
| 195 | + ...(descriptor?.inputType ? { inputType: descriptor.inputType } : {}), | |
| 196 | + ...(descriptor?.outputType ? { outputType: descriptor.outputType } : {}), | |
| 281 | 197 | }; |
| 282 | 198 | }; |
| 283 | 199 | |
| 284 | −const graphqlToolRecord = (input: { | |
| 200 | +const inspectionToolDetailFromOperation = (input: { | |
| 285 | 201 | source: Source; |
| 286 | − namespace: string; | |
| 287 | − manifest: GraphqlToolManifest; | |
| 288 | − definition: GraphqlToolDefinition; | |
| 289 | −}): InspectionToolRecord => { | |
| 290 | − const presentation = buildGraphqlToolPresentation({ | |
| 291 | − manifest: input.manifest, | |
| 292 | − definition: input.definition, | |
| 202 | + operation: StoredSourceRecipeOperationRecord; | |
| 203 | + metadata: SourceAdapterPersistedOperationMetadata; | |
| 204 | + schemaBundleId: string | null; | |
| 205 | +}): SourceInspectionToolDetail => { | |
| 206 | + const summary = persistedToolSummaryFromRecipeOperation({ | |
| 207 | + source: input.source, | |
| 208 | + operation: input.operation, | |
| 209 | + metadata: input.metadata, | |
| 210 | + includeTypes: true, | |
| 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 | 213 | return { |
| 314 | 214 | summary, |
| 315 | − detail: { | |
| 316 | − summary, | |
| 317 | − definitionJson: asPrettyJson(input.definition), | |
| 318 | − documentationJson: null, | |
| 319 | − providerDataJson: presentation.providerDataJson, | |
| 320 | − inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 321 | − outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 322 | − exampleInputJson: presentation.exampleInputJson ?? null, | |
| 323 | − exampleOutputJson: null, | |
| 324 | − }, | |
| 325 | − searchText: searchTextFromSummary(summary), | |
| 326 | − }; | |
| 215 | + definitionJson: null, | |
| 216 | + documentationJson: null, | |
| 217 | + providerDataJson: input.operation.providerDataJson, | |
| 218 | + inputSchemaJson: input.operation.inputSchemaJson, | |
| 219 | + outputSchemaJson: input.operation.outputSchemaJson, | |
| 220 | + schemaBundleId: input.schemaBundleId, | |
| 221 | + exampleInputJson: null, | |
| 222 | + exampleOutputJson: null, | |
| 223 | + } satisfies SourceInspectionToolDetail; | |
| 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 | 226 | const resolveSourceInspection = (input: { |
| 444 | 227 | workspaceId: WorkspaceId; |
| 445 | 228 | sourceId: SourceId; |
| 446 | 229 | }) => |
| 447 | 230 | Effect.gen(function* () { |
| 448 | − const { recipe } = yield* loadSourceRecipeRecord(input); | |
| 449 | − | |
| 450 | − if (recipe.source.kind === "openapi" && recipe.manifest !== null) { | |
| 451 | − return yield* loadOpenApiInspection({ | |
| 452 | − recipe, | |
| 453 | − }).pipe( | |
| 454 | − Effect.catchAll(() => | |
| 455 | − loadPersistedInspection({ | |
| 456 | − recipe, | |
| 457 | − }), | |
| 458 | − ), | |
| 459 | − ); | |
| 460 | − } | |
| 461 | − | |
| 462 | − if (recipe.source.kind === "graphql" && recipe.manifest !== null) { | |
| 463 | − return yield* loadGraphqlInspection({ | |
| 464 | − recipe, | |
| 465 | − }).pipe( | |
| 466 | − Effect.catchAll(() => | |
| 467 | − loadPersistedInspection({ | |
| 468 | − recipe, | |
| 469 | − }), | |
| 470 | − ), | |
| 471 | − ); | |
| 472 | − } | |
| 231 | + const { source, operations, schemaBundleId } = yield* loadSourceRecipeRecord(input); | |
| 232 | + const namespace = source.namespace ?? namespaceFromSourceName(source.name); | |
| 473 | 233 | |
| 474 | − return yield* loadPersistedInspection({ | |
| 475 | − recipe, | |
| 476 | − }); | |
| 234 | + return { | |
| 235 | + source, | |
| 236 | + namespace, | |
| 237 | + pipelineKind: "persisted", | |
| 238 | + schemaBundleId, | |
| 239 | + tools: yield* Effect.forEach(operations, (operation) => | |
| 240 | + Effect.gen(function* () { | |
| 241 | + const path = recipeToolPath({ | |
| 242 | + source, | |
| 243 | + operation, | |
| 244 | + }); | |
| 245 | + const metadata = yield* recipeToolMetadata({ | |
| 246 | + source, | |
| 247 | + operation, | |
| 248 | + path, | |
| 249 | + }); | |
| 250 | + const listItem = inspectionToolListItemFromOperation({ | |
| 251 | + source, | |
| 252 | + operation, | |
| 253 | + metadata, | |
| 254 | + }); | |
| 255 | + | |
| 256 | + return { | |
| 257 | + operation, | |
| 258 | + metadata, | |
| 259 | + listItem, | |
| 260 | + searchText: metadata.searchText, | |
| 261 | + } satisfies InspectionToolRecord; | |
| 262 | + }) | |
| 263 | + ), | |
| 264 | + } satisfies ResolvedSourceInspection; | |
| 477 | 265 | }); |
| 478 | 266 | |
| 479 | 267 | const scoreTool = (input: { |
| @@ -482,15 +270,12 @@ const scoreTool = (input: { · 2 unmodified lines | ||
| 482 | 270 | }): SourceInspectionDiscoverResultItem | null => { |
| 483 | 271 | let score = 0; |
| 484 | 272 | const reasons: Array<string> = []; |
| 485 | − const pathTokens = tokenize(input.tool.summary.path); | |
| 486 | − const titleTokens = tokenize(input.tool.summary.title ?? ""); | |
| 487 | − const descriptionTokens = tokenize(input.tool.summary.description ?? ""); | |
| 488 | − const tagTokens = input.tool.summary.tags.flatMap(tokenize); | |
| 489 | − const typeTokens = tokenize( | |
| 490 | − `${input.tool.summary.inputType ?? ""} ${input.tool.summary.outputType ?? ""}`, | |
| 491 | − ); | |
| 273 | + const pathTokens = tokenize(input.tool.listItem.path); | |
| 274 | + const titleTokens = tokenize(input.tool.operation.title ?? ""); | |
| 275 | + const descriptionTokens = tokenize(input.tool.operation.description ?? ""); | |
| 276 | + const tagTokens = input.tool.metadata.tags.flatMap(tokenize); | |
| 492 | 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 | 281 | for (const token of input.queryTokens) { |
| @@ -514,11 +299,6 @@ const scoreTool = (input: { · 17 unmodified lines | ||
| 514 | 299 | reasons.push(`method/path matches ${token} (+6)`); |
| 515 | 300 | continue; |
| 516 | 301 | } |
| 517 | − if (typeTokens.includes(token)) { | |
| 518 | − score += 4; | |
| 519 | − reasons.push(`type signature matches ${token} (+4)`); | |
| 520 | − continue; | |
| 521 | − } | |
| 522 | 302 | if (descriptionTokens.includes(token) || input.tool.searchText.includes(token)) { |
| 523 | 303 | score += 2; |
| 524 | 304 | reasons.push(`description/text matches ${token} (+2)`); |
| @@ -530,16 +310,10 @@ const scoreTool = (input: { · 5 unmodified lines | ||
| 530 | 310 | } |
| 531 | 311 | |
| 532 | 312 | return { |
| 533 | − path: input.tool.summary.path, | |
| 313 | + path: input.tool.listItem.path, | |
| 534 | 314 | score, |
| 535 | − ...(input.tool.summary.description | |
| 536 | − ? { description: input.tool.summary.description } | |
| 537 | − : {}), | |
| 538 | − ...(input.tool.summary.inputType | |
| 539 | − ? { inputType: input.tool.summary.inputType } | |
| 540 | − : {}), | |
| 541 | − ...(input.tool.summary.outputType | |
| 542 | − ? { outputType: input.tool.summary.outputType } | |
| 315 | + ...(input.tool.operation.description | |
| 316 | + ? { description: input.tool.operation.description } | |
| 543 | 317 | : {}), |
| 544 | 318 | reasons, |
| 545 | 319 | } satisfies SourceInspectionDiscoverResultItem; |
| @@ -575,10 +349,7 @@ export const getSourceInspection = (input: { · 29 unmodified lines | ||
| 575 | 349 | namespace: inspection.namespace, |
| 576 | 350 | pipelineKind: inspection.pipelineKind, |
| 577 | 351 | toolCount: inspection.tools.length, |
| 578 | − rawDocumentText: inspection.rawDocumentText, | |
| 579 | − manifestJson: inspection.manifestJson, | |
| 580 | − definitionsJson: inspection.definitionsJson, | |
| 581 | − tools: inspection.tools.map((tool) => tool.summary), | |
| 352 | + tools: inspection.tools.map((tool) => tool.listItem), | |
| 582 | 353 | } satisfies SourceInspection; |
| 583 | 354 | }).pipe( |
| 584 | 355 | Effect.mapError((cause) => |
| @@ -599,7 +370,7 @@ export const getSourceInspectionToolDetail = (input: { · 14 unmodified lines | ||
| 599 | 370 | workspaceId: input.workspaceId, |
| 600 | 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 | 375 | if (!tool) { |
| 605 | 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 | 390 | }).pipe( |
| 615 | 391 | Effect.mapError((cause) => |
| 616 | 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 | 451 | export const discoverSourceInspectionTools = (input: { |
| 624 | 452 | workspaceId: WorkspaceId; |
| 625 | 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 | 54 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| 42 | 55 | id: SourceIdSchema.make("src_runtime_recipe"), |
| 43 | 56 | workspaceId: WorkspaceIdSchema.make("ws_runtime_recipe"), |
| @@ -52,6 +65,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 8 unmodified lines | ||
| 52 | 65 | headers: null, |
| 53 | 66 | specUrl: "https://api.github.com/openapi.json", |
| 54 | 67 | defaultHeaders: null, |
| 68 | + importAuthPolicy: "reuse_runtime", | |
| 69 | + importAuth: { kind: "none" }, | |
| 55 | 70 | auth: { kind: "none" }, |
| 56 | 71 | sourceHash: null, |
| 57 | 72 | lastError: null, |
| @@ -61,8 +76,8 @@ const makeSource = (overrides: Partial<Source> = {}): Source => ({ · 3 unmodified lines | ||
| 61 | 76 | }); |
| 62 | 77 | |
| 63 | 78 | const makeOperation = ( |
| 64 | − overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }>> = {}, | |
| 65 | −): Extract<StoredSourceRecipeOperationRecord, { providerKind: "openapi" }> => ({ | |
| 79 | + overrides: Partial<StoredSourceRecipeOperationRecord> = {}, | |
| 80 | +): StoredSourceRecipeOperationRecord => ({ | |
| 66 | 81 | id: "src_recipe_op_runtime", |
| 67 | 82 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), |
| 68 | 83 | operationKey: "getRepo", |
| @@ -85,25 +100,30 @@ const makeOperation = ( · 16 unmodified lines | ||
| 85 | 100 | providerKind: "openapi", |
| 86 | 101 | providerDataJson: JSON.stringify({ |
| 87 | 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 | 119 | createdAt: 1000, |
| 100 | 120 | updatedAt: 1000, |
| 101 | 121 | ...overrides, |
| 102 | 122 | }); |
| 103 | 123 | |
| 104 | 124 | const makeGraphqlOperation = ( |
| 105 | − overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }>> = {}, | |
| 106 | −): Extract<StoredSourceRecipeOperationRecord, { providerKind: "graphql" }> => ({ | |
| 125 | + overrides: Partial<StoredSourceRecipeOperationRecord> = {}, | |
| 126 | +): StoredSourceRecipeOperationRecord => ({ | |
| 107 | 127 | id: "src_recipe_op_graphql_runtime", |
| 108 | 128 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), |
| 109 | 129 | operationKey: "viewer", |
| @@ -126,25 +146,27 @@ const makeGraphqlOperation = ( · 16 unmodified lines | ||
| 126 | 146 | providerKind: "graphql", |
| 127 | 147 | providerDataJson: JSON.stringify({ |
| 128 | 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 | 162 | createdAt: 1000, |
| 141 | 163 | updatedAt: 1000, |
| 142 | 164 | ...overrides, |
| 143 | 165 | }); |
| 144 | 166 | |
| 145 | 167 | const makeMcpOperation = ( |
| 146 | − overrides: Partial<Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }>> = {}, | |
| 147 | −): Extract<StoredSourceRecipeOperationRecord, { providerKind: "mcp" }> => ({ | |
| 168 | + overrides: Partial<StoredSourceRecipeOperationRecord> = {}, | |
| 169 | +): StoredSourceRecipeOperationRecord => ({ | |
| 148 | 170 | id: "src_recipe_op_mcp_runtime", |
| 149 | 171 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_runtime"), |
| 150 | 172 | operationKey: "echo", |
| @@ -159,17 +181,10 @@ const makeMcpOperation = ( · 8 unmodified lines | ||
| 159 | 181 | providerKind: "mcp", |
| 160 | 182 | providerDataJson: JSON.stringify({ |
| 161 | 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 | 188 | createdAt: 1000, |
| 174 | 189 | updatedAt: 1000, |
| 175 | 190 | ...overrides, |
| @@ -248,19 +263,14 @@ describe("source-recipes-runtime", () => { · 72 unmodified lines | ||
| 248 | 263 | expect(recipeToolSearchNamespace({ |
| 249 | 264 | source: graphqlSource, |
| 250 | 265 | path: "ignored.path", |
| 251 | − operation: makeGraphqlOperation({ | |
| 252 | − graphqlOperationType: "query", | |
| 253 | − graphqlOperationName: "viewer", | |
| 254 | − }), | |
| 266 | + operation: makeGraphqlOperation(), | |
| 255 | 267 | })).toBe("issues"); |
| 256 | 268 | }); |
| 257 | 269 | |
| 258 | 270 | it("computes interaction modes and descriptor fields correctly", () => { |
| 259 | 271 | expect(recipeToolDescriptor({ |
| 260 | 272 | source: makeSource(), |
| 261 | − operation: makeOperation({ | |
| 262 | − openApiMethod: "get", | |
| 263 | − }), | |
| 273 | + operation: makeOperation(), | |
| 264 | 274 | path: "github.getRepo", |
| 265 | 275 | includeSchemas: true, |
| 266 | 276 | }).interaction).toBe("auto"); |
| @@ -268,7 +278,7 @@ describe("source-recipes-runtime", () => { · 1 unmodified line | ||
| 268 | 278 | expect(recipeToolDescriptor({ |
| 269 | 279 | source: makeSource(), |
| 270 | 280 | operation: makeOperation({ |
| 271 | − openApiMethod: "delete", | |
| 281 | + operationKind: "delete", | |
| 272 | 282 | }), |
| 273 | 283 | path: "github.deleteRepo", |
| 274 | 284 | includeSchemas: true, |
| @@ -280,10 +290,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | ||
| 280 | 290 | endpoint: "https://example.com/graphql", |
| 281 | 291 | specUrl: null, |
| 282 | 292 | }), |
| 283 | − operation: makeGraphqlOperation({ | |
| 284 | − graphqlOperationType: "query", | |
| 285 | − graphqlOperationName: "viewer", | |
| 286 | − }), | |
| 293 | + operation: makeGraphqlOperation(), | |
| 287 | 294 | path: "graphql.viewer", |
| 288 | 295 | includeSchemas: true, |
| 289 | 296 | }).interaction).toBe("auto"); |
| @@ -295,8 +302,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | ||
| 295 | 302 | specUrl: null, |
| 296 | 303 | }), |
| 297 | 304 | operation: makeGraphqlOperation({ |
| 298 | − graphqlOperationType: "mutation", | |
| 299 | − graphqlOperationName: "createIssue", | |
| 305 | + operationKind: "write", | |
| 300 | 306 | }), |
| 301 | 307 | path: "graphql.createIssue", |
| 302 | 308 | includeSchemas: true, |
| @@ -309,9 +315,7 @@ describe("source-recipes-runtime", () => { · 6 unmodified lines | ||
| 309 | 315 | specUrl: null, |
| 310 | 316 | transport: "streamable-http", |
| 311 | 317 | }), |
| 312 | − operation: makeMcpOperation({ | |
| 313 | − mcpToolName: "echo", | |
| 314 | − }), | |
| 318 | + operation: makeMcpOperation(), | |
| 315 | 319 | path: "mcp.echo", |
| 316 | 320 | includeSchemas: true, |
| 317 | 321 | }).interaction).toBe("auto"); |
| @@ -349,14 +353,9 @@ describe("source-recipes-runtime", () => { · 31 unmodified lines | ||
| 349 | 353 | status: "connected", |
| 350 | 354 | enabled: true, |
| 351 | 355 | namespace: "github", |
| 352 | − bindingConfigJson: null, | |
| 353 | − transport: null, | |
| 354 | − queryParamsJson: null, | |
| 355 | − headersJson: null, | |
| 356 | − specUrl: "https://api.github.com/openapi.json", | |
| 357 | − defaultHeadersJson: null, | |
| 356 | + importAuthPolicy: "reuse_runtime", | |
| 357 | + bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 358 | 358 | sourceHash: null, |
| 359 | − sourceDocumentText: null, | |
| 360 | 359 | lastError: null, |
| 361 | 360 | createdAt: 1000, |
| 362 | 361 | updatedAt: 1000, |
| @@ -368,30 +367,36 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | ||
| 368 | 367 | sourceConfigJson: "{}", |
| 369 | 368 | manifestJson: null, |
| 370 | 369 | manifestHash: null, |
| 370 | + materializationHash: null, | |
| 371 | 371 | createdAt: 1000, |
| 372 | 372 | updatedAt: 1000, |
| 373 | 373 | }, |
| 374 | 374 | documents: [], |
| 375 | + schemaBundles: [], | |
| 375 | 376 | operations: [makeOperation({ |
| 376 | 377 | searchText: "", |
| 377 | 378 | })], |
| 378 | 379 | manifest: null, |
| 379 | 380 | }; |
| 380 | 381 | |
| 381 | − const expanded = expandRecipeTools({ | |
| 382 | − recipes: [recipe], | |
| 383 | − includeSchemas: false, | |
| 384 | − }); | |
| 382 | + const expanded = Effect.runSync( | |
| 383 | + expandRecipeTools({ | |
| 384 | + recipes: [recipe], | |
| 385 | + includeSchemas: false, | |
| 386 | + }), | |
| 387 | + ); | |
| 385 | 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 | 394 | recipes: [{ |
| 390 | 395 | ...recipe, |
| 391 | 396 | operations: [], |
| 392 | 397 | }], |
| 393 | 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 | 445 | const manifest = await Effect.runPromise(extractOpenApiManifest("GitHub", openApiDocument)); |
| 441 | 446 | const definition = compileOpenApiToolDefinitions(manifest)[0]!; |
| 442 | 447 | const presentation = buildOpenApiToolPresentation({ |
| 443 | − manifest, | |
| 444 | 448 | definition, |
| 445 | 449 | }); |
| 446 | 450 | |
| @@ -452,7 +456,7 @@ describe("source-recipes-runtime", () => { · 5 unmodified lines | ||
| 452 | 456 | }); |
| 453 | 457 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ |
| 454 | 458 | id: recipeId, |
| 455 | − kind: "http_recipe", | |
| 459 | + kind: "http_api", | |
| 456 | 460 | importerKind: "openapi", |
| 457 | 461 | providerKey: "generic_http", |
| 458 | 462 | name: "GitHub", |
| @@ -474,6 +478,7 @@ describe("source-recipes-runtime", () => { · 15 unmodified lines | ||
| 474 | 478 | }), |
| 475 | 479 | manifestJson: JSON.stringify(manifest), |
| 476 | 480 | manifestHash: manifest.sourceHash, |
| 481 | + materializationHash: manifest.sourceHash, | |
| 477 | 482 | createdAt: 1000, |
| 478 | 483 | updatedAt: 1000, |
| 479 | 484 | })); |
| @@ -504,13 +509,6 @@ describe("source-recipes-runtime", () => { · 24 unmodified lines | ||
| 504 | 509 | inputSchemaJson: presentation.inputSchemaJson ?? null, |
| 505 | 510 | outputSchemaJson: presentation.outputSchemaJson ?? null, |
| 506 | 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 | 524 | status: "connected", |
| 527 | 525 | enabled: true, |
| 528 | 526 | namespace: "github", |
| 529 | − bindingConfigJson: null, | |
| 530 | − transport: null, | |
| 531 | − queryParamsJson: null, | |
| 532 | − headersJson: null, | |
| 533 | − specUrl: "https://api.github.com/openapi.json", | |
| 534 | − defaultHeadersJson: null, | |
| 527 | + importAuthPolicy: "reuse_runtime", | |
| 528 | + bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 535 | 529 | sourceHash: manifest.sourceHash, |
| 536 | − sourceDocumentText: null, | |
| 537 | 530 | lastError: null, |
| 538 | 531 | createdAt: 1000 + index, |
| 539 | 532 | updatedAt: 1000 + index, |
| @@ -573,7 +566,7 @@ describe("source-recipes-runtime", () => { · 33 unmodified lines | ||
| 573 | 566 | }); |
| 574 | 567 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ |
| 575 | 568 | id: recipeId, |
| 576 | − kind: "graphql_recipe", | |
| 569 | + kind: "http_api", | |
| 577 | 570 | importerKind: "graphql_introspection", |
| 578 | 571 | providerKey: "generic_graphql", |
| 579 | 572 | name: "GraphQL Demo", |
| @@ -594,6 +587,7 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines | ||
| 594 | 587 | }), |
| 595 | 588 | manifestJson: null, |
| 596 | 589 | manifestHash: null, |
| 590 | + materializationHash: null, | |
| 597 | 591 | createdAt: 1000, |
| 598 | 592 | updatedAt: 1000, |
| 599 | 593 | })); |
| @@ -608,14 +602,9 @@ describe("source-recipes-runtime", () => { · 8 unmodified lines | ||
| 608 | 602 | status: "connected", |
| 609 | 603 | enabled: true, |
| 610 | 604 | namespace: "graphql", |
| 611 | − bindingConfigJson: null, | |
| 612 | − transport: null, | |
| 613 | − queryParamsJson: null, | |
| 614 | − headersJson: null, | |
| 615 | − specUrl: null, | |
| 616 | − defaultHeadersJson: null, | |
| 605 | + importAuthPolicy: "reuse_runtime", | |
| 606 | + bindingConfigJson: graphqlBindingConfigJson(), | |
| 617 | 607 | sourceHash: null, |
| 618 | − sourceDocumentText: null, | |
| 619 | 608 | lastError: null, |
| 620 | 609 | createdAt: 1000, |
| 621 | 610 | updatedAt: 1000, |
| @@ -659,7 +648,7 @@ describe("source-recipes-runtime", () => { · 37 unmodified lines | ||
| 659 | 648 | }); |
| 660 | 649 | await Effect.runPromise(persistence.rows.sourceRecipes.upsert({ |
| 661 | 650 | id: recipeId, |
| 662 | − kind: "http_recipe", | |
| 651 | + kind: "http_api", | |
| 663 | 652 | importerKind: "openapi", |
| 664 | 653 | providerKey: "generic_http", |
| 665 | 654 | name: "Broken GitHub", |
| @@ -680,14 +669,9 @@ describe("source-recipes-runtime", () => { · 14 unmodified lines | ||
| 680 | 669 | status: "connected", |
| 681 | 670 | enabled: true, |
| 682 | 671 | namespace: "github", |
| 683 | − bindingConfigJson: null, | |
| 684 | − transport: null, | |
| 685 | − queryParamsJson: null, | |
| 686 | − headersJson: null, | |
| 687 | − specUrl: "https://api.github.com/openapi.json", | |
| 688 | − defaultHeadersJson: null, | |
| 672 | + importAuthPolicy: "reuse_runtime", | |
| 673 | + bindingConfigJson: openApiBindingConfigJson("https://api.github.com/openapi.json"), | |
| 689 | 674 | sourceHash: null, |
| 690 | − sourceDocumentText: null, | |
| 691 | 675 | lastError: null, |
| 692 | 676 | createdAt: 1000, |
| 693 | 677 | updatedAt: 1000, |
| @@ -711,6 +695,7 @@ describe("source-recipes-runtime", () => { · 17 unmodified lines | ||
| 711 | 695 | }), |
| 712 | 696 | manifestJson: "{bad-json", |
| 713 | 697 | manifestHash: null, |
| 698 | + materializationHash: null, | |
| 714 | 699 | createdAt: 1000, |
| 715 | 700 | updatedAt: 1000, |
| 716 | 701 | })); |
| @@ -1,15 +1,6 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type ToolDescriptor, |
| 3 | − type ToolPath, | |
| 4 | − typeSignatureFromSchemaJson, | |
| 5 | 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 | 4 | import type { SqlControlPlaneRows } from "#persistence"; |
| 14 | 5 | import type { |
| 15 | 6 | AccountId, |
| @@ -17,29 +8,30 @@ import type { · 1 unmodified line | ||
| 17 | 8 | StoredSourceRecord, |
| 18 | 9 | StoredSourceRecipeDocumentRecord, |
| 19 | 10 | StoredSourceRecipeOperationRecord, |
| 11 | + StoredSourceRecipeSchemaBundleRecord, | |
| 20 | 12 | StoredSourceRecipeRevisionRecord, |
| 21 | 13 | WorkspaceId, |
| 22 | 14 | } from "#schema"; |
| 23 | 15 | import * as Effect from "effect/Effect"; |
| 24 | 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 | 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 | 29 | export type LoadedSourceRecipe = { |
| 39 | 30 | source: Source; |
| 40 | 31 | sourceRecord: StoredSourceRecord; |
| 41 | 32 | revision: StoredSourceRecipeRevisionRecord; |
| 42 | 33 | documents: readonly StoredSourceRecipeDocumentRecord[]; |
| 34 | + schemaBundles: readonly StoredSourceRecipeSchemaBundleRecord[]; | |
| 43 | 35 | operations: readonly StoredSourceRecipeOperationRecord[]; |
| 44 | 36 | manifest: RecipeManifest; |
| 45 | 37 | }; |
| @@ -52,58 +44,31 @@ export type LoadedSourceRecipeTool = { · 6 unmodified lines | ||
| 52 | 44 | sourceRecord: StoredSourceRecord; |
| 53 | 45 | revision: StoredSourceRecipeRevisionRecord; |
| 54 | 46 | operation: StoredSourceRecipeOperationRecord; |
| 47 | + metadata: SourceAdapterPersistedOperationMetadata; | |
| 48 | + schemaBundleId: string | null; | |
| 55 | 49 | manifest: RecipeManifest; |
| 56 | 50 | descriptor: ToolDescriptor; |
| 57 | 51 | }; |
| 58 | 52 | |
| 59 | −const parseJson = <T>(input: { | |
| 60 | − label: string; | |
| 61 | − value: string | null; | |
| 62 | −}): Effect.Effect<T | null, Error, never> => | |
| 63 | − input.value === null | |
| 64 | − ? Effect.succeed<T | null>(null) | |
| 65 | − : Effect.try({ | |
| 66 | − try: () => { | |
| 67 | − const value = input.value!; | |
| 68 | − return JSON.parse(value) as T; | |
| 69 | − }, | |
| 70 | − catch: (cause) => | |
| 71 | − cause instanceof Error | |
| 72 | − ? new Error(`Invalid ${input.label}: ${cause.message}`) | |
| 73 | − : new Error(`Invalid ${input.label}: ${String(cause)}`), | |
| 74 | − }); | |
| 53 | +export type LoadedSourceRecipeToolIndexEntry = { | |
| 54 | + path: string; | |
| 55 | + searchNamespace: string; | |
| 56 | + searchText: string; | |
| 57 | + source: Source; | |
| 58 | + sourceRecord: StoredSourceRecord; | |
| 59 | + operation: StoredSourceRecipeOperationRecord; | |
| 60 | + metadata: SourceAdapterPersistedOperationMetadata; | |
| 61 | + schemaBundleId: string | null; | |
| 62 | + descriptor: ToolDescriptor; | |
| 63 | +}; | |
| 75 | 64 | |
| 76 | 65 | const parseManifestForRecipe = (input: { |
| 77 | 66 | source: Source; |
| 78 | 67 | revision: StoredSourceRecipeRevisionRecord; |
| 79 | 68 | }): Effect.Effect<RecipeManifest, Error, never> => |
| 80 | − Effect.gen(function* () { | |
| 81 | − if (input.revision.manifestJson === null) { | |
| 82 | − return null; | |
| 83 | − } | |
| 84 | − | |
| 85 | − if (input.source.kind === "openapi") { | |
| 86 | − return yield* parseJson<OpenApiToolManifest>({ | |
| 87 | − label: `OpenAPI manifest for ${input.source.id}`, | |
| 88 | − value: input.revision.manifestJson, | |
| 89 | − }); | |
| 90 | − } | |
| 91 | − | |
| 92 | − if (input.source.kind === "graphql") { | |
| 93 | − return yield* parseJson<GraphqlToolManifest>({ | |
| 94 | − label: `GraphQL manifest for ${input.source.id}`, | |
| 95 | − value: input.revision.manifestJson, | |
| 96 | − }); | |
| 97 | − } | |
| 98 | − | |
| 99 | − if (input.source.kind === "mcp") { | |
| 100 | − return yield* parseJson<McpToolManifest>({ | |
| 101 | − label: `MCP manifest for ${input.source.id}`, | |
| 102 | − value: input.revision.manifestJson, | |
| 103 | − }); | |
| 104 | − } | |
| 105 | − | |
| 106 | − return null; | |
| 69 | + getSourceAdapterForSource(input.source).parseManifest({ | |
| 70 | + source: input.source, | |
| 71 | + manifestJson: input.revision.manifestJson, | |
| 107 | 72 | }); |
| 108 | 73 | |
| 109 | 74 | const catalogNamespaceFromPath = (path: string): string => { |
| @@ -124,74 +89,34 @@ export const recipeToolSearchNamespace = (input: { · 14 unmodified lines | ||
| 124 | 89 | path: string; |
| 125 | 90 | operation: StoredSourceRecipeOperationRecord; |
| 126 | 91 | }): string => |
| 127 | − input.operation.providerKind === "graphql" | |
| 128 | − ? (input.source.namespace ?? namespaceFromSourceName(input.source.name)) | |
| 129 | − : catalogNamespaceFromPath(input.path); | |
| 92 | + getSourceAdapterForOperation(input.operation).searchNamespace?.({ | |
| 93 | + source: input.source, | |
| 94 | + path: input.path, | |
| 95 | + operation: input.operation, | |
| 96 | + }) | |
| 97 | + ?? catalogNamespaceFromPath(input.path); | |
| 130 | 98 | |
| 131 | 99 | export const recipeToolDescriptor = (input: { |
| 132 | 100 | source: Source; |
| 133 | 101 | operation: StoredSourceRecipeOperationRecord; |
| 134 | 102 | path: string; |
| 103 | + schemaBundleId?: string | null; | |
| 135 | 104 | includeSchemas: boolean; |
| 136 | −}): ToolDescriptor => { | |
| 137 | − const interaction = | |
| 138 | − input.operation.providerKind === "openapi" | |
| 139 | − ? ( | |
| 140 | − input.operation.openApiMethod?.toUpperCase() === "GET" | |
| 141 | − || input.operation.openApiMethod?.toUpperCase() === "HEAD" | |
| 142 | − ? "auto" | |
| 143 | − : "required" | |
| 144 | − ) | |
| 145 | − : input.operation.providerKind === "graphql" | |
| 146 | − ? input.operation.graphqlOperationType === "query" | |
| 147 | − ? "auto" | |
| 148 | − : "required" | |
| 149 | − : "auto"; | |
| 150 | − | |
| 151 | − return { | |
| 152 | − path: asToolPath(input.path), | |
| 153 | − sourceKey: input.source.id, | |
| 154 | − description: input.operation.description ?? input.operation.title ?? undefined, | |
| 155 | − interaction, | |
| 156 | − inputType: typeSignatureFromSchemaJson( | |
| 157 | − input.operation.inputSchemaJson ?? undefined, | |
| 158 | − "unknown", | |
| 159 | − 320, | |
| 160 | − ), | |
| 161 | − outputType: | |
| 162 | − input.operation.providerKind === "openapi" | |
| 163 | − ? openApiOutputTypeSignatureFromSchemaJson( | |
| 164 | − input.operation.outputSchemaJson ?? undefined, | |
| 165 | − 320, | |
| 166 | − ) | |
| 167 | − : typeSignatureFromSchemaJson( | |
| 168 | − input.operation.outputSchemaJson ?? undefined, | |
| 169 | − "unknown", | |
| 170 | − 320, | |
| 171 | − ), | |
| 172 | − inputSchemaJson: input.includeSchemas ? input.operation.inputSchemaJson ?? undefined : undefined, | |
| 173 | − outputSchemaJson: input.includeSchemas ? input.operation.outputSchemaJson ?? undefined : undefined, | |
| 174 | − ...(input.operation.providerKind | |
| 175 | − ? { providerKind: input.operation.providerKind } | |
| 176 | − : {}), | |
| 177 | − ...(input.operation.providerDataJson | |
| 178 | − ? { providerDataJson: input.operation.providerDataJson } | |
| 179 | − : {}), | |
| 180 | − }; | |
| 181 | −}; | |
| 105 | +}): ToolDescriptor => | |
| 106 | + getSourceAdapterForOperation(input.operation).createToolDescriptor(input); | |
| 107 | + | |
| 108 | +export const recipeToolMetadata = (input: { | |
| 109 | + source: Source; | |
| 110 | + operation: StoredSourceRecipeOperationRecord; | |
| 111 | + path: string; | |
| 112 | +}): Effect.Effect<SourceAdapterPersistedOperationMetadata, Error, never> => | |
| 113 | + getSourceAdapterForOperation(input.operation).describePersistedOperation(input); | |
| 182 | 114 | |
| 183 | 115 | const sourceRecipeDocumentForSource = (input: { |
| 184 | 116 | source: Source; |
| 185 | 117 | documents: readonly StoredSourceRecipeDocumentRecord[]; |
| 186 | 118 | }): StoredSourceRecipeDocumentRecord | null => { |
| 187 | − const preferredKind = | |
| 188 | − input.source.kind === "openapi" | |
| 189 | − ? "openapi" | |
| 190 | − : input.source.kind === "graphql" | |
| 191 | − ? "graphql_introspection" | |
| 192 | − : input.source.kind === "mcp" | |
| 193 | − ? "mcp_manifest" | |
| 194 | − : null; | |
| 119 | + const preferredKind = getSourceAdapterForSource(input.source).primaryDocumentKind; | |
| 195 | 120 | |
| 196 | 121 | if (preferredKind === null) { |
| 197 | 122 | return null; |
| @@ -206,6 +131,25 @@ export const recipePrimaryDocumentText = (input: { · 8 unmodified lines | ||
| 206 | 131 | }): string | null => |
| 207 | 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 | 153 | export const loadWorkspaceSourceRecipes = (input: { |
| 210 | 154 | rows: SqlControlPlaneRows; |
| 211 | 155 | workspaceId: WorkspaceId; |
| @@ -224,12 +168,19 @@ export const loadWorkspaceSourceRecipes = (input: { · 12 unmodified lines | ||
| 224 | 168 | const revisions = yield* input.rows.sourceRecipeRevisions.listByIds(revisionIds); |
| 225 | 169 | const revisionById = new Map(revisions.map((revision) => [revision.id, revision])); |
| 226 | 170 | const documents = yield* input.rows.sourceRecipeDocuments.listByRevisionIds(revisionIds); |
| 171 | + const schemaBundles = yield* input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds); | |
| 227 | 172 | const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>(); |
| 228 | 173 | for (const document of documents) { |
| 229 | 174 | const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? []; |
| 230 | 175 | existing.push(document); |
| 231 | 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 | 184 | const operations = yield* input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds); |
| 234 | 185 | const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>(); |
| 235 | 186 | for (const operation of operations) { |
| @@ -264,6 +215,7 @@ export const loadWorkspaceSourceRecipes = (input: { · 28 unmodified lines | ||
| 264 | 215 | sourceRecord, |
| 265 | 216 | revision, |
| 266 | 217 | documents: documentsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], |
| 218 | + schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 267 | 219 | operations: operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], |
| 268 | 220 | manifest, |
| 269 | 221 | } satisfies LoadedSourceRecipe; |
| @@ -299,8 +251,9 @@ export const loadSourceRecipe = (input: { · 29 unmodified lines | ||
| 299 | 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 | 255 | input.rows.sourceRecipeDocuments.listByRevisionId(sourceRecord.value.recipeRevisionId), |
| 256 | + input.rows.sourceRecipeSchemaBundles.listByRevisionId(sourceRecord.value.recipeRevisionId), | |
| 304 | 257 | input.rows.sourceRecipeOperations.listByRevisionId(sourceRecord.value.recipeRevisionId), |
| 305 | 258 | parseManifestForRecipe({ |
| 306 | 259 | source, |
| @@ -313,6 +266,7 @@ export const loadSourceRecipe = (input: { · 6 unmodified lines | ||
| 313 | 266 | sourceRecord: sourceRecord.value, |
| 314 | 267 | revision: revision.value, |
| 315 | 268 | documents, |
| 269 | + schemaBundles, | |
| 316 | 270 | operations, |
| 317 | 271 | manifest, |
| 318 | 272 | } satisfies LoadedSourceRecipe; |
| @@ -321,18 +275,245 @@ export const loadSourceRecipe = (input: { · 2 unmodified lines | ||
| 321 | 275 | export const expandRecipeTools = (input: { |
| 322 | 276 | recipes: readonly LoadedSourceRecipe[]; |
| 323 | 277 | includeSchemas: boolean; |
| 324 | −}): readonly LoadedSourceRecipeTool[] => | |
| 325 | − input.recipes.flatMap((recipe) => | |
| 326 | − recipe.operations.map((operation) => { | |
| 278 | +}): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> => | |
| 279 | + Effect.map( | |
| 280 | + Effect.forEach(input.recipes, (recipe) => | |
| 281 | + Effect.forEach(recipe.operations, (operation) => | |
| 282 | + Effect.gen(function* () { | |
| 283 | + const path = recipeToolPath({ | |
| 284 | + source: recipe.source, | |
| 285 | + operation, | |
| 286 | + }); | |
| 287 | + const searchNamespace = recipeToolSearchNamespace({ | |
| 288 | + source: recipe.source, | |
| 289 | + path, | |
| 290 | + operation, | |
| 291 | + }); | |
| 292 | + const schemaBundleId = primarySchemaBundleForRevision({ | |
| 293 | + source: recipe.source, | |
| 294 | + schemaBundles: recipe.schemaBundles, | |
| 295 | + })?.id ?? null; | |
| 296 | + const metadata = yield* recipeToolMetadata({ | |
| 297 | + source: recipe.source, | |
| 298 | + operation, | |
| 299 | + path, | |
| 300 | + }); | |
| 301 | + | |
| 302 | + return { | |
| 303 | + path, | |
| 304 | + searchNamespace, | |
| 305 | + searchText: [ | |
| 306 | + path, | |
| 307 | + searchNamespace, | |
| 308 | + recipe.source.name, | |
| 309 | + metadata.searchText, | |
| 310 | + ] | |
| 311 | + .filter((part) => part.length > 0) | |
| 312 | + .join(" ") | |
| 313 | + .toLowerCase(), | |
| 314 | + source: recipe.source, | |
| 315 | + sourceRecord: recipe.sourceRecord, | |
| 316 | + revision: recipe.revision, | |
| 317 | + operation, | |
| 318 | + metadata, | |
| 319 | + schemaBundleId, | |
| 320 | + manifest: recipe.manifest, | |
| 321 | + descriptor: recipeToolDescriptor({ | |
| 322 | + source: recipe.source, | |
| 323 | + operation, | |
| 324 | + path, | |
| 325 | + schemaBundleId, | |
| 326 | + includeSchemas: input.includeSchemas, | |
| 327 | + }), | |
| 328 | + } satisfies LoadedSourceRecipeTool; | |
| 329 | + }) | |
| 330 | + ), | |
| 331 | + ), | |
| 332 | + (recipes) => recipes.flat(), | |
| 333 | + ); | |
| 334 | + | |
| 335 | +export const loadWorkspaceSourceRecipeToolIndex = (input: { | |
| 336 | + rows: SqlControlPlaneRows; | |
| 337 | + workspaceId: WorkspaceId; | |
| 338 | + actorAccountId?: AccountId | null; | |
| 339 | + includeSchemas: boolean; | |
| 340 | +}): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> => | |
| 341 | + Effect.gen(function* () { | |
| 342 | + const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | |
| 343 | + const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | |
| 344 | + actorAccountId: input.actorAccountId, | |
| 345 | + }); | |
| 346 | + const sourceById = new Map(sources.map((source) => [source.id, source])); | |
| 347 | + const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id)); | |
| 348 | + const revisionIds = [...new Set(relevantSourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))]; | |
| 349 | + const [operations, schemaBundles] = yield* Effect.all([ | |
| 350 | + input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds), | |
| 351 | + input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds), | |
| 352 | + ]); | |
| 353 | + | |
| 354 | + const operationsByRevisionId = new Map<string, StoredSourceRecipeOperationRecord[]>(); | |
| 355 | + for (const operation of operations) { | |
| 356 | + const existing = operationsByRevisionId.get(operation.recipeRevisionId) ?? []; | |
| 357 | + existing.push(operation); | |
| 358 | + operationsByRevisionId.set(operation.recipeRevisionId, existing); | |
| 359 | + } | |
| 360 | + | |
| 361 | + const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | |
| 362 | + for (const schemaBundle of schemaBundles) { | |
| 363 | + const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | |
| 364 | + existing.push(schemaBundle); | |
| 365 | + schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | |
| 366 | + } | |
| 367 | + | |
| 368 | + const entryGroups = yield* Effect.forEach( | |
| 369 | + relevantSourceRecords, | |
| 370 | + (sourceRecord) => { | |
| 371 | + const source = sourceById.get(sourceRecord.id); | |
| 372 | + if (!source) { | |
| 373 | + return Effect.succeed<readonly LoadedSourceRecipeToolIndexEntry[]>([]); | |
| 374 | + } | |
| 375 | + | |
| 376 | + return Effect.forEach( | |
| 377 | + operationsByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 378 | + (operation) => | |
| 379 | + Effect.gen(function* () { | |
| 380 | + const path = recipeToolPath({ | |
| 381 | + source, | |
| 382 | + operation, | |
| 383 | + }); | |
| 384 | + const searchNamespace = recipeToolSearchNamespace({ | |
| 385 | + source, | |
| 386 | + path, | |
| 387 | + operation, | |
| 388 | + }); | |
| 389 | + const schemaBundleId = primarySchemaBundleForRevision({ | |
| 390 | + source, | |
| 391 | + schemaBundles: schemaBundlesByRevisionId.get(sourceRecord.recipeRevisionId) ?? [], | |
| 392 | + })?.id ?? null; | |
| 393 | + const metadata = yield* recipeToolMetadata({ | |
| 394 | + source, | |
| 395 | + operation, | |
| 396 | + path, | |
| 397 | + }); | |
| 398 | + | |
| 399 | + return { | |
| 400 | + path, | |
| 401 | + searchNamespace, | |
| 402 | + searchText: [ | |
| 403 | + path, | |
| 404 | + searchNamespace, | |
| 405 | + source.name, | |
| 406 | + metadata.searchText, | |
| 407 | + ] | |
| 408 | + .filter((part) => part.length > 0) | |
| 409 | + .join(" ") | |
| 410 | + .toLowerCase(), | |
| 411 | + source, | |
| 412 | + sourceRecord, | |
| 413 | + operation, | |
| 414 | + metadata, | |
| 415 | + schemaBundleId, | |
| 416 | + descriptor: recipeToolDescriptor({ | |
| 417 | + source, | |
| 418 | + operation, | |
| 419 | + path, | |
| 420 | + schemaBundleId, | |
| 421 | + includeSchemas: input.includeSchemas, | |
| 422 | + }), | |
| 423 | + } satisfies LoadedSourceRecipeToolIndexEntry; | |
| 424 | + }), | |
| 425 | + ); | |
| 426 | + }, | |
| 427 | + ); | |
| 428 | + | |
| 429 | + return entryGroups.flat(); | |
| 430 | + }); | |
| 431 | + | |
| 432 | +export const loadWorkspaceSourceRecipeToolByPath = (input: { | |
| 433 | + rows: SqlControlPlaneRows; | |
| 434 | + workspaceId: WorkspaceId; | |
| 435 | + path: string; | |
| 436 | + actorAccountId?: AccountId | null; | |
| 437 | + includeSchemas: boolean; | |
| 438 | +}): Effect.Effect<LoadedSourceRecipeToolIndexEntry | null, Error, never> => | |
| 439 | + Effect.gen(function* () { | |
| 440 | + const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId); | |
| 441 | + const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId, { | |
| 442 | + actorAccountId: input.actorAccountId, | |
| 443 | + }); | |
| 444 | + const sourceById = new Map(sources.map((source) => [source.id, source])); | |
| 445 | + const relevantSourceRecords = sourceRecords.filter((sourceRecord) => sourceById.has(sourceRecord.id)); | |
| 446 | + | |
| 447 | + const candidates = relevantSourceRecords.flatMap((sourceRecord) => { | |
| 448 | + const source = sourceById.get(sourceRecord.id); | |
| 449 | + if (!source) { | |
| 450 | + return []; | |
| 451 | + } | |
| 452 | + | |
| 453 | + const namespace = source.namespace ?? namespaceFromSourceName(source.name); | |
| 454 | + if (namespace.length > 0) { | |
| 455 | + if (!input.path.startsWith(`${namespace}.`)) { | |
| 456 | + return []; | |
| 457 | + } | |
| 458 | + | |
| 459 | + return [{ | |
| 460 | + source, | |
| 461 | + sourceRecord, | |
| 462 | + toolId: input.path.slice(namespace.length + 1), | |
| 463 | + }]; | |
| 464 | + } | |
| 465 | + | |
| 466 | + return [{ | |
| 467 | + source, | |
| 468 | + sourceRecord, | |
| 469 | + toolId: input.path, | |
| 470 | + }]; | |
| 471 | + }); | |
| 472 | + | |
| 473 | + if (candidates.length === 0) { | |
| 474 | + return null; | |
| 475 | + } | |
| 476 | + | |
| 477 | + const revisionIds = [...new Set(candidates.map((candidate) => candidate.sourceRecord.recipeRevisionId))]; | |
| 478 | + const [operations, schemaBundles] = yield* Effect.all([ | |
| 479 | + input.rows.sourceRecipeOperations.listByRevisionIds(revisionIds), | |
| 480 | + input.rows.sourceRecipeSchemaBundles.listByRevisionIds(revisionIds), | |
| 481 | + ]); | |
| 482 | + const schemaBundlesByRevisionId = new Map<string, StoredSourceRecipeSchemaBundleRecord[]>(); | |
| 483 | + for (const schemaBundle of schemaBundles) { | |
| 484 | + const existing = schemaBundlesByRevisionId.get(schemaBundle.recipeRevisionId) ?? []; | |
| 485 | + existing.push(schemaBundle); | |
| 486 | + schemaBundlesByRevisionId.set(schemaBundle.recipeRevisionId, existing); | |
| 487 | + } | |
| 488 | + | |
| 489 | + for (const candidate of candidates) { | |
| 490 | + const operation = operations.find((entry) => | |
| 491 | + entry.recipeRevisionId === candidate.sourceRecord.recipeRevisionId | |
| 492 | + && entry.toolId === candidate.toolId | |
| 493 | + ); | |
| 494 | + if (!operation) { | |
| 495 | + continue; | |
| 496 | + } | |
| 497 | + | |
| 327 | 498 | const path = recipeToolPath({ |
| 328 | − source: recipe.source, | |
| 499 | + source: candidate.source, | |
| 329 | 500 | operation, |
| 330 | 501 | }); |
| 331 | 502 | const searchNamespace = recipeToolSearchNamespace({ |
| 332 | − source: recipe.source, | |
| 503 | + source: candidate.source, | |
| 333 | 504 | path, |
| 334 | 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 | 518 | return { |
| 338 | 519 | path, |
| @@ -340,23 +521,26 @@ export const expandRecipeTools = (input: { · 1 unmodified line | ||
| 340 | 521 | searchText: [ |
| 341 | 522 | path, |
| 342 | 523 | searchNamespace, |
| 343 | − recipe.source.name, | |
| 344 | − operation.searchText, | |
| 524 | + candidate.source.name, | |
| 525 | + metadata.searchText, | |
| 345 | 526 | ] |
| 346 | 527 | .filter((part) => part.length > 0) |
| 347 | 528 | .join(" ") |
| 348 | 529 | .toLowerCase(), |
| 349 | − source: recipe.source, | |
| 350 | − sourceRecord: recipe.sourceRecord, | |
| 351 | − revision: recipe.revision, | |
| 530 | + source: candidate.source, | |
| 531 | + sourceRecord: candidate.sourceRecord, | |
| 352 | 532 | operation, |
| 353 | − manifest: recipe.manifest, | |
| 533 | + metadata, | |
| 534 | + schemaBundleId, | |
| 354 | 535 | descriptor: recipeToolDescriptor({ |
| 355 | − source: recipe.source, | |
| 536 | + source: candidate.source, | |
| 356 | 537 | operation, |
| 357 | 538 | path, |
| 539 | + schemaBundleId, | |
| 358 | 540 | includeSchemas: input.includeSchemas, |
| 359 | 541 | }), |
| 360 | − } satisfies LoadedSourceRecipeTool; | |
| 361 | − }) | |
| 362 | − ); | |
| 542 | + } satisfies LoadedSourceRecipeToolIndexEntry; | |
| 543 | + } | |
| 544 | + | |
| 545 | + return null; | |
| 546 | + }); | |
| @@ -62,6 +62,8 @@ const makeOpenApiSource = (input: { · 61 unmodified lines | ||
| 62 | 62 | headers: null, |
| 63 | 63 | specUrl: input.specUrl ?? "https://example.com/openapi.json", |
| 64 | 64 | defaultHeaders: null, |
| 65 | + importAuthPolicy: "reuse_runtime", | |
| 66 | + importAuth: { kind: "none" }, | |
| 65 | 67 | auth: input.auth, |
| 66 | 68 | sourceHash: null, |
| 67 | 69 | lastError: null, |
| @@ -142,6 +144,7 @@ describe("source-store", () => { · 74 unmodified lines | ||
| 142 | 144 | executionId: null, |
| 143 | 145 | interactionId: null, |
| 144 | 146 | providerKind: "mcp_oauth", |
| 147 | + credentialSlot: "runtime", | |
| 145 | 148 | status: "pending", |
| 146 | 149 | state: "state_source_store", |
| 147 | 150 | sessionDataJson: encodeSessionDataJson({ |
| @@ -341,17 +344,24 @@ describe("source-store", () => { · 193 unmodified lines | ||
| 341 | 344 | inputSchemaJson: null, |
| 342 | 345 | outputSchemaJson: null, |
| 343 | 346 | providerKind: "openapi", |
| 344 | − providerDataJson: null, | |
| 345 | − mcpToolName: null, | |
| 346 | − openApiMethod: "get", | |
| 347 | − openApiPathTemplate: "/repos/{owner}/{repo}", | |
| 348 | − openApiOperationHash: "hash_recipe_rewrite", | |
| 349 | − openApiRawToolId: "repos_getRepo", | |
| 350 | − openApiOperationId: "repos.getRepo", | |
| 351 | − openApiTagsJson: JSON.stringify(["repos"]), | |
| 352 | − openApiRequestBodyRequired: null, | |
| 353 | − graphqlOperationType: null, | |
| 354 | − graphqlOperationName: null, | |
| 347 | + providerDataJson: JSON.stringify({ | |
| 348 | + kind: "openapi", | |
| 349 | + toolId: "getRepo", | |
| 350 | + rawToolId: "repos_getRepo", | |
| 351 | + operationId: "repos.getRepo", | |
| 352 | + group: "repos", | |
| 353 | + leaf: "getRepo", | |
| 354 | + tags: ["repos"], | |
| 355 | + method: "get", | |
| 356 | + path: "/repos/{owner}/{repo}", | |
| 357 | + operationHash: "hash_recipe_rewrite", | |
| 358 | + invocation: { | |
| 359 | + method: "get", | |
| 360 | + pathTemplate: "/repos/{owner}/{repo}", | |
| 361 | + parameters: [], | |
| 362 | + requestBody: null, | |
| 363 | + }, | |
| 364 | + }), | |
| 355 | 365 | createdAt: now, |
| 356 | 366 | updatedAt: now, |
| 357 | 367 | }], |
| @@ -465,17 +475,24 @@ describe("source-store", () => { · 107 unmodified lines | ||
| 465 | 475 | inputSchemaJson: null, |
| 466 | 476 | outputSchemaJson: null, |
| 467 | 477 | providerKind: "openapi", |
| 468 | − providerDataJson: null, | |
| 469 | − mcpToolName: null, | |
| 470 | − openApiMethod: "get", | |
| 471 | − openApiPathTemplate: "/repos/{owner}/{repo}", | |
| 472 | − openApiOperationHash: "hash_shared_recipe", | |
| 473 | − openApiRawToolId: "repos_getRepo", | |
| 474 | − openApiOperationId: "repos.getRepo", | |
| 475 | − openApiTagsJson: JSON.stringify(["repos"]), | |
| 476 | − openApiRequestBodyRequired: null, | |
| 477 | − graphqlOperationType: null, | |
| 478 | − graphqlOperationName: null, | |
| 478 | + providerDataJson: JSON.stringify({ | |
| 479 | + kind: "openapi", | |
| 480 | + toolId: "getRepo", | |
| 481 | + rawToolId: "repos_getRepo", | |
| 482 | + operationId: "repos.getRepo", | |
| 483 | + group: "repos", | |
| 484 | + leaf: "getRepo", | |
| 485 | + tags: ["repos"], | |
| 486 | + method: "get", | |
| 487 | + path: "/repos/{owner}/{repo}", | |
| 488 | + operationHash: "hash_shared_recipe", | |
| 489 | + invocation: { | |
| 490 | + method: "get", | |
| 491 | + pathTemplate: "/repos/{owner}/{repo}", | |
| 492 | + parameters: [], | |
| 493 | + requestBody: null, | |
| 494 | + }, | |
| 495 | + }), | |
| 479 | 496 | createdAt: now, |
| 480 | 497 | updatedAt: now, |
| 481 | 498 | }], |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | AccountId, |
| 3 | 3 | Credential, |
| 4 | + CredentialSlot, | |
| 4 | 5 | SecretRef, |
| 5 | 6 | Source, |
| 6 | 7 | SourceRecipeId, |
| @@ -64,23 +65,29 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 57 unmodified lines | ||
| 64 | 65 | const selectPreferredCredential = (input: { |
| 65 | 66 | credentials: ReadonlyArray<Credential>; |
| 66 | 67 | actorAccountId?: AccountId | null; |
| 68 | + slot: CredentialSlot; | |
| 67 | 69 | }): Credential | null => { |
| 70 | + const matchingSlot = input.credentials.filter((credential) => credential.slot === input.slot); | |
| 71 | + | |
| 68 | 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 | 74 | if (exact) { |
| 71 | 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 | 82 | const selectExactCredential = (input: { |
| 79 | 83 | credentials: ReadonlyArray<Credential>; |
| 80 | 84 | actorAccountId?: AccountId | null; |
| 85 | + slot: CredentialSlot; | |
| 81 | 86 | }): Credential | null => |
| 82 | 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 | 91 | ) ?? null; |
| 85 | 92 | |
| 86 | 93 | export const loadSourcesInWorkspace = ( |
| @@ -98,8 +105,16 @@ export const loadSourcesInWorkspace = ( · 11 unmodified lines | ||
| 98 | 105 | const preferred = selectPreferredCredential({ |
| 99 | 106 | credentials: matches, |
| 100 | 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 | 120 | return yield* projectSourcesFromStorage({ |
| @@ -132,11 +147,18 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 26 unmodified lines | ||
| 132 | 147 | const credential = selectPreferredCredential({ |
| 133 | 148 | credentials, |
| 134 | 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 | 158 | return yield* projectSourceFromStorage({ |
| 138 | 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 | 201 | if (revisionReferenceCount === 0) { |
| 180 | 202 | yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId); |
| 203 | + yield* rows.sourceRecipeSchemaBundles.removeByRevisionId(input.recipeRevisionId); | |
| 181 | 204 | yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId); |
| 182 | 205 | } |
| 183 | 206 | |
| @@ -192,6 +215,7 @@ const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { · 8 unmodified lines | ||
| 192 | 215 | (recipeRevision) => |
| 193 | 216 | Effect.all([ |
| 194 | 217 | rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id), |
| 218 | + rows.sourceRecipeSchemaBundles.removeByRevisionId(recipeRevision.id), | |
| 195 | 219 | rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id), |
| 196 | 220 | ]), |
| 197 | 221 | { discard: true }, |
| @@ -248,10 +272,18 @@ export const persistSource = ( · 50 unmodified lines | ||
| 248 | 272 | const existingCredential = selectExactCredential({ |
| 249 | 273 | credentials: existingCredentials, |
| 250 | 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 | 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 | 287 | const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId); |
| 256 | 288 | const nextRevision = createSourceRecipeRevisionRecord({ |
| 257 | 289 | source, |
| @@ -266,6 +298,9 @@ export const persistSource = ( · 8 unmodified lines | ||
| 266 | 298 | manifestHash: Option.isSome(existingTargetRevision) |
| 267 | 299 | ? existingTargetRevision.value.manifestHash |
| 268 | 300 | : null, |
| 301 | + materializationHash: Option.isSome(existingTargetRevision) | |
| 302 | + ? existingTargetRevision.value.materializationHash | |
| 303 | + : null, | |
| 269 | 304 | }); |
| 270 | 305 | |
| 271 | 306 | const nextRecipe = createSourceRecipeRecord({ |
| @@ -274,12 +309,13 @@ export const persistSource = ( · 2 unmodified lines | ||
| 274 | 309 | latestRevisionId: nextRevision.id, |
| 275 | 310 | }); |
| 276 | 311 | |
| 277 | − const { sourceRecord, credential } = splitSourceForStorage({ | |
| 312 | + const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({ | |
| 278 | 313 | source, |
| 279 | 314 | recipeId: nextRecipe.id, |
| 280 | 315 | recipeRevisionId: nextRevision.id, |
| 281 | 316 | actorAccountId: options.actorAccountId, |
| 282 | − existingCredentialId: existingCredential?.id ?? null, | |
| 317 | + existingRuntimeCredentialId: existingCredential?.id ?? null, | |
| 318 | + existingImportCredentialId: existingImportCredential?.id ?? null, | |
| 283 | 319 | }); |
| 284 | 320 | |
| 285 | 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 | 350 | yield* rows.credentials.removeByWorkspaceSourceAndActor({ |
| 315 | 351 | workspaceId: source.workspaceId, |
| 316 | 352 | sourceId: source.id, |
| 317 | 353 | actorAccountId: options.actorAccountId ?? null, |
| 354 | + slot: "runtime", | |
| 318 | 355 | }); |
| 319 | 356 | } else { |
| 320 | − yield* rows.credentials.upsert(credential); | |
| 357 | + yield* rows.credentials.upsert(runtimeCredential); | |
| 321 | 358 | } |
| 322 | 359 | |
| 323 | 360 | yield* cleanupCredentialSecretRefs(rows, { |
| 324 | 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 | 381 | return source; |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | createSourceFromPayload, |
| 19 | 19 | updateSourceFromPayload, |
| 20 | 20 | } from "./source-definitions"; |
| 21 | +import { getSourceAdapterForSource } from "./source-adapters"; | |
| 21 | 22 | import { |
| 22 | 23 | mapPersistenceError, |
| 23 | 24 | } from "./operations-shared"; |
| @@ -26,7 +27,7 @@ import { · 2 unmodified lines | ||
| 26 | 27 | } from "./operation-errors"; |
| 27 | 28 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; |
| 28 | 29 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; |
| 29 | −import { syncSourceToolArtifacts } from "./tool-artifacts"; | |
| 30 | +import { syncSourceMaterialization } from "./source-materialization"; | |
| 30 | 31 | import { |
| 31 | 32 | loadSourceById, |
| 32 | 33 | loadSourcesInWorkspace, |
| @@ -43,12 +44,7 @@ const sourceOps = { · 10 unmodified lines | ||
| 43 | 44 | } as const; |
| 44 | 45 | |
| 45 | 46 | const shouldAutoProbeSource = (source: Source): boolean => |
| 46 | − source.enabled | |
| 47 | − && ( | |
| 48 | − (source.kind === "openapi" && !!source.specUrl) | |
| 49 | − || source.kind === "graphql" | |
| 50 | − ) | |
| 51 | − && (source.status === "draft" || source.status === "probing"); | |
| 47 | + getSourceAdapterForSource(source).shouldAutoProbe(source); | |
| 52 | 48 | |
| 53 | 49 | const syncArtifactsForSource = (input: { |
| 54 | 50 | store: ControlPlaneStoreShape; |
| @@ -73,7 +69,7 @@ const syncArtifactsForSource = (input: { · 18 unmodified lines | ||
| 73 | 69 | : input.source; |
| 74 | 70 | |
| 75 | 71 | const synced = yield* Effect.either( |
| 76 | − syncSourceToolArtifacts({ | |
| 72 | + syncSourceMaterialization({ | |
| 77 | 73 | rows: input.store, |
| 78 | 74 | source: sourceForSync, |
| 79 | 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 | 36 | ExecutionIdSchema, |
| 37 | 37 | SecretMaterialIdSchema, |
| 38 | 38 | SourceIdSchema, |
| 39 | + SourceRecipeSchemaBundleIdSchema, | |
| 39 | 40 | WorkspaceIdSchema, |
| 40 | 41 | type Source, |
| 41 | 42 | } from "#schema"; |
| @@ -60,7 +61,7 @@ import { · 18 unmodified lines | ||
| 60 | 61 | } from "./source-auth-service"; |
| 61 | 62 | import { createLiveExecutionManager } from "./live-execution"; |
| 62 | 63 | import { persistSource } from "./source-store"; |
| 63 | −import { persistMcpToolArtifactsFromManifest } from "./tool-artifacts"; | |
| 64 | +import { persistMcpRecipeMaterializationFromManifest } from "./source-materialization"; | |
| 64 | 65 | import { createWorkspaceExecutionEnvironmentResolver } from "./workspace-execution-environment"; |
| 65 | 66 | |
| 66 | 67 | type CountedMcpServer = { |
| @@ -85,7 +86,9 @@ type GraphqlServer = { · 18 unmodified lines | ||
| 85 | 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 | 92 | parts |
| 90 | 93 | .flatMap((part) => (part ? [part.trim()] : [])) |
| 91 | 94 | .filter(Boolean) |
| @@ -263,7 +266,10 @@ const GraphqlMutationType = new GraphQLObjectType({ · 171 unmodified lines | ||
| 263 | 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 | 273 | id: "iss_created", |
| 268 | 274 | identifier: "ENG-999", |
| 269 | 275 | title: args.input.title, |
| @@ -285,17 +291,21 @@ const testGraphqlSchema = new GraphQLSchema({ · 15 unmodified lines | ||
| 285 | 291 | const closeScope = (scope: Scope.CloseableScope) => |
| 286 | 292 | Scope.close(scope, Exit.void).pipe(Effect.orDie); |
| 287 | 293 | |
| 288 | −const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | |
| 289 | − Effect.acquireRelease( | |
| 294 | +const makePersistence: Effect.Effect< | |
| 295 | + SqlControlPlanePersistence, | |
| 296 | + unknown, | |
| 297 | + Scope.Scope | |
| 298 | +> = Effect.acquireRelease( | |
| 290 | 299 | createSqlControlPlanePersistence({ |
| 291 | 300 | localDataDir: ":memory:", |
| 292 | 301 | }), |
| 293 | 302 | (persistence) => |
| 294 | 303 | Effect.tryPromise({ |
| 295 | 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 | 307 | }).pipe(Effect.orDie), |
| 298 | − ); | |
| 308 | +); | |
| 299 | 309 | |
| 300 | 310 | const makeCountedMcpServer = Effect.acquireRelease( |
| 301 | 311 | Effect.promise<CountedMcpServer>( |
| @@ -425,37 +435,38 @@ const makeCountedMcpServer = Effect.acquireRelease( · 123 unmodified lines | ||
| 425 | 435 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 426 | 436 | const repoParam = HttpApiSchema.param("repo", Schema.String); |
| 427 | 437 | |
| 428 | −class GeneratedReposApi extends HttpApiGroup.make("repos") | |
| 429 | − .add( | |
| 430 | − HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}` | |
| 431 | − .addSuccess( | |
| 432 | − Schema.Struct({ | |
| 433 | − ok: Schema.Boolean, | |
| 434 | − full_name: Schema.String, | |
| 435 | − }), | |
| 436 | − ), | |
| 437 | − ) {} | |
| 438 | +class GeneratedReposApi extends HttpApiGroup.make("repos").add( | |
| 439 | + HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`.addSuccess( | |
| 440 | + Schema.Struct({ | |
| 441 | + ok: Schema.Boolean, | |
| 442 | + full_name: Schema.String, | |
| 443 | + }), | |
| 444 | + ), | |
| 445 | +) {} | |
| 438 | 446 | |
| 439 | 447 | class GeneratedApi extends HttpApi.make("generated").add(GeneratedReposApi) {} |
| 440 | 448 | |
| 441 | 449 | const makeOpenApiSpecServer = Effect.acquireRelease( |
| 442 | 450 | Effect.gen(function* () { |
| 443 | 451 | const seenAuthHeaders: OpenApiSpecServer["seenAuthHeaders"] = []; |
| 444 | − const handlersLayer = HttpApiBuilder.group(GeneratedApi, "repos", (handlers) => | |
| 445 | − handlers.handle("getRepo", ({ path, request }) => | |
| 446 | − Effect.sync(() => { | |
| 447 | − seenAuthHeaders.push( | |
| 448 | − typeof request.headers.authorization === "string" | |
| 449 | − ? request.headers.authorization | |
| 450 | − : null, | |
| 451 | − ); | |
| 452 | + const handlersLayer = HttpApiBuilder.group( | |
| 453 | + GeneratedApi, | |
| 454 | + "repos", | |
| 455 | + (handlers) => | |
| 456 | + handlers.handle("getRepo", ({ path, request }) => | |
| 457 | + Effect.sync(() => { | |
| 458 | + seenAuthHeaders.push( | |
| 459 | + typeof request.headers.authorization === "string" | |
| 460 | + ? request.headers.authorization | |
| 461 | + : null, | |
| 462 | + ); | |
| 452 | 463 | |
| 453 | − return { | |
| 454 | − ok: true, | |
| 455 | − full_name: `${path.owner}/${path.repo}`, | |
| 456 | − }; | |
| 457 | − }) | |
| 458 | − ) | |
| 464 | + return { | |
| 465 | + ok: true, | |
| 466 | + full_name: `${path.owner}/${path.repo}`, | |
| 467 | + }; | |
| 468 | + }), | |
| 469 | + ), | |
| 459 | 470 | ); |
| 460 | 471 | const apiLayer = HttpApiBuilder.api(GeneratedApi).pipe( |
| 461 | 472 | Layer.provide(handlersLayer), |
| @@ -468,9 +479,8 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 6 unmodified lines | ||
| 468 | 479 | const scope = yield* Scope.make(); |
| 469 | 480 | const context = yield* Layer.buildWithScope(serverLayer, scope).pipe( |
| 470 | 481 | Effect.catchAll((error) => |
| 471 | − closeScope(scope).pipe( | |
| 472 | − Effect.zipRight(Effect.fail(error)), | |
| 473 | − )), | |
| 482 | + closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))), | |
| 483 | + ), | |
| 474 | 484 | ); |
| 475 | 485 | const server = Context.get(context, HttpServer.HttpServer); |
| 476 | 486 | const baseUrl = HttpServer.formatAddress(server.address); |
| @@ -485,8 +495,9 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 8 unmodified lines | ||
| 485 | 495 | (server) => |
| 486 | 496 | Effect.tryPromise({ |
| 487 | 497 | try: () => server.close(), |
| 488 | − catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | |
| 489 | − }).pipe(Effect.orDie), | |
| 498 | + catch: (cause) => | |
| 499 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 500 | + }).pipe(Effect.orDie), | |
| 490 | 501 | ); |
| 491 | 502 | |
| 492 | 503 | const makeGraphqlServer = Effect.acquireRelease( |
| @@ -518,7 +529,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 25 unmodified lines | ||
| 518 | 529 | |
| 519 | 530 | const result = await graphql({ |
| 520 | 531 | schema: testGraphqlSchema, |
| 521 | − source: typeof parsedBody.query === "string" ? parsedBody.query : "", | |
| 532 | + source: | |
| 533 | + typeof parsedBody.query === "string" ? parsedBody.query : "", | |
| 522 | 534 | variableValues: |
| 523 | 535 | parsedBody.variables && typeof parsedBody.variables === "object" |
| 524 | 536 | ? (parsedBody.variables as Record<string, unknown>) |
| @@ -561,7 +573,8 @@ const makeGraphqlServer = Effect.acquireRelease( · 36 unmodified lines | ||
| 561 | 573 | (server) => |
| 562 | 574 | Effect.tryPromise({ |
| 563 | 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 | 578 | }).pipe(Effect.orDie), |
| 566 | 579 | ); |
| 567 | 580 | |
| @@ -587,6 +600,8 @@ const persistConnectedEchoTool = (input: { · 19 unmodified lines | ||
| 587 | 600 | headers: null, |
| 588 | 601 | specUrl: null, |
| 589 | 602 | defaultHeaders: null, |
| 603 | + importAuthPolicy: "reuse_runtime", | |
| 604 | + importAuth: { kind: "none" }, | |
| 590 | 605 | auth: { kind: "none" }, |
| 591 | 606 | sourceHash: null, |
| 592 | 607 | lastError: null, |
| @@ -595,24 +610,26 @@ const persistConnectedEchoTool = (input: { · 2 unmodified lines | ||
| 595 | 610 | }; |
| 596 | 611 | |
| 597 | 612 | yield* persistSource(input.persistence.rows, source); |
| 598 | − yield* persistMcpToolArtifactsFromManifest({ | |
| 613 | + yield* persistMcpRecipeMaterializationFromManifest({ | |
| 599 | 614 | rows: input.persistence.rows, |
| 600 | 615 | source, |
| 601 | − manifestEntries: [{ | |
| 602 | − toolId: "echo", | |
| 603 | − toolName: "echo", | |
| 604 | − description: "Echo the provided string", | |
| 605 | − inputSchemaJson: JSON.stringify({ | |
| 606 | − type: "object", | |
| 607 | − properties: { | |
| 608 | − value: { | |
| 609 | − type: "string", | |
| 616 | + manifestEntries: [ | |
| 617 | + { | |
| 618 | + toolId: "echo", | |
| 619 | + toolName: "echo", | |
| 620 | + description: "Echo the provided string", | |
| 621 | + inputSchemaJson: JSON.stringify({ | |
| 622 | + type: "object", | |
| 623 | + properties: { | |
| 624 | + value: { | |
| 625 | + type: "string", | |
| 626 | + }, | |
| 610 | 627 | }, |
| 611 | − }, | |
| 612 | − required: ["value"], | |
| 613 | − additionalProperties: false, | |
| 614 | − }), | |
| 615 | − }], | |
| 628 | + required: ["value"], | |
| 629 | + additionalProperties: false, | |
| 630 | + }), | |
| 631 | + }, | |
| 632 | + ], | |
| 616 | 633 | }); |
| 617 | 634 | }); |
| 618 | 635 | |
| @@ -635,31 +652,52 @@ const persistConnectedGithubOpenApiSource = (input: { · 16 unmodified lines | ||
| 635 | 652 | version: "1.0.0", |
| 636 | 653 | }, |
| 637 | 654 | paths: { |
| 638 | − "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}": { | |
| 639 | − get: { | |
| 640 | − operationId: "actions/get-environment-secret", | |
| 641 | − tags: ["actions"], | |
| 642 | − summary: "Get an environment secret", | |
| 643 | − description: | |
| 644 | − "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.", | |
| 645 | − parameters: [ | |
| 646 | − { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
| 647 | − { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
| 648 | − { name: "environment_name", in: "path", required: true, schema: { type: "string" } }, | |
| 649 | − { name: "secret_name", in: "path", required: true, schema: { type: "string" } }, | |
| 650 | − ], | |
| 651 | − responses: { | |
| 652 | − 200: { | |
| 653 | − description: "ok", | |
| 654 | − content: { | |
| 655 | − "application/json": { | |
| 656 | − schema: { type: "object" }, | |
| 655 | + "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}": | |
| 656 | + { | |
| 657 | + get: { | |
| 658 | + operationId: "actions/get-environment-secret", | |
| 659 | + tags: ["actions"], | |
| 660 | + summary: "Get an environment secret", | |
| 661 | + description: | |
| 662 | + "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.", | |
| 663 | + parameters: [ | |
| 664 | + { | |
| 665 | + name: "owner", | |
| 666 | + in: "path", | |
| 667 | + required: true, | |
| 668 | + schema: { type: "string" }, | |
| 669 | + }, | |
| 670 | + { | |
| 671 | + name: "repo", | |
| 672 | + in: "path", | |
| 673 | + required: true, | |
| 674 | + schema: { type: "string" }, | |
| 675 | + }, | |
| 676 | + { | |
| 677 | + name: "environment_name", | |
| 678 | + in: "path", | |
| 679 | + required: true, | |
| 680 | + schema: { type: "string" }, | |
| 681 | + }, | |
| 682 | + { | |
| 683 | + name: "secret_name", | |
| 684 | + in: "path", | |
| 685 | + required: true, | |
| 686 | + schema: { type: "string" }, | |
| 687 | + }, | |
| 688 | + ], | |
| 689 | + responses: { | |
| 690 | + 200: { | |
| 691 | + description: "ok", | |
| 692 | + content: { | |
| 693 | + "application/json": { | |
| 694 | + schema: { $ref: "#/components/schemas/Repository" }, | |
| 695 | + }, | |
| 657 | 696 | }, |
| 658 | 697 | }, |
| 659 | 698 | }, |
| 660 | 699 | }, |
| 661 | 700 | }, |
| 662 | − }, | |
| 663 | 701 | "/orgs/{org}/actions/runners/{runner_id}": { |
| 664 | 702 | get: { |
| 665 | 703 | operationId: "actions/get-self-hosted-runner-for-org", |
| @@ -668,8 +706,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 2 unmodified lines | ||
| 668 | 706 | description: |
| 669 | 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 | 708 | parameters: [ |
| 671 | − { name: "org", in: "path", required: true, schema: { type: "string" } }, | |
| 672 | − { name: "runner_id", in: "path", required: true, schema: { type: "integer" } }, | |
| 709 | + { | |
| 710 | + name: "org", | |
| 711 | + in: "path", | |
| 712 | + required: true, | |
| 713 | + schema: { type: "string" }, | |
| 714 | + }, | |
| 715 | + { | |
| 716 | + name: "runner_id", | |
| 717 | + in: "path", | |
| 718 | + required: true, | |
| 719 | + schema: { type: "integer" }, | |
| 720 | + }, | |
| 673 | 721 | ], |
| 674 | 722 | responses: { |
| 675 | 723 | 200: { |
| @@ -690,8 +738,18 @@ const persistConnectedGithubOpenApiSource = (input: { · 14 unmodified lines | ||
| 690 | 738 | summary: "Get a repository", |
| 691 | 739 | description: "Gets a repository by owner and name.", |
| 692 | 740 | parameters: [ |
| 693 | − { name: "owner", in: "path", required: true, schema: { type: "string" } }, | |
| 694 | − { name: "repo", in: "path", required: true, schema: { type: "string" } }, | |
| 741 | + { | |
| 742 | + name: "owner", | |
| 743 | + in: "path", | |
| 744 | + required: true, | |
| 745 | + schema: { type: "string" }, | |
| 746 | + }, | |
| 747 | + { | |
| 748 | + name: "repo", | |
| 749 | + in: "path", | |
| 750 | + required: true, | |
| 751 | + schema: { type: "string" }, | |
| 752 | + }, | |
| 695 | 753 | ], |
| 696 | 754 | responses: { |
| 697 | 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 | 800 | const source: Source = { |
| @@ -741,6 +811,8 @@ const persistConnectedGithubOpenApiSource = (input: { · 10 unmodified lines | ||
| 741 | 811 | headers: null, |
| 742 | 812 | specUrl: "https://example.com/github-openapi.json", |
| 743 | 813 | defaultHeaders: null, |
| 814 | + importAuthPolicy: "reuse_runtime", | |
| 815 | + importAuth: { kind: "none" }, | |
| 744 | 816 | auth: input.auth |
| 745 | 817 | ? { |
| 746 | 818 | kind: "bearer", |
| @@ -769,12 +841,15 @@ const persistConnectedGithubOpenApiSource = (input: { · 22 unmodified lines | ||
| 769 | 841 | ), |
| 770 | 842 | ); |
| 771 | 843 | const definitions = compileOpenApiToolDefinitions(manifest); |
| 772 | − const sourceRecord = yield* input.persistence.rows.sources.getByWorkspaceAndId( | |
| 773 | − input.workspaceId, | |
| 774 | − input.sourceId, | |
| 775 | − ); | |
| 844 | + const sourceRecord = | |
| 845 | + yield* input.persistence.rows.sources.getByWorkspaceAndId( | |
| 846 | + input.workspaceId, | |
| 847 | + input.sourceId, | |
| 848 | + ); | |
| 776 | 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 | 855 | yield* input.persistence.rows.sourceRecipeRevisions.update( |
| @@ -787,23 +862,54 @@ const persistConnectedGithubOpenApiSource = (input: { · 6 unmodified lines | ||
| 787 | 862 | ); |
| 788 | 863 | yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ |
| 789 | 864 | recipeRevisionId: sourceRecord.value.recipeRevisionId, |
| 790 | − documents: [{ | |
| 791 | − id: `src_recipe_doc_${randomUUID()}`, | |
| 792 | − recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 793 | − documentKind: "openapi", | |
| 794 | − documentKey: source.specUrl ?? source.endpoint, | |
| 795 | − contentText: openApiDocumentText, | |
| 796 | − contentHash: manifest.sourceHash, | |
| 797 | − fetchedAt: now, | |
| 798 | − createdAt: now, | |
| 799 | − updatedAt: now, | |
| 800 | − }], | |
| 865 | + documents: [ | |
| 866 | + { | |
| 867 | + id: `src_recipe_doc_${randomUUID()}`, | |
| 868 | + recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 869 | + documentKind: "openapi", | |
| 870 | + documentKey: source.specUrl ?? source.endpoint, | |
| 871 | + contentText: openApiDocumentText, | |
| 872 | + contentHash: manifest.sourceHash, | |
| 873 | + fetchedAt: now, | |
| 874 | + createdAt: now, | |
| 875 | + updatedAt: now, | |
| 876 | + }, | |
| 877 | + ], | |
| 878 | + }); | |
| 879 | + const refEntries = Object.entries(manifest.refHintTable ?? {}); | |
| 880 | + yield* input.persistence.rows.sourceRecipeSchemaBundles.replaceForRevision({ | |
| 881 | + recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 882 | + bundles: | |
| 883 | + refEntries.length > 0 | |
| 884 | + ? [ | |
| 885 | + { | |
| 886 | + id: SourceRecipeSchemaBundleIdSchema.make( | |
| 887 | + `src_recipe_bundle_${randomUUID()}`, | |
| 888 | + ), | |
| 889 | + recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 890 | + bundleKind: "json_schema_ref_map", | |
| 891 | + refsJson: JSON.stringify( | |
| 892 | + Object.fromEntries( | |
| 893 | + refEntries.map(([ref, rawValue]) => { | |
| 894 | + try { | |
| 895 | + return [ref, JSON.parse(rawValue) as unknown]; | |
| 896 | + } catch { | |
| 897 | + return [ref, rawValue]; | |
| 898 | + } | |
| 899 | + }), | |
| 900 | + ), | |
| 901 | + ), | |
| 902 | + contentHash: manifest.sourceHash, | |
| 903 | + createdAt: now, | |
| 904 | + updatedAt: now, | |
| 905 | + }, | |
| 906 | + ] | |
| 907 | + : [], | |
| 801 | 908 | }); |
| 802 | 909 | yield* input.persistence.rows.sourceRecipeOperations.replaceForRevision({ |
| 803 | 910 | recipeRevisionId: sourceRecord.value.recipeRevisionId, |
| 804 | 911 | operations: definitions.map((definition) => { |
| 805 | 912 | const presentation = buildOpenApiToolPresentation({ |
| 806 | − manifest, | |
| 807 | 913 | definition, |
| 808 | 914 | }); |
| 809 | 915 | const method = definition.method.toUpperCase(); |
| @@ -838,25 +944,19 @@ const persistConnectedGithubOpenApiSource = (input: { · 28 unmodified lines | ||
| 838 | 944 | outputSchemaJson: presentation.outputSchemaJson ?? null, |
| 839 | 945 | providerKind: "openapi", |
| 840 | 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 | 947 | createdAt: now, |
| 852 | 948 | updatedAt: now, |
| 853 | 949 | }; |
| 854 | 950 | }), |
| 855 | 951 | }); |
| 856 | − yield* input.persistence.rows.sources.update(input.workspaceId, input.sourceId, { | |
| 857 | − sourceHash: manifest.sourceHash, | |
| 858 | − updatedAt: now, | |
| 859 | − }); | |
| 952 | + yield* input.persistence.rows.sources.update( | |
| 953 | + input.workspaceId, | |
| 954 | + input.sourceId, | |
| 955 | + { | |
| 956 | + sourceHash: manifest.sourceHash, | |
| 957 | + updatedAt: now, | |
| 958 | + }, | |
| 959 | + ); | |
| 860 | 960 | |
| 861 | 961 | return; |
| 862 | 962 | }); |
| @@ -866,17 +966,24 @@ const makeResolver = (persistence: SqlControlPlanePersistence) => · 3 unmodified lines | ||
| 866 | 966 | rows: persistence.rows, |
| 867 | 967 | sourceAuthService: { |
| 868 | 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 | 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 | 974 | connectMcpSource: () => Effect.fail(new Error("not implemented in test")), |
| 873 | − startSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | |
| 874 | − completeSourceOAuthSession: () => Effect.fail(new Error("not implemented in test")), | |
| 875 | − completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")), | |
| 975 | + startSourceOAuthSession: () => | |
| 976 | + Effect.fail(new Error("not implemented in test")), | |
| 977 | + completeSourceOAuthSession: () => | |
| 978 | + Effect.fail(new Error("not implemented in test")), | |
| 979 | + completeSourceCredentialSetup: () => | |
| 980 | + Effect.fail(new Error("not implemented in test")), | |
| 876 | 981 | } as RuntimeSourceAuthService, |
| 877 | 982 | }); |
| 878 | 983 | |
| 879 | −const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) => | |
| 984 | +const makeResolverWithLiveSourceAuth = ( | |
| 985 | + persistence: SqlControlPlanePersistence, | |
| 986 | +) => | |
| 880 | 987 | createWorkspaceExecutionEnvironmentResolver({ |
| 881 | 988 | rows: persistence.rows, |
| 882 | 989 | sourceAuthService: createRuntimeSourceAuthService({ |
| @@ -902,7 +1009,8 @@ describe("workspace-execution-environment", () => { · 19 unmodified lines | ||
| 902 | 1009 | |
| 903 | 1010 | yield* Effect.tryPromise({ |
| 904 | 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 | 1014 | }).pipe(Effect.orDie); |
| 907 | 1015 | |
| 908 | 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", () => | |
| 935 | − Effect.gen(function* () { | |
| 936 | − const persistence = yield* makePersistence; | |
| 937 | − const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); | |
| 938 | − const sourceId = SourceIdSchema.make("src_github_discovery"); | |
| 939 | − yield* persistConnectedGithubOpenApiSource({ | |
| 940 | − persistence, | |
| 941 | − workspaceId, | |
| 942 | − sourceId, | |
| 943 | − }); | |
| 1042 | + it.scoped( | |
| 1043 | + "prefers namespace and path matches over noisy GitHub boilerplate", | |
| 1044 | + () => | |
| 1045 | + Effect.gen(function* () { | |
| 1046 | + const persistence = yield* makePersistence; | |
| 1047 | + const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); | |
| 1048 | + const sourceId = SourceIdSchema.make("src_github_discovery"); | |
| 1049 | + yield* persistConnectedGithubOpenApiSource({ | |
| 1050 | + persistence, | |
| 1051 | + workspaceId, | |
| 1052 | + sourceId, | |
| 1053 | + }); | |
| 944 | 1054 | |
| 945 | − const resolveEnvironment = makeResolver(persistence); | |
| 946 | − const environment = yield* resolveEnvironment({ | |
| 947 | − workspaceId, | |
| 948 | − accountId: AccountIdSchema.make("acc_github_discovery"), | |
| 949 | − executionId: ExecutionIdSchema.make("exec_github_discovery"), | |
| 950 | − }); | |
| 1055 | + const resolveEnvironment = makeResolver(persistence); | |
| 1056 | + const environment = yield* resolveEnvironment({ | |
| 1057 | + workspaceId, | |
| 1058 | + accountId: AccountIdSchema.make("acc_github_discovery"), | |
| 1059 | + executionId: ExecutionIdSchema.make("exec_github_discovery"), | |
| 1060 | + }); | |
| 951 | 1061 | |
| 952 | − const firstQuery = (yield* environment.toolInvoker.invoke({ | |
| 953 | − path: "discover", | |
| 954 | − args: { | |
| 955 | − query: "current authenticated user profile", | |
| 956 | − limit: 5, | |
| 957 | − }, | |
| 958 | − })) as { | |
| 959 | − bestPath: string | null; | |
| 960 | − results: Array<{ path: string }>; | |
| 961 | − }; | |
| 1062 | + const firstQuery = (yield* environment.toolInvoker.invoke({ | |
| 1063 | + path: "discover", | |
| 1064 | + args: { | |
| 1065 | + query: "current authenticated user profile", | |
| 1066 | + limit: 5, | |
| 1067 | + }, | |
| 1068 | + })) as { | |
| 1069 | + bestPath: string | null; | |
| 1070 | + results: Array<{ path: string }>; | |
| 1071 | + }; | |
| 962 | 1072 | |
| 963 | − expect(firstQuery.bestPath).toBe("github.users.getAuthenticated"); | |
| 964 | − expect(firstQuery.results[0]?.path).toBe("github.users.getAuthenticated"); | |
| 1073 | + expect(firstQuery.bestPath).toBe("github.users.getAuthenticated"); | |
| 1074 | + expect(firstQuery.results[0]?.path).toBe( | |
| 1075 | + "github.users.getAuthenticated", | |
| 1076 | + ); | |
| 965 | 1077 | |
| 966 | − const secondQuery = (yield* environment.toolInvoker.invoke({ | |
| 967 | − path: "discover", | |
| 968 | − args: { | |
| 969 | − query: "users get myself current user profile", | |
| 970 | − limit: 5, | |
| 971 | − }, | |
| 972 | − })) as { | |
| 973 | − bestPath: string | null; | |
| 974 | − results: Array<{ path: string }>; | |
| 975 | − }; | |
| 1078 | + const secondQuery = (yield* environment.toolInvoker.invoke({ | |
| 1079 | + path: "discover", | |
| 1080 | + args: { | |
| 1081 | + query: "users get myself current user profile", | |
| 1082 | + limit: 5, | |
| 1083 | + }, | |
| 1084 | + })) as { | |
| 1085 | + bestPath: string | null; | |
| 1086 | + results: Array<{ path: string }>; | |
| 1087 | + }; | |
| 976 | 1088 | |
| 977 | − expect(secondQuery.bestPath).toBe("github.users.getAuthenticated"); | |
| 978 | − expect(secondQuery.results[0]?.path).toBe("github.users.getAuthenticated"); | |
| 979 | − }), | |
| 1089 | + expect(secondQuery.bestPath).toBe("github.users.getAuthenticated"); | |
| 1090 | + expect(secondQuery.results[0]?.path).toBe( | |
| 1091 | + "github.users.getAuthenticated", | |
| 1092 | + ); | |
| 1093 | + }), | |
| 980 | 1094 | ); |
| 981 | 1095 | |
| 982 | 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", () => | |
| 1018 | − Effect.gen(function* () { | |
| 1019 | − const specServer = yield* makeOpenApiSpecServer; | |
| 1020 | − const persistence = yield* makePersistence; | |
| 1021 | − const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); | |
| 1022 | − const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1023 | − const now = Date.now(); | |
| 1024 | − const tokenSecretMaterialId = SecretMaterialIdSchema.make( | |
| 1025 | − "sec_test_openapi_bearer", | |
| 1026 | − ); | |
| 1027 | − yield* persistence.rows.secretMaterials.upsert({ | |
| 1028 | − id: tokenSecretMaterialId, | |
| 1029 | − name: null, | |
| 1030 | − purpose: "auth_material", | |
| 1031 | − value: "ghp_test_token", | |
| 1032 | − createdAt: now, | |
| 1033 | − updatedAt: now, | |
| 1034 | − }); | |
| 1035 | − let capturedElicitation: | |
| 1036 | − | { | |
| 1037 | − mode?: string; | |
| 1038 | − url?: string; | |
| 1039 | − elicitationId?: string; | |
| 1040 | − } | |
| 1041 | − | null = null; | |
| 1042 | − | |
| 1043 | − const environment = yield* resolveEnvironment({ | |
| 1044 | − workspaceId, | |
| 1045 | − accountId: AccountIdSchema.make("acc_add_openapi"), | |
| 1046 | − executionId: ExecutionIdSchema.make("exec_add_openapi"), | |
| 1047 | − onElicitation: (request) => | |
| 1048 | − Effect.sync(() => { | |
| 1049 | − capturedElicitation = request.elicitation as { | |
| 1050 | − mode?: string; | |
| 1051 | − url?: string; | |
| 1052 | − elicitationId?: string; | |
| 1053 | − }; | |
| 1054 | − | |
| 1055 | − return { | |
| 1056 | − action: "accept" as const, | |
| 1057 | − content: { | |
| 1058 | − authKind: "bearer", | |
| 1059 | − tokenRef: { | |
| 1060 | − providerId: "postgres", | |
| 1061 | − handle: tokenSecretMaterialId, | |
| 1131 | + it.scoped( | |
| 1132 | + "adds an OpenAPI source through executor.sources.add with elicited credentials", | |
| 1133 | + () => | |
| 1134 | + Effect.gen(function* () { | |
| 1135 | + const specServer = yield* makeOpenApiSpecServer; | |
| 1136 | + const persistence = yield* makePersistence; | |
| 1137 | + const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); | |
| 1138 | + const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1139 | + const now = Date.now(); | |
| 1140 | + const tokenSecretMaterialId = SecretMaterialIdSchema.make( | |
| 1141 | + "sec_test_openapi_bearer", | |
| 1142 | + ); | |
| 1143 | + yield* persistence.rows.secretMaterials.upsert({ | |
| 1144 | + id: tokenSecretMaterialId, | |
| 1145 | + name: null, | |
| 1146 | + purpose: "auth_material", | |
| 1147 | + value: "ghp_test_token", | |
| 1148 | + createdAt: now, | |
| 1149 | + updatedAt: now, | |
| 1150 | + }); | |
| 1151 | + let capturedElicitation: { | |
| 1152 | + mode?: string; | |
| 1153 | + url?: string; | |
| 1154 | + elicitationId?: string; | |
| 1155 | + } | null = null; | |
| 1156 | + | |
| 1157 | + const environment = yield* resolveEnvironment({ | |
| 1158 | + workspaceId, | |
| 1159 | + accountId: AccountIdSchema.make("acc_add_openapi"), | |
| 1160 | + executionId: ExecutionIdSchema.make("exec_add_openapi"), | |
| 1161 | + onElicitation: (request) => | |
| 1162 | + Effect.sync(() => { | |
| 1163 | + capturedElicitation = request.elicitation as { | |
| 1164 | + mode?: string; | |
| 1165 | + url?: string; | |
| 1166 | + elicitationId?: string; | |
| 1167 | + }; | |
| 1168 | + | |
| 1169 | + return { | |
| 1170 | + action: "accept" as const, | |
| 1171 | + content: { | |
| 1172 | + authKind: "bearer", | |
| 1173 | + tokenRef: { | |
| 1174 | + providerId: "postgres", | |
| 1175 | + handle: tokenSecretMaterialId, | |
| 1176 | + }, | |
| 1062 | 1177 | }, |
| 1063 | − }, | |
| 1064 | − }; | |
| 1065 | − }), | |
| 1066 | − }); | |
| 1178 | + }; | |
| 1179 | + }), | |
| 1180 | + }); | |
| 1067 | 1181 | |
| 1068 | − const added = (yield* environment.toolInvoker.invoke({ | |
| 1069 | − path: "executor.sources.add", | |
| 1070 | − args: { | |
| 1071 | − kind: "openapi", | |
| 1072 | − endpoint: specServer.baseUrl, | |
| 1073 | − specUrl: specServer.specUrl, | |
| 1074 | − name: "GitHub", | |
| 1075 | − namespace: "github", | |
| 1076 | − }, | |
| 1077 | − context: { | |
| 1078 | − runId: "exec_add_openapi", | |
| 1079 | − }, | |
| 1080 | − })) as { | |
| 1081 | − id: SourceId; | |
| 1082 | − kind: string; | |
| 1083 | − status: string; | |
| 1084 | − auth: { | |
| 1182 | + const added = (yield* environment.toolInvoker.invoke({ | |
| 1183 | + path: "executor.sources.add", | |
| 1184 | + args: { | |
| 1185 | + kind: "openapi", | |
| 1186 | + endpoint: specServer.baseUrl, | |
| 1187 | + specUrl: specServer.specUrl, | |
| 1188 | + name: "GitHub", | |
| 1189 | + namespace: "github", | |
| 1190 | + }, | |
| 1191 | + context: { | |
| 1192 | + runId: "exec_add_openapi", | |
| 1193 | + }, | |
| 1194 | + })) as { | |
| 1195 | + id: SourceId; | |
| 1085 | 1196 | kind: string; |
| 1086 | − token?: { | |
| 1087 | − providerId: string; | |
| 1197 | + status: 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"); | |
| 1093 | − expect(added.status).toBe("connected"); | |
| 1094 | − expect(added.auth.kind).toBe("bearer"); | |
| 1095 | − expect(added.auth.token?.providerId).toBe("postgres"); | |
| 1096 | − expect(capturedElicitation).toMatchObject({ | |
| 1097 | − mode: "url", | |
| 1098 | − url: expect.stringContaining("/v1/workspaces/"), | |
| 1099 | − elicitationId: expect.stringContaining("executor.sources.add:"), | |
| 1100 | − }); | |
| 1101 | − expect((capturedElicitation as { url?: string } | null)?.url).toContain( | |
| 1102 | − encodeURIComponent("exec_add_openapi:executor.sources.add:"), | |
| 1103 | − ); | |
| 1104 | − | |
| 1105 | − const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId( | |
| 1106 | − workspaceId, | |
| 1107 | − added.id, | |
| 1108 | − ); | |
| 1109 | − expect(Option.isSome(storedSource)).toBe(true); | |
| 1110 | − if (Option.isSome(storedSource)) { | |
| 1111 | − const documents = yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | |
| 1112 | − storedSource.value.recipeRevisionId, | |
| 1206 | + expect(added.kind).toBe("openapi"); | |
| 1207 | + expect(added.status).toBe("connected"); | |
| 1208 | + expect(added.auth.kind).toBe("bearer"); | |
| 1209 | + expect(added.auth.token?.providerId).toBe("postgres"); | |
| 1210 | + expect(capturedElicitation).toMatchObject({ | |
| 1211 | + mode: "url", | |
| 1212 | + url: expect.stringContaining("/v1/workspaces/"), | |
| 1213 | + elicitationId: expect.stringContaining("executor.sources.add:"), | |
| 1214 | + }); | |
| 1215 | + expect((capturedElicitation as { url?: string } | null)?.url).toContain( | |
| 1216 | + encodeURIComponent("exec_add_openapi:executor.sources.add:"), | |
| 1113 | 1217 | ); |
| 1114 | − expect(documents[0]?.contentText).toContain('"operationId":"repos.getRepo"'); | |
| 1115 | − } | |
| 1116 | 1218 | |
| 1117 | − const freshEnvironment = yield* resolveEnvironment({ | |
| 1118 | − workspaceId, | |
| 1119 | − accountId: AccountIdSchema.make("acc_add_openapi"), | |
| 1120 | − executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"), | |
| 1121 | − }); | |
| 1219 | + const storedSource = | |
| 1220 | + yield* persistence.rows.sources.getByWorkspaceAndId( | |
| 1221 | + workspaceId, | |
| 1222 | + added.id, | |
| 1223 | + ); | |
| 1224 | + expect(Option.isSome(storedSource)).toBe(true); | |
| 1225 | + if (Option.isSome(storedSource)) { | |
| 1226 | + const documents = | |
| 1227 | + yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | |
| 1228 | + storedSource.value.recipeRevisionId, | |
| 1229 | + ); | |
| 1230 | + expect(documents[0]?.contentText).toContain( | |
| 1231 | + '"operationId":"repos.getRepo"', | |
| 1232 | + ); | |
| 1233 | + } | |
| 1122 | 1234 | |
| 1123 | − const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1124 | − path: "discover", | |
| 1125 | − args: { | |
| 1126 | − query: "github repo", | |
| 1127 | − limit: 5, | |
| 1128 | − }, | |
| 1129 | − })) as { | |
| 1130 | − bestPath: string | null; | |
| 1131 | − }; | |
| 1235 | + const freshEnvironment = yield* resolveEnvironment({ | |
| 1236 | + workspaceId, | |
| 1237 | + accountId: AccountIdSchema.make("acc_add_openapi"), | |
| 1238 | + executionId: ExecutionIdSchema.make("exec_add_openapi_fresh"), | |
| 1239 | + }); | |
| 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({ | |
| 1136 | − path: "github.repos.getRepo", | |
| 1137 | − args: { | |
| 1138 | − owner: "vercel", | |
| 1139 | − repo: "ai", | |
| 1140 | − }, | |
| 1141 | − })) as { | |
| 1142 | − ok: boolean; | |
| 1143 | − full_name: string; | |
| 1144 | − }; | |
| 1251 | + expect(discovered.bestPath).toBe("github.repos.getRepo"); | |
| 1145 | 1252 | |
| 1146 | − expect(invoked).toEqual({ | |
| 1147 | − ok: true, | |
| 1148 | − full_name: "vercel/ai", | |
| 1149 | − }); | |
| 1150 | − expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]); | |
| 1151 | − }), | |
| 1253 | + const invoked = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1254 | + path: "github.repos.getRepo", | |
| 1255 | + args: { | |
| 1256 | + owner: "vercel", | |
| 1257 | + repo: "ai", | |
| 1258 | + }, | |
| 1259 | + })) as { | |
| 1260 | + ok: boolean; | |
| 1261 | + full_name: string; | |
| 1262 | + }; | |
| 1263 | + | |
| 1264 | + expect(invoked).toEqual({ | |
| 1265 | + ok: true, | |
| 1266 | + full_name: "vercel/ai", | |
| 1267 | + }); | |
| 1268 | + expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_test_token"]); | |
| 1269 | + }), | |
| 1152 | 1270 | ); |
| 1153 | 1271 | |
| 1154 | − it.scoped("adds a GraphQL source through executor.sources.add with elicited credentials", () => | |
| 1155 | − Effect.gen(function* () { | |
| 1156 | − const graphqlServer = yield* makeGraphqlServer; | |
| 1157 | − const persistence = yield* makePersistence; | |
| 1158 | − const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); | |
| 1159 | − const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1160 | − const now = Date.now(); | |
| 1161 | − const tokenSecretMaterialId = SecretMaterialIdSchema.make( | |
| 1162 | − "sec_test_graphql_bearer", | |
| 1163 | − ); | |
| 1164 | − yield* persistence.rows.secretMaterials.upsert({ | |
| 1165 | − id: tokenSecretMaterialId, | |
| 1166 | − name: null, | |
| 1167 | − purpose: "auth_material", | |
| 1168 | − value: "linear_test_token", | |
| 1169 | − createdAt: now, | |
| 1170 | − updatedAt: now, | |
| 1171 | − }); | |
| 1272 | + it.scoped( | |
| 1273 | + "adds a GraphQL source through executor.sources.add with elicited credentials", | |
| 1274 | + () => | |
| 1275 | + Effect.gen(function* () { | |
| 1276 | + const graphqlServer = yield* makeGraphqlServer; | |
| 1277 | + const persistence = yield* makePersistence; | |
| 1278 | + const workspaceId = WorkspaceIdSchema.make("ws_add_graphql"); | |
| 1279 | + const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1280 | + const now = Date.now(); | |
| 1281 | + const tokenSecretMaterialId = SecretMaterialIdSchema.make( | |
| 1282 | + "sec_test_graphql_bearer", | |
| 1283 | + ); | |
| 1284 | + yield* persistence.rows.secretMaterials.upsert({ | |
| 1285 | + id: tokenSecretMaterialId, | |
| 1286 | + name: null, | |
| 1287 | + purpose: "auth_material", | |
| 1288 | + value: "linear_test_token", | |
| 1289 | + createdAt: now, | |
| 1290 | + updatedAt: now, | |
| 1291 | + }); | |
| 1172 | 1292 | |
| 1173 | − const environment = yield* resolveEnvironment({ | |
| 1174 | − workspaceId, | |
| 1175 | − accountId: AccountIdSchema.make("acc_add_graphql"), | |
| 1176 | − executionId: ExecutionIdSchema.make("exec_add_graphql"), | |
| 1177 | − onElicitation: () => | |
| 1178 | − Effect.succeed({ | |
| 1179 | − action: "accept" as const, | |
| 1180 | − content: { | |
| 1181 | − authKind: "bearer", | |
| 1182 | − tokenRef: { | |
| 1183 | − providerId: "postgres", | |
| 1184 | − handle: tokenSecretMaterialId, | |
| 1293 | + const environment = yield* resolveEnvironment({ | |
| 1294 | + workspaceId, | |
| 1295 | + accountId: AccountIdSchema.make("acc_add_graphql"), | |
| 1296 | + executionId: ExecutionIdSchema.make("exec_add_graphql"), | |
| 1297 | + onElicitation: () => | |
| 1298 | + Effect.succeed({ | |
| 1299 | + action: "accept" as const, | |
| 1300 | + content: { | |
| 1301 | + authKind: "bearer", | |
| 1302 | + tokenRef: { | |
| 1303 | + providerId: "postgres", | |
| 1304 | + handle: tokenSecretMaterialId, | |
| 1305 | + }, | |
| 1185 | 1306 | }, |
| 1186 | − }, | |
| 1187 | − }), | |
| 1188 | − }); | |
| 1307 | + }), | |
| 1308 | + }); | |
| 1189 | 1309 | |
| 1190 | − const added = (yield* environment.toolInvoker.invoke({ | |
| 1191 | − path: "executor.sources.add", | |
| 1192 | − args: { | |
| 1193 | − kind: "graphql", | |
| 1194 | − endpoint: graphqlServer.endpoint, | |
| 1195 | − name: "Linear", | |
| 1196 | − namespace: "linear", | |
| 1197 | − }, | |
| 1198 | − context: { | |
| 1199 | − runId: "exec_add_graphql", | |
| 1200 | − }, | |
| 1201 | − })) as { | |
| 1202 | − id: SourceId; | |
| 1203 | − kind: string; | |
| 1204 | − status: string; | |
| 1205 | − auth: { | |
| 1310 | + const added = (yield* environment.toolInvoker.invoke({ | |
| 1311 | + path: "executor.sources.add", | |
| 1312 | + args: { | |
| 1313 | + kind: "graphql", | |
| 1314 | + endpoint: graphqlServer.endpoint, | |
| 1315 | + name: "Linear", | |
| 1316 | + namespace: "linear", | |
| 1317 | + }, | |
| 1318 | + context: { | |
| 1319 | + runId: "exec_add_graphql", | |
| 1320 | + }, | |
| 1321 | + })) as { | |
| 1322 | + id: SourceId; | |
| 1206 | 1323 | kind: string; |
| 1207 | − token?: { | |
| 1208 | − providerId: string; | |
| 1324 | + status: 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"); | |
| 1214 | − expect(added.status).toBe("connected"); | |
| 1215 | − expect(added.auth.kind).toBe("bearer"); | |
| 1216 | − expect(added.auth.token?.providerId).toBe("postgres"); | |
| 1333 | + expect(added.kind).toBe("graphql"); | |
| 1334 | + expect(added.status).toBe("connected"); | |
| 1335 | + expect(added.auth.kind).toBe("bearer"); | |
| 1336 | + expect(added.auth.token?.providerId).toBe("postgres"); | |
| 1217 | 1337 | |
| 1218 | − const storedSource = yield* persistence.rows.sources.getByWorkspaceAndId( | |
| 1219 | − workspaceId, | |
| 1220 | − added.id, | |
| 1221 | − ); | |
| 1222 | − expect(Option.isSome(storedSource)).toBe(true); | |
| 1223 | − if (Option.isSome(storedSource)) { | |
| 1224 | − const documents = yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | |
| 1225 | − storedSource.value.recipeRevisionId, | |
| 1338 | + const storedSource = | |
| 1339 | + yield* persistence.rows.sources.getByWorkspaceAndId( | |
| 1340 | + workspaceId, | |
| 1341 | + added.id, | |
| 1342 | + ); | |
| 1343 | + expect(Option.isSome(storedSource)).toBe(true); | |
| 1344 | + if (Option.isSome(storedSource)) { | |
| 1345 | + const documents = | |
| 1346 | + yield* persistence.rows.sourceRecipeDocuments.listByRevisionId( | |
| 1347 | + storedSource.value.recipeRevisionId, | |
| 1348 | + ); | |
| 1349 | + expect(documents[0]?.contentText).toContain('"__schema"'); | |
| 1350 | + expect( | |
| 1351 | + (yield* persistence.rows.sourceRecipeSchemaBundles.listByRevisionId( | |
| 1352 | + storedSource.value.recipeRevisionId, | |
| 1353 | + )).length, | |
| 1354 | + ).toBe(1); | |
| 1355 | + } | |
| 1356 | + | |
| 1357 | + const connectedSourceRecord = Option.getOrThrow(storedSource); | |
| 1358 | + yield* persistence.rows.sourceRecipeRevisions.update( | |
| 1359 | + connectedSourceRecord.recipeRevisionId, | |
| 1360 | + { | |
| 1361 | + manifestJson: null, | |
| 1362 | + updatedAt: Date.now(), | |
| 1363 | + }, | |
| 1226 | 1364 | ); |
| 1227 | − expect(documents[0]?.contentText).toContain('"__schema"'); | |
| 1228 | − } | |
| 1229 | 1365 | |
| 1230 | − const freshEnvironment = yield* resolveEnvironment({ | |
| 1231 | − workspaceId, | |
| 1232 | − accountId: AccountIdSchema.make("acc_add_graphql"), | |
| 1233 | − executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), | |
| 1234 | − onElicitation: () => | |
| 1235 | − Effect.succeed({ | |
| 1236 | − action: "accept" as const, | |
| 1237 | − }), | |
| 1238 | − }); | |
| 1366 | + const freshEnvironment = yield* resolveEnvironment({ | |
| 1367 | + workspaceId, | |
| 1368 | + accountId: AccountIdSchema.make("acc_add_graphql"), | |
| 1369 | + executionId: ExecutionIdSchema.make("exec_add_graphql_fresh"), | |
| 1370 | + onElicitation: () => | |
| 1371 | + Effect.succeed({ | |
| 1372 | + action: "accept" as const, | |
| 1373 | + }), | |
| 1374 | + }); | |
| 1239 | 1375 | |
| 1240 | − const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1241 | − path: "discover", | |
| 1242 | − args: { | |
| 1243 | − query: "linear current user viewer", | |
| 1244 | − limit: 5, | |
| 1245 | − }, | |
| 1246 | − })) as { | |
| 1247 | − bestPath: string | null; | |
| 1248 | − }; | |
| 1376 | + const discovered = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1377 | + path: "discover", | |
| 1378 | + args: { | |
| 1379 | + query: "linear current user viewer", | |
| 1380 | + limit: 5, | |
| 1381 | + }, | |
| 1382 | + })) as { | |
| 1383 | + bestPath: string | null; | |
| 1384 | + }; | |
| 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({ | |
| 1253 | − path: "describe.tool", | |
| 1254 | − args: { | |
| 1255 | − path: "linear.createIssue", | |
| 1256 | − includeSchemas: true, | |
| 1257 | − }, | |
| 1258 | − })) as { | |
| 1259 | − path: string; | |
| 1260 | − inputType?: string; | |
| 1261 | − inputSchemaJson?: string; | |
| 1262 | − } | null; | |
| 1263 | − | |
| 1264 | − expect(describeCreateIssue?.path).toBe("linear.createIssue"); | |
| 1265 | − expect(describeCreateIssue?.inputType).toContain("input"); | |
| 1266 | − expect(describeCreateIssue?.inputType).toContain("title"); | |
| 1267 | − | |
| 1268 | − const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1269 | − path: "linear.viewer", | |
| 1270 | − args: {}, | |
| 1271 | − })) as { | |
| 1272 | − data: { | |
| 1273 | − id: string; | |
| 1274 | − name: string; | |
| 1275 | − __typename: string; | |
| 1388 | + const describeCreateIssue = (yield* freshEnvironment.toolInvoker.invoke( | |
| 1389 | + { | |
| 1390 | + path: "describe.tool", | |
| 1391 | + args: { | |
| 1392 | + path: "linear.createIssue", | |
| 1393 | + includeSchemas: true, | |
| 1394 | + }, | |
| 1395 | + }, | |
| 1396 | + )) as { | |
| 1397 | + path: string; | |
| 1398 | + inputType?: string; | |
| 1399 | + inputSchemaJson?: string; | |
| 1400 | + schemaBundleId?: string; | |
| 1401 | + } | null; | |
| 1402 | + | |
| 1403 | + expect(describeCreateIssue?.path).toBe("linear.createIssue"); | |
| 1404 | + expect(describeCreateIssue?.inputType).toContain("input"); | |
| 1405 | + expect(describeCreateIssue?.inputType).toContain("CreateIssueInput"); | |
| 1406 | + expect(describeCreateIssue?.inputSchemaJson).toContain("$ref"); | |
| 1407 | + expect(describeCreateIssue?.schemaBundleId).toBeDefined(); | |
| 1408 | + | |
| 1409 | + const graphqlSchemaBundle = (yield* freshEnvironment.toolInvoker.invoke( | |
| 1410 | + { | |
| 1411 | + path: "describe.schemaBundle", | |
| 1412 | + args: { | |
| 1413 | + id: describeCreateIssue?.schemaBundleId, | |
| 1414 | + }, | |
| 1415 | + }, | |
| 1416 | + )) as { | |
| 1417 | + kind: string; | |
| 1418 | + refsJson: string; | |
| 1419 | + } | null; | |
| 1420 | + | |
| 1421 | + expect(graphqlSchemaBundle?.kind).toBe("json_schema_ref_map"); | |
| 1422 | + expect(graphqlSchemaBundle?.refsJson).toContain("CreateIssueInput"); | |
| 1423 | + expect(graphqlSchemaBundle?.refsJson).toContain('"title"'); | |
| 1424 | + | |
| 1425 | + const viewerResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1426 | + path: "linear.viewer", | |
| 1427 | + args: {}, | |
| 1428 | + })) as { | |
| 1429 | + data: { | |
| 1430 | + id: string; | |
| 1431 | + name: string; | |
| 1432 | + __typename: string; | |
| 1433 | + }; | |
| 1434 | + errors: unknown[]; | |
| 1276 | 1435 | }; |
| 1277 | − errors: unknown[]; | |
| 1278 | − }; | |
| 1279 | 1436 | |
| 1280 | − expect(viewerResult).toMatchObject({ | |
| 1281 | − data: { | |
| 1282 | − id: "usr_123", | |
| 1283 | − name: "Rhys", | |
| 1284 | − __typename: "User", | |
| 1285 | − }, | |
| 1286 | − errors: [], | |
| 1287 | − }); | |
| 1437 | + expect(viewerResult).toMatchObject({ | |
| 1438 | + data: { | |
| 1439 | + id: "usr_123", | |
| 1440 | + name: "Rhys", | |
| 1441 | + __typename: "User", | |
| 1442 | + }, | |
| 1443 | + errors: [], | |
| 1444 | + }); | |
| 1288 | 1445 | |
| 1289 | − const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1290 | − path: "linear.createIssue", | |
| 1291 | − args: { | |
| 1292 | − input: { | |
| 1293 | − title: "Ship GraphQL field tools", | |
| 1446 | + const createIssueResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1447 | + path: "linear.createIssue", | |
| 1448 | + args: { | |
| 1449 | + input: { | |
| 1450 | + title: "Ship GraphQL field tools", | |
| 1451 | + }, | |
| 1294 | 1452 | }, |
| 1295 | − }, | |
| 1296 | − })) as { | |
| 1297 | − data: { | |
| 1298 | − id: string; | |
| 1299 | − identifier: string; | |
| 1300 | − title: string; | |
| 1301 | − __typename: string; | |
| 1453 | + })) as { | |
| 1454 | + data: { | |
| 1455 | + id: string; | |
| 1456 | + identifier: string; | |
| 1457 | + title: string; | |
| 1458 | + __typename: string; | |
| 1459 | + }; | |
| 1460 | + errors: unknown[]; | |
| 1302 | 1461 | }; |
| 1303 | − errors: unknown[]; | |
| 1304 | − }; | |
| 1305 | 1462 | |
| 1306 | − expect(createIssueResult).toMatchObject({ | |
| 1307 | − data: { | |
| 1308 | − id: "iss_created", | |
| 1309 | − identifier: "ENG-999", | |
| 1310 | − title: "Ship GraphQL field tools", | |
| 1311 | − __typename: "Issue", | |
| 1312 | − }, | |
| 1313 | − errors: [], | |
| 1314 | − }); | |
| 1315 | − | |
| 1316 | − const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1317 | − path: "linear.request", | |
| 1318 | − args: { | |
| 1319 | − query: "query Viewer { viewer { id name } }", | |
| 1320 | − }, | |
| 1321 | − })) as { | |
| 1322 | − status: number; | |
| 1323 | − body: { | |
| 1463 | + expect(createIssueResult).toMatchObject({ | |
| 1324 | 1464 | data: { |
| 1325 | − viewer: { | |
| 1326 | − id: string; | |
| 1327 | − name: string; | |
| 1465 | + id: "iss_created", | |
| 1466 | + identifier: "ENG-999", | |
| 1467 | + title: "Ship GraphQL field tools", | |
| 1468 | + __typename: "Issue", | |
| 1469 | + }, | |
| 1470 | + errors: [], | |
| 1471 | + }); | |
| 1472 | + | |
| 1473 | + const rawRequestResult = (yield* freshEnvironment.toolInvoker.invoke({ | |
| 1474 | + path: "linear.request", | |
| 1475 | + args: { | |
| 1476 | + query: "query Viewer { viewer { id name } }", | |
| 1477 | + }, | |
| 1478 | + })) as { | |
| 1479 | + status: number; | |
| 1480 | + body: { | |
| 1481 | + data: { | |
| 1482 | + viewer: { | |
| 1483 | + id: string; | |
| 1484 | + name: string; | |
| 1485 | + }; | |
| 1328 | 1486 | }; |
| 1329 | 1487 | }; |
| 1330 | 1488 | }; |
| 1331 | − }; | |
| 1332 | 1489 | |
| 1333 | − expect(rawRequestResult.status).toBe(200); | |
| 1334 | − expect(rawRequestResult.body).toEqual({ | |
| 1335 | − data: { | |
| 1336 | − viewer: { | |
| 1337 | − id: "usr_123", | |
| 1338 | − name: "Rhys", | |
| 1490 | + expect(rawRequestResult.status).toBe(200); | |
| 1491 | + expect(rawRequestResult.body).toEqual({ | |
| 1492 | + data: { | |
| 1493 | + viewer: { | |
| 1494 | + id: "usr_123", | |
| 1495 | + name: "Rhys", | |
| 1496 | + }, | |
| 1339 | 1497 | }, |
| 1340 | − }, | |
| 1341 | − }); | |
| 1342 | − expect(graphqlServer.seenAuthHeaders).toEqual([ | |
| 1343 | − "Bearer linear_test_token", | |
| 1344 | − "Bearer linear_test_token", | |
| 1345 | − "Bearer linear_test_token", | |
| 1346 | − "Bearer linear_test_token", | |
| 1347 | − ]); | |
| 1348 | − }), | |
| 1498 | + }); | |
| 1499 | + expect(graphqlServer.seenAuthHeaders).toEqual([ | |
| 1500 | + "Bearer linear_test_token", | |
| 1501 | + "Bearer linear_test_token", | |
| 1502 | + "Bearer linear_test_token", | |
| 1503 | + "Bearer linear_test_token", | |
| 1504 | + ]); | |
| 1505 | + }), | |
| 1349 | 1506 | ); |
| 1350 | 1507 | |
| 1351 | 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", () => | |
| 1401 | − Effect.gen(function* () { | |
| 1402 | − const persistence = yield* makePersistence; | |
| 1403 | − const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1557 | + it.scoped( | |
| 1558 | + "describes persisted OpenAPI tools with compact schemas and shared bundle ids", | |
| 1559 | + () => | |
| 1560 | + Effect.gen(function* () { | |
| 1561 | + const persistence = yield* makePersistence; | |
| 1562 | + const workspaceId = WorkspaceIdSchema.make( | |
| 1563 | + "ws_openapi_describe_bundle", | |
| 1564 | + ); | |
| 1565 | + const sourceId = SourceIdSchema.make("src_openapi_describe_bundle"); | |
| 1404 | 1566 | |
| 1405 | − const environment = yield* resolveEnvironment({ | |
| 1406 | − workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"), | |
| 1407 | − accountId: AccountIdSchema.make("acc_describe_add_source"), | |
| 1408 | − executionId: ExecutionIdSchema.make("exec_describe_add_source"), | |
| 1409 | − }); | |
| 1567 | + yield* persistConnectedGithubOpenApiSource({ | |
| 1568 | + persistence, | |
| 1569 | + workspaceId, | |
| 1570 | + sourceId, | |
| 1571 | + }); | |
| 1410 | 1572 | |
| 1411 | − const described = (yield* environment.toolInvoker.invoke({ | |
| 1412 | − path: "describe.tool", | |
| 1413 | − args: { | |
| 1414 | − path: "executor.sources.add", | |
| 1415 | − includeSchemas: true, | |
| 1416 | − }, | |
| 1417 | − })) as { | |
| 1418 | − path: string; | |
| 1419 | − description?: string; | |
| 1420 | − inputType?: string; | |
| 1421 | − inputSchemaJson?: string; | |
| 1422 | − } | null; | |
| 1423 | − | |
| 1424 | − expect(described?.path).toBe("executor.sources.add"); | |
| 1425 | − expect(described?.description).toContain("Source add input shapes:"); | |
| 1426 | − expect(described?.description).toContain("specUrl"); | |
| 1427 | − expect(described?.description).toContain("credential setup"); | |
| 1428 | − expect(described?.inputType).toContain("endpoint"); | |
| 1429 | − expect(described?.inputType).not.toContain("auth"); | |
| 1430 | − | |
| 1431 | − const inputSchema = described?.inputSchemaJson | |
| 1432 | − ? JSON.parse(described.inputSchemaJson) as Record<string, unknown> | |
| 1433 | − : null; | |
| 1434 | − expect(inputSchema).not.toBeNull(); | |
| 1435 | − expect(JSON.stringify(inputSchema)).toContain("specUrl"); | |
| 1436 | − expect(JSON.stringify(inputSchema)).toContain("endpoint"); | |
| 1437 | − expect(JSON.stringify(inputSchema)).not.toContain("\"auth\""); | |
| 1438 | − }), | |
| 1573 | + const resolveEnvironment = makeResolver(persistence); | |
| 1574 | + const environment = yield* resolveEnvironment({ | |
| 1575 | + workspaceId, | |
| 1576 | + accountId: AccountIdSchema.make("acc_openapi_describe_bundle"), | |
| 1577 | + executionId: ExecutionIdSchema.make("exec_openapi_describe_bundle"), | |
| 1578 | + }); | |
| 1579 | + | |
| 1580 | + const described = (yield* environment.toolInvoker.invoke({ | |
| 1581 | + path: "describe.tool", | |
| 1582 | + args: { | |
| 1583 | + path: "github.repos.getRepo", | |
| 1584 | + includeSchemas: true, | |
| 1585 | + }, | |
| 1586 | + })) as { | |
| 1587 | + path: string; | |
| 1588 | + inputSchemaJson?: string; | |
| 1589 | + outputSchemaJson?: string; | |
| 1590 | + schemaBundleId?: string; | |
| 1591 | + } | null; | |
| 1592 | + | |
| 1593 | + expect(described?.path).toBe("github.repos.getRepo"); | |
| 1594 | + expect(described?.inputSchemaJson).toContain('"owner"'); | |
| 1595 | + expect(described?.outputSchemaJson).toContain('"type"'); | |
| 1596 | + expect(described?.schemaBundleId).toBeDefined(); | |
| 1597 | + | |
| 1598 | + const schemaBundle = (yield* environment.toolInvoker.invoke({ | |
| 1599 | + path: "describe.schemaBundle", | |
| 1600 | + args: { | |
| 1601 | + id: described?.schemaBundleId, | |
| 1602 | + }, | |
| 1603 | + })) as { | |
| 1604 | + id: string; | |
| 1605 | + kind: string; | |
| 1606 | + refsJson: string; | |
| 1607 | + } | null; | |
| 1608 | + | |
| 1609 | + expect(schemaBundle?.id).toBe(described?.schemaBundleId); | |
| 1610 | + expect(schemaBundle?.kind).toBe("json_schema_ref_map"); | |
| 1611 | + expect(schemaBundle?.refsJson).toContain("Repository"); | |
| 1612 | + }), | |
| 1613 | + ); | |
| 1614 | + | |
| 1615 | + it.scoped( | |
| 1616 | + "describes executor.sources.add with derived type info and schemas", | |
| 1617 | + () => | |
| 1618 | + Effect.gen(function* () { | |
| 1619 | + const persistence = yield* makePersistence; | |
| 1620 | + const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); | |
| 1621 | + | |
| 1622 | + const environment = yield* resolveEnvironment({ | |
| 1623 | + workspaceId: WorkspaceIdSchema.make("ws_describe_add_source"), | |
| 1624 | + accountId: AccountIdSchema.make("acc_describe_add_source"), | |
| 1625 | + executionId: ExecutionIdSchema.make("exec_describe_add_source"), | |
| 1626 | + }); | |
| 1627 | + | |
| 1628 | + const described = (yield* environment.toolInvoker.invoke({ | |
| 1629 | + path: "describe.tool", | |
| 1630 | + args: { | |
| 1631 | + path: "executor.sources.add", | |
| 1632 | + includeSchemas: true, | |
| 1633 | + }, | |
| 1634 | + })) as { | |
| 1635 | + path: string; | |
| 1636 | + description?: string; | |
| 1637 | + inputType?: string; | |
| 1638 | + inputSchemaJson?: string; | |
| 1639 | + } | null; | |
| 1640 | + | |
| 1641 | + expect(described?.path).toBe("executor.sources.add"); | |
| 1642 | + expect(described?.description).toContain("Source add input shapes:"); | |
| 1643 | + expect(described?.description).toContain("specUrl"); | |
| 1644 | + expect(described?.description).toContain("credential setup"); | |
| 1645 | + expect(described?.inputType).toContain("endpoint"); | |
| 1646 | + expect(described?.inputType).toContain("importAuth"); | |
| 1647 | + | |
| 1648 | + const inputSchema = described?.inputSchemaJson | |
| 1649 | + ? (JSON.parse(described.inputSchemaJson) as Record<string, unknown>) | |
| 1650 | + : null; | |
| 1651 | + expect(inputSchema).not.toBeNull(); | |
| 1652 | + expect(JSON.stringify(inputSchema)).toContain("specUrl"); | |
| 1653 | + expect(JSON.stringify(inputSchema)).toContain("endpoint"); | |
| 1654 | + expect(JSON.stringify(inputSchema)).toContain('"auth"'); | |
| 1655 | + expect(JSON.stringify(inputSchema)).toContain('"importAuth"'); | |
| 1656 | + }), | |
| 1439 | 1657 | ); |
| 1440 | 1658 | }); |
| @@ -7,27 +7,19 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type ToolInvoker, |
| 8 | 8 | type ToolNamespace, |
| 9 | 9 | type ToolPath, |
| 10 | + type ToolSchemaBundle, | |
| 10 | 11 | } from "@executor/codemode-core"; |
| 11 | 12 | import { |
| 12 | − createSdkMcpConnector, | |
| 13 | − createMcpToolsFromManifest, | |
| 14 | − type McpToolManifest, | |
| 15 | −} from "@executor/codemode-mcp"; | |
| 16 | −import { | |
| 17 | − createOpenApiToolsFromManifest, | |
| 18 | − type OpenApiToolManifest, | |
| 19 | −} from "@executor/codemode-openapi"; | |
| 20 | −import { isDenoAvailable, | |
| 21 | − makeDenoSubprocessExecutor } from "@executor/runtime-deno-subprocess"; | |
| 13 | + isDenoAvailable, | |
| 14 | + makeDenoSubprocessExecutor, | |
| 15 | +} from "@executor/runtime-deno-subprocess"; | |
| 22 | 16 | import { makeSesExecutor } from "@executor/runtime-ses"; |
| 23 | 17 | import { |
| 24 | 18 | SqlControlPlaneRowsService, |
| 25 | 19 | type SqlControlPlaneRows, |
| 26 | 20 | } from "#persistence"; |
| 27 | −import type { | |
| 28 | − AccountId, | |
| 29 | − Source, | |
| 30 | −} from "#schema"; | |
| 21 | +import type { AccountId, Source, SourceRecipeSchemaBundleId } from "#schema"; | |
| 22 | +import { SourceRecipeSchemaBundleIdSchema } from "#schema"; | |
| 31 | 23 | import * as Context from "effect/Context"; |
| 32 | 24 | import * as Either from "effect/Either"; |
| 33 | 25 | import * as Effect from "effect/Effect"; |
| @@ -40,28 +32,23 @@ import type { · 6 unmodified lines | ||
| 40 | 32 | ResolveExecutionEnvironment, |
| 41 | 33 | } from "./execution-state"; |
| 42 | 34 | import { createExecutorToolMap } from "./executor-tools"; |
| 43 | −import { | |
| 44 | − createGraphqlToolsFromManifest, | |
| 45 | − type GraphqlToolManifest, | |
| 46 | −} from "./graphql-tools"; | |
| 47 | 35 | import { |
| 48 | 36 | RuntimeSourceAuthServiceTag, |
| 49 | 37 | type RuntimeSourceAuthService, |
| 50 | 38 | } from "./source-auth-service"; |
| 51 | 39 | import { |
| 52 | − expandRecipeTools, | |
| 53 | − loadWorkspaceSourceRecipes, | |
| 54 | − type LoadedSourceRecipeTool, | |
| 40 | + loadWorkspaceSourceRecipeToolByPath, | |
| 41 | + loadWorkspaceSourceRecipeToolIndex, | |
| 42 | + type LoadedSourceRecipeToolIndexEntry, | |
| 55 | 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 | 47 | import { |
| 57 | 48 | createDefaultSecretMaterialResolver, |
| 58 | 49 | type ResolveSecretMaterial, |
| 59 | 50 | type SecretMaterialResolveContext, |
| 60 | 51 | } from "./secret-material-providers"; |
| 61 | −import { | |
| 62 | − namespaceFromSourceName, | |
| 63 | − resolveSourceAuthMaterial, | |
| 64 | −} from "./tool-artifacts"; | |
| 65 | 52 | import { |
| 66 | 53 | evaluateInvocationPolicy, |
| 67 | 54 | type InvocationDescriptor, |
| @@ -76,7 +63,6 @@ const tokenize = (value: string): string[] => · 8 unmodified lines | ||
| 76 | 63 | .split(/[^a-z0-9]+/) |
| 77 | 64 | .filter(Boolean); |
| 78 | 65 | |
| 79 | − | |
| 80 | 66 | const LOW_SIGNAL_QUERY_TOKENS = new Set([ |
| 81 | 67 | "a", |
| 82 | 68 | "an", |
| @@ -101,15 +87,15 @@ const LOW_SIGNAL_QUERY_TOKENS = new Set([ · 18 unmodified lines | ||
| 101 | 87 | ]); |
| 102 | 88 | |
| 103 | 89 | const singularizeToken = (value: string): string => |
| 104 | − value.length > 3 && value.endsWith("s") | |
| 105 | − ? value.slice(0, -1) | |
| 106 | − : value; | |
| 90 | + value.length > 3 && value.endsWith("s") ? value.slice(0, -1) : value; | |
| 107 | 91 | |
| 108 | 92 | const tokenEquals = (left: string, right: string): boolean => |
| 109 | 93 | left === right || singularizeToken(left) === singularizeToken(right); |
| 110 | 94 | |
| 111 | −const hasTokenMatch = (tokens: readonly string[], queryToken: string): boolean => | |
| 112 | − tokens.some((token) => tokenEquals(token, queryToken)); | |
| 95 | +const hasTokenMatch = ( | |
| 96 | + tokens: readonly string[], | |
| 97 | + queryToken: string, | |
| 98 | +): boolean => tokens.some((token) => tokenEquals(token, queryToken)); | |
| 113 | 99 | |
| 114 | 100 | const hasSubstringMatch = (value: string, queryToken: string): boolean => { |
| 115 | 101 | if (value.includes(queryToken)) { |
| @@ -132,7 +118,6 @@ const SecretResolutionContextEnvelopeSchema = Schema.Struct({ · 16 unmodified lines | ||
| 132 | 118 | const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither( |
| 133 | 119 | SecretResolutionContextEnvelopeSchema, |
| 134 | 120 | ); |
| 135 | − | |
| 136 | 121 | const toSecretResolutionContext = ( |
| 137 | 122 | value: unknown, |
| 138 | 123 | ): SecretMaterialResolveContext | undefined => { |
| @@ -154,37 +139,89 @@ const loadWorkspaceRecipeTools = (input: { · 15 unmodified lines | ||
| 154 | 139 | workspaceId: Source["workspaceId"]; |
| 155 | 140 | accountId: AccountId; |
| 156 | 141 | includeSchemas: boolean; |
| 157 | −}): Effect.Effect<readonly LoadedSourceRecipeTool[], Error, never> => | |
| 158 | − Effect.gen(function* () { | |
| 159 | − const recipes = yield* loadWorkspaceSourceRecipes({ | |
| 142 | +}): Effect.Effect<readonly LoadedSourceRecipeToolIndexEntry[], Error, never> => | |
| 143 | + Effect.map( | |
| 144 | + loadWorkspaceSourceRecipeToolIndex({ | |
| 160 | 145 | rows: input.rows, |
| 161 | 146 | workspaceId: input.workspaceId, |
| 162 | 147 | actorAccountId: input.accountId, |
| 163 | − }); | |
| 164 | − | |
| 165 | − return expandRecipeTools({ | |
| 166 | − recipes: recipes.filter((recipe) => | |
| 167 | − recipe.source.enabled && recipe.source.status === "connected" | |
| 168 | − ), | |
| 169 | 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 | 210 | const scoreRecipeTool = ( |
| 174 | 211 | queryTokens: readonly string[], |
| 175 | − tool: LoadedSourceRecipeTool, | |
| 212 | + tool: LoadedSourceRecipeToolIndexEntry, | |
| 176 | 213 | ): number => { |
| 177 | 214 | const pathText = tool.path.toLowerCase(); |
| 178 | 215 | const namespaceText = tool.searchNamespace.toLowerCase(); |
| 179 | 216 | const toolIdText = tool.operation.toolId.toLowerCase(); |
| 180 | 217 | const titleText = tool.operation.title?.toLowerCase() ?? ""; |
| 181 | 218 | const descriptionText = tool.operation.description?.toLowerCase() ?? ""; |
| 182 | − const templateText = tool.operation.openApiPathTemplate?.toLowerCase() ?? ""; | |
| 219 | + const templateText = tool.metadata.pathTemplate?.toLowerCase() ?? ""; | |
| 183 | 220 | |
| 184 | 221 | const pathTokens = tokenize(`${tool.path} ${tool.operation.toolId}`); |
| 185 | 222 | const namespaceTokens = tokenize(tool.searchNamespace); |
| 186 | 223 | const titleTokens = tokenize(tool.operation.title ?? ""); |
| 187 | − const templateTokens = tokenize(tool.operation.openApiPathTemplate ?? ""); | |
| 224 | + const templateTokens = tokenize(tool.metadata.pathTemplate ?? ""); | |
| 188 | 225 | |
| 189 | 226 | let score = 0; |
| 190 | 227 | let structuralHits = 0; |
| @@ -220,7 +257,10 @@ const scoreRecipeTool = ( · 29 unmodified lines | ||
| 220 | 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 | 264 | score += 6 * weight; |
| 225 | 265 | structuralHits += 1; |
| 226 | 266 | pathHits += 1; |
| @@ -234,7 +274,10 @@ const scoreRecipeTool = ( · 7 unmodified lines | ||
| 234 | 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 | 281 | score += 4 * weight; |
| 239 | 282 | structuralHits += 1; |
| 240 | 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 | 294 | if (strongTokens.length >= 2) { |
| 250 | 295 | for (let index = 0; index < strongTokens.length - 1; index += 1) { |
| 251 | 296 | const current = strongTokens[index]!; |
| @@ -256,7 +301,12 @@ const scoreRecipeTool = ( · 4 unmodified lines | ||
| 256 | 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 | 310 | score += 10; |
| 261 | 311 | } |
| 262 | 312 | } |
| @@ -285,39 +335,29 @@ const approvalSchema = { · 22 unmodified lines | ||
| 285 | 335 | additionalProperties: false, |
| 286 | 336 | } satisfies Record<string, unknown>; |
| 287 | 337 | |
| 288 | −const approvalMessageForInvocation = (descriptor: InvocationDescriptor): string => { | |
| 289 | − if (descriptor.httpMethod && descriptor.httpPathTemplate) { | |
| 290 | − return `Allow ${descriptor.httpMethod.toUpperCase()} ${descriptor.httpPathTemplate}?`; | |
| 291 | − } | |
| 292 | − | |
| 293 | − if (descriptor.graphqlOperationType) { | |
| 294 | − return `Allow GraphQL ${descriptor.graphqlOperationType} ${descriptor.toolPath}?`; | |
| 338 | +const approvalMessageForInvocation = ( | |
| 339 | + descriptor: InvocationDescriptor, | |
| 340 | +): string => { | |
| 341 | + if (descriptor.approvalLabel) { | |
| 342 | + return `Allow ${descriptor.approvalLabel}?`; | |
| 295 | 343 | } |
| 296 | 344 | |
| 297 | 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 | 348 | const toInvocationDescriptorFromRecipeTool = (input: { |
| 308 | − tool: LoadedSourceRecipeTool; | |
| 349 | + tool: LoadedSourceRecipeToolIndexEntry; | |
| 309 | 350 | }): InvocationDescriptor => ({ |
| 310 | 351 | toolPath: input.tool.path, |
| 311 | 352 | sourceId: input.tool.source.id, |
| 312 | 353 | sourceName: input.tool.source.name, |
| 313 | 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 | 358 | operationKind: input.tool.operation.operationKind, |
| 316 | − httpMethod: input.tool.operation.openApiMethod?.toUpperCase() ?? null, | |
| 317 | − httpPathTemplate: input.tool.operation.openApiPathTemplate, | |
| 318 | − graphqlOperationType: toGraphqlInvocationOperationType( | |
| 319 | − input.tool.operation.graphqlOperationType, | |
| 320 | − ), | |
| 359 | + interaction: input.tool.metadata.interaction, | |
| 360 | + approvalLabel: input.tool.metadata.approvalLabel, | |
| 321 | 361 | }); |
| 322 | 362 | |
| 323 | 363 | const authorizePersistedToolInvocation = (input: { |
| @@ -328,23 +368,29 @@ const authorizePersistedToolInvocation = (input: { · 4 unmodified lines | ||
| 328 | 368 | args: unknown; |
| 329 | 369 | source: Source; |
| 330 | 370 | context?: Record<string, unknown>; |
| 331 | − onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"]; | |
| 371 | + onElicitation?: Parameters< | |
| 372 | + typeof makeToolInvokerFromTools | |
| 373 | + >[0]["onElicitation"]; | |
| 332 | 374 | }): Effect.Effect<void, Error, never> => |
| 333 | 375 | Effect.gen(function* () { |
| 334 | − const workspace = yield* input.rows.workspaces.getById(input.workspaceId).pipe( | |
| 335 | − Effect.mapError((cause) => | |
| 336 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 337 | − ), | |
| 338 | − ); | |
| 339 | − const policies = Option.isSome(workspace) | |
| 340 | − ? yield* input.rows.policies.listForWorkspaceContext({ | |
| 341 | − organizationId: workspace.value.organizationId, | |
| 342 | − workspaceId: input.workspaceId, | |
| 343 | − }).pipe( | |
| 376 | + const workspace = yield* input.rows.workspaces | |
| 377 | + .getById(input.workspaceId) | |
| 378 | + .pipe( | |
| 344 | 379 | Effect.mapError((cause) => |
| 345 | 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 | 396 | const decision = evaluateInvocationPolicy({ |
| @@ -358,8 +404,8 @@ const authorizePersistedToolInvocation = (input: { · 7 unmodified lines | ||
| 358 | 404 | : ("org_unknown" as never), |
| 359 | 405 | accountId: input.accountId, |
| 360 | 406 | clientId: |
| 361 | − typeof input.context?.clientId === "string" | |
| 362 | − && input.context.clientId.length > 0 | |
| 407 | + typeof input.context?.clientId === "string" && | |
| 408 | + input.context.clientId.length > 0 | |
| 363 | 409 | ? input.context.clientId |
| 364 | 410 | : null, |
| 365 | 411 | }, |
| @@ -375,45 +421,52 @@ const authorizePersistedToolInvocation = (input: { · 9 unmodified lines | ||
| 375 | 421 | |
| 376 | 422 | if (!input.onElicitation) { |
| 377 | 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 | |
| 383 | − ? `tool_execution_gate:${input.context.callId}` | |
| 384 | − : `tool_execution_gate:${crypto.randomUUID()}`; | |
| 385 | − const response = yield* input.onElicitation({ | |
| 386 | − interactionId, | |
| 387 | − path: asToolPath(input.descriptor.toolPath), | |
| 388 | − sourceKey: input.source.id, | |
| 389 | − args: input.args, | |
| 390 | − context: { | |
| 391 | − ...(input.context ?? {}), | |
| 392 | − interactionPurpose: "tool_execution_gate", | |
| 393 | − interactionReason: decision.reason, | |
| 394 | − invocationDescriptor: { | |
| 395 | − operationKind: input.descriptor.operationKind, | |
| 396 | − httpMethod: input.descriptor.httpMethod, | |
| 397 | − httpPathTemplate: input.descriptor.httpPathTemplate, | |
| 398 | − graphqlOperationType: input.descriptor.graphqlOperationType, | |
| 399 | − sourceId: input.source.id, | |
| 400 | − sourceName: input.source.name, | |
| 430 | + const interactionId = | |
| 431 | + typeof input.context?.callId === "string" && | |
| 432 | + input.context.callId.length > 0 | |
| 433 | + ? `tool_execution_gate:${input.context.callId}` | |
| 434 | + : `tool_execution_gate:${crypto.randomUUID()}`; | |
| 435 | + const response = yield* input | |
| 436 | + .onElicitation({ | |
| 437 | + interactionId, | |
| 438 | + path: asToolPath(input.descriptor.toolPath), | |
| 439 | + sourceKey: input.source.id, | |
| 440 | + args: input.args, | |
| 441 | + context: { | |
| 442 | + ...(input.context ?? {}), | |
| 443 | + interactionPurpose: "tool_execution_gate", | |
| 444 | + interactionReason: decision.reason, | |
| 445 | + invocationDescriptor: { | |
| 446 | + operationKind: input.descriptor.operationKind, | |
| 447 | + interaction: input.descriptor.interaction, | |
| 448 | + approvalLabel: input.descriptor.approvalLabel, | |
| 449 | + sourceId: input.source.id, | |
| 450 | + sourceName: input.source.name, | |
| 451 | + }, | |
| 401 | 452 | }, |
| 402 | − }, | |
| 403 | − elicitation: { | |
| 404 | − mode: "form", | |
| 405 | − message: approvalMessageForInvocation(input.descriptor), | |
| 406 | − requestedSchema: approvalSchema, | |
| 407 | − }, | |
| 408 | − }).pipe( | |
| 409 | − Effect.mapError((cause) => | |
| 410 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 411 | − ), | |
| 412 | − ); | |
| 453 | + elicitation: { | |
| 454 | + mode: "form", | |
| 455 | + message: approvalMessageForInvocation(input.descriptor), | |
| 456 | + requestedSchema: approvalSchema, | |
| 457 | + }, | |
| 458 | + }) | |
| 459 | + .pipe( | |
| 460 | + Effect.mapError((cause) => | |
| 461 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 462 | + ), | |
| 463 | + ); | |
| 413 | 464 | |
| 414 | 465 | if (response.action !== "accept") { |
| 415 | 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 | 503 | namespace: namespace.namespace, |
| 451 | 504 | displayName: namespace.displayName ?? existing?.displayName, |
| 452 | 505 | toolCount: |
| 453 | − namespace.toolCount !== undefined || existing?.toolCount === undefined | |
| 506 | + namespace.toolCount !== undefined || | |
| 507 | + existing?.toolCount === undefined | |
| 454 | 508 | ? namespace.toolCount |
| 455 | 509 | : existing.toolCount, |
| 456 | 510 | }); |
| @@ -478,7 +532,9 @@ const createWorkspaceToolCatalog = (input: { · 21 unmodified lines | ||
| 478 | 532 | if (!query) { |
| 479 | 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 [ | |
| 494 | − ...recipeTools.map((tool) => tool.descriptor), | |
| 495 | − ...executor, | |
| 496 | − ] | |
| 549 | + return [...recipeTools.map((tool) => tool.descriptor), ...executor] | |
| 497 | 550 | .sort((left, right) => left.path.localeCompare(right.path)) |
| 498 | 551 | .slice(0, limit); |
| 499 | 552 | }), |
| @@ -508,13 +561,21 @@ const createWorkspaceToolCatalog = (input: { · 8 unmodified lines | ||
| 508 | 561 | return executor; |
| 509 | 562 | } |
| 510 | 563 | |
| 511 | − const recipeTools = yield* loadWorkspaceRecipeTools({ | |
| 564 | + const recipeTool = yield* loadWorkspaceRecipeToolByPath({ | |
| 512 | 565 | rows: input.rows, |
| 513 | 566 | workspaceId: input.workspaceId, |
| 514 | 567 | accountId: input.accountId, |
| 568 | + path, | |
| 515 | 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 | 581 | searchTools: ({ query, namespace, limit }) => |
| @@ -528,7 +589,9 @@ const createWorkspaceToolCatalog = (input: { · 7 unmodified lines | ||
| 528 | 589 | includeSchemas: false, |
| 529 | 590 | }).pipe( |
| 530 | 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 | 597 | input.executorCatalog.searchTools({ |
| @@ -546,8 +609,9 @@ const createWorkspaceToolCatalog = (input: { · 11 unmodified lines | ||
| 546 | 609 | .filter((hit) => hit.score > 0); |
| 547 | 610 | |
| 548 | 611 | return [...recipeHits, ...executor] |
| 549 | − .sort((left, right) => | |
| 550 | − right.score - left.score || left.path.localeCompare(right.path), | |
| 612 | + .sort( | |
| 613 | + (left, right) => | |
| 614 | + right.score - left.score || left.path.localeCompare(right.path), | |
| 551 | 615 | ) |
| 552 | 616 | .slice(0, limit); |
| 553 | 617 | }), |
| @@ -559,11 +623,13 @@ const createWorkspaceToolInvoker = (input: { · 5 unmodified lines | ||
| 559 | 623 | rows: SqlControlPlaneRows; |
| 560 | 624 | resolveSecretMaterial: ResolveSecretMaterial; |
| 561 | 625 | sourceAuthService: RuntimeSourceAuthService; |
| 562 | − onElicitation?: Parameters<typeof makeToolInvokerFromTools>[0]["onElicitation"]; | |
| 626 | + onElicitation?: Parameters< | |
| 627 | + typeof makeToolInvokerFromTools | |
| 628 | + >[0]["onElicitation"]; | |
| 563 | 629 | }): { |
| 564 | 630 | catalog: ToolCatalog; |
| 565 | 631 | toolInvoker: ToolInvoker; |
| 566 | − } => { | |
| 632 | +} => { | |
| 567 | 633 | const executorTools = createExecutorToolMap({ |
| 568 | 634 | workspaceId: input.workspaceId, |
| 569 | 635 | accountId: input.accountId, |
| @@ -596,15 +662,17 @@ const createWorkspaceToolInvoker = (input: { · 26 unmodified lines | ||
| 596 | 662 | context?: Record<string, unknown>; |
| 597 | 663 | }) => |
| 598 | 664 | Effect.gen(function* () { |
| 599 | − const recipeTools = yield* loadWorkspaceRecipeTools({ | |
| 665 | + const recipeTool = yield* loadWorkspaceRecipeToolByPath({ | |
| 600 | 666 | rows: input.rows, |
| 601 | 667 | workspaceId: input.workspaceId, |
| 602 | 668 | accountId: input.accountId, |
| 669 | + path: invocation.path, | |
| 603 | 670 | includeSchemas: false, |
| 604 | 671 | }); |
| 605 | − const recipeTool = recipeTools.find((tool) => tool.path === invocation.path); | |
| 606 | 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 | 678 | yield* authorizePersistedToolInvocation({ |
| @@ -623,94 +691,40 @@ const createWorkspaceToolInvoker = (input: { · 12 unmodified lines | ||
| 623 | 691 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 624 | 692 | context: toSecretResolutionContext(invocation.context), |
| 625 | 693 | }); |
| 626 | − | |
| 627 | − if (recipeTool.operation.providerKind === "openapi") { | |
| 628 | − if (recipeTool.manifest === null) { | |
| 629 | − return yield* Effect.fail( | |
| 630 | − new Error(`Missing OpenAPI manifest for ${recipeTool.source.id}`), | |
| 631 | − ); | |
| 632 | − } | |
| 633 | − | |
| 634 | − const tools = createOpenApiToolsFromManifest({ | |
| 635 | − manifest: recipeTool.manifest as OpenApiToolManifest, | |
| 636 | − baseUrl: recipeTool.source.endpoint, | |
| 637 | − namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name), | |
| 638 | − sourceKey: recipeTool.source.id, | |
| 639 | − defaultHeaders: recipeTool.source.defaultHeaders ?? {}, | |
| 640 | − credentialHeaders: auth.headers, | |
| 641 | − }); | |
| 642 | − | |
| 643 | − return yield* makeToolInvokerFromTools({ | |
| 644 | − tools, | |
| 645 | − onElicitation: input.onElicitation, | |
| 646 | − }).invoke({ | |
| 647 | − path: invocation.path, | |
| 648 | − args: invocation.args, | |
| 649 | − context: invocation.context, | |
| 650 | − }); | |
| 694 | + const schemaBundle = recipeTool.schemaBundleId | |
| 695 | + ? yield* loadWorkspaceSchemaBundle({ | |
| 696 | + rows: input.rows, | |
| 697 | + workspaceId: input.workspaceId, | |
| 698 | + id: SourceRecipeSchemaBundleIdSchema.make(recipeTool.schemaBundleId), | |
| 699 | + }) | |
| 700 | + : null; | |
| 701 | + const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( | |
| 702 | + input.workspaceId, | |
| 703 | + recipeTool.source.id, | |
| 704 | + ); | |
| 705 | + let manifestJson: string | null = null; | |
| 706 | + if (Option.isSome(sourceRecord)) { | |
| 707 | + const revision = yield* input.rows.sourceRecipeRevisions.getById( | |
| 708 | + sourceRecord.value.recipeRevisionId, | |
| 709 | + ); | |
| 710 | + manifestJson = Option.isSome(revision) ? revision.value.manifestJson : null; | |
| 651 | 711 | } |
| 652 | 712 | |
| 653 | − if (recipeTool.operation.providerKind === "graphql") { | |
| 654 | − if (recipeTool.manifest === null) { | |
| 655 | − return yield* Effect.fail( | |
| 656 | − new Error(`Missing GraphQL manifest for ${recipeTool.source.id}`), | |
| 657 | − ); | |
| 658 | − } | |
| 659 | − | |
| 660 | − const tools = createGraphqlToolsFromManifest({ | |
| 661 | − manifest: recipeTool.manifest as GraphqlToolManifest, | |
| 662 | − endpoint: recipeTool.source.endpoint, | |
| 663 | − namespace: recipeTool.source.namespace ?? namespaceFromSourceName(recipeTool.source.name), | |
| 664 | − sourceKey: recipeTool.source.id, | |
| 665 | − defaultHeaders: recipeTool.source.defaultHeaders ?? {}, | |
| 666 | − credentialHeaders: auth.headers, | |
| 667 | − }); | |
| 668 | − | |
| 669 | − return yield* makeToolInvokerFromTools({ | |
| 670 | − tools, | |
| 671 | − onElicitation: input.onElicitation, | |
| 672 | − }).invoke({ | |
| 713 | + return yield* getSourceAdapterForOperation(recipeTool.operation) | |
| 714 | + .invokePersistedTool({ | |
| 715 | + rows: input.rows, | |
| 716 | + workspaceId: input.workspaceId, | |
| 717 | + accountId: input.accountId, | |
| 718 | + source: recipeTool.source, | |
| 673 | 719 | path: invocation.path, |
| 720 | + operation: recipeTool.operation, | |
| 721 | + schemaBundle, | |
| 722 | + manifestJson, | |
| 723 | + auth, | |
| 674 | 724 | args: invocation.args, |
| 675 | 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 | 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 | 730 | return { |
| @@ -732,8 +746,8 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 15 unmodified lines | ||
| 732 | 746 | sourceAuthService: RuntimeSourceAuthService; |
| 733 | 747 | }): ResolveExecutionEnvironment => { |
| 734 | 748 | const resolveSecretMaterial = |
| 735 | − input.resolveSecretMaterial | |
| 736 | − ?? createDefaultSecretMaterialResolver({ | |
| 749 | + input.resolveSecretMaterial ?? | |
| 750 | + createDefaultSecretMaterialResolver({ | |
| 737 | 751 | rows: input.rows, |
| 738 | 752 | }); |
| 739 | 753 | |
| @@ -765,25 +779,26 @@ export class RuntimeExecutionResolverService extends Context.Tag( · 25 unmodified lines | ||
| 765 | 779 | )< |
| 766 | 780 | RuntimeExecutionResolverService, |
| 767 | 781 | ReturnType<typeof createWorkspaceExecutionEnvironmentResolver> |
| 768 | −>() { | |
| 769 | −} | |
| 770 | − | |
| 771 | −export const RuntimeExecutionResolverLive = (input: { | |
| 772 | − executionResolver?: ResolveExecutionEnvironment; | |
| 773 | − resolveSecretMaterial?: ResolveSecretMaterial; | |
| 774 | −} = {}) => | |
| 782 | +>() {} | |
| 783 | + | |
| 784 | +export const RuntimeExecutionResolverLive = ( | |
| 785 | + input: { | |
| 786 | + executionResolver?: ResolveExecutionEnvironment; | |
| 787 | + resolveSecretMaterial?: ResolveSecretMaterial; | |
| 788 | + } = {}, | |
| 789 | +) => | |
| 775 | 790 | input.executionResolver |
| 776 | 791 | ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver) |
| 777 | 792 | : Layer.effect( |
| 778 | − RuntimeExecutionResolverService, | |
| 779 | − Effect.gen(function* () { | |
| 780 | − const rows = yield* SqlControlPlaneRowsService; | |
| 781 | − const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | |
| 782 | − | |
| 783 | − return createWorkspaceExecutionEnvironmentResolver({ | |
| 784 | − rows, | |
| 785 | − sourceAuthService, | |
| 786 | − resolveSecretMaterial: input.resolveSecretMaterial, | |
| 787 | − }); | |
| 788 | − }), | |
| 789 | − ); | |
| 793 | + RuntimeExecutionResolverService, | |
| 794 | + Effect.gen(function* () { | |
| 795 | + const rows = yield* SqlControlPlaneRowsService; | |
| 796 | + const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | |
| 797 | + | |
| 798 | + return createWorkspaceExecutionEnvironmentResolver({ | |
| 799 | + rows, | |
| 800 | + sourceAuthService, | |
| 801 | + resolveSecretMaterial: input.resolveSecretMaterial, | |
| 802 | + }); | |
| 803 | + }), | |
| 804 | + ); | |
| @@ -11,6 +11,9 @@ export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecip · 10 unmodified lines | ||
| 11 | 11 | export const SourceRecipeRevisionIdSchema = Schema.String.pipe( |
| 12 | 12 | Schema.brand("SourceRecipeRevisionId"), |
| 13 | 13 | ); |
| 14 | +export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe( | |
| 15 | + Schema.brand("SourceRecipeSchemaBundleId"), | |
| 16 | +); | |
| 14 | 17 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 15 | 18 | Schema.brand("SourceAuthSessionId"), |
| 16 | 19 | ); |
| @@ -37,6 +40,7 @@ export type WorkspaceId = typeof WorkspaceIdSchema.Type; · 20 unmodified lines | ||
| 37 | 40 | export type SourceId = typeof SourceIdSchema.Type; |
| 38 | 41 | export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; |
| 39 | 42 | export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; |
| 43 | +export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type; | |
| 40 | 44 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 41 | 45 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 42 | 46 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; |
| @@ -8,7 +8,6 @@ export * from "./models/source"; · 7 unmodified lines | ||
| 8 | 8 | export * from "./models/source-recipe"; |
| 9 | 9 | export * from "./models/credential"; |
| 10 | 10 | export * from "./models/source-oauth-client"; |
| 11 | −export * from "./models/tool-artifact"; | |
| 12 | 11 | export * from "./models/secret-material"; |
| 13 | 12 | export * from "./models/source-auth-session"; |
| 14 | 13 | export * from "./models/policy"; |
| @@ -16,3 +15,4 @@ export * from "./models/local-installation"; · 1 unmodified line | ||
| 16 | 15 | export * from "./models/execution"; |
| 17 | 16 | export * from "./models/source-inspection"; |
| 18 | 17 | export * from "./models/source-discovery"; |
| 18 | +export * from "./models/data-migration"; | |
| @@ -11,12 +11,14 @@ import { · 10 unmodified lines | ||
| 11 | 11 | } from "../ids"; |
| 12 | 12 | |
| 13 | 13 | export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); |
| 14 | +export const CredentialSlotSchema = Schema.Literal("runtime", "import"); | |
| 14 | 15 | |
| 15 | 16 | const credentialSchemaOverrides = { |
| 16 | 17 | id: CredentialIdSchema, |
| 17 | 18 | workspaceId: WorkspaceIdSchema, |
| 18 | 19 | sourceId: SourceIdSchema, |
| 19 | 20 | actorAccountId: Schema.NullOr(AccountIdSchema), |
| 21 | + slot: CredentialSlotSchema, | |
| 20 | 22 | authKind: CredentialAuthKindSchema, |
| 21 | 23 | authHeaderName: Schema.String, |
| 22 | 24 | authPrefix: Schema.String, |
| @@ -34,4 +36,5 @@ export const CredentialSchema = createSelectSchema( · 11 unmodified lines | ||
| 34 | 36 | ); |
| 35 | 37 | |
| 36 | 38 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; |
| 39 | +export type CredentialSlot = typeof CredentialSlotSchema.Type; | |
| 37 | 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 | 11 | SourceIdSchema, |
| 12 | 12 | WorkspaceIdSchema, |
| 13 | 13 | } from "../ids"; |
| 14 | +import { CredentialSlotSchema } from "./credential"; | |
| 14 | 15 | |
| 15 | 16 | export const SourceAuthSessionProviderKindSchema = Schema.Literal( |
| 16 | 17 | "mcp_oauth", |
| @@ -78,6 +79,7 @@ const sourceAuthSessionSchemaOverrides = { · 61 unmodified lines | ||
| 78 | 79 | workspaceId: WorkspaceIdSchema, |
| 79 | 80 | sourceId: SourceIdSchema, |
| 80 | 81 | actorAccountId: Schema.NullOr(AccountIdSchema), |
| 82 | + credentialSlot: CredentialSlotSchema, | |
| 81 | 83 | executionId: Schema.NullOr(ExecutionIdSchema), |
| 82 | 84 | interactionId: Schema.NullOr(ExecutionInteractionIdSchema), |
| 83 | 85 | providerKind: SourceAuthSessionProviderKindSchema, |
| @@ -1,4 +1,3 @@ | ||
| 1 | −import { OpenApiHttpMethodSchema } from "@executor/codemode-openapi"; | |
| 2 | 1 | import { Schema } from "effect"; |
| 3 | 2 | |
| 4 | 3 | import { SourceSchema } from "./source"; |
| @@ -21,21 +20,23 @@ export const SourceInspectionToolSummarySchema = Schema.Struct({ · 16 unmodified lines | ||
| 21 | 20 | group: Schema.NullOr(Schema.String), |
| 22 | 21 | leaf: Schema.NullOr(Schema.String), |
| 23 | 22 | tags: Schema.Array(Schema.String), |
| 24 | − method: Schema.NullOr(OpenApiHttpMethodSchema), | |
| 23 | + method: Schema.NullOr(Schema.String), | |
| 25 | 24 | pathTemplate: Schema.NullOr(Schema.String), |
| 26 | 25 | inputType: Schema.optional(Schema.String), |
| 27 | 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 | 34 | export const SourceInspectionSchema = Schema.Struct({ |
| 31 | 35 | source: SourceSchema, |
| 32 | 36 | namespace: Schema.String, |
| 33 | 37 | pipelineKind: SourceInspectionPipelineKindSchema, |
| 34 | 38 | toolCount: Schema.Number, |
| 35 | − rawDocumentText: Schema.NullOr(Schema.String), | |
| 36 | − manifestJson: Schema.NullOr(Schema.String), | |
| 37 | − definitionsJson: Schema.NullOr(Schema.String), | |
| 38 | − tools: Schema.Array(SourceInspectionToolSummarySchema), | |
| 39 | + tools: Schema.Array(SourceInspectionToolListItemSchema), | |
| 39 | 40 | }); |
| 40 | 41 | |
| 41 | 42 | export const SourceInspectionToolDetailSchema = Schema.Struct({ |
| @@ -45,10 +46,18 @@ export const SourceInspectionToolDetailSchema = Schema.Struct({ · 3 unmodified lines | ||
| 45 | 46 | providerDataJson: Schema.NullOr(Schema.String), |
| 46 | 47 | inputSchemaJson: Schema.NullOr(Schema.String), |
| 47 | 48 | outputSchemaJson: Schema.NullOr(Schema.String), |
| 49 | + schemaBundleId: Schema.NullOr(Schema.String), | |
| 48 | 50 | exampleInputJson: Schema.NullOr(Schema.String), |
| 49 | 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 | 61 | export const SourceInspectionDiscoverPayloadSchema = Schema.Struct({ |
| 53 | 62 | query: Schema.String, |
| 54 | 63 | limit: Schema.optional(Schema.Number), |
| @@ -75,9 +84,13 @@ export type SourceInspectionPipelineKind = · 20 unmodified lines | ||
| 75 | 84 | typeof SourceInspectionPipelineKindSchema.Type; |
| 76 | 85 | export type SourceInspectionToolSummary = |
| 77 | 86 | typeof SourceInspectionToolSummarySchema.Type; |
| 87 | +export type SourceInspectionToolListItem = | |
| 88 | + typeof SourceInspectionToolListItemSchema.Type; | |
| 78 | 89 | export type SourceInspection = typeof SourceInspectionSchema.Type; |
| 79 | 90 | export type SourceInspectionToolDetail = |
| 80 | 91 | typeof SourceInspectionToolDetailSchema.Type; |
| 92 | +export type SourceInspectionSchemaBundle = | |
| 93 | + typeof SourceInspectionSchemaBundleSchema.Type; | |
| 81 | 94 | export type SourceInspectionDiscoverPayload = |
| 82 | 95 | typeof SourceInspectionDiscoverPayloadSchema.Type; |
| 83 | 96 | export type SourceInspectionDiscoverResultItem = |
| @@ -4,6 +4,7 @@ import { Schema } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | sourceRecipeDocumentsTable, |
| 6 | 6 | sourceRecipeOperationsTable, |
| 7 | + sourceRecipeSchemaBundlesTable, | |
| 7 | 8 | sourceRecipeRevisionsTable, |
| 8 | 9 | sourceRecipesTable, |
| 9 | 10 | } from "../../persistence/schema"; |
| @@ -11,24 +12,16 @@ import { TimestampMsSchema } from "../common"; · 1 unmodified line | ||
| 11 | 12 | import { |
| 12 | 13 | SourceRecipeIdSchema, |
| 13 | 14 | SourceRecipeRevisionIdSchema, |
| 15 | + SourceRecipeSchemaBundleIdSchema, | |
| 14 | 16 | } from "../ids"; |
| 15 | 17 | |
| 16 | 18 | export const SourceRecipeKindSchema = Schema.Literal( |
| 17 | − "http_recipe", | |
| 18 | − "graphql_recipe", | |
| 19 | − "mcp_recipe", | |
| 20 | − "internal_recipe", | |
| 19 | + "http_api", | |
| 20 | + "mcp", | |
| 21 | + "internal", | |
| 21 | 22 | ); |
| 22 | 23 | |
| 23 | −export const SourceRecipeImporterKindSchema = Schema.Literal( | |
| 24 | − "openapi", | |
| 25 | − "google_discovery", | |
| 26 | − "postman_collection", | |
| 27 | − "snippet_bundle", | |
| 28 | − "graphql_introspection", | |
| 29 | − "mcp_manifest", | |
| 30 | − "internal_manifest", | |
| 31 | −); | |
| 24 | +export const SourceRecipeImporterKindSchema = Schema.String; | |
| 32 | 25 | |
| 33 | 26 | export const SourceRecipeVisibilitySchema = Schema.Literal( |
| 34 | 27 | "private", |
| @@ -37,14 +30,9 @@ export const SourceRecipeVisibilitySchema = Schema.Literal( · 2 unmodified lines | ||
| 37 | 30 | "public", |
| 38 | 31 | ); |
| 39 | 32 | |
| 40 | −export const SourceRecipeDocumentKindSchema = Schema.Literal( | |
| 41 | − "google_discovery", | |
| 42 | − "openapi", | |
| 43 | − "postman_collection", | |
| 44 | − "postman_environment", | |
| 45 | − "graphql_introspection", | |
| 46 | − "mcp_manifest", | |
| 47 | −); | |
| 33 | +export const SourceRecipeDocumentKindSchema = Schema.String; | |
| 34 | + | |
| 35 | +export const SourceRecipeSchemaBundleKindSchema = Schema.String; | |
| 48 | 36 | |
| 49 | 37 | export const SourceRecipeTransportKindSchema = Schema.Literal( |
| 50 | 38 | "http", |
| @@ -60,29 +48,7 @@ export const SourceRecipeOperationKindSchema = Schema.Literal( · 9 unmodified lines | ||
| 60 | 48 | "unknown", |
| 61 | 49 | ); |
| 62 | 50 | |
| 63 | −export const SourceRecipeOperationProviderKindSchema = Schema.Literal( | |
| 64 | − "mcp", | |
| 65 | − "openapi", | |
| 66 | − "graphql", | |
| 67 | − "internal", | |
| 68 | −); | |
| 69 | − | |
| 70 | −export const SourceRecipeOpenApiMethodSchema = Schema.Literal( | |
| 71 | − "get", | |
| 72 | − "put", | |
| 73 | − "post", | |
| 74 | − "delete", | |
| 75 | − "patch", | |
| 76 | − "head", | |
| 77 | − "options", | |
| 78 | − "trace", | |
| 79 | −); | |
| 80 | − | |
| 81 | −export const SourceRecipeGraphqlOperationTypeSchema = Schema.Literal( | |
| 82 | − "query", | |
| 83 | − "mutation", | |
| 84 | − "subscription", | |
| 85 | −); | |
| 51 | +export const SourceRecipeOperationProviderKindSchema = Schema.String; | |
| 86 | 52 | |
| 87 | 53 | const recipeRowSchemaOverrides = { |
| 88 | 54 | id: SourceRecipeIdSchema, |
| @@ -98,6 +64,7 @@ const recipeRevisionRowSchemaOverrides = { · 9 unmodified lines | ||
| 98 | 64 | id: SourceRecipeRevisionIdSchema, |
| 99 | 65 | recipeId: SourceRecipeIdSchema, |
| 100 | 66 | revisionNumber: Schema.Number, |
| 67 | + materializationHash: Schema.NullOr(Schema.String), | |
| 101 | 68 | createdAt: TimestampMsSchema, |
| 102 | 69 | updatedAt: TimestampMsSchema, |
| 103 | 70 | } as const; |
| @@ -110,13 +77,19 @@ const recipeDocumentRowSchemaOverrides = { · 6 unmodified lines | ||
| 110 | 77 | updatedAt: TimestampMsSchema, |
| 111 | 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 | 88 | const sourceRecipeOperationRowSchemaOverrides = { |
| 114 | 89 | recipeRevisionId: SourceRecipeRevisionIdSchema, |
| 115 | 90 | transportKind: SourceRecipeTransportKindSchema, |
| 116 | 91 | operationKind: SourceRecipeOperationKindSchema, |
| 117 | 92 | providerKind: SourceRecipeOperationProviderKindSchema, |
| 118 | − openApiMethod: Schema.NullOr(SourceRecipeOpenApiMethodSchema), | |
| 119 | − graphqlOperationType: Schema.NullOr(SourceRecipeGraphqlOperationTypeSchema), | |
| 120 | 93 | createdAt: TimestampMsSchema, |
| 121 | 94 | updatedAt: TimestampMsSchema, |
| 122 | 95 | } as const; |
| @@ -136,97 +109,16 @@ export const StoredSourceRecipeDocumentRecordSchema = createSelectSchema( · 13 unmodified lines | ||
| 136 | 109 | recipeDocumentRowSchemaOverrides, |
| 137 | 110 | ); |
| 138 | 111 | |
| 112 | +export const StoredSourceRecipeSchemaBundleRecordSchema = createSelectSchema( | |
| 113 | + sourceRecipeSchemaBundlesTable, | |
| 114 | + recipeSchemaBundleRowSchemaOverrides, | |
| 115 | +); | |
| 116 | + | |
| 139 | 117 | export const StoredSourceRecipeOperationRowSchema = createSelectSchema( |
| 140 | 118 | sourceRecipeOperationsTable, |
| 141 | 119 | sourceRecipeOperationRowSchemaOverrides, |
| 142 | 120 | ); |
| 143 | − | |
| 144 | −const sourceRecipeOperationCommonFields = { | |
| 145 | − id: Schema.String, | |
| 146 | − recipeRevisionId: SourceRecipeRevisionIdSchema, | |
| 147 | − operationKey: Schema.String, | |
| 148 | − toolId: Schema.String, | |
| 149 | − title: Schema.NullOr(Schema.String), | |
| 150 | − description: Schema.NullOr(Schema.String), | |
| 151 | − operationKind: SourceRecipeOperationKindSchema, | |
| 152 | − searchText: Schema.String, | |
| 153 | − inputSchemaJson: Schema.NullOr(Schema.String), | |
| 154 | − outputSchemaJson: Schema.NullOr(Schema.String), | |
| 155 | − providerDataJson: Schema.NullOr(Schema.String), | |
| 156 | − createdAt: TimestampMsSchema, | |
| 157 | − updatedAt: TimestampMsSchema, | |
| 158 | −} as const; | |
| 159 | − | |
| 160 | −export const StoredOpenApiSourceRecipeOperationRecordSchema = Schema.Struct({ | |
| 161 | − ...sourceRecipeOperationCommonFields, | |
| 162 | − transportKind: Schema.Literal("http"), | |
| 163 | − providerKind: Schema.Literal("openapi"), | |
| 164 | − mcpToolName: Schema.Null, | |
| 165 | − openApiMethod: SourceRecipeOpenApiMethodSchema, | |
| 166 | − openApiPathTemplate: Schema.String, | |
| 167 | − openApiOperationHash: Schema.String, | |
| 168 | − openApiRawToolId: Schema.String, | |
| 169 | − openApiOperationId: Schema.NullOr(Schema.String), | |
| 170 | − openApiTagsJson: Schema.String, | |
| 171 | − openApiRequestBodyRequired: Schema.NullOr(Schema.Boolean), | |
| 172 | − graphqlOperationType: Schema.Null, | |
| 173 | − graphqlOperationName: Schema.Null, | |
| 174 | −}); | |
| 175 | − | |
| 176 | −export const StoredGraphqlSourceRecipeOperationRecordSchema = Schema.Struct({ | |
| 177 | − ...sourceRecipeOperationCommonFields, | |
| 178 | − transportKind: Schema.Literal("graphql"), | |
| 179 | − providerKind: Schema.Literal("graphql"), | |
| 180 | − mcpToolName: Schema.Null, | |
| 181 | − openApiMethod: Schema.Null, | |
| 182 | − openApiPathTemplate: Schema.Null, | |
| 183 | − openApiOperationHash: Schema.Null, | |
| 184 | − openApiRawToolId: Schema.Null, | |
| 185 | − openApiOperationId: Schema.Null, | |
| 186 | − openApiTagsJson: Schema.Null, | |
| 187 | − openApiRequestBodyRequired: Schema.Null, | |
| 188 | − graphqlOperationType: Schema.NullOr(SourceRecipeGraphqlOperationTypeSchema), | |
| 189 | − graphqlOperationName: Schema.NullOr(Schema.String), | |
| 190 | −}); | |
| 191 | − | |
| 192 | −export const StoredMcpSourceRecipeOperationRecordSchema = Schema.Struct({ | |
| 193 | − ...sourceRecipeOperationCommonFields, | |
| 194 | − transportKind: Schema.Literal("mcp"), | |
| 195 | − providerKind: Schema.Literal("mcp"), | |
| 196 | − mcpToolName: Schema.String, | |
| 197 | − openApiMethod: Schema.Null, | |
| 198 | − openApiPathTemplate: Schema.Null, | |
| 199 | − openApiOperationHash: Schema.Null, | |
| 200 | − openApiRawToolId: Schema.Null, | |
| 201 | − openApiOperationId: Schema.Null, | |
| 202 | − openApiTagsJson: Schema.Null, | |
| 203 | − openApiRequestBodyRequired: Schema.Null, | |
| 204 | − graphqlOperationType: Schema.Null, | |
| 205 | − graphqlOperationName: Schema.Null, | |
| 206 | −}); | |
| 207 | − | |
| 208 | −export const StoredInternalSourceRecipeOperationRecordSchema = Schema.Struct({ | |
| 209 | − ...sourceRecipeOperationCommonFields, | |
| 210 | − transportKind: Schema.Literal("internal"), | |
| 211 | − providerKind: Schema.Literal("internal"), | |
| 212 | − mcpToolName: Schema.Null, | |
| 213 | − openApiMethod: Schema.Null, | |
| 214 | − openApiPathTemplate: Schema.Null, | |
| 215 | − openApiOperationHash: Schema.Null, | |
| 216 | − openApiRawToolId: Schema.Null, | |
| 217 | − openApiOperationId: Schema.Null, | |
| 218 | − openApiTagsJson: Schema.Null, | |
| 219 | − openApiRequestBodyRequired: Schema.Null, | |
| 220 | − graphqlOperationType: Schema.Null, | |
| 221 | − graphqlOperationName: Schema.Null, | |
| 222 | −}); | |
| 223 | − | |
| 224 | −export const StoredSourceRecipeOperationRecordSchema = Schema.Union( | |
| 225 | − StoredOpenApiSourceRecipeOperationRecordSchema, | |
| 226 | − StoredGraphqlSourceRecipeOperationRecordSchema, | |
| 227 | − StoredMcpSourceRecipeOperationRecordSchema, | |
| 228 | − StoredInternalSourceRecipeOperationRecordSchema, | |
| 229 | −).annotations({ | |
| 121 | +export const StoredSourceRecipeOperationRecordSchema = StoredSourceRecipeOperationRowSchema.annotations({ | |
| 230 | 122 | identifier: "StoredSourceRecipeOperationRecord", |
| 231 | 123 | }); |
| 232 | 124 | |
| @@ -234,24 +126,17 @@ export type SourceRecipeKind = typeof SourceRecipeKindSchema.Type; · 1 unmodified line | ||
| 234 | 126 | export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type; |
| 235 | 127 | export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type; |
| 236 | 128 | export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type; |
| 129 | +export type SourceRecipeSchemaBundleKind = | |
| 130 | + typeof SourceRecipeSchemaBundleKindSchema.Type; | |
| 237 | 131 | export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type; |
| 238 | 132 | export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type; |
| 239 | 133 | export type SourceRecipeOperationProviderKind = |
| 240 | 134 | typeof SourceRecipeOperationProviderKindSchema.Type; |
| 241 | −export type SourceRecipeOpenApiMethod = typeof SourceRecipeOpenApiMethodSchema.Type; | |
| 242 | −export type SourceRecipeGraphqlOperationType = | |
| 243 | − typeof SourceRecipeGraphqlOperationTypeSchema.Type; | |
| 244 | 135 | export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type; |
| 245 | 136 | export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type; |
| 246 | 137 | export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type; |
| 138 | +export type StoredSourceRecipeSchemaBundleRecord = | |
| 139 | + typeof StoredSourceRecipeSchemaBundleRecordSchema.Type; | |
| 247 | 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 | 141 | export type StoredSourceRecipeOperationRecord = |
| 257 | 142 | typeof StoredSourceRecipeOperationRecordSchema.Type; |
| @@ -12,12 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | WorkspaceIdSchema, |
| 13 | 13 | } from "../ids"; |
| 14 | 14 | |
| 15 | −export const SourceKindSchema = Schema.Literal( | |
| 16 | − "mcp", | |
| 17 | − "openapi", | |
| 18 | − "graphql", | |
| 19 | − "internal", | |
| 20 | −); | |
| 15 | +export const SourceKindSchema = Schema.String; | |
| 21 | 16 | |
| 22 | 17 | export const SourceStatusSchema = Schema.Literal( |
| 23 | 18 | "draft", |
| @@ -33,6 +28,12 @@ export const SourceTransportSchema = Schema.Literal( · 9 unmodified lines | ||
| 33 | 28 | "sse", |
| 34 | 29 | ); |
| 35 | 30 | |
| 31 | +export const SourceImportAuthPolicySchema = Schema.Literal( | |
| 32 | + "none", | |
| 33 | + "reuse_runtime", | |
| 34 | + "separate", | |
| 35 | +); | |
| 36 | + | |
| 36 | 37 | export const SecretRefSchema = Schema.Struct({ |
| 37 | 38 | providerId: Schema.String, |
| 38 | 39 | handle: Schema.String, |
| @@ -69,7 +70,7 @@ const sourceRowSchemaOverrides = { · 30 unmodified lines | ||
| 69 | 70 | recipeRevisionId: SourceRecipeRevisionIdSchema, |
| 70 | 71 | kind: SourceKindSchema, |
| 71 | 72 | status: SourceStatusSchema, |
| 72 | − transport: Schema.NullOr(SourceTransportSchema), | |
| 73 | + importAuthPolicy: SourceImportAuthPolicySchema, | |
| 73 | 74 | createdAt: TimestampMsSchema, |
| 74 | 75 | updatedAt: TimestampMsSchema, |
| 75 | 76 | } as const; |
| @@ -89,14 +90,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 13 unmodified lines | ||
| 89 | 90 | status: SourceStatusSchema, |
| 90 | 91 | enabled: Schema.Boolean, |
| 91 | 92 | namespace: Schema.NullOr(Schema.String), |
| 92 | − bindingConfigJson: Schema.NullOr(Schema.String), | |
| 93 | − transport: Schema.NullOr(SourceTransportSchema), | |
| 94 | − queryParamsJson: Schema.NullOr(Schema.String), | |
| 95 | − headersJson: Schema.NullOr(Schema.String), | |
| 96 | − specUrl: Schema.NullOr(Schema.String), | |
| 97 | − defaultHeadersJson: Schema.NullOr(Schema.String), | |
| 93 | + importAuthPolicy: SourceImportAuthPolicySchema, | |
| 94 | + bindingConfigJson: Schema.String, | |
| 98 | 95 | sourceHash: Schema.NullOr(Schema.String), |
| 99 | − sourceDocumentText: Schema.NullOr(Schema.String), | |
| 100 | 96 | lastError: Schema.NullOr(Schema.String), |
| 101 | 97 | createdAt: TimestampMsSchema, |
| 102 | 98 | updatedAt: TimestampMsSchema, |
| @@ -114,14 +110,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 11 unmodified lines | ||
| 114 | 110 | status: row.status, |
| 115 | 111 | enabled: row.enabled, |
| 116 | 112 | namespace: row.namespace, |
| 113 | + importAuthPolicy: row.importAuthPolicy, | |
| 117 | 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 | 115 | sourceHash: row.sourceHash, |
| 124 | − sourceDocumentText: row.sourceDocumentText, | |
| 125 | 116 | lastError: row.lastError, |
| 126 | 117 | createdAt: row.createdAt, |
| 127 | 118 | updatedAt: row.updatedAt, |
| @@ -137,14 +128,9 @@ export const StoredSourceRecordSchema = Schema.transform( · 9 unmodified lines | ||
| 137 | 128 | status: source.status, |
| 138 | 129 | enabled: source.enabled, |
| 139 | 130 | namespace: source.namespace, |
| 131 | + importAuthPolicy: source.importAuthPolicy, | |
| 140 | 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 | 133 | sourceHash: source.sourceHash, |
| 147 | − sourceDocumentText: source.sourceDocumentText, | |
| 148 | 134 | lastError: source.lastError, |
| 149 | 135 | createdAt: source.createdAt, |
| 150 | 136 | updatedAt: source.updatedAt, |
| @@ -166,6 +152,8 @@ export const SourceSchema = Schema.Struct({ · 15 unmodified lines | ||
| 166 | 152 | headers: Schema.NullOr(StringMapSchema), |
| 167 | 153 | specUrl: Schema.NullOr(Schema.String), |
| 168 | 154 | defaultHeaders: Schema.NullOr(StringMapSchema), |
| 155 | + importAuthPolicy: SourceImportAuthPolicySchema, | |
| 156 | + importAuth: SourceAuthSchema, | |
| 169 | 157 | auth: SourceAuthSchema, |
| 170 | 158 | sourceHash: Schema.NullOr(Schema.String), |
| 171 | 159 | lastError: Schema.NullOr(Schema.String), |
| @@ -176,6 +164,7 @@ export const SourceSchema = Schema.Struct({ · 4 unmodified lines | ||
| 176 | 164 | export type SourceKind = typeof SourceKindSchema.Type; |
| 177 | 165 | export type SourceStatus = typeof SourceStatusSchema.Type; |
| 178 | 166 | export type SourceTransport = typeof SourceTransportSchema.Type; |
| 167 | +export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type; | |
| 179 | 168 | export type SecretRef = typeof SecretRefSchema.Type; |
| 180 | 169 | export type SourceAuth = typeof SourceAuthSchema.Type; |
| 181 | 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 | 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 | 20 | const decode = Schema.decodeUnknownEither(StoredSourceRecipeOperationRecordSchema); |
| 21 | 21 | |
| 22 | − const openApiRecord = decode({ | |
| 22 | + const operationRecord = decode({ | |
| 23 | 23 | id: "src_recipe_op_1", |
| 24 | 24 | recipeRevisionId: "src_recipe_rev_1", |
| 25 | 25 | operationKey: "getRepo", |
| @@ -32,28 +32,20 @@ describe("control-plane-schema", () => { · 6 unmodified lines | ||
| 32 | 32 | inputSchemaJson: null, |
| 33 | 33 | outputSchemaJson: null, |
| 34 | 34 | providerKind: "openapi", |
| 35 | − providerDataJson: null, | |
| 36 | − mcpToolName: null, | |
| 37 | − openApiMethod: "get", | |
| 38 | − openApiPathTemplate: "/repos/{owner}/{repo}", | |
| 39 | − openApiOperationHash: "hash", | |
| 40 | − openApiRawToolId: "repos_getRepo", | |
| 41 | − openApiOperationId: "repos.getRepo", | |
| 42 | − openApiTagsJson: "[]", | |
| 43 | − openApiRequestBodyRequired: null, | |
| 44 | − graphqlOperationType: null, | |
| 45 | − graphqlOperationName: null, | |
| 35 | + providerDataJson: JSON.stringify({ | |
| 36 | + kind: "openapi", | |
| 37 | + }), | |
| 46 | 38 | createdAt: 1, |
| 47 | 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 | 45 | id: "src_recipe_op_2", |
| 54 | 46 | recipeRevisionId: "src_recipe_rev_1", |
| 55 | 47 | operationKey: "viewer", |
| 56 | − transportKind: "graphql", | |
| 48 | + transportKind: "ftp", | |
| 57 | 49 | toolId: "viewer", |
| 58 | 50 | title: "Viewer", |
| 59 | 51 | description: null, |
| @@ -62,21 +54,13 @@ describe("control-plane-schema", () => { · 2 unmodified lines | ||
| 62 | 54 | inputSchemaJson: null, |
| 63 | 55 | outputSchemaJson: null, |
| 64 | 56 | providerKind: "graphql", |
| 65 | − providerDataJson: null, | |
| 66 | − mcpToolName: null, | |
| 67 | − openApiMethod: "get", | |
| 68 | − openApiPathTemplate: null, | |
| 69 | − openApiOperationHash: null, | |
| 70 | − openApiRawToolId: null, | |
| 71 | − openApiOperationId: null, | |
| 72 | − openApiTagsJson: null, | |
| 73 | − openApiRequestBodyRequired: null, | |
| 74 | − graphqlOperationType: "query", | |
| 75 | − graphqlOperationName: "viewer", | |
| 57 | + providerDataJson: JSON.stringify({ | |
| 58 | + kind: "graphql", | |
| 59 | + }), | |
| 76 | 60 | createdAt: 1, |
| 77 | 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 | 16 | type SecretRef, |
| 17 | 17 | type Source, |
| 18 | 18 | type SourceId, |
| 19 | − type StoredToolArtifactRecord, | |
| 20 | 19 | type WorkspaceId, |
| 21 | 20 | } from "@executor/control-plane"; |
| 22 | 21 | import { |
| @@ -59,6 +58,29 @@ const catalogNamespaceFromPath = (path: string): string => { · 36 unmodified lines | ||
| 59 | 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 | 84 | const toDescriptor = ( |
| 63 | 85 | artifact: StoredToolArtifactRecord, |
| 64 | 86 | includeSchemas: boolean, |
| @@ -282,15 +304,11 @@ const resolveSourceCallContext = (input: { · 217 unmodified lines | ||
| 282 | 304 | |
| 283 | 305 | const createProviderInvoker = (): ProviderInvoker => ({ |
| 284 | 306 | async invoke({ source, artifact, args, runtime, context }) { |
| 285 | − const invocation = artifact.providerKind === "mcp" | |
| 286 | − ? { | |
| 287 | − toolName: artifact.mcpToolName ?? artifact.title ?? artifact.toolId, | |
| 288 | − } | |
| 289 | − : { | |
| 290 | − method: artifact.openApiMethod, | |
| 291 | − pathTemplate: artifact.openApiPathTemplate, | |
| 292 | − operationHash: artifact.openApiOperationHash, | |
| 293 | − }; | |
| 307 | + const invocation = { | |
| 308 | + method: artifact.openApiMethod, | |
| 309 | + pathTemplate: artifact.openApiPathTemplate, | |
| 310 | + operationHash: artifact.openApiOperationHash, | |
| 311 | + }; | |
| 294 | 312 | |
| 295 | 313 | return { |
| 296 | 314 | sourceId: source.id, |
| @@ -337,6 +355,7 @@ const createWorkspaceToolCatalog = (input: { · 40 unmodified lines | ||
| 337 | 355 | path, |
| 338 | 356 | }).then((artifact) => (artifact ? toDescriptor(artifact, includeSchemas) : null)) |
| 339 | 357 | ), |
| 358 | + getSchemaBundle: () => Effect.succeed(null), | |
| 340 | 359 | searchTools: ({ query, namespace, limit }) => |
| 341 | 360 | Effect.promise(() => |
| 342 | 361 | input.toolStore.list({ |
| @@ -480,6 +499,8 @@ const openApiSource = (input: { · 137 unmodified lines | ||
| 480 | 499 | headers: null, |
| 481 | 500 | specUrl: input.specUrl ?? null, |
| 482 | 501 | defaultHeaders: null, |
| 502 | + importAuthPolicy: "reuse_runtime", | |
| 503 | + importAuth: { kind: "none" }, | |
| 483 | 504 | auth: input.auth, |
| 484 | 505 | sourceHash: null, |
| 485 | 506 | lastError: null, |
| @@ -523,7 +544,6 @@ const openApiArtifact = (input: { · 37 unmodified lines | ||
| 523 | 544 | inputSchemaJson: input.inputSchemaJson ?? null, |
| 524 | 545 | outputSchemaJson: input.outputSchemaJson ?? null, |
| 525 | 546 | providerKind: "openapi", |
| 526 | − mcpToolName: null, | |
| 527 | 547 | openApiMethod: input.method, |
| 528 | 548 | openApiPathTemplate: input.pathTemplate, |
| 529 | 549 | openApiOperationHash: input.operationHash ?? input.toolId, |
| @@ -759,7 +779,8 @@ describe("source runtime", () => { · 229 unmodified lines | ||
| 759 | 779 | "Workflow:", |
| 760 | 780 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 761 | 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 | 784 | "Do not use fetch; use tools.* only.", |
| 764 | 785 | ].join("\n"), |
| 765 | 786 | ); |
| @@ -828,7 +849,8 @@ describe("source runtime", () => { · 62 unmodified lines | ||
| 828 | 849 | "Workflow:", |
| 829 | 850 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 830 | 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 | 854 | "Do not use fetch; use tools.* only.", |
| 833 | 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 | 19 | SourceDiscoveryResult, |
| 20 | 20 | SourceInspection, |
| 21 | 21 | SourceInspectionDiscoverResult, |
| 22 | + SourceInspectionSchemaBundle, | |
| 22 | 23 | SourceInspectionToolDetail, |
| 23 | 24 | StartSourceOAuthPayload, |
| 24 | 25 | StartSourceOAuthResult, |
| @@ -58,6 +59,13 @@ type SourceToolDetailKeyParts = readonly [ · 33 unmodified lines | ||
| 58 | 59 | Source["id"], |
| 59 | 60 | string | null, |
| 60 | 61 | ]; |
| 62 | +type SourceSchemaBundleKeyParts = readonly [ | |
| 63 | + boolean, | |
| 64 | + Source["workspaceId"], | |
| 65 | + string, | |
| 66 | + Source["id"], | |
| 67 | + string | null, | |
| 68 | +]; | |
| 61 | 69 | type SourceDiscoveryKeyParts = readonly [ |
| 62 | 70 | boolean, |
| 63 | 71 | Source["workspaceId"], |
| @@ -78,6 +86,7 @@ type ActiveQueryCollections = { · 14 unmodified lines | ||
| 78 | 86 | sources: Set<string>; |
| 79 | 87 | inspections: Set<string>; |
| 80 | 88 | toolDetails: Set<string>; |
| 89 | + schemaBundles: Set<string>; | |
| 81 | 90 | discoveries: Set<string>; |
| 82 | 91 | }; |
| 83 | 92 | |
| @@ -153,6 +162,17 @@ const encodeToolDetailKey = ( · 69 unmodified lines | ||
| 153 | 162 | ): string => |
| 154 | 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 | 176 | const encodeDiscoveryKey = ( |
| 157 | 177 | enabled: boolean, |
| 158 | 178 | workspaceId: Source["workspaceId"], |
| @@ -335,6 +355,25 @@ const sourceInspectionToolAtom = Atom.family((key: string) => { · 176 unmodified lines | ||
| 335 | 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 | 377 | const sourceDiscoveryAtom = Atom.family((key: string) => { |
| 339 | 378 | const [enabled, workspaceId, accountId, sourceId, query, limit] = decodeAtomKey<SourceDiscoveryKeyParts>(key); |
| 340 | 379 | |
| @@ -496,6 +535,7 @@ const createActiveQueryCollections = (): ActiveQueryCollections => ({ · 155 unmodified lines | ||
| 496 | 535 | sources: new Set(), |
| 497 | 536 | inspections: new Set(), |
| 498 | 537 | toolDetails: new Set(), |
| 538 | + schemaBundles: new Set(), | |
| 499 | 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 | 600 | activeQueries.discoveries.forEach((key) => { |
| 554 | 601 | const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceDiscoveryKeyParts>(key); |
| 555 | 602 | if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) { |
| @@ -625,6 +672,8 @@ const createOptimisticSource = (input: { · 69 unmodified lines | ||
| 625 | 672 | headers: input.payload.headers ?? null, |
| 626 | 673 | specUrl: input.payload.specUrl ?? null, |
| 627 | 674 | defaultHeaders: input.payload.defaultHeaders ?? null, |
| 675 | + importAuthPolicy: input.payload.importAuthPolicy ?? "reuse_runtime", | |
| 676 | + importAuth: input.payload.importAuth ?? { kind: "none" }, | |
| 628 | 677 | auth: input.payload.auth ?? { kind: "none" }, |
| 629 | 678 | sourceHash: input.payload.sourceHash ?? null, |
| 630 | 679 | lastError: input.payload.lastError ?? null, |
| @@ -937,6 +986,27 @@ export const useSourceToolDetail = ( · 306 unmodified lines | ||
| 937 | 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 | 1010 | export const useSourceDiscovery = (input: { |
| 941 | 1011 | sourceId: string; |
| 942 | 1012 | query: string; |