Unified · Split
| @@ -33,6 +33,19 @@ const put = <K extends string, V>(record: Record<K, V>, key: K, value: V) => { · 32 unmodified lines | |||
| 33 | record[key] = value; | 33 | record[key] = value; |
| 34 | }; | 34 | }; |
| 35 | 35 | ||
| 36 | const expectNullableShapeContains = ( | ||
| 37 | catalog: CatalogV1, | ||
| 38 | nullableShapeId: ReturnType<typeof ShapeSymbolIdSchema.make>, | ||
| 39 | expectedInnerShapeId: ReturnType<typeof ShapeSymbolIdSchema.make>, | ||
| 40 | ) => { | ||
| 41 | const shape = catalog.symbols[nullableShapeId]; | ||
| 42 | if (shape?.kind !== "shape" || shape.node.type !== "anyOf") { | ||
| 43 | throw new Error("Expected nullable anyOf shape"); | ||
| 44 | } | ||
| 45 | |||
| 46 | expect(shape.node.items).toContain(expectedInnerShapeId); | ||
| 47 | }; | ||
| 48 | |||
| 36 | const docId = DocumentIdSchema.make("doc_primary"); | 49 | const docId = DocumentIdSchema.make("doc_primary"); |
| 37 | const baseProvenance = (pointer: string): ProvenanceRef[] => [{ | 50 | const baseProvenance = (pointer: string): ProvenanceRef[] => [{ |
| 38 | relation: "declared", | 51 | relation: "declared", |
| @@ -630,7 +643,6 @@ describe("IR catalog", () => { · 591 unmodified lines | |||
| 630 | 643 | ||
| 631 | const descriptor = projected.toolDescriptors[CapabilityIdSchema.make("cap_events_update")]; | 644 | const descriptor = projected.toolDescriptors[CapabilityIdSchema.make("cap_events_update")]; |
| 632 | expect(descriptor.toolPath).toEqual(["google", "calendar", "events", "update"]); | 645 | expect(descriptor.toolPath).toEqual(["google", "calendar", "events", "update"]); |
| 633 | expect(descriptor.resultShapeId).toBe(ShapeSymbolIdSchema.make("shape_event_result")); | ||
| 634 | 646 | ||
| 635 | const callShape = projected.catalog.symbols[descriptor.callShapeId]; | 647 | const callShape = projected.catalog.symbols[descriptor.callShapeId]; |
| 636 | expect(callShape?.kind).toBe("shape"); | 648 | expect(callShape?.kind).toBe("shape"); |
| @@ -646,6 +658,19 @@ describe("IR catalog", () => { · 9 unmodified lines | |||
| 646 | ]); | 658 | ]); |
| 647 | expect(callShape.node.required).toEqual(["calendarId", "eventId", "body"]); | 659 | expect(callShape.node.required).toEqual(["calendarId", "eventId", "body"]); |
| 648 | 660 | ||
| 661 | const resultShape = descriptor.resultShapeId | ||
| 662 | ? projected.catalog.symbols[descriptor.resultShapeId] | ||
| 663 | : undefined; | ||
| 664 | if (resultShape?.kind !== "shape" || resultShape.node.type !== "object") { | ||
| 665 | throw new Error("Expected projected HTTP result envelope"); | ||
| 666 | } | ||
| 667 | expect(Object.keys(resultShape.node.fields)).toEqual(["data", "error", "headers", "status"]); | ||
| 668 | expectNullableShapeContains( | ||
| 669 | projected.catalog, | ||
| 670 | resultShape.node.fields.data.shapeId, | ||
| 671 | ShapeSymbolIdSchema.make("shape_event_result"), | ||
| 672 | ); | ||
| 673 | |||
| 649 | const searchDoc = projected.searchDocs[CapabilityIdSchema.make("cap_events_update")]; | 674 | const searchDoc = projected.searchDocs[CapabilityIdSchema.make("cap_events_update")]; |
| 650 | expect(searchDoc.authHints).toContain("oauth2"); | 675 | expect(searchDoc.authHints).toContain("oauth2"); |
| 651 | expect(searchDoc.tags).toEqual(["calendar", "events"]); | 676 | expect(searchDoc.tags).toEqual(["calendar", "events"]); |
| @@ -680,7 +705,18 @@ describe("IR catalog", () => { · 28 unmodified lines | |||
| 680 | } | 705 | } |
| 681 | 706 | ||
| 682 | expect(Object.keys(callShape.node.fields)).toEqual(["id", "input", "select"]); | 707 | expect(Object.keys(callShape.node.fields)).toEqual(["id", "input", "select"]); |
| 683 | expect(descriptor.resultShapeId).toBe(ShapeSymbolIdSchema.make("shape_graphql_result")); | 708 | const resultShape = descriptor.resultShapeId |
| 709 | ? projected.catalog.symbols[descriptor.resultShapeId] | ||
| 710 | : undefined; | ||
| 711 | if (resultShape?.kind !== "shape" || resultShape.node.type !== "object") { | ||
| 712 | throw new Error("Expected projected GraphQL result envelope"); | ||
| 713 | } | ||
| 714 | expect(Object.keys(resultShape.node.fields)).toEqual(["data", "error", "headers", "status"]); | ||
| 715 | expectNullableShapeContains( | ||
| 716 | projected.catalog, | ||
| 717 | resultShape.node.fields.data.shapeId, | ||
| 718 | ShapeSymbolIdSchema.make("shape_graphql_result"), | ||
| 719 | ); | ||
| 684 | }); | 720 | }); |
| 685 | 721 | ||
| 686 | it("builds inspect views and invocation plans from the projected graph", () => { | 722 | it("builds inspect views and invocation plans from the projected graph", () => { |
| @@ -622,12 +622,17 @@ const responseContentCandidates = ( · 621 unmodified lines | |||
| 622 | content.shapeId ? [{ mediaType: content.mediaType, shapeId: content.shapeId }] : [], | 622 | content.shapeId ? [{ mediaType: content.mediaType, shapeId: content.shapeId }] : [], |
| 623 | ); | 623 | ); |
| 624 | 624 | ||
| 625 | const projectResultShapeFromResponses = ( | 625 | type RankedResponseVariantEntry = { |
| 626 | variant: ResponseSet["variants"][number]; | ||
| 627 | response: ResponseSymbol; | ||
| 628 | score: number; | ||
| 629 | }; | ||
| 630 | |||
| 631 | const responseVariantEntries = ( | ||
| 626 | catalog: CatalogV1, | 632 | catalog: CatalogV1, |
| 627 | capability: Capability, | ||
| 628 | responseSet: ResponseSet, | 633 | responseSet: ResponseSet, |
| 629 | ): ShapeSymbolId | undefined => { | 634 | ): RankedResponseVariantEntry[] => |
| 630 | const ranked = [...responseSet.variants] | 635 | [...responseSet.variants] |
| 631 | .map((variant) => { | 636 | .map((variant) => { |
| 632 | const response = getResponseSymbol(catalog, variant.responseId); | 637 | const response = getResponseSymbol(catalog, variant.responseId); |
| 633 | return response | 638 | return response |
| @@ -638,14 +643,27 @@ const projectResultShapeFromResponses = ( · 4 unmodified lines | |||
| 638 | } | 643 | } |
| 639 | : null; | 644 | : null; |
| 640 | }) | 645 | }) |
| 641 | .filter((entry): entry is { variant: ResponseSet["variants"][number]; response: ResponseSymbol; score: number } => entry !== null) | 646 | .filter((entry): entry is RankedResponseVariantEntry => entry !== null); |
| 642 | .sort((left, right) => right.score - left.score); | ||
| 643 | 647 | ||
| 644 | const preferred = ranked.some(({ score }) => score >= 60) | 648 | const isSuccessStatusMatch = (match: StatusMatch): boolean => { |
| 645 | ? ranked.filter(({ score }) => score >= 60) | 649 | switch (match.kind) { |
| 646 | : ranked.filter(({ score }) => score === (ranked[0]?.score ?? 0)); | 650 | case "exact": |
| 651 | return match.status >= 200 && match.status < 300; | ||
| 652 | case "range": | ||
| 653 | return match.value === "2XX"; | ||
| 654 | case "default": | ||
| 655 | return false; | ||
| 656 | } | ||
| 657 | }; | ||
| 647 | 658 | ||
| 648 | const jsonCandidates = preferred.flatMap(({ response }) => | 659 | const contentShapeIdFromResponseEntries = ( |
| 660 | catalog: CatalogV1, | ||
| 661 | capability: Capability, | ||
| 662 | label: string, | ||
| 663 | title: string, | ||
| 664 | entries: readonly RankedResponseVariantEntry[], | ||
| 665 | ): ShapeSymbolId | undefined => { | ||
| 666 | const jsonCandidates = entries.flatMap(({ response }) => | ||
| 649 | responseContentCandidates(response).filter((candidate) => isJsonMediaType(candidate.mediaType)), | 667 | responseContentCandidates(response).filter((candidate) => isJsonMediaType(candidate.mediaType)), |
| 650 | ); | 668 | ); |
| 651 | 669 | ||
| @@ -657,12 +675,12 @@ const projectResultShapeFromResponses = ( · 5 unmodified lines | |||
| 657 | mediaType: candidate.mediaType, | 675 | mediaType: candidate.mediaType, |
| 658 | shapeId: candidate.shapeId, | 676 | shapeId: candidate.shapeId, |
| 659 | })), | 677 | })), |
| 660 | `responseSet:${responseSet.id}:json`, | 678 | `${label}:json`, |
| 661 | ); | 679 | ); |
| 662 | } | 680 | } |
| 663 | 681 | ||
| 664 | const fallbackShapeIds = unique( | 682 | const fallbackShapeIds = unique( |
| 665 | preferred.flatMap(({ response }) => | 683 | entries.flatMap(({ response }) => |
| 666 | responseContentCandidates(response).map((candidate) => candidate.shapeId), | 684 | responseContentCandidates(response).map((candidate) => candidate.shapeId), |
| 667 | ), | 685 | ), |
| 668 | ); | 686 | ); |
| @@ -677,8 +695,8 @@ const projectResultShapeFromResponses = ( · 8 unmodified lines | |||
| 677 | 695 | ||
| 678 | return createSyntheticShape(catalog, { | 696 | return createSyntheticShape(catalog, { |
| 679 | capability, | 697 | capability, |
| 680 | label: `responseSet:${responseSet.id}:union`, | 698 | label: `${label}:union`, |
| 681 | title: `${capability.surface.title ?? capability.id} result`, | 699 | title, |
| 682 | node: { | 700 | node: { |
| 683 | type: "anyOf", | 701 | type: "anyOf", |
| 684 | items: fallbackShapeIds, | 702 | items: fallbackShapeIds, |
| @@ -692,6 +710,297 @@ const projectResultShapeFromResponses = ( · 7 unmodified lines | |||
| 692 | }); | 710 | }); |
| 693 | }; | 711 | }; |
| 694 | 712 | ||
| 713 | const projectResultShapeFromResponses = ( | ||
| 714 | catalog: CatalogV1, | ||
| 715 | capability: Capability, | ||
| 716 | responseSet: ResponseSet, | ||
| 717 | ): ShapeSymbolId | undefined => | ||
| 718 | contentShapeIdFromResponseEntries( | ||
| 719 | catalog, | ||
| 720 | capability, | ||
| 721 | `responseSet:${responseSet.id}`, | ||
| 722 | `${capability.surface.title ?? capability.id} result`, | ||
| 723 | responseVariantEntries(catalog, responseSet).filter(({ variant }) => isSuccessStatusMatch(variant.match)), | ||
| 724 | ); | ||
| 725 | |||
| 726 | const projectErrorShapeFromResponses = ( | ||
| 727 | catalog: CatalogV1, | ||
| 728 | capability: Capability, | ||
| 729 | responseSet: ResponseSet, | ||
| 730 | ): ShapeSymbolId | undefined => | ||
| 731 | contentShapeIdFromResponseEntries( | ||
| 732 | catalog, | ||
| 733 | capability, | ||
| 734 | `responseSet:${responseSet.id}:error`, | ||
| 735 | `${capability.surface.title ?? capability.id} error`, | ||
| 736 | responseVariantEntries(catalog, responseSet).filter(({ variant }) => !isSuccessStatusMatch(variant.match)), | ||
| 737 | ); | ||
| 738 | |||
| 739 | const scalarShape = ( | ||
| 740 | catalog: CatalogV1, | ||
| 741 | capability: Capability, | ||
| 742 | input: { | ||
| 743 | label: string; | ||
| 744 | title: string; | ||
| 745 | scalar: "string" | "number" | "integer" | "boolean" | "null" | "bytes"; | ||
| 746 | }, | ||
| 747 | ): ShapeSymbolId => | ||
| 748 | createSyntheticShape(catalog, { | ||
| 749 | capability, | ||
| 750 | label: input.label, | ||
| 751 | title: input.title, | ||
| 752 | node: { | ||
| 753 | type: "scalar", | ||
| 754 | scalar: input.scalar, | ||
| 755 | }, | ||
| 756 | }); | ||
| 757 | |||
| 758 | const constNullShape = ( | ||
| 759 | catalog: CatalogV1, | ||
| 760 | capability: Capability, | ||
| 761 | label: string, | ||
| 762 | ): ShapeSymbolId => | ||
| 763 | createSyntheticShape(catalog, { | ||
| 764 | capability, | ||
| 765 | label, | ||
| 766 | title: "null", | ||
| 767 | node: { | ||
| 768 | type: "const", | ||
| 769 | value: null, | ||
| 770 | }, | ||
| 771 | }); | ||
| 772 | |||
| 773 | const unknownShape = ( | ||
| 774 | catalog: CatalogV1, | ||
| 775 | capability: Capability, | ||
| 776 | input: { | ||
| 777 | label: string; | ||
| 778 | title: string; | ||
| 779 | reason: string; | ||
| 780 | }, | ||
| 781 | ): ShapeSymbolId => | ||
| 782 | createSyntheticShape(catalog, { | ||
| 783 | capability, | ||
| 784 | label: input.label, | ||
| 785 | title: input.title, | ||
| 786 | node: { | ||
| 787 | type: "unknown", | ||
| 788 | reason: input.reason, | ||
| 789 | }, | ||
| 790 | }); | ||
| 791 | |||
| 792 | const nullableShape = ( | ||
| 793 | catalog: CatalogV1, | ||
| 794 | capability: Capability, | ||
| 795 | input: { | ||
| 796 | label: string; | ||
| 797 | title: string; | ||
| 798 | baseShapeId: ShapeSymbolId; | ||
| 799 | }, | ||
| 800 | ): ShapeSymbolId => { | ||
| 801 | const nullShapeId = constNullShape(catalog, capability, `${input.label}:null`); | ||
| 802 | if (input.baseShapeId === nullShapeId) { | ||
| 803 | return nullShapeId; | ||
| 804 | } | ||
| 805 | |||
| 806 | return createSyntheticShape(catalog, { | ||
| 807 | capability, | ||
| 808 | label: `${input.label}:nullable`, | ||
| 809 | title: input.title, | ||
| 810 | node: { | ||
| 811 | type: "anyOf", | ||
| 812 | items: unique([input.baseShapeId, nullShapeId]), | ||
| 813 | }, | ||
| 814 | }); | ||
| 815 | }; | ||
| 816 | |||
| 817 | const headersShape = ( | ||
| 818 | catalog: CatalogV1, | ||
| 819 | capability: Capability, | ||
| 820 | label: string, | ||
| 821 | ): ShapeSymbolId => { | ||
| 822 | const valueShapeId = scalarShape(catalog, capability, { | ||
| 823 | label: `${label}:value`, | ||
| 824 | title: "Header value", | ||
| 825 | scalar: "string", | ||
| 826 | }); | ||
| 827 | |||
| 828 | return createSyntheticShape(catalog, { | ||
| 829 | capability, | ||
| 830 | label, | ||
| 831 | title: "Response headers", | ||
| 832 | node: { | ||
| 833 | type: "object", | ||
| 834 | fields: {}, | ||
| 835 | additionalProperties: valueShapeId, | ||
| 836 | }, | ||
| 837 | }); | ||
| 838 | }; | ||
| 839 | |||
| 840 | const shapeFieldShapeId = ( | ||
| 841 | catalog: CatalogV1, | ||
| 842 | shapeId: ShapeSymbolId | undefined, | ||
| 843 | fieldName: string, | ||
| 844 | seen = new Set<ShapeSymbolId>(), | ||
| 845 | ): ShapeSymbolId | undefined => { | ||
| 846 | if (!shapeId || seen.has(shapeId)) { | ||
| 847 | return undefined; | ||
| 848 | } | ||
| 849 | |||
| 850 | const shape = getShape(catalog, shapeId); | ||
| 851 | if (!shape) { | ||
| 852 | return undefined; | ||
| 853 | } | ||
| 854 | |||
| 855 | const nextSeen = new Set(seen); | ||
| 856 | nextSeen.add(shapeId); | ||
| 857 | |||
| 858 | switch (shape.node.type) { | ||
| 859 | case "ref": | ||
| 860 | return shapeFieldShapeId(catalog, shape.node.target, fieldName, nextSeen); | ||
| 861 | case "object": | ||
| 862 | return shape.node.fields[fieldName]?.shapeId; | ||
| 863 | case "allOf": | ||
| 864 | for (const item of shape.node.items) { | ||
| 865 | const found = shapeFieldShapeId(catalog, item, fieldName, nextSeen); | ||
| 866 | if (found) { | ||
| 867 | return found; | ||
| 868 | } | ||
| 869 | } | ||
| 870 | return undefined; | ||
| 871 | default: | ||
| 872 | return undefined; | ||
| 873 | } | ||
| 874 | }; | ||
| 875 | |||
| 876 | const projectExecutionResultShape = ( | ||
| 877 | catalog: CatalogV1, | ||
| 878 | capability: Capability, | ||
| 879 | executable: Executable, | ||
| 880 | responseSet: ResponseSet, | ||
| 881 | ): ShapeSymbolId => { | ||
| 882 | const genericDataShape = unknownShape(catalog, capability, { | ||
| 883 | label: `executionResult:${capability.id}:data:unknown`, | ||
| 884 | title: "Response data", | ||
| 885 | reason: `Execution result data for ${capability.id} is not statically known`, | ||
| 886 | }); | ||
| 887 | const genericErrorShape = unknownShape(catalog, capability, { | ||
| 888 | label: `executionResult:${capability.id}:error:unknown`, | ||
| 889 | title: "Response error", | ||
| 890 | reason: `Execution result error for ${capability.id} is not statically known`, | ||
| 891 | }); | ||
| 892 | const genericHeadersShape = headersShape( | ||
| 893 | catalog, | ||
| 894 | capability, | ||
| 895 | `executionResult:${capability.id}:headers`, | ||
| 896 | ); | ||
| 897 | const genericStatusShape = scalarShape(catalog, capability, { | ||
| 898 | label: `executionResult:${capability.id}:status`, | ||
| 899 | title: "Response status", | ||
| 900 | scalar: "integer", | ||
| 901 | }); | ||
| 902 | |||
| 903 | let dataShapeId: ShapeSymbolId | undefined; | ||
| 904 | let errorShapeId: ShapeSymbolId | undefined; | ||
| 905 | let headersShapeId: ShapeSymbolId = genericHeadersShape; | ||
| 906 | let statusShapeId: ShapeSymbolId = genericStatusShape; | ||
| 907 | |||
| 908 | switch (executable.protocol) { | ||
| 909 | case "http": | ||
| 910 | dataShapeId = projectResultShapeFromResponses(catalog, capability, responseSet); | ||
| 911 | errorShapeId = projectErrorShapeFromResponses(catalog, capability, responseSet); | ||
| 912 | break; | ||
| 913 | case "graphql": | ||
| 914 | dataShapeId = | ||
| 915 | shapeFieldShapeId(catalog, executable.resultShapeId, "data") | ||
| 916 | ?? shapeFieldShapeId(catalog, executable.resultShapeId, "body") | ||
| 917 | ?? executable.resultShapeId; | ||
| 918 | errorShapeId = | ||
| 919 | shapeFieldShapeId(catalog, executable.resultShapeId, "error") | ||
| 920 | ?? shapeFieldShapeId(catalog, executable.resultShapeId, "errors"); | ||
| 921 | headersShapeId = | ||
| 922 | shapeFieldShapeId(catalog, executable.resultShapeId, "headers") | ||
| 923 | ?? genericHeadersShape; | ||
| 924 | statusShapeId = | ||
| 925 | shapeFieldShapeId(catalog, executable.resultShapeId, "status") | ||
| 926 | ?? genericStatusShape; | ||
| 927 | break; | ||
| 928 | case "mcp": | ||
| 929 | dataShapeId = executable.outputShapeId; | ||
| 930 | errorShapeId = undefined; | ||
| 931 | statusShapeId = constNullShape( | ||
| 932 | catalog, | ||
| 933 | capability, | ||
| 934 | `executionResult:${capability.id}:status:null`, | ||
| 935 | ); | ||
| 936 | break; | ||
| 937 | } | ||
| 938 | |||
| 939 | const dataFieldShapeId = nullableShape(catalog, capability, { | ||
| 940 | label: `executionResult:${capability.id}:data`, | ||
| 941 | title: "Result data", | ||
| 942 | baseShapeId: dataShapeId ?? genericDataShape, | ||
| 943 | }); | ||
| 944 | const errorFieldShapeId = nullableShape(catalog, capability, { | ||
| 945 | label: `executionResult:${capability.id}:error`, | ||
| 946 | title: "Result error", | ||
| 947 | baseShapeId: errorShapeId ?? genericErrorShape, | ||
| 948 | }); | ||
| 949 | const statusFieldShapeId = nullableShape(catalog, capability, { | ||
| 950 | label: `executionResult:${capability.id}:status`, | ||
| 951 | title: "Result status", | ||
| 952 | baseShapeId: statusShapeId, | ||
| 953 | }); | ||
| 954 | |||
| 955 | return createSyntheticShape(catalog, { | ||
| 956 | capability, | ||
| 957 | label: `executionResult:${capability.id}`, | ||
| 958 | title: `${capability.surface.title ?? capability.id} result`, | ||
| 959 | node: { | ||
| 960 | type: "object", | ||
| 961 | fields: { | ||
| 962 | data: { | ||
| 963 | shapeId: dataFieldShapeId, | ||
| 964 | docs: { | ||
| 965 | description: "Successful result payload when available.", | ||
| 966 | }, | ||
| 967 | }, | ||
| 968 | error: { | ||
| 969 | shapeId: errorFieldShapeId, | ||
| 970 | docs: { | ||
| 971 | description: "Error payload when the remote execution completed but failed.", | ||
| 972 | }, | ||
| 973 | }, | ||
| 974 | headers: { | ||
| 975 | shapeId: headersShapeId, | ||
| 976 | docs: { | ||
| 977 | description: "Response headers when available.", | ||
| 978 | }, | ||
| 979 | }, | ||
| 980 | status: { | ||
| 981 | shapeId: statusFieldShapeId, | ||
| 982 | docs: { | ||
| 983 | description: "Transport status code when available.", | ||
| 984 | }, | ||
| 985 | }, | ||
| 986 | }, | ||
| 987 | required: ["data", "error", "headers", "status"], | ||
| 988 | additionalProperties: false, | ||
| 989 | }, | ||
| 990 | diagnostic: { | ||
| 991 | level: "info", | ||
| 992 | code: "projection_result_shape_synthesized", | ||
| 993 | message: `Synthesized execution result envelope for ${capability.id}`, | ||
| 994 | relatedSymbolIds: unique([ | ||
| 995 | dataFieldShapeId, | ||
| 996 | errorFieldShapeId, | ||
| 997 | headersShapeId, | ||
| 998 | statusFieldShapeId, | ||
| 999 | ]), | ||
| 1000 | }, | ||
| 1001 | }); | ||
| 1002 | }; | ||
| 1003 | |||
| 695 | const groupFieldShape = ( | 1004 | const groupFieldShape = ( |
| 696 | catalog: CatalogV1, | 1005 | catalog: CatalogV1, |
| 697 | capability: Capability, | 1006 | capability: Capability, |
| @@ -1073,15 +1382,15 @@ const projectCapability = ( · 375 unmodified lines | |||
| 1073 | switch (executable.protocol) { | 1382 | switch (executable.protocol) { |
| 1074 | case "http": | 1383 | case "http": |
| 1075 | callShapeId = projectHttpCallShape(catalog, capability, executable); | 1384 | callShapeId = projectHttpCallShape(catalog, capability, executable); |
| 1076 | resultShapeId = projectResultShapeFromResponses(catalog, capability, responseSet); | 1385 | resultShapeId = projectExecutionResultShape(catalog, capability, executable, responseSet); |
| 1077 | break; | 1386 | break; |
| 1078 | case "graphql": | 1387 | case "graphql": |
| 1079 | callShapeId = projectGraphqlCallShape(catalog, capability, executable); | 1388 | callShapeId = projectGraphqlCallShape(catalog, capability, executable); |
| 1080 | resultShapeId = executable.resultShapeId; | 1389 | resultShapeId = projectExecutionResultShape(catalog, capability, executable, responseSet); |
| 1081 | break; | 1390 | break; |
| 1082 | case "mcp": | 1391 | case "mcp": |
| 1083 | callShapeId = projectMcpCallShape(catalog, capability, executable); | 1392 | callShapeId = projectMcpCallShape(catalog, capability, executable); |
| 1084 | resultShapeId = executable.outputShapeId; | 1393 | resultShapeId = projectExecutionResultShape(catalog, capability, executable, responseSet); |
| 1085 | break; | 1394 | break; |
| 1086 | } | 1395 | } |
| 1087 | 1396 | ||
| @@ -65,15 +65,11 @@ const readSourceTransport = (source: Source): "auto" | "streamable-http" | "sse" · 64 unmodified lines | |||
| 65 | return undefined; | 65 | return undefined; |
| 66 | }; | 66 | }; |
| 67 | 67 | ||
| 68 | const summarizeHttpBody = (body: unknown): string => { | 68 | type ExecutionEnvelope = { |
| 69 | if (typeof body === "string") { | 69 | data: unknown; |
| 70 | return body.slice(0, 400); | 70 | error: unknown; |
| 71 | } | 71 | headers: Record<string, string>; |
| 72 | try { | 72 | status: number | null; |
| 73 | return JSON.stringify(body).slice(0, 400); | ||
| 74 | } catch { | ||
| 75 | return String(body); | ||
| 76 | } | ||
| 77 | }; | 73 | }; |
| 78 | 74 | ||
| 79 | const decodeFetchBody = async (response: Response): Promise<unknown> => { | 75 | const decodeFetchBody = async (response: Response): Promise<unknown> => { |
| @@ -87,6 +83,16 @@ const decodeFetchBody = async (response: Response): Promise<unknown> => { · 7 unmodified lines | |||
| 87 | return response.text(); | 83 | return response.text(); |
| 88 | }; | 84 | }; |
| 89 | 85 | ||
| 86 | const responseHeadersRecord = (response: Response): Record<string, string> => { | ||
| 87 | const headers: Record<string, string> = {}; | ||
| 88 | response.headers.forEach((value, key) => { | ||
| 89 | headers[key] = value; | ||
| 90 | }); | ||
| 91 | return headers; | ||
| 92 | }; | ||
| 93 | |||
| 94 | const executionEnvelope = (input: ExecutionEnvelope): ExecutionEnvelope => input; | ||
| 95 | |||
| 90 | const parameterById = (catalog: CatalogV1, parameterId: string): ParameterSymbol | undefined => { | 96 | const parameterById = (catalog: CatalogV1, parameterId: string): ParameterSymbol | undefined => { |
| 91 | const symbol = catalog.symbols[parameterId]; | 97 | const symbol = catalog.symbols[parameterId]; |
| 92 | return symbol?.kind === "parameter" ? symbol : undefined; | 98 | return symbol?.kind === "parameter" ? symbol : undefined; |
| @@ -340,11 +346,12 @@ const executeHttp = (input: { · 247 unmodified lines | |||
| 340 | ...(body !== undefined ? { body } : {}), | 346 | ...(body !== undefined ? { body } : {}), |
| 341 | }); | 347 | }); |
| 342 | const responseBody = await decodeFetchBody(response); | 348 | const responseBody = await decodeFetchBody(response); |
| 343 | if (!response.ok) { | 349 | return executionEnvelope({ |
| 344 | throw new Error(`HTTP ${response.status}: ${summarizeHttpBody(responseBody)}`); | 350 | data: response.ok ? responseBody : null, |
| 345 | } | 351 | error: response.ok ? null : responseBody, |
| 346 | 352 | headers: responseHeadersRecord(response), | |
| 347 | return responseBody; | 353 | status: response.status, |
| 354 | }); | ||
| 348 | }, | 355 | }, |
| 349 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | 356 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), |
| 350 | }); | 357 | }); |
| @@ -394,7 +401,9 @@ const executeGraphql = (input: { · 43 unmodified lines | |||
| 394 | Effect.tryPromise({ | 401 | Effect.tryPromise({ |
| 395 | try: async () => { | 402 | try: async () => { |
| 396 | const argsRecord = asObject(input.args); | 403 | const argsRecord = asObject(input.args); |
| 397 | const native = input.executable.native?.[0]?.value as Record<string, unknown> | undefined; | 404 | const native = input.executable.native |
| 405 | ?.find((blob) => blob.kind === "graphql_provider_data") | ||
| 406 | ?.value as Record<string, unknown> | undefined; | ||
| 398 | const requestHeaders = { | 407 | const requestHeaders = { |
| 399 | ...readSourceHeaders(input.source), | 408 | ...readSourceHeaders(input.source), |
| 400 | ...input.auth.headers, | 409 | ...input.auth.headers, |
| @@ -430,14 +439,19 @@ const executeGraphql = (input: { · 29 unmodified lines | |||
| 430 | }), | 439 | }), |
| 431 | }); | 440 | }); |
| 432 | const body = await decodeFetchBody(response); | 441 | const body = await decodeFetchBody(response); |
| 433 | if (!response.ok) { | 442 | const bodyRecord = asObject(body); |
| 434 | throw new Error(`GraphQL HTTP ${response.status}: ${summarizeHttpBody(body)}`); | 443 | const errors = Array.isArray(bodyRecord.errors) ? bodyRecord.errors : []; |
| 435 | } | 444 | return executionEnvelope({ |
| 436 | return body; | 445 | data: body, |
| 446 | error: errors.length > 0 ? errors : (response.status >= 400 ? body : null), | ||
| 447 | headers: responseHeadersRecord(response), | ||
| 448 | status: response.status, | ||
| 449 | }); | ||
| 437 | } | 450 | } |
| 438 | 451 | ||
| 439 | const operationDocument = asString(native.operationDocument)!; | 452 | const operationDocument = asString(native.operationDocument)!; |
| 440 | const operationName = asString(native.operationName) ?? undefined; | 453 | const operationName = asString(native.operationName) ?? undefined; |
| 454 | const fieldName = asString(native.fieldName) ?? input.executable.rootField; | ||
| 441 | const variables = graphqlArgsPayload({ | 455 | const variables = graphqlArgsPayload({ |
| 442 | catalog: input.catalog, | 456 | catalog: input.catalog, |
| 443 | executable: input.executable, | 457 | executable: input.executable, |
| @@ -468,18 +482,14 @@ const executeGraphql = (input: { · 24 unmodified lines | |||
| 468 | }), | 482 | }), |
| 469 | }); | 483 | }); |
| 470 | const body = asObject(await decodeFetchBody(response)); | 484 | const body = asObject(await decodeFetchBody(response)); |
| 471 | if (!response.ok) { | ||
| 472 | throw new Error(`GraphQL HTTP ${response.status}: ${summarizeHttpBody(body)}`); | ||
| 473 | } | ||
| 474 | |||
| 475 | const errors = Array.isArray(body.errors) ? body.errors : []; | 485 | const errors = Array.isArray(body.errors) ? body.errors : []; |
| 476 | const data = asObject(body.data); | 486 | const data = asObject(body.data); |
| 477 | const fieldName = asString(native.fieldName) ?? input.executable.rootField; | 487 | return executionEnvelope({ |
| 478 | return { | ||
| 479 | data: data[fieldName] ?? null, | 488 | data: data[fieldName] ?? null, |
| 480 | errors, | 489 | error: errors.length > 0 ? errors : (response.status >= 400 ? body : null), |
| 481 | isError: errors.length > 0, | 490 | headers: responseHeadersRecord(response), |
| 482 | }; | 491 | status: response.status, |
| 492 | }); | ||
| 483 | }, | 493 | }, |
| 484 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | 494 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), |
| 485 | }); | 495 | }); |
| @@ -572,7 +582,15 @@ const executeMcp = (input: { · 86 unmodified lines | |||
| 572 | } | 582 | } |
| 573 | : undefined; | 583 | : undefined; |
| 574 | 584 | ||
| 575 | return await definition.execute(asObject(payload), executionContext); | 585 | const result = await definition.execute(asObject(payload), executionContext); |
| 586 | const resultRecord = asObject(result); | ||
| 587 | const isError = resultRecord.isError === true; | ||
| 588 | return executionEnvelope({ | ||
| 589 | data: isError ? null : (result ?? null), | ||
| 590 | error: isError ? result : null, | ||
| 591 | headers: {}, | ||
| 592 | status: null, | ||
| 593 | }); | ||
| 576 | }, | 594 | }, |
| 577 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), | 595 | catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), |
| 578 | }); | 596 | }); |
| @@ -109,8 +109,11 @@ describe("local-source-artifacts", () => { · 108 unmodified lines | |||
| 109 | const persistedArtifact = JSON.parse(readFileSync(path, "utf8")); | 109 | const persistedArtifact = JSON.parse(readFileSync(path, "utf8")); |
| 110 | const persistedDocument = Object.values(persistedArtifact.snapshot.catalog.documents)[0] as { | 110 | const persistedDocument = Object.values(persistedArtifact.snapshot.catalog.documents)[0] as { |
| 111 | native?: ReadonlyArray<{ kind?: string }>; | 111 | native?: ReadonlyArray<{ kind?: string }>; |
| 112 | provenance?: unknown; | ||
| 112 | }; | 113 | }; |
| 113 | expect(persistedDocument.native?.some((blob) => blob.kind === "source_document") ?? false).toBe(false); | 114 | expect(persistedDocument.native?.some((blob) => blob.kind === "source_document") ?? false).toBe(false); |
| 115 | expect(persistedDocument.provenance).toBeUndefined(); | ||
| 116 | expect(Object.keys(persistedArtifact.snapshot.catalog.diagnostics)).toHaveLength(0); | ||
| 114 | 117 | ||
| 115 | const decoded = yield* readLocalSourceArtifact({ | 118 | const decoded = yield* readLocalSourceArtifact({ |
| 116 | context, | 119 | context, |
| @@ -120,6 +123,7 @@ describe("local-source-artifacts", () => { · 3 unmodified lines | |||
| 120 | expect(decoded?.snapshot.import.adapterKey).toBe("openapi"); | 123 | expect(decoded?.snapshot.import.adapterKey).toBe("openapi"); |
| 121 | expect(decoded?.sourceId).toBe("src_test"); | 124 | expect(decoded?.sourceId).toBe("src_test"); |
| 122 | expect((Object.values(decoded?.snapshot.catalog.documents ?? {})[0] as { native?: Array<{ value?: unknown }> })?.native?.[0]?.value).toBe('{"openapi":"3.1.0"}'); | 125 | expect((Object.values(decoded?.snapshot.catalog.documents ?? {})[0] as { native?: Array<{ value?: unknown }> })?.native?.[0]?.value).toBe('{"openapi":"3.1.0"}'); |
| 126 | expect((Object.values(decoded?.snapshot.catalog.documents ?? {})[0] as { provenance?: unknown }).provenance).toBeUndefined(); | ||
| 123 | }).pipe(Effect.provide(NodeFileSystem.layer)), | 127 | }).pipe(Effect.provide(NodeFileSystem.layer)), |
| 124 | ); | 128 | ); |
| 125 | 129 | ||
| @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema"; · 15 unmodified lines | |||
| 16 | import { | 16 | import { |
| 17 | CatalogSnapshotV1Schema, | 17 | CatalogSnapshotV1Schema, |
| 18 | type NativeBlob, | 18 | type NativeBlob, |
| 19 | type ShapeNode, | ||
| 19 | type SourceDocument, | 20 | type SourceDocument, |
| 20 | } from "../ir/model"; | 21 | } from "../ir/model"; |
| 21 | import type { SourceCatalogSyncResult } from "./source-catalog-support"; | 22 | import type { SourceCatalogSyncResult } from "./source-catalog-support"; |
| @@ -57,6 +58,10 @@ const mapFileSystemError = (path: string, action: string) => (cause: unknown) => · 35 unmodified lines | |||
| 57 | const mutableRecord = <K extends string, V>(value: Readonly<Record<K, V>>): Record<K, V> => | 58 | const mutableRecord = <K extends string, V>(value: Readonly<Record<K, V>>): Record<K, V> => |
| 58 | value as Record<K, V>; | 59 | value as Record<K, V>; |
| 59 | 60 | ||
| 61 | const clearProvenance = (value: unknown): void => { | ||
| 62 | (value as { provenance: Array<unknown> }).provenance = []; | ||
| 63 | }; | ||
| 64 | |||
| 60 | const localSourceArtifactPath = (input: { | 65 | const localSourceArtifactPath = (input: { |
| 61 | context: ResolvedLocalWorkspaceContext; | 66 | context: ResolvedLocalWorkspaceContext; |
| 62 | sourceId: string; | 67 | sourceId: string; |
| @@ -154,6 +159,109 @@ const hydrateArtifactSourceDocuments = (input: { · 91 unmodified lines | |||
| 154 | return nextArtifact; | 159 | return nextArtifact; |
| 155 | }; | 160 | }; |
| 156 | 161 | ||
| 162 | const stripShapeFieldDocs = (node: ShapeNode): void => { | ||
| 163 | switch (node.type) { | ||
| 164 | case "object": | ||
| 165 | case "graphqlInterface": | ||
| 166 | for (const field of Object.values(node.fields)) { | ||
| 167 | delete (field as { docs?: unknown }).docs; | ||
| 168 | } | ||
| 169 | return; | ||
| 170 | default: | ||
| 171 | return; | ||
| 172 | } | ||
| 173 | }; | ||
| 174 | |||
| 175 | const stripRuntimeMetadata = (artifact: LocalSourceArtifact): LocalSourceArtifact => { | ||
| 176 | const nextArtifact = structuredClone(artifact); | ||
| 177 | |||
| 178 | for (const document of Object.values(nextArtifact.snapshot.catalog.documents)) { | ||
| 179 | delete (document as { provenance?: unknown }).provenance; | ||
| 180 | delete (document as { docs?: unknown }).docs; | ||
| 181 | delete (document as { native?: unknown }).native; | ||
| 182 | } | ||
| 183 | |||
| 184 | for (const resource of Object.values(nextArtifact.snapshot.catalog.resources)) { | ||
| 185 | clearProvenance(resource as unknown); | ||
| 186 | delete (resource as { docs?: unknown }).docs; | ||
| 187 | delete (resource as { native?: unknown }).native; | ||
| 188 | } | ||
| 189 | |||
| 190 | for (const scope of Object.values(nextArtifact.snapshot.catalog.scopes)) { | ||
| 191 | clearProvenance(scope as unknown); | ||
| 192 | delete (scope as { docs?: unknown }).docs; | ||
| 193 | delete (scope as { native?: unknown }).native; | ||
| 194 | } | ||
| 195 | |||
| 196 | for (const capability of Object.values(nextArtifact.snapshot.catalog.capabilities)) { | ||
| 197 | clearProvenance(capability as unknown); | ||
| 198 | delete (capability as { native?: unknown }).native; | ||
| 199 | delete (capability as { exampleIds?: unknown }).exampleIds; | ||
| 200 | } | ||
| 201 | |||
| 202 | for (const executable of Object.values(nextArtifact.snapshot.catalog.executables)) { | ||
| 203 | clearProvenance(executable as unknown); | ||
| 204 | if (Array.isArray((executable as { native?: NativeBlob[] }).native)) { | ||
| 205 | const keptNative = ((executable as { native?: NativeBlob[] }).native ?? []).filter((blob) => | ||
| 206 | blob.kind === "google_discovery_provider_data" | ||
| 207 | ); | ||
| 208 | if (keptNative.length > 0) { | ||
| 209 | (executable as { native?: NativeBlob[] }).native = keptNative; | ||
| 210 | } else { | ||
| 211 | delete (executable as { native?: unknown }).native; | ||
| 212 | } | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | for (const responseSet of Object.values(nextArtifact.snapshot.catalog.responseSets)) { | ||
| 217 | clearProvenance(responseSet as unknown); | ||
| 218 | delete (responseSet as { native?: unknown }).native; | ||
| 219 | } | ||
| 220 | |||
| 221 | for (const diagnostic of Object.values(nextArtifact.snapshot.catalog.diagnostics)) { | ||
| 222 | delete (diagnostic as { provenance?: unknown }).provenance; | ||
| 223 | } | ||
| 224 | |||
| 225 | const mutableSymbols = mutableRecord(nextArtifact.snapshot.catalog.symbols); | ||
| 226 | for (const [symbolId, symbol] of Object.entries(mutableSymbols)) { | ||
| 227 | if (symbol.kind === "example") { | ||
| 228 | delete mutableSymbols[symbolId as keyof typeof mutableSymbols]; | ||
| 229 | continue; | ||
| 230 | } | ||
| 231 | |||
| 232 | clearProvenance(symbol as unknown); | ||
| 233 | delete (symbol as { docs?: unknown }).docs; | ||
| 234 | delete (symbol as { diagnosticIds?: unknown }).diagnosticIds; | ||
| 235 | delete (symbol as { native?: unknown }).native; | ||
| 236 | delete (symbol as { deprecated?: unknown }).deprecated; | ||
| 237 | |||
| 238 | switch (symbol.kind) { | ||
| 239 | case "shape": | ||
| 240 | stripShapeFieldDocs(symbol.node); | ||
| 241 | break; | ||
| 242 | case "parameter": | ||
| 243 | case "header": | ||
| 244 | case "response": | ||
| 245 | delete (symbol as { exampleIds?: unknown }).exampleIds; | ||
| 246 | if ("content" in symbol && Array.isArray(symbol.content)) { | ||
| 247 | for (const content of symbol.content) { | ||
| 248 | delete (content as { exampleIds?: unknown }).exampleIds; | ||
| 249 | } | ||
| 250 | } | ||
| 251 | break; | ||
| 252 | case "requestBody": | ||
| 253 | for (const content of symbol.contents) { | ||
| 254 | delete (content as { exampleIds?: unknown }).exampleIds; | ||
| 255 | } | ||
| 256 | break; | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 260 | mutableRecord(nextArtifact.snapshot.catalog).diagnostics = {}; | ||
| 261 | |||
| 262 | return nextArtifact; | ||
| 263 | }; | ||
| 264 | |||
| 157 | const snapshotHash = (snapshot: SourceCatalogSyncResult["snapshot"]): string => | 265 | const snapshotHash = (snapshot: SourceCatalogSyncResult["snapshot"]): string => |
| 158 | contentHash(JSON.stringify(snapshot)); | 266 | contentHash(JSON.stringify(snapshot)); |
| 159 | 267 | ||
| @@ -281,6 +389,7 @@ export const writeLocalSourceArtifact = (input: { · 121 unmodified lines | |||
| 281 | const legacyPath = legacyLocalSourceArtifactPath(input); | 389 | const legacyPath = legacyLocalSourceArtifactPath(input); |
| 282 | const sourceDocumentDirectory = localSourceDocumentDirectory(input); | 390 | const sourceDocumentDirectory = localSourceDocumentDirectory(input); |
| 283 | const split = splitArtifactSourceDocuments(input.artifact); | 391 | const split = splitArtifactSourceDocuments(input.artifact); |
| 392 | const runtimeArtifact = stripRuntimeMetadata(split.artifact); | ||
| 284 | yield* fs.makeDirectory(directory, { recursive: true }).pipe( | 393 | yield* fs.makeDirectory(directory, { recursive: true }).pipe( |
| 285 | Effect.mapError(mapFileSystemError(directory, "create source artifact directory")), | 394 | Effect.mapError(mapFileSystemError(directory, "create source artifact directory")), |
| 286 | ); | 395 | ); |
| @@ -302,7 +411,7 @@ export const writeLocalSourceArtifact = (input: { · 15 unmodified lines | |||
| 302 | ); | 411 | ); |
| 303 | } | 412 | } |
| 304 | } | 413 | } |
| 305 | yield* fs.writeFileString(path, `${JSON.stringify(split.artifact)}\n`).pipe( | 414 | yield* fs.writeFileString(path, `${JSON.stringify(runtimeArtifact)}\n`).pipe( |
| 306 | Effect.mapError(mapFileSystemError(path, "write source artifact")), | 415 | Effect.mapError(mapFileSystemError(path, "write source artifact")), |
| 307 | ); | 416 | ); |
| 308 | const legacyExists = yield* fs.exists(legacyPath).pipe( | 417 | const legacyExists = yield* fs.exists(legacyPath).pipe( |
| @@ -708,7 +708,9 @@ describe("source-catalog-snapshot", () => { · 707 unmodified lines | |||
| 708 | expect(executable.operationType).toBe("query"); | 708 | expect(executable.operationType).toBe("query"); |
| 709 | expect(executable.rootField).toBe("viewer"); | 709 | expect(executable.rootField).toBe("viewer"); |
| 710 | expect(executable.selectionMode).toBe("fixed"); | 710 | expect(executable.selectionMode).toBe("fixed"); |
| 711 | expect(executable.native).toBeUndefined(); | 711 | expect(executable.native).toMatchObject([{ |
| 712 | kind: "graphql_provider_data", | ||
| 713 | }]); | ||
| 712 | }); | 714 | }); |
| 713 | 715 | ||
| 714 | it("projects MCP metadata into capability semantics", () => { | 716 | it("projects MCP metadata into capability semantics", () => { |
| @@ -2009,6 +2009,12 @@ const createGraphqlCapability = (input: { · 2008 unmodified lines | |||
| 2009 | responseSetId, | 2009 | responseSetId, |
| 2010 | synthetic: false, | 2010 | synthetic: false, |
| 2011 | provenance: provenanceFor(input.documentId, `#/graphql/${input.operation.providerData.toolId}/executable`), | 2011 | provenance: provenanceFor(input.documentId, `#/graphql/${input.operation.providerData.toolId}/executable`), |
| 2012 | native: [nativeBlob({ | ||
| 2013 | source: input.source, | ||
| 2014 | kind: "graphql_provider_data", | ||
| 2015 | pointer: `#/graphql/${input.operation.providerData.toolId}/providerData`, | ||
| 2016 | value: input.operation.providerData, | ||
| 2017 | })], | ||
| 2012 | } satisfies GraphQLExecutable; | 2018 | } satisfies GraphQLExecutable; |
| 2013 | 2019 | ||
| 2014 | const effect = input.operation.effect; | 2020 | const effect = input.operation.effect; |