community: remove premium protocols and streaming surface
This commit is contained in:
Generated
-426
@@ -380,27 +380,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crank-adapter-grpc"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"crank-core",
|
|
||||||
"crank-proto",
|
|
||||||
"futures-util",
|
|
||||||
"prost",
|
|
||||||
"prost-reflect",
|
|
||||||
"prost-types",
|
|
||||||
"protoc-bin-vendored",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
|
||||||
"tonic",
|
|
||||||
"tonic-prost",
|
|
||||||
"tonic-prost-build",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crank-adapter-rest"
|
name = "crank-adapter-rest"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -438,19 +417,6 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crank-proto"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"crank-core",
|
|
||||||
"crank-schema",
|
|
||||||
"prost",
|
|
||||||
"prost-reflect",
|
|
||||||
"prost-types",
|
|
||||||
"serde",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crank-registry"
|
name = "crank-registry"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -627,16 +593,6 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "errno"
|
|
||||||
version = "0.3.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "etcetera"
|
name = "etcetera"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -671,12 +627,6 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fixedbitset"
|
|
||||||
version = "0.5.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -688,12 +638,6 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fnv"
|
|
||||||
version = "1.0.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -852,25 +796,6 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h2"
|
|
||||||
version = "0.4.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
|
||||||
dependencies = [
|
|
||||||
"atomic-waker",
|
|
||||||
"bytes",
|
|
||||||
"fnv",
|
|
||||||
"futures-core",
|
|
||||||
"futures-sink",
|
|
||||||
"http",
|
|
||||||
"indexmap",
|
|
||||||
"slab",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -991,7 +916,6 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1021,19 +945,6 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.6",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-timeout"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
|
||||||
dependencies = [
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tokio",
|
|
||||||
"tower-service",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1202,15 +1113,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "itertools"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
|
||||||
dependencies = [
|
|
||||||
"either",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1276,12 +1178,6 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -1337,7 +1233,6 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"crank-adapter-grpc",
|
|
||||||
"crank-core",
|
"crank-core",
|
||||||
"crank-mapping",
|
"crank-mapping",
|
||||||
"crank-registry",
|
"crank-registry",
|
||||||
@@ -1390,12 +1285,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "multimap"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1479,15 +1368,6 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ordered-float"
|
|
||||||
version = "2.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -1543,37 +1423,6 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "petgraph"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
|
||||||
dependencies = [
|
|
||||||
"fixedbitset",
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
"indexmap",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project"
|
|
||||||
version = "1.1.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
|
||||||
dependencies = [
|
|
||||||
"pin-project-internal",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project-internal"
|
|
||||||
version = "1.1.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1674,136 +1523,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prost"
|
|
||||||
version = "0.14.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"prost-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prost-build"
|
|
||||||
version = "0.14.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
|
|
||||||
dependencies = [
|
|
||||||
"heck",
|
|
||||||
"itertools",
|
|
||||||
"log",
|
|
||||||
"multimap",
|
|
||||||
"petgraph",
|
|
||||||
"prettyplease",
|
|
||||||
"prost",
|
|
||||||
"prost-types",
|
|
||||||
"pulldown-cmark",
|
|
||||||
"pulldown-cmark-to-cmark",
|
|
||||||
"regex",
|
|
||||||
"syn",
|
|
||||||
"tempfile",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prost-derive"
|
|
||||||
version = "0.14.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"itertools",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prost-reflect"
|
|
||||||
version = "0.16.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b89455ef41ed200cafc47c76c552ee7792370ac420497e551f16123a9135f76e"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"prost",
|
|
||||||
"prost-types",
|
|
||||||
"serde",
|
|
||||||
"serde-value",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prost-types"
|
|
||||||
version = "0.14.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
|
|
||||||
dependencies = [
|
|
||||||
"prost",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
|
|
||||||
dependencies = [
|
|
||||||
"protoc-bin-vendored-linux-aarch_64",
|
|
||||||
"protoc-bin-vendored-linux-ppcle_64",
|
|
||||||
"protoc-bin-vendored-linux-s390_64",
|
|
||||||
"protoc-bin-vendored-linux-x86_32",
|
|
||||||
"protoc-bin-vendored-linux-x86_64",
|
|
||||||
"protoc-bin-vendored-macos-aarch_64",
|
|
||||||
"protoc-bin-vendored-macos-x86_64",
|
|
||||||
"protoc-bin-vendored-win32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-linux-aarch_64"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-linux-ppcle_64"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-linux-s390_64"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-linux-x86_32"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-linux-x86_64"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-macos-aarch_64"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-macos-x86_64"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protoc-bin-vendored-win32"
|
|
||||||
version = "3.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psl-types"
|
name = "psl-types"
|
||||||
version = "2.0.11"
|
version = "2.0.11"
|
||||||
@@ -1820,26 +1539,6 @@ dependencies = [
|
|||||||
"psl-types",
|
"psl-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pulldown-cmark"
|
|
||||||
version = "0.13.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"memchr",
|
|
||||||
"unicase",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pulldown-cmark-to-cmark"
|
|
||||||
version = "22.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90"
|
|
||||||
dependencies = [
|
|
||||||
"pulldown-cmark",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -2017,18 +1716,6 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "regex"
|
|
||||||
version = "1.12.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
|
||||||
dependencies = [
|
|
||||||
"aho-corasick",
|
|
||||||
"memchr",
|
|
||||||
"regex-automata",
|
|
||||||
"regex-syntax",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -2129,19 +1816,6 @@ version = "2.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "1.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.37"
|
version = "0.23.37"
|
||||||
@@ -2226,16 +1900,6 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde-value"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
|
|
||||||
dependencies = [
|
|
||||||
"ordered-float",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -2686,19 +2350,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tempfile"
|
|
||||||
version = "3.27.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
|
||||||
dependencies = [
|
|
||||||
"fastrand",
|
|
||||||
"getrandom 0.4.2",
|
|
||||||
"once_cell",
|
|
||||||
"rustix",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
@@ -2844,74 +2495,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tonic"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"axum",
|
|
||||||
"base64",
|
|
||||||
"bytes",
|
|
||||||
"h2",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-timeout",
|
|
||||||
"hyper-util",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project",
|
|
||||||
"socket2 0.6.3",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
"tower",
|
|
||||||
"tower-layer",
|
|
||||||
"tower-service",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tonic-build"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734"
|
|
||||||
dependencies = [
|
|
||||||
"prettyplease",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tonic-prost"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"prost",
|
|
||||||
"tonic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tonic-prost-build"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a"
|
|
||||||
dependencies = [
|
|
||||||
"prettyplease",
|
|
||||||
"proc-macro2",
|
|
||||||
"prost-build",
|
|
||||||
"prost-types",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
"tempfile",
|
|
||||||
"tonic-build",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -2920,12 +2503,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"indexmap",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3035,12 +2615,6 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicase"
|
|
||||||
version = "2.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ members = [
|
|||||||
"crates/crank-core",
|
"crates/crank-core",
|
||||||
"crates/crank-schema",
|
"crates/crank-schema",
|
||||||
"crates/crank-mapping",
|
"crates/crank-mapping",
|
||||||
"crates/crank-proto",
|
|
||||||
"crates/crank-registry",
|
"crates/crank-registry",
|
||||||
"crates/crank-runtime",
|
"crates/crank-runtime",
|
||||||
"crates/crank-adapter-rest",
|
"crates/crank-adapter-rest",
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ Progress:
|
|||||||
- `crank-runtime` now has protocol feature seams, and the runtime crate compiles with `--no-default-features` as a `REST-only` base
|
- `crank-runtime` now has protocol feature seams, and the runtime crate compiles with `--no-default-features` as a `REST-only` base
|
||||||
- pending:
|
- pending:
|
||||||
- implement the whitelist from `docs/community-source-whitelist.md`
|
- implement the whitelist from `docs/community-source-whitelist.md`
|
||||||
- make Community workspace compile and run without premium protocol crates
|
- make Community copy/docs pass finish the remaining premium string cleanup in `catalog`, `agents`, `settings`, `workspace-setup`, and `i18n`
|
||||||
- make Community UI export stop depending on premium wizard and streaming surfaces
|
- decide whether legacy streaming internals in `admin-api` stay dormant until `enterprise/cloud` extraction or move out before the next Community release cut
|
||||||
- only after that, perform a clean whitelist-based force-push into `crank-community`
|
|
||||||
- continue physical split by isolating `enterprise/cloud` delta away from the extracted Community base
|
- continue physical split by isolating `enterprise/cloud` delta away from the extracted Community base
|
||||||
|
- only after that, perform a clean whitelist-based force-push into `crank-community`
|
||||||
|
|
||||||
## Planned
|
## Planned
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,9 @@ COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml
|
|||||||
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
|
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
|
||||||
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
|
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
|
||||||
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
|
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
|
||||||
COPY crates/crank-proto/Cargo.toml crates/crank-proto/Cargo.toml
|
|
||||||
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
|
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
|
||||||
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
|
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
|
||||||
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
|
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
|
||||||
COPY crates/crank-adapter-graphql/Cargo.toml crates/crank-adapter-graphql/Cargo.toml
|
|
||||||
COPY crates/crank-adapter-grpc/Cargo.toml crates/crank-adapter-grpc/Cargo.toml
|
|
||||||
COPY crates/crank-adapter-grpc/build.rs crates/crank-adapter-grpc/build.rs
|
|
||||||
COPY crates/crank-adapter-grpc/proto crates/crank-adapter-grpc/proto
|
|
||||||
COPY crates/crank-adapter-websocket/Cargo.toml crates/crank-adapter-websocket/Cargo.toml
|
|
||||||
COPY crates/crank-adapter-soap/Cargo.toml crates/crank-adapter-soap/Cargo.toml
|
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
apps/admin-api/src \
|
apps/admin-api/src \
|
||||||
@@ -26,27 +19,17 @@ RUN mkdir -p \
|
|||||||
crates/crank-core/src \
|
crates/crank-core/src \
|
||||||
crates/crank-schema/src \
|
crates/crank-schema/src \
|
||||||
crates/crank-mapping/src \
|
crates/crank-mapping/src \
|
||||||
crates/crank-proto/src \
|
|
||||||
crates/crank-registry/src \
|
crates/crank-registry/src \
|
||||||
crates/crank-runtime/src \
|
crates/crank-runtime/src \
|
||||||
crates/crank-adapter-rest/src \
|
crates/crank-adapter-rest/src \
|
||||||
crates/crank-adapter-graphql/src \
|
|
||||||
crates/crank-adapter-grpc/src \
|
|
||||||
crates/crank-adapter-websocket/src \
|
|
||||||
crates/crank-adapter-soap/src \
|
|
||||||
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
|
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
|
||||||
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
|
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-proto/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-graphql/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-grpc/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-websocket/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-soap/src/lib.rs
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use crate::{
|
|||||||
capabilities::get_capabilities,
|
capabilities::get_capabilities,
|
||||||
machine_auth::{issue_agent_token, issue_one_time_agent_token},
|
machine_auth::{issue_agent_token, issue_one_time_agent_token},
|
||||||
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
||||||
|
streaming::list_protocol_capabilities,
|
||||||
operations::{
|
operations::{
|
||||||
archive_operation, create_operation, create_version, delete_operation,
|
archive_operation, create_operation, create_version, delete_operation,
|
||||||
export_operation, generate_draft, get_operation, get_operation_version,
|
export_operation, generate_draft, get_operation, get_operation_version,
|
||||||
@@ -33,10 +34,6 @@ use crate::{
|
|||||||
upload_output_json,
|
upload_output_json,
|
||||||
},
|
},
|
||||||
secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret},
|
secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret},
|
||||||
streaming::{
|
|
||||||
cancel_async_job, get_async_job, get_async_job_result, get_stream_session,
|
|
||||||
list_async_jobs, list_protocol_capabilities, list_stream_sessions, stop_stream_session,
|
|
||||||
},
|
|
||||||
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
|
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@@ -131,17 +128,7 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.route("/usage", get(get_usage))
|
.route("/usage", get(get_usage))
|
||||||
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
||||||
.route("/usage/agents/{agent_id}", get(get_agent_usage))
|
.route("/usage/agents/{agent_id}", get(get_agent_usage))
|
||||||
.route("/protocol-capabilities", get(list_protocol_capabilities))
|
.route("/protocol-capabilities", get(list_protocol_capabilities));
|
||||||
.route("/stream-sessions", get(list_stream_sessions))
|
|
||||||
.route("/stream-sessions/{session_id}", get(get_stream_session))
|
|
||||||
.route(
|
|
||||||
"/stream-sessions/{session_id}/stop",
|
|
||||||
post(stop_stream_session),
|
|
||||||
)
|
|
||||||
.route("/async-jobs", get(list_async_jobs))
|
|
||||||
.route("/async-jobs/{job_id}", get(get_async_job))
|
|
||||||
.route("/async-jobs/{job_id}/cancel", post(cancel_async_job))
|
|
||||||
.route("/async-jobs/{job_id}/result", get(get_async_job_result));
|
|
||||||
|
|
||||||
let workspace_root_router = Router::new()
|
let workspace_root_router = Router::new()
|
||||||
.route("/capabilities", get(get_capabilities))
|
.route("/capabilities", get(get_capabilities))
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, State},
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
routes::access::WorkspacePath,
|
routes::access::WorkspacePath,
|
||||||
service::{AsyncJobsQuery, StreamSessionsQuery},
|
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct WorkspaceStreamSessionPath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub session_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub struct WorkspaceAsyncJobPath {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub job_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_protocol_capabilities(
|
pub async fn list_protocol_capabilities(
|
||||||
Path(_path): Path<WorkspacePath>,
|
Path(_path): Path<WorkspacePath>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -31,107 +18,3 @@ pub async fn list_protocol_capabilities(
|
|||||||
"items": state.service.list_protocol_capabilities().await
|
"items": state.service.list_protocol_capabilities().await
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_stream_sessions(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
Query(query): Query<StreamSessionsQuery>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let page = state
|
|
||||||
.service
|
|
||||||
.list_stream_sessions(&path.workspace_id.as_str().into(), query.clone())
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({
|
|
||||||
"items": page.items,
|
|
||||||
"page": query.page.unwrap_or(1),
|
|
||||||
"page_size": query.page_size.unwrap_or(20),
|
|
||||||
"total": page.total,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_stream_session(
|
|
||||||
Path(path): Path<WorkspaceStreamSessionPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let session = state
|
|
||||||
.service
|
|
||||||
.get_stream_session(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.session_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(session)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop_stream_session(
|
|
||||||
Path(path): Path<WorkspaceStreamSessionPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let session = state
|
|
||||||
.service
|
|
||||||
.stop_stream_session(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.session_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(session)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_async_jobs(
|
|
||||||
Path(path): Path<WorkspacePath>,
|
|
||||||
Query(query): Query<AsyncJobsQuery>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let page = state
|
|
||||||
.service
|
|
||||||
.list_async_jobs(&path.workspace_id.as_str().into(), query.clone())
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!({
|
|
||||||
"items": page.items,
|
|
||||||
"page": query.page.unwrap_or(1),
|
|
||||||
"page_size": query.page_size.unwrap_or(20),
|
|
||||||
"total": page.total,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_async_job(
|
|
||||||
Path(path): Path<WorkspaceAsyncJobPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let job = state
|
|
||||||
.service
|
|
||||||
.get_async_job(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.job_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(job)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cancel_async_job(
|
|
||||||
Path(path): Path<WorkspaceAsyncJobPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let job = state
|
|
||||||
.service
|
|
||||||
.cancel_async_job(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.job_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(job)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_async_job_result(
|
|
||||||
Path(path): Path<WorkspaceAsyncJobPath>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Json<Value>, ApiError> {
|
|
||||||
let result = state
|
|
||||||
.service
|
|
||||||
.get_async_job_result(
|
|
||||||
&path.workspace_id.as_str().into(),
|
|
||||||
&path.job_id.as_str().into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(Json(json!(result)))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ tracing-subscriber.workspace = true
|
|||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] }
|
|
||||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||||
crank-schema = { path = "../../crates/crank-schema" }
|
crank-schema = { path = "../../crates/crank-schema" }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
|||||||
@@ -9,16 +9,9 @@ COPY apps/mcp-server/Cargo.toml apps/mcp-server/Cargo.toml
|
|||||||
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
|
COPY crates/crank-core/Cargo.toml crates/crank-core/Cargo.toml
|
||||||
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
|
COPY crates/crank-schema/Cargo.toml crates/crank-schema/Cargo.toml
|
||||||
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
|
COPY crates/crank-mapping/Cargo.toml crates/crank-mapping/Cargo.toml
|
||||||
COPY crates/crank-proto/Cargo.toml crates/crank-proto/Cargo.toml
|
|
||||||
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
|
COPY crates/crank-registry/Cargo.toml crates/crank-registry/Cargo.toml
|
||||||
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
|
COPY crates/crank-runtime/Cargo.toml crates/crank-runtime/Cargo.toml
|
||||||
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
|
COPY crates/crank-adapter-rest/Cargo.toml crates/crank-adapter-rest/Cargo.toml
|
||||||
COPY crates/crank-adapter-graphql/Cargo.toml crates/crank-adapter-graphql/Cargo.toml
|
|
||||||
COPY crates/crank-adapter-grpc/Cargo.toml crates/crank-adapter-grpc/Cargo.toml
|
|
||||||
COPY crates/crank-adapter-grpc/build.rs crates/crank-adapter-grpc/build.rs
|
|
||||||
COPY crates/crank-adapter-grpc/proto crates/crank-adapter-grpc/proto
|
|
||||||
COPY crates/crank-adapter-websocket/Cargo.toml crates/crank-adapter-websocket/Cargo.toml
|
|
||||||
COPY crates/crank-adapter-soap/Cargo.toml crates/crank-adapter-soap/Cargo.toml
|
|
||||||
|
|
||||||
RUN mkdir -p \
|
RUN mkdir -p \
|
||||||
apps/admin-api/src \
|
apps/admin-api/src \
|
||||||
@@ -26,27 +19,17 @@ RUN mkdir -p \
|
|||||||
crates/crank-core/src \
|
crates/crank-core/src \
|
||||||
crates/crank-schema/src \
|
crates/crank-schema/src \
|
||||||
crates/crank-mapping/src \
|
crates/crank-mapping/src \
|
||||||
crates/crank-proto/src \
|
|
||||||
crates/crank-registry/src \
|
crates/crank-registry/src \
|
||||||
crates/crank-runtime/src \
|
crates/crank-runtime/src \
|
||||||
crates/crank-adapter-rest/src \
|
crates/crank-adapter-rest/src \
|
||||||
crates/crank-adapter-graphql/src \
|
|
||||||
crates/crank-adapter-grpc/src \
|
|
||||||
crates/crank-adapter-websocket/src \
|
|
||||||
crates/crank-adapter-soap/src \
|
|
||||||
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
|
&& printf 'fn main() {}\n' > apps/admin-api/src/main.rs \
|
||||||
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
|
&& printf 'fn main() {}\n' > apps/mcp-server/src/main.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-core/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-schema/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-mapping/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-proto/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-registry/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-runtime/src/lib.rs \
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs \
|
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-graphql/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-grpc/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-websocket/src/lib.rs \
|
|
||||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-soap/src/lib.rs
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<base href="../">
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Crank — Async Jobs</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
|
||||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar">
|
|
||||||
<a href="/" class="nav-logo">
|
|
||||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
|
||||||
<span class="nav-logo-text">Crank</span>
|
|
||||||
</a>
|
|
||||||
<div class="ws-switcher" id="ws-switcher">
|
|
||||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
|
||||||
<div class="ws-dot" id="ws-dot">A</div>
|
|
||||||
<span class="ws-current-name" id="ws-current-name">workspace</span>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
|
||||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
|
||||||
<div id="ws-dropdown-list"></div>
|
|
||||||
<div class="ws-dropdown-footer">
|
|
||||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
|
||||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="nav-links">
|
|
||||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
|
||||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
|
||||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
|
||||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
|
||||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
|
||||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-right">
|
|
||||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
|
||||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications"><svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg></button>
|
|
||||||
<div class="nav-divider"></div>
|
|
||||||
<div class="user-menu">
|
|
||||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
|
||||||
<div class="user-dropdown">
|
|
||||||
<div class="user-dropdown-header"><div class="user-dropdown-name">Crank</div><div class="user-dropdown-role">—</div></div>
|
|
||||||
<button class="user-dropdown-item" data-action="settings-link"><svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg><span data-i18n="nav.settings">Settings</span></button>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<button class="user-dropdown-item danger" data-action="logout"><svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg><span data-i18n="nav.logout">Log out</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="mobile-nav" hidden>
|
|
||||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
|
||||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
|
||||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
|
||||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
|
||||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
|
||||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="page-header-text">
|
|
||||||
<h1 class="page-title" data-i18n="async_jobs.title">Async jobs</h1>
|
|
||||||
<p class="page-subtitle" data-i18n="async_jobs.subtitle">Inspect deferred jobs started by streaming MCP tool families.</p>
|
|
||||||
</div>
|
|
||||||
<div class="page-header-actions">
|
|
||||||
<button class="btn-secondary" id="async-jobs-refresh" type="button" data-i18n="async_jobs.refresh">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="section-card-body">
|
|
||||||
<div class="list-toolbar">
|
|
||||||
<div class="list-toolbar-group">
|
|
||||||
<select class="page-select" id="async-jobs-status">
|
|
||||||
<option value="" data-i18n="async_jobs.filter.all_statuses">All statuses</option>
|
|
||||||
<option value="created" data-i18n="async_jobs.status.created">Created</option>
|
|
||||||
<option value="running" data-i18n="async_jobs.status.running">Running</option>
|
|
||||||
<option value="completed" data-i18n="async_jobs.status.completed">Completed</option>
|
|
||||||
<option value="failed" data-i18n="async_jobs.status.failed">Failed</option>
|
|
||||||
<option value="cancelled" data-i18n="async_jobs.status.cancelled">Cancelled</option>
|
|
||||||
<option value="expired" data-i18n="async_jobs.status.expired">Expired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="list-toolbar-group">
|
|
||||||
<span class="section-card-subtitle" id="async-jobs-summary"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="resource-list" id="async-jobs-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="%CRANK_BUNDLE_ASYNC_JOBS%"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<base href="../">
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Crank — Stream Sessions</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="css/variables.css">
|
|
||||||
<link rel="stylesheet" href="css/layout.css">
|
|
||||||
<link rel="stylesheet" href="css/pages.css">
|
|
||||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar">
|
|
||||||
<a href="/" class="nav-logo">
|
|
||||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
|
||||||
<span class="nav-logo-text">Crank</span>
|
|
||||||
</a>
|
|
||||||
<div class="ws-switcher" id="ws-switcher">
|
|
||||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
|
||||||
<div class="ws-dot" id="ws-dot">A</div>
|
|
||||||
<span class="ws-current-name" id="ws-current-name">workspace</span>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
|
||||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
|
||||||
<div id="ws-dropdown-list"></div>
|
|
||||||
<div class="ws-dropdown-footer">
|
|
||||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
|
||||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="nav-links">
|
|
||||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
|
||||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
|
||||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
|
||||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
|
||||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
|
||||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-right">
|
|
||||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
|
||||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications"><svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg></button>
|
|
||||||
<div class="nav-divider"></div>
|
|
||||||
<div class="user-menu">
|
|
||||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
|
||||||
<div class="user-dropdown">
|
|
||||||
<div class="user-dropdown-header"><div class="user-dropdown-name">Crank</div><div class="user-dropdown-role">—</div></div>
|
|
||||||
<button class="user-dropdown-item" data-action="settings-link"><svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg><span data-i18n="nav.settings">Settings</span></button>
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
<button class="user-dropdown-item danger" data-action="logout"><svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg><span data-i18n="nav.logout">Log out</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="mobile-nav" hidden>
|
|
||||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
|
||||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
|
||||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
|
||||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
|
||||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
|
||||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="page-header-text">
|
|
||||||
<h1 class="page-title" data-i18n="stream_sessions.title">Stream sessions</h1>
|
|
||||||
<p class="page-subtitle" data-i18n="stream_sessions.subtitle">Inspect stateful streaming sessions created by generated MCP tool families.</p>
|
|
||||||
</div>
|
|
||||||
<div class="page-header-actions">
|
|
||||||
<button class="btn-secondary" id="stream-sessions-refresh" type="button" data-i18n="stream_sessions.refresh">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="section-card-body">
|
|
||||||
<div class="list-toolbar">
|
|
||||||
<div class="list-toolbar-group">
|
|
||||||
<select class="page-select" id="stream-sessions-status">
|
|
||||||
<option value="" data-i18n="stream_sessions.filter.all_statuses">All statuses</option>
|
|
||||||
<option value="created" data-i18n="stream_sessions.status.created">Created</option>
|
|
||||||
<option value="running" data-i18n="stream_sessions.status.running">Running</option>
|
|
||||||
<option value="failed" data-i18n="stream_sessions.status.failed">Failed</option>
|
|
||||||
<option value="stopped" data-i18n="stream_sessions.status.stopped">Stopped</option>
|
|
||||||
<option value="expired" data-i18n="stream_sessions.status.expired">Expired</option>
|
|
||||||
</select>
|
|
||||||
<select class="page-select" id="stream-sessions-mode">
|
|
||||||
<option value="" data-i18n="stream_sessions.filter.all_modes">All modes</option>
|
|
||||||
<option value="window">window</option>
|
|
||||||
<option value="session">session</option>
|
|
||||||
<option value="async_job">async_job</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="list-toolbar-group">
|
|
||||||
<span class="section-card-subtitle" id="stream-sessions-summary"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="resource-list" id="stream-sessions-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="%CRANK_BUNDLE_STREAM_SESSIONS%"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+2
-2
@@ -173,13 +173,13 @@
|
|||||||
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" x-show="openDropdown === 'protocol'" x-transition>
|
<div class="dropdown-menu" x-show="openDropdown === 'protocol'" x-transition>
|
||||||
<template x-for="p in ['rest','graphql','grpc','websocket','soap']" :key="p">
|
<template x-for="p in ['rest']" :key="p">
|
||||||
<button
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
:class="{ selected: filterProtocol === p }"
|
:class="{ selected: filterProtocol === p }"
|
||||||
@click="setProtocol(p)"
|
@click="setProtocol(p)"
|
||||||
>
|
>
|
||||||
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : p === 'grpc' ? 'gRPC' : p === 'websocket' ? 'WebSocket' : 'SOAP'"></span>
|
<span x-text="p === 'rest' ? 'REST' : p.toUpperCase()"></span>
|
||||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -376,27 +376,6 @@
|
|||||||
getProtocolCapabilities: function(workspaceId) {
|
getProtocolCapabilities: function(workspaceId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/protocol-capabilities');
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/protocol-capabilities');
|
||||||
},
|
},
|
||||||
listStreamSessions: function(workspaceId, params) {
|
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/stream-sessions' + query(params));
|
|
||||||
},
|
|
||||||
getStreamSession: function(workspaceId, sessionId) {
|
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/stream-sessions/' + encodeURIComponent(sessionId));
|
|
||||||
},
|
|
||||||
stopStreamSession: function(workspaceId, sessionId) {
|
|
||||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/stream-sessions/' + encodeURIComponent(sessionId) + '/stop', {});
|
|
||||||
},
|
|
||||||
listAsyncJobs: function(workspaceId, params) {
|
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs' + query(params));
|
|
||||||
},
|
|
||||||
getAsyncJob: function(workspaceId, jobId) {
|
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs/' + encodeURIComponent(jobId));
|
|
||||||
},
|
|
||||||
cancelAsyncJob: function(workspaceId, jobId) {
|
|
||||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs/' + encodeURIComponent(jobId) + '/cancel', {});
|
|
||||||
},
|
|
||||||
getAsyncJobResult: function(workspaceId, jobId) {
|
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs/' + encodeURIComponent(jobId) + '/result');
|
|
||||||
},
|
|
||||||
getOperationUsage: function(workspaceId, operationId, params) {
|
getOperationUsage: function(workspaceId, operationId, params) {
|
||||||
return get(
|
return get(
|
||||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params)
|
'/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params)
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var state = {
|
|
||||||
items: [],
|
|
||||||
workspaceId: null,
|
|
||||||
loading: false,
|
|
||||||
error: '',
|
|
||||||
openId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
var list = document.getElementById('async-jobs-list');
|
|
||||||
var summary = document.getElementById('async-jobs-summary');
|
|
||||||
var refreshButton = document.getElementById('async-jobs-refresh');
|
|
||||||
var statusFilter = document.getElementById('async-jobs-status');
|
|
||||||
|
|
||||||
function tKey(key, vars) {
|
|
||||||
if (!window.t) return key;
|
|
||||||
return t(key, vars);
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentWorkspaceId() {
|
|
||||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
|
||||||
return workspace ? workspace.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatJson(value) {
|
|
||||||
if (value === null || value === undefined) return '';
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value) {
|
|
||||||
if (!value) return '—';
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function element(tag, className, text) {
|
|
||||||
var node = document.createElement(tag);
|
|
||||||
if (className) node.className = className;
|
|
||||||
if (text !== undefined && text !== null) node.textContent = text;
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmpty(title, body) {
|
|
||||||
list.innerHTML = '';
|
|
||||||
var empty = element('div', 'empty-state');
|
|
||||||
empty.appendChild(element('div', 'empty-state-title', title));
|
|
||||||
empty.appendChild(element('div', 'empty-state-text', body));
|
|
||||||
list.appendChild(empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMetaItem(parent, label, value) {
|
|
||||||
var item = element('div', 'resource-meta-item');
|
|
||||||
item.appendChild(element('div', 'resource-meta-label', label));
|
|
||||||
item.appendChild(element('div', 'resource-meta-value', value));
|
|
||||||
parent.appendChild(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendDetailBlock(parent, title, value, dataResultFor) {
|
|
||||||
var block = element('div', 'resource-detail-block');
|
|
||||||
block.appendChild(element('div', 'resource-detail-title', title));
|
|
||||||
var pre = element('pre', 'resource-detail-pre', value);
|
|
||||||
if (dataResultFor) pre.setAttribute('data-result-for', dataResultFor);
|
|
||||||
block.appendChild(pre);
|
|
||||||
parent.appendChild(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderJobCard(item) {
|
|
||||||
var open = state.openId === item.id;
|
|
||||||
var card = element('div', 'resource-card');
|
|
||||||
card.setAttribute('data-job-id', item.id);
|
|
||||||
|
|
||||||
var header = element('div', 'resource-card-header');
|
|
||||||
var headerMain = element('div');
|
|
||||||
headerMain.appendChild(element('div', 'resource-card-title', item.id));
|
|
||||||
headerMain.appendChild(element('div', 'resource-card-subtitle', tKey('async_jobs.operation', { operation: item.operation_id })));
|
|
||||||
|
|
||||||
var pillRow = element('div', 'resource-pill-row');
|
|
||||||
pillRow.appendChild(
|
|
||||||
element('span', 'resource-status-pill ' + String(item.status || '').toLowerCase(), tKey('async_jobs.status.' + item.status))
|
|
||||||
);
|
|
||||||
headerMain.appendChild(pillRow);
|
|
||||||
header.appendChild(headerMain);
|
|
||||||
|
|
||||||
var actions = element('div', 'resource-card-actions');
|
|
||||||
if (item.status === 'created' || item.status === 'running') {
|
|
||||||
var cancelButton = element('button', 'btn-secondary', tKey('async_jobs.cancel'));
|
|
||||||
cancelButton.setAttribute('data-action', 'cancel');
|
|
||||||
cancelButton.setAttribute('data-job-id', item.id);
|
|
||||||
actions.appendChild(cancelButton);
|
|
||||||
}
|
|
||||||
var toggleButton = element(
|
|
||||||
'button',
|
|
||||||
'btn-secondary',
|
|
||||||
open ? tKey('async_jobs.hide_details') : tKey('async_jobs.show_details')
|
|
||||||
);
|
|
||||||
toggleButton.setAttribute('data-action', 'toggle');
|
|
||||||
toggleButton.setAttribute('data-job-id', item.id);
|
|
||||||
actions.appendChild(toggleButton);
|
|
||||||
header.appendChild(actions);
|
|
||||||
card.appendChild(header);
|
|
||||||
|
|
||||||
var metaGrid = element('div', 'resource-meta-grid');
|
|
||||||
appendMetaItem(metaGrid, tKey('async_jobs.meta.created'), formatDateTime(item.created_at));
|
|
||||||
appendMetaItem(metaGrid, tKey('async_jobs.meta.updated'), formatDateTime(item.updated_at));
|
|
||||||
appendMetaItem(metaGrid, tKey('async_jobs.meta.finished'), formatDateTime(item.finished_at));
|
|
||||||
appendMetaItem(metaGrid, tKey('async_jobs.meta.expires'), formatDateTime(item.expires_at));
|
|
||||||
card.appendChild(metaGrid);
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
appendDetailBlock(card, tKey('async_jobs.progress'), formatJson(item.progress));
|
|
||||||
appendDetailBlock(card, tKey('async_jobs.error_preview'), formatJson(item.error));
|
|
||||||
if (
|
|
||||||
item.status === 'completed' ||
|
|
||||||
item.status === 'failed' ||
|
|
||||||
item.status === 'cancelled' ||
|
|
||||||
item.status === 'expired'
|
|
||||||
) {
|
|
||||||
appendDetailBlock(card, tKey('async_jobs.result'), tKey('async_jobs.result_loading'), item.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
summary.textContent = tKey('async_jobs.summary', { count: state.items.length });
|
|
||||||
|
|
||||||
if (state.loading && state.items.length === 0) {
|
|
||||||
renderEmpty(tKey('async_jobs.loading.title'), tKey('async_jobs.loading.body'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.error) {
|
|
||||||
renderEmpty(tKey('async_jobs.error.title'), state.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.items.length) {
|
|
||||||
renderEmpty(tKey('async_jobs.empty.title'), tKey('async_jobs.empty.body'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = '';
|
|
||||||
state.items.forEach(function(item) {
|
|
||||||
list.appendChild(renderJobCard(item));
|
|
||||||
});
|
|
||||||
|
|
||||||
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
var jobId = this.getAttribute('data-job-id');
|
|
||||||
state.openId = state.openId === jobId ? null : jobId;
|
|
||||||
if (state.openId) {
|
|
||||||
void loadDetail(jobId);
|
|
||||||
} else {
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
list.querySelectorAll('[data-action="cancel"]').forEach(function(button) {
|
|
||||||
button.addEventListener('click', async function () {
|
|
||||||
try {
|
|
||||||
var jobId = this.getAttribute('data-job-id');
|
|
||||||
await window.CrankApi.cancelAsyncJob(state.workspaceId, jobId);
|
|
||||||
if (window.CrankUi) {
|
|
||||||
window.CrankUi.success(tKey('async_jobs.toast.cancel.body'), tKey('async_jobs.toast.cancel.title'));
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
} catch (error) {
|
|
||||||
if (window.CrankUi) {
|
|
||||||
window.CrankUi.error(error.message || tKey('async_jobs.toast.cancel_error.body'), tKey('async_jobs.toast.cancel_error.title'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadResult(jobId) {
|
|
||||||
try {
|
|
||||||
var result = await window.CrankApi.getAsyncJobResult(state.workspaceId, jobId);
|
|
||||||
var node = document.querySelector('[data-result-for="' + jobId + '"]');
|
|
||||||
if (node) node.textContent = formatJson(result);
|
|
||||||
} catch (error) {
|
|
||||||
var failedNode = document.querySelector('[data-result-for="' + jobId + '"]');
|
|
||||||
if (failedNode) failedNode.textContent = error.message || tKey('async_jobs.result_error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail(jobId) {
|
|
||||||
var detail = await window.CrankApi.getAsyncJob(state.workspaceId, jobId);
|
|
||||||
state.items = state.items.map(function(item) {
|
|
||||||
return item.id === jobId ? detail : item;
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
if (detail.status === 'completed' || detail.status === 'failed' || detail.status === 'cancelled' || detail.status === 'expired') {
|
|
||||||
await loadResult(jobId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
state.workspaceId = currentWorkspaceId();
|
|
||||||
if (!state.workspaceId) {
|
|
||||||
state.items = [];
|
|
||||||
state.error = tKey('async_jobs.error.workspace');
|
|
||||||
render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.loading = true;
|
|
||||||
state.error = '';
|
|
||||||
render();
|
|
||||||
|
|
||||||
try {
|
|
||||||
var response = await window.CrankApi.listAsyncJobs(state.workspaceId, {
|
|
||||||
status: statusFilter.value || null,
|
|
||||||
page: 1,
|
|
||||||
page_size: 50,
|
|
||||||
});
|
|
||||||
state.items = response.items || [];
|
|
||||||
} catch (error) {
|
|
||||||
state.error = error.message || tKey('async_jobs.error.load');
|
|
||||||
} finally {
|
|
||||||
state.loading = false;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshButton.addEventListener('click', load);
|
|
||||||
statusFilter.addEventListener('change', load);
|
|
||||||
document.addEventListener('workspace:changed', load);
|
|
||||||
void load();
|
|
||||||
});
|
|
||||||
@@ -9,8 +9,6 @@
|
|||||||
secrets: '/secrets',
|
secrets: '/secrets',
|
||||||
logs: '/logs',
|
logs: '/logs',
|
||||||
usage: '/usage',
|
usage: '/usage',
|
||||||
streamSessions: '/stream-sessions',
|
|
||||||
asyncJobs: '/async-jobs',
|
|
||||||
settings: '/settings',
|
settings: '/settings',
|
||||||
workspaceSetup: '/workspace-setup',
|
workspaceSetup: '/workspace-setup',
|
||||||
wizard: '/wizard/'
|
wizard: '/wizard/'
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
var state = {
|
|
||||||
items: [],
|
|
||||||
workspaceId: null,
|
|
||||||
loading: false,
|
|
||||||
error: '',
|
|
||||||
openId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
var list = document.getElementById('stream-sessions-list');
|
|
||||||
var summary = document.getElementById('stream-sessions-summary');
|
|
||||||
var refreshButton = document.getElementById('stream-sessions-refresh');
|
|
||||||
var statusFilter = document.getElementById('stream-sessions-status');
|
|
||||||
var modeFilter = document.getElementById('stream-sessions-mode');
|
|
||||||
|
|
||||||
function tKey(key, vars) {
|
|
||||||
if (!window.t) return key;
|
|
||||||
return t(key, vars);
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentWorkspaceId() {
|
|
||||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
|
||||||
return workspace ? workspace.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatJson(value) {
|
|
||||||
if (value === null || value === undefined) return '';
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value) {
|
|
||||||
if (!value) return '—';
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function element(tag, className, text) {
|
|
||||||
var node = document.createElement(tag);
|
|
||||||
if (className) node.className = className;
|
|
||||||
if (text !== undefined && text !== null) node.textContent = text;
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEmpty(title, body) {
|
|
||||||
list.innerHTML = '';
|
|
||||||
var empty = element('div', 'empty-state');
|
|
||||||
empty.appendChild(element('div', 'empty-state-title', title));
|
|
||||||
empty.appendChild(element('div', 'empty-state-text', body));
|
|
||||||
list.appendChild(empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMetaItem(parent, label, value) {
|
|
||||||
var item = element('div', 'resource-meta-item');
|
|
||||||
item.appendChild(element('div', 'resource-meta-label', label));
|
|
||||||
item.appendChild(element('div', 'resource-meta-value', value));
|
|
||||||
parent.appendChild(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendDetailBlock(parent, title, value) {
|
|
||||||
var block = element('div', 'resource-detail-block');
|
|
||||||
block.appendChild(element('div', 'resource-detail-title', title));
|
|
||||||
block.appendChild(element('pre', 'resource-detail-pre', formatJson(value)));
|
|
||||||
parent.appendChild(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSessionCard(item) {
|
|
||||||
var open = state.openId === item.id;
|
|
||||||
var card = element('div', 'resource-card');
|
|
||||||
card.setAttribute('data-session-id', item.id);
|
|
||||||
|
|
||||||
var header = element('div', 'resource-card-header');
|
|
||||||
var headerMain = element('div');
|
|
||||||
headerMain.appendChild(element('div', 'resource-card-title', item.id));
|
|
||||||
headerMain.appendChild(element('div', 'resource-card-subtitle', tKey('stream_sessions.operation', { operation: item.operation_id })));
|
|
||||||
|
|
||||||
var pillRow = element('div', 'resource-pill-row');
|
|
||||||
var statusPill = element('span', 'resource-status-pill ' + String(item.status || '').toLowerCase(), tKey('stream_sessions.status.' + item.status));
|
|
||||||
pillRow.appendChild(statusPill);
|
|
||||||
pillRow.appendChild(element('span', 'resource-status-pill', item.mode));
|
|
||||||
pillRow.appendChild(element('span', 'resource-status-pill', item.protocol));
|
|
||||||
headerMain.appendChild(pillRow);
|
|
||||||
header.appendChild(headerMain);
|
|
||||||
|
|
||||||
var actions = element('div', 'resource-card-actions');
|
|
||||||
if (item.status === 'created' || item.status === 'running') {
|
|
||||||
var stopButton = element('button', 'btn-secondary', tKey('stream_sessions.stop'));
|
|
||||||
stopButton.setAttribute('data-action', 'stop');
|
|
||||||
stopButton.setAttribute('data-session-id', item.id);
|
|
||||||
actions.appendChild(stopButton);
|
|
||||||
}
|
|
||||||
var toggleButton = element(
|
|
||||||
'button',
|
|
||||||
'btn-secondary',
|
|
||||||
open ? tKey('stream_sessions.hide_details') : tKey('stream_sessions.show_details')
|
|
||||||
);
|
|
||||||
toggleButton.setAttribute('data-action', 'toggle');
|
|
||||||
toggleButton.setAttribute('data-session-id', item.id);
|
|
||||||
actions.appendChild(toggleButton);
|
|
||||||
header.appendChild(actions);
|
|
||||||
card.appendChild(header);
|
|
||||||
|
|
||||||
var metaGrid = element('div', 'resource-meta-grid');
|
|
||||||
appendMetaItem(metaGrid, tKey('stream_sessions.meta.created'), formatDateTime(item.created_at));
|
|
||||||
appendMetaItem(metaGrid, tKey('stream_sessions.meta.last_poll'), formatDateTime(item.last_poll_at));
|
|
||||||
appendMetaItem(metaGrid, tKey('stream_sessions.meta.expires'), formatDateTime(item.expires_at));
|
|
||||||
appendMetaItem(metaGrid, tKey('stream_sessions.meta.agent'), item.agent_id || '—');
|
|
||||||
card.appendChild(metaGrid);
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
appendDetailBlock(card, tKey('stream_sessions.cursor'), item.cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
summary.textContent = tKey('stream_sessions.summary', { count: state.items.length });
|
|
||||||
|
|
||||||
if (state.loading && state.items.length === 0) {
|
|
||||||
renderEmpty(tKey('stream_sessions.loading.title'), tKey('stream_sessions.loading.body'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.error) {
|
|
||||||
renderEmpty(tKey('stream_sessions.error.title'), state.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.items.length) {
|
|
||||||
renderEmpty(tKey('stream_sessions.empty.title'), tKey('stream_sessions.empty.body'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = '';
|
|
||||||
state.items.forEach(function(item) {
|
|
||||||
list.appendChild(renderSessionCard(item));
|
|
||||||
});
|
|
||||||
|
|
||||||
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
var sessionId = this.getAttribute('data-session-id');
|
|
||||||
state.openId = state.openId === sessionId ? null : sessionId;
|
|
||||||
if (state.openId) {
|
|
||||||
void loadDetail(sessionId);
|
|
||||||
} else {
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
list.querySelectorAll('[data-action="stop"]').forEach(function(button) {
|
|
||||||
button.addEventListener('click', async function () {
|
|
||||||
try {
|
|
||||||
var sessionId = this.getAttribute('data-session-id');
|
|
||||||
await window.CrankApi.stopStreamSession(state.workspaceId, sessionId);
|
|
||||||
if (window.CrankUi) {
|
|
||||||
window.CrankUi.success(tKey('stream_sessions.toast.stop.body'), tKey('stream_sessions.toast.stop.title'));
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
} catch (error) {
|
|
||||||
if (window.CrankUi) {
|
|
||||||
window.CrankUi.error(error.message || tKey('stream_sessions.toast.stop_error.body'), tKey('stream_sessions.toast.stop_error.title'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDetail(sessionId) {
|
|
||||||
var detail = await window.CrankApi.getStreamSession(state.workspaceId, sessionId);
|
|
||||||
state.items = state.items.map(function(item) {
|
|
||||||
return item.id === sessionId ? detail : item;
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
state.workspaceId = currentWorkspaceId();
|
|
||||||
if (!state.workspaceId) {
|
|
||||||
state.items = [];
|
|
||||||
state.error = tKey('stream_sessions.error.workspace');
|
|
||||||
render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.loading = true;
|
|
||||||
state.error = '';
|
|
||||||
render();
|
|
||||||
|
|
||||||
try {
|
|
||||||
var response = await window.CrankApi.listStreamSessions(state.workspaceId, {
|
|
||||||
status: statusFilter.value || null,
|
|
||||||
mode: modeFilter.value || null,
|
|
||||||
page: 1,
|
|
||||||
page_size: 50,
|
|
||||||
});
|
|
||||||
state.items = response.items || [];
|
|
||||||
} catch (error) {
|
|
||||||
state.error = error.message || tKey('stream_sessions.error.load');
|
|
||||||
} finally {
|
|
||||||
state.loading = false;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshButton.addEventListener('click', load);
|
|
||||||
statusFilter.addEventListener('change', load);
|
|
||||||
modeFilter.addEventListener('change', load);
|
|
||||||
document.addEventListener('workspace:changed', load);
|
|
||||||
void load();
|
|
||||||
});
|
|
||||||
@@ -38,14 +38,6 @@ server {
|
|||||||
try_files /html/usage.html =404;
|
try_files /html/usage.html =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /stream-sessions {
|
|
||||||
try_files /html/stream-sessions.html =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /async-jobs {
|
|
||||||
try_files /html/async-jobs.html =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /settings {
|
location = /settings {
|
||||||
try_files /html/settings.html =404;
|
try_files /html/settings.html =404;
|
||||||
}
|
}
|
||||||
@@ -91,14 +83,6 @@ server {
|
|||||||
return 302 /usage;
|
return 302 /usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /html/stream-sessions.html {
|
|
||||||
return 302 /stream-sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /html/async-jobs.html {
|
|
||||||
return 302 /async-jobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /html/settings.html {
|
location = /html/settings.html {
|
||||||
return 302 /settings;
|
return 302 /settings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,18 +77,6 @@ const BUNDLES = {
|
|||||||
'js/settings.js',
|
'js/settings.js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'stream-sessions': {
|
|
||||||
files: [
|
|
||||||
'js/nav.js',
|
|
||||||
'js/stream-sessions.js',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'async-jobs': {
|
|
||||||
files: [
|
|
||||||
'js/nav.js',
|
|
||||||
'js/async-jobs.js',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'workspace-setup': {
|
'workspace-setup': {
|
||||||
files: [
|
files: [
|
||||||
'js/diagnostics.js',
|
'js/diagnostics.js',
|
||||||
@@ -220,11 +208,9 @@ async function main() {
|
|||||||
rewriteHtml('html/login.html', manifest, '..');
|
rewriteHtml('html/login.html', manifest, '..');
|
||||||
rewriteHtml('html/agents.html', manifest, '..');
|
rewriteHtml('html/agents.html', manifest, '..');
|
||||||
rewriteHtml('html/api-keys.html', manifest, '..');
|
rewriteHtml('html/api-keys.html', manifest, '..');
|
||||||
rewriteHtml('html/async-jobs.html', manifest, '..');
|
|
||||||
rewriteHtml('html/logs.html', manifest, '..');
|
rewriteHtml('html/logs.html', manifest, '..');
|
||||||
rewriteHtml('html/secrets.html', manifest, '..');
|
rewriteHtml('html/secrets.html', manifest, '..');
|
||||||
rewriteHtml('html/settings.html', manifest, '..');
|
rewriteHtml('html/settings.html', manifest, '..');
|
||||||
rewriteHtml('html/stream-sessions.html', manifest, '..');
|
|
||||||
rewriteHtml('html/usage.html', manifest, '..');
|
rewriteHtml('html/usage.html', manifest, '..');
|
||||||
rewriteHtml('html/workspace-setup.html', manifest, '..');
|
rewriteHtml('html/workspace-setup.html', manifest, '..');
|
||||||
rewriteHtml('html/wizard/index.html', manifest, '../..');
|
rewriteHtml('html/wizard/index.html', manifest, '../..');
|
||||||
|
|||||||
@@ -58,20 +58,10 @@ cleanup() {
|
|||||||
kill "$(cat "$TMP_DIR/ui-server.pid")" >/dev/null 2>&1 || true
|
kill "$(cat "$TMP_DIR/ui-server.pid")" >/dev/null 2>&1 || true
|
||||||
rm -f "$TMP_DIR/ui-server.pid"
|
rm -f "$TMP_DIR/ui-server.pid"
|
||||||
fi
|
fi
|
||||||
if [[ -f "$TMP_DIR/stream-fixture.pid" ]]; then
|
|
||||||
kill "$(cat "$TMP_DIR/stream-fixture.pid")" >/dev/null 2>&1 || true
|
|
||||||
rm -f "$TMP_DIR/stream-fixture.pid"
|
|
||||||
fi
|
|
||||||
if [[ -f "$TMP_DIR/grpc-fixture.pid" ]]; then
|
|
||||||
kill "$(cat "$TMP_DIR/grpc-fixture.pid")" >/dev/null 2>&1 || true
|
|
||||||
rm -f "$TMP_DIR/grpc-fixture.pid"
|
|
||||||
fi
|
|
||||||
docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
|
docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
|
||||||
kill_port_processes "$UI_PORT"
|
kill_port_processes "$UI_PORT"
|
||||||
kill_port_processes "$ADMIN_PORT"
|
kill_port_processes "$ADMIN_PORT"
|
||||||
kill_port_processes "$MCP_PORT"
|
kill_port_processes "$MCP_PORT"
|
||||||
kill_port_processes "$STREAM_FIXTURE_PORT"
|
|
||||||
kill_port_processes "${GRPC_FIXTURE_BIND##*:}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
@@ -128,9 +118,6 @@ export CRANK_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASSWORD"
|
|||||||
export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E"
|
export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E"
|
||||||
export CRANK_DEMO_SEED="true"
|
export CRANK_DEMO_SEED="true"
|
||||||
export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT"
|
export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT"
|
||||||
export CRANK_E2E_STREAM_FIXTURE_PORT="$STREAM_FIXTURE_PORT"
|
|
||||||
export CRANK_E2E_GRPC_FIXTURE_BIND="$GRPC_FIXTURE_BIND"
|
|
||||||
|
|
||||||
mkdir -p "$CRANK_STORAGE_ROOT"
|
mkdir -p "$CRANK_STORAGE_ROOT"
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -153,24 +140,6 @@ until curl -fsS "http://127.0.0.1:$MCP_PORT/health" >/dev/null 2>&1; do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
(
|
|
||||||
cd "$ROOT_DIR/apps/ui"
|
|
||||||
node scripts/stream-fixture-server.js >"$LOG_DIR/stream-fixture.log" 2>&1
|
|
||||||
) &
|
|
||||||
echo $! > "$TMP_DIR/stream-fixture.pid"
|
|
||||||
|
|
||||||
until curl -fsS "http://127.0.0.1:$STREAM_FIXTURE_PORT/health" >/dev/null 2>&1; do
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$ROOT_DIR"
|
|
||||||
cargo run -p crank-adapter-grpc --features test-support --example stream_fixture >"$LOG_DIR/grpc-fixture.log" 2>&1
|
|
||||||
) &
|
|
||||||
echo $! > "$TMP_DIR/grpc-fixture.pid"
|
|
||||||
|
|
||||||
wait_for_port "${GRPC_FIXTURE_BIND%%:*}" "${GRPC_FIXTURE_BIND##*:}"
|
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR/apps/ui"
|
cd "$ROOT_DIR/apps/ui"
|
||||||
node scripts/playwright-ui-server.js >"$LOG_DIR/ui-server.log" 2>&1
|
node scripts/playwright-ui-server.js >"$LOG_DIR/ui-server.log" 2>&1
|
||||||
|
|||||||
@@ -53,12 +53,6 @@ function mapRoute(urlPath) {
|
|||||||
if (urlPath === '/usage') {
|
if (urlPath === '/usage') {
|
||||||
return path.join(ROOT_DIR, 'html', 'usage.html');
|
return path.join(ROOT_DIR, 'html', 'usage.html');
|
||||||
}
|
}
|
||||||
if (urlPath === '/stream-sessions') {
|
|
||||||
return path.join(ROOT_DIR, 'html', 'stream-sessions.html');
|
|
||||||
}
|
|
||||||
if (urlPath === '/async-jobs') {
|
|
||||||
return path.join(ROOT_DIR, 'html', 'async-jobs.html');
|
|
||||||
}
|
|
||||||
if (urlPath === '/settings') {
|
if (urlPath === '/settings') {
|
||||||
return path.join(ROOT_DIR, 'html', 'settings.html');
|
return path.join(ROOT_DIR, 'html', 'settings.html');
|
||||||
}
|
}
|
||||||
@@ -171,14 +165,6 @@ const server = http.createServer((request, response) => {
|
|||||||
redirect(response, '/usage');
|
redirect(response, '/usage');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (urlPath === '/html/stream-sessions.html') {
|
|
||||||
redirect(response, '/stream-sessions');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (urlPath === '/html/async-jobs.html') {
|
|
||||||
redirect(response, '/async-jobs');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (urlPath === '/html/settings.html') {
|
if (urlPath === '/html/settings.html') {
|
||||||
redirect(response, '/settings');
|
redirect(response, '/settings');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
const crypto = require('crypto');
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const PORT = Number(process.env.CRANK_E2E_STREAM_FIXTURE_PORT || 3310);
|
|
||||||
|
|
||||||
function writeJson(response, status, payload) {
|
|
||||||
response.writeHead(status, {
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
response.end(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJsonBody(request) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let buffer = '';
|
|
||||||
request.setEncoding('utf8');
|
|
||||||
request.on('data', (chunk) => {
|
|
||||||
buffer += chunk;
|
|
||||||
});
|
|
||||||
request.on('end', () => {
|
|
||||||
if (!buffer) {
|
|
||||||
resolve({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(buffer));
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
request.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const server = http.createServer((request, response) => {
|
|
||||||
const url = new URL(request.url, `http://127.0.0.1:${PORT}`);
|
|
||||||
|
|
||||||
if (url.pathname === '/health') {
|
|
||||||
writeJson(response, 200, { ok: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/sse/logs') {
|
|
||||||
response.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = [
|
|
||||||
{ level: 'info', message: 'billing started', cursor: 'c1' },
|
|
||||||
{ level: 'warn', message: 'cache warmup slow', cursor: 'c2' },
|
|
||||||
{ level: 'error', message: 'invoice timeout', cursor: 'c3' },
|
|
||||||
];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (index >= events.length) {
|
|
||||||
clearInterval(timer);
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.write('event: message\n');
|
|
||||||
response.write(`data: ${JSON.stringify(events[index])}\n\n`);
|
|
||||||
index += 1;
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
request.on('close', () => {
|
|
||||||
clearInterval(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/snapshot/metrics') {
|
|
||||||
writeJson(response, 200, {
|
|
||||||
summary: {
|
|
||||||
service: 'billing',
|
|
||||||
error_rate: 0.12,
|
|
||||||
},
|
|
||||||
items: [
|
|
||||||
{ timestamp: '2026-04-06T10:00:00Z', cpu: 0.41, memory: 0.67 },
|
|
||||||
{ timestamp: '2026-04-06T10:00:05Z', cpu: 0.39, memory: 0.65 },
|
|
||||||
],
|
|
||||||
done: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/crm/leads' && request.method === 'POST') {
|
|
||||||
readJsonBody(request)
|
|
||||||
.then((payload) => {
|
|
||||||
writeJson(response, 200, {
|
|
||||||
id: 'lead_123',
|
|
||||||
email: payload.email || null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
writeJson(response, 400, { error: 'invalid_json' });
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/soap/leads' && request.method === 'POST') {
|
|
||||||
let buffer = '';
|
|
||||||
request.setEncoding('utf8');
|
|
||||||
request.on('data', (chunk) => {
|
|
||||||
buffer += chunk;
|
|
||||||
});
|
|
||||||
request.on('end', () => {
|
|
||||||
const hasEmail = buffer.includes('<email>user@example.com</email>')
|
|
||||||
|| buffer.includes('<m:email>user@example.com</m:email>');
|
|
||||||
|
|
||||||
if (!hasEmail) {
|
|
||||||
response.writeHead(400, {
|
|
||||||
'Content-Type': 'text/xml; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
response.end([
|
|
||||||
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">',
|
|
||||||
'<soap:Body>',
|
|
||||||
'<soap:Fault>',
|
|
||||||
'<faultcode>Client</faultcode>',
|
|
||||||
'<faultstring>missing email</faultstring>',
|
|
||||||
'</soap:Fault>',
|
|
||||||
'</soap:Body>',
|
|
||||||
'</soap:Envelope>',
|
|
||||||
].join(''));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.writeHead(200, {
|
|
||||||
'Content-Type': 'text/xml; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
response.end([
|
|
||||||
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">',
|
|
||||||
'<soap:Body>',
|
|
||||||
'<CreateLeadResponse>',
|
|
||||||
'<id>lead_soap_123</id>',
|
|
||||||
'<status>created</status>',
|
|
||||||
'</CreateLeadResponse>',
|
|
||||||
'</soap:Body>',
|
|
||||||
'</soap:Envelope>',
|
|
||||||
].join(''));
|
|
||||||
});
|
|
||||||
request.on('error', () => {
|
|
||||||
response.writeHead(500, {
|
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
});
|
|
||||||
response.end('fixture_error');
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJson(response, 404, { error: 'not_found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
function writeWebSocketFrame(socket, payload) {
|
|
||||||
const body = Buffer.from(payload, 'utf8');
|
|
||||||
const header = [];
|
|
||||||
header.push(0x81);
|
|
||||||
if (body.length < 126) {
|
|
||||||
header.push(body.length);
|
|
||||||
} else if (body.length < 65536) {
|
|
||||||
header.push(126, (body.length >> 8) & 0xff, body.length & 0xff);
|
|
||||||
} else {
|
|
||||||
throw new Error('fixture payload is unexpectedly large');
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.write(Buffer.concat([Buffer.from(header), body]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptWebSocket(request, socket) {
|
|
||||||
const key = request.headers['sec-websocket-key'];
|
|
||||||
if (!key) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const accept = crypto
|
|
||||||
.createHash('sha1')
|
|
||||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
|
||||||
.digest('base64');
|
|
||||||
|
|
||||||
socket.write(
|
|
||||||
[
|
|
||||||
'HTTP/1.1 101 Switching Protocols',
|
|
||||||
'Upgrade: websocket',
|
|
||||||
'Connection: Upgrade',
|
|
||||||
`Sec-WebSocket-Accept: ${accept}`,
|
|
||||||
'\r\n',
|
|
||||||
].join('\r\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const messages = [
|
|
||||||
{ type: 'tick', seq: 1, value: 101 },
|
|
||||||
{ type: 'tick', seq: 2, value: 102 },
|
|
||||||
{ type: 'tick', seq: 3, value: 103 },
|
|
||||||
];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (index >= messages.length) {
|
|
||||||
clearInterval(timer);
|
|
||||||
socket.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeWebSocketFrame(socket, JSON.stringify(messages[index]));
|
|
||||||
index += 1;
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
socket.on('close', () => clearInterval(timer));
|
|
||||||
socket.on('end', () => clearInterval(timer));
|
|
||||||
socket.on('error', () => clearInterval(timer));
|
|
||||||
}
|
|
||||||
|
|
||||||
server.on('upgrade', (request, socket, head) => {
|
|
||||||
if (request.url !== '/events') {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (head && head.length) {
|
|
||||||
socket.unshift(head);
|
|
||||||
}
|
|
||||||
acceptWebSocket(request, socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, '127.0.0.1', () => {
|
|
||||||
console.log(`Streaming fixtures listening on http://127.0.0.1:${PORT}`);
|
|
||||||
});
|
|
||||||
@@ -10,8 +10,6 @@ const CORE_PAGES = [
|
|||||||
{ path: '/usage', heading: localized('Usage', 'Использование') },
|
{ path: '/usage', heading: localized('Usage', 'Использование') },
|
||||||
{ path: '/workspace-setup', heading: localized('Workspace', 'Воркспейс') },
|
{ path: '/workspace-setup', heading: localized('Workspace', 'Воркспейс') },
|
||||||
{ path: '/settings', heading: localized('Settings', 'Настройки') },
|
{ path: '/settings', heading: localized('Settings', 'Настройки') },
|
||||||
{ path: '/stream-sessions', heading: localized('Stream Sessions', 'Stream Sessions') },
|
|
||||||
{ path: '/async-jobs', heading: localized('Async Jobs', 'Async Jobs') },
|
|
||||||
{ path: '/wizard/', heading: localized('Create', 'Создать') },
|
{ path: '/wizard/', heading: localized('Create', 'Создать') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const {
|
|
||||||
buildGrpcSessionOperationPayload,
|
|
||||||
buildRestAsyncJobOperationPayload,
|
|
||||||
buildRestWindowOperationPayload,
|
|
||||||
initializeMcpSession,
|
|
||||||
login,
|
|
||||||
mcpToolCall,
|
|
||||||
setupPublishedAgent,
|
|
||||||
uniqueName,
|
|
||||||
} = require('./helpers');
|
|
||||||
|
|
||||||
test('rest window tool executes through MCP', async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
|
|
||||||
const operationName = uniqueName('playwright_window_logs');
|
|
||||||
const agentSlug = uniqueName('sales_window');
|
|
||||||
const payload = buildRestWindowOperationPayload(operationName);
|
|
||||||
|
|
||||||
const bundle = await setupPublishedAgent(page, {
|
|
||||||
operationPayload: payload,
|
|
||||||
agentSlug,
|
|
||||||
toolName: operationName,
|
|
||||||
toolTitle: 'Playwright Window Logs',
|
|
||||||
toolDescription: 'Collects a bounded SSE log window from the local fixture.',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mcp = await initializeMcpSession({
|
|
||||||
workspaceSlug: bundle.workspace.slug,
|
|
||||||
agentSlug,
|
|
||||||
apiKey: bundle.apiKeySecret,
|
|
||||||
});
|
|
||||||
const result = await mcpToolCall(mcp, operationName, {});
|
|
||||||
|
|
||||||
expect(result.window_complete).toBe(true);
|
|
||||||
expect(result.has_more).toBe(false);
|
|
||||||
expect(Array.isArray(result.items)).toBe(true);
|
|
||||||
expect(result.items).toHaveLength(3);
|
|
||||||
expect(result.items[0].message).toBe('billing started');
|
|
||||||
expect(result.items[2].message).toBe('invoice timeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('grpc session tools create persisted stream sessions visible in UI', async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
|
|
||||||
const operationName = uniqueName('playwright_echo_session');
|
|
||||||
const agentSlug = uniqueName('sales_session');
|
|
||||||
const payload = buildGrpcSessionOperationPayload(operationName);
|
|
||||||
|
|
||||||
const bundle = await setupPublishedAgent(page, {
|
|
||||||
operationPayload: payload,
|
|
||||||
agentSlug,
|
|
||||||
toolName: operationName,
|
|
||||||
toolTitle: 'Playwright gRPC Session',
|
|
||||||
toolDescription: 'Streams gRPC echo messages through a bounded MCP session tool family.',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mcp = await initializeMcpSession({
|
|
||||||
workspaceSlug: bundle.workspace.slug,
|
|
||||||
agentSlug,
|
|
||||||
apiKey: bundle.apiKeySecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
const started = await mcpToolCall(mcp, `${operationName}_start`, { message: 'hello' });
|
|
||||||
const sessionId = started.session_id;
|
|
||||||
expect(started.status).toBe('running');
|
|
||||||
|
|
||||||
const polled = await mcpToolCall(mcp, `${operationName}_poll`, { session_id: sessionId });
|
|
||||||
expect(polled.session_id).toBe(sessionId);
|
|
||||||
expect(Array.isArray(polled.items)).toBe(true);
|
|
||||||
expect(polled.items[0].message).toContain('hello');
|
|
||||||
|
|
||||||
await page.goto('/stream-sessions');
|
|
||||||
const sessionCard = page.locator(`.resource-card[data-session-id="${sessionId}"]`);
|
|
||||||
await expect(sessionCard).toBeVisible();
|
|
||||||
await sessionCard.locator('[data-action="toggle"]').click();
|
|
||||||
await expect(sessionCard.locator('.resource-detail-pre')).toBeVisible();
|
|
||||||
await sessionCard.locator('[data-action="stop"]').click();
|
|
||||||
await expect(sessionCard.locator('[data-action="stop"]')).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('async job tools complete and expose results in UI', async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
|
|
||||||
const operationName = uniqueName('playwright_async_lead');
|
|
||||||
const agentSlug = uniqueName('sales_async');
|
|
||||||
const payload = buildRestAsyncJobOperationPayload(operationName);
|
|
||||||
|
|
||||||
const bundle = await setupPublishedAgent(page, {
|
|
||||||
operationPayload: payload,
|
|
||||||
agentSlug,
|
|
||||||
toolName: operationName,
|
|
||||||
toolTitle: 'Playwright Async Lead',
|
|
||||||
toolDescription: 'Creates a lead through the async job MCP tool family.',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mcp = await initializeMcpSession({
|
|
||||||
workspaceSlug: bundle.workspace.slug,
|
|
||||||
agentSlug,
|
|
||||||
apiKey: bundle.apiKeySecret,
|
|
||||||
});
|
|
||||||
|
|
||||||
const started = await mcpToolCall(mcp, `${operationName}_start`, { email: 'user@example.com' });
|
|
||||||
const jobId = started.job_id;
|
|
||||||
expect(started.status).toBe('running');
|
|
||||||
|
|
||||||
let status = null;
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
||||||
status = await mcpToolCall(mcp, `${operationName}_status`, { job_id: jobId });
|
|
||||||
if (status.status === 'completed') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(status.status).toBe('completed');
|
|
||||||
const result = await mcpToolCall(mcp, `${operationName}_result`, { job_id: jobId });
|
|
||||||
expect(result.id).toBe('lead_123');
|
|
||||||
|
|
||||||
await page.goto('/async-jobs');
|
|
||||||
const jobCard = page.locator(`.resource-card[data-job-id="${jobId}"]`);
|
|
||||||
await expect(jobCard).toBeVisible();
|
|
||||||
await jobCard.locator('[data-action="toggle"]').click();
|
|
||||||
await expect(page.locator(`[data-result-for="${jobId}"]`)).toContainText('lead_123');
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "crank-adapter-graphql"
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
crank-core = { path = "../crank-core" }
|
|
||||||
crank-mapping = { path = "../crank-mapping" }
|
|
||||||
reqwest.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
axum.workspace = true
|
|
||||||
tokio.workspace = true
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
use std::{collections::BTreeMap, time::Duration};
|
|
||||||
|
|
||||||
use crank_core::GraphqlTarget;
|
|
||||||
use crank_mapping::JsonPath;
|
|
||||||
use reqwest::{
|
|
||||||
Client,
|
|
||||||
header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue},
|
|
||||||
};
|
|
||||||
use serde_json::{Map, Value, json};
|
|
||||||
|
|
||||||
use crate::{GraphqlAdapterError, GraphqlRequest, GraphqlResponse};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct GraphqlAdapter {
|
|
||||||
client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GraphqlAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GraphqlAdapter {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
client: Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
&self,
|
|
||||||
target: &GraphqlTarget,
|
|
||||||
request: &GraphqlRequest,
|
|
||||||
) -> Result<GraphqlResponse, GraphqlAdapterError> {
|
|
||||||
let endpoint = reqwest::Url::parse(&target.endpoint).map_err(|_| {
|
|
||||||
GraphqlAdapterError::InvalidEndpoint {
|
|
||||||
url: target.endpoint.clone(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let headers = build_headers(request)?;
|
|
||||||
let variables = normalize_variables(request.variables.clone())?;
|
|
||||||
let payload = json!({
|
|
||||||
"query": target.query_template,
|
|
||||||
"operationName": target.operation_name,
|
|
||||||
"variables": variables
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.post(endpoint)
|
|
||||||
.headers(headers)
|
|
||||||
.timeout(Duration::from_millis(request.timeout_ms))
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let status = response.status();
|
|
||||||
let headers = normalize_headers(response.headers());
|
|
||||||
let body = decode_body(response).await?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(GraphqlAdapterError::UnexpectedStatus {
|
|
||||||
status: status.as_u16(),
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(errors) = body.get("errors").cloned() {
|
|
||||||
return Err(GraphqlAdapterError::OperationErrors {
|
|
||||||
data: body.get("data").cloned(),
|
|
||||||
errors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let response_path = JsonPath::parse(&target.response_path).map_err(|_| {
|
|
||||||
GraphqlAdapterError::InvalidResponsePath {
|
|
||||||
path: target.response_path.clone(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let response_context = json!({
|
|
||||||
"response": {
|
|
||||||
"body": body
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let data = response_path
|
|
||||||
.read(&response_context)
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| GraphqlAdapterError::ResponsePathNotFound {
|
|
||||||
path: target.response_path.clone(),
|
|
||||||
})?;
|
|
||||||
let body = response_context["response"]["body"].clone();
|
|
||||||
|
|
||||||
Ok(GraphqlResponse {
|
|
||||||
status_code: status.as_u16(),
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_headers(request: &GraphqlRequest) -> Result<HeaderMap, GraphqlAdapterError> {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
|
||||||
|
|
||||||
for (name, value) in &request.headers {
|
|
||||||
let header_name = HeaderName::try_from(name.as_str()).map_err(|_| {
|
|
||||||
GraphqlAdapterError::InvalidHeaderName {
|
|
||||||
header: name.clone(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let header_value = HeaderValue::try_from(value.as_str()).map_err(|_| {
|
|
||||||
GraphqlAdapterError::InvalidHeaderValue {
|
|
||||||
header: name.clone(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
headers.insert(header_name, header_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_variables(variables: Option<Value>) -> Result<Value, GraphqlAdapterError> {
|
|
||||||
match variables {
|
|
||||||
None | Some(Value::Null) => Ok(Value::Object(Map::new())),
|
|
||||||
Some(Value::Object(values)) => Ok(Value::Object(values)),
|
|
||||||
Some(_) => Err(GraphqlAdapterError::InvalidVariablesShape),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn decode_body(response: reqwest::Response) -> Result<Value, GraphqlAdapterError> {
|
|
||||||
let bytes = response.bytes().await?;
|
|
||||||
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Ok(Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
match serde_json::from_slice::<Value>(&bytes) {
|
|
||||||
Ok(value) => Ok(value),
|
|
||||||
Err(_) => Ok(Value::String(
|
|
||||||
String::from_utf8_lossy(&bytes).trim().to_owned(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
|
|
||||||
headers
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(name, value)| {
|
|
||||||
value
|
|
||||||
.to_str()
|
|
||||||
.ok()
|
|
||||||
.map(|value| (name.as_str().to_owned(), value.to_owned()))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use axum::{Json, Router, routing::post};
|
|
||||||
use crank_core::{GraphqlOperationType, GraphqlTarget};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
use crate::{GraphqlAdapter, GraphqlAdapterError, GraphqlRequest};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn executes_graphql_request_and_extracts_response_path() {
|
|
||||||
let endpoint = spawn_graphql_server().await;
|
|
||||||
let adapter = GraphqlAdapter::new();
|
|
||||||
let target = GraphqlTarget {
|
|
||||||
endpoint,
|
|
||||||
operation_type: GraphqlOperationType::Mutation,
|
|
||||||
operation_name: "CreateLead".to_owned(),
|
|
||||||
query_template:
|
|
||||||
"mutation CreateLead($email: String!) { createLead(email: $email) { id status } }"
|
|
||||||
.to_owned(),
|
|
||||||
response_path: "$.response.body.data.createLead".to_owned(),
|
|
||||||
};
|
|
||||||
let request = GraphqlRequest {
|
|
||||||
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
|
|
||||||
variables: Some(json!({ "email": "user@example.com" })),
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = adapter.execute(&target, &request).await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.status_code, 200);
|
|
||||||
assert_eq!(
|
|
||||||
response.data,
|
|
||||||
json!({ "id": "lead_123", "status": "created" })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn returns_operation_errors_from_graphql_body() {
|
|
||||||
let endpoint = spawn_graphql_server().await;
|
|
||||||
let adapter = GraphqlAdapter::new();
|
|
||||||
let target = GraphqlTarget {
|
|
||||||
endpoint,
|
|
||||||
operation_type: GraphqlOperationType::Mutation,
|
|
||||||
operation_name: "CreateLead".to_owned(),
|
|
||||||
query_template:
|
|
||||||
"mutation CreateLead($email: String!) { createLead(email: $email) { id status } }"
|
|
||||||
.to_owned(),
|
|
||||||
response_path: "$.response.body.data.createLead".to_owned(),
|
|
||||||
};
|
|
||||||
let request = GraphqlRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
variables: Some(json!({ "email": "fail@example.com" })),
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
let error = adapter.execute(&target, &request).await.unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
error,
|
|
||||||
GraphqlAdapterError::OperationErrors {
|
|
||||||
errors: Value::Array(_),
|
|
||||||
..
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn_graphql_server() -> String {
|
|
||||||
let app = Router::new().route("/", post(graphql_handler));
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
format!("http://{}", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn graphql_handler(Json(payload): Json<Value>) -> Json<Value> {
|
|
||||||
let email = payload
|
|
||||||
.get("variables")
|
|
||||||
.and_then(Value::as_object)
|
|
||||||
.and_then(|variables| variables.get("email"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if email == "fail@example.com" {
|
|
||||||
return Json(json!({
|
|
||||||
"data": { "createLead": null },
|
|
||||||
"errors": [{ "message": "lead creation failed" }]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Json(json!({
|
|
||||||
"data": {
|
|
||||||
"createLead": {
|
|
||||||
"id": "lead_123",
|
|
||||||
"status": "created"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
use serde_json::Value;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum GraphqlAdapterError {
|
|
||||||
#[error("invalid graphql endpoint: {url}")]
|
|
||||||
InvalidEndpoint { url: String },
|
|
||||||
#[error("invalid header name {header}")]
|
|
||||||
InvalidHeaderName { header: String },
|
|
||||||
#[error("invalid header value for {header}")]
|
|
||||||
InvalidHeaderValue { header: String },
|
|
||||||
#[error("graphql variables must be a JSON object")]
|
|
||||||
InvalidVariablesShape,
|
|
||||||
#[error("invalid graphql response path: {path}")]
|
|
||||||
InvalidResponsePath { path: String },
|
|
||||||
#[error("graphql response path did not match any value: {path}")]
|
|
||||||
ResponsePathNotFound { path: String },
|
|
||||||
#[error("request failed")]
|
|
||||||
Transport(#[from] reqwest::Error),
|
|
||||||
#[error("graphql endpoint returned status {status}")]
|
|
||||||
UnexpectedStatus { status: u16, body: Value },
|
|
||||||
#[error("graphql operation returned errors")]
|
|
||||||
OperationErrors { errors: Value, data: Option<Value> },
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
mod client;
|
|
||||||
mod error;
|
|
||||||
mod model;
|
|
||||||
|
|
||||||
pub use client::GraphqlAdapter;
|
|
||||||
pub use error::GraphqlAdapterError;
|
|
||||||
pub use model::{GraphqlRequest, GraphqlResponse};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
|
||||||
pub struct GraphqlRequest {
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub variables: Option<Value>,
|
|
||||||
pub timeout_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct GraphqlResponse {
|
|
||||||
pub status_code: u16,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub body: Value,
|
|
||||||
pub data: Value,
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "crank-adapter-grpc"
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[features]
|
|
||||||
test-support = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
base64.workspace = true
|
|
||||||
crank-core = { path = "../crank-core" }
|
|
||||||
crank-proto = { path = "../crank-proto" }
|
|
||||||
futures-util = "0.3"
|
|
||||||
prost.workspace = true
|
|
||||||
prost-reflect.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
tonic.workspace = true
|
|
||||||
tonic-prost.workspace = true
|
|
||||||
tokio.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
prost-types.workspace = true
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
protoc-bin-vendored.workspace = true
|
|
||||||
tonic-prost-build.workspace = true
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
use std::{env, path::PathBuf};
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc must exist");
|
|
||||||
unsafe {
|
|
||||||
env::set_var("PROTOC", protoc);
|
|
||||||
}
|
|
||||||
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR must exist"));
|
|
||||||
let descriptor_path = out_dir.join("echo_descriptor.bin");
|
|
||||||
|
|
||||||
tonic_prost_build::configure()
|
|
||||||
.build_server(true)
|
|
||||||
.build_client(true)
|
|
||||||
.file_descriptor_set_path(descriptor_path)
|
|
||||||
.compile_protos(&["proto/echo.proto"], &["proto"])
|
|
||||||
.expect("echo proto must compile");
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use crank_adapter_grpc::test_support::{EchoServiceImpl, echo};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tonic::transport::Server;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let bind = std::env::var("CRANK_E2E_GRPC_FIXTURE_BIND")
|
|
||||||
.unwrap_or_else(|_| "127.0.0.1:3311".to_owned());
|
|
||||||
let listener = TcpListener::bind(&bind).await?;
|
|
||||||
let incoming = tonic::transport::server::TcpIncoming::from(listener);
|
|
||||||
|
|
||||||
println!("gRPC stream fixture listening on http://{bind}");
|
|
||||||
|
|
||||||
Server::builder()
|
|
||||||
.add_service(echo::echo_service_server::EchoServiceServer::new(
|
|
||||||
EchoServiceImpl,
|
|
||||||
))
|
|
||||||
.serve_with_incoming(incoming)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package echo;
|
|
||||||
|
|
||||||
service EchoService {
|
|
||||||
rpc UnaryEcho(EchoRequest) returns (EchoResponse);
|
|
||||||
rpc ServerEcho(EchoRequest) returns (stream EchoResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
message EchoRequest {
|
|
||||||
string message = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message EchoResponse {
|
|
||||||
string message = 1;
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
use std::{collections::BTreeMap, str::FromStr, time::Duration};
|
|
||||||
|
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
||||||
use crank_core::GrpcTarget;
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use prost::Message;
|
|
||||||
use prost_reflect::{DescriptorPool, MethodDescriptor, prost_types::FileDescriptorSet};
|
|
||||||
use serde_json::json;
|
|
||||||
use tokio::time::{Instant, timeout_at};
|
|
||||||
use tonic::{
|
|
||||||
Request,
|
|
||||||
client::Grpc,
|
|
||||||
metadata::{KeyAndValueRef, MetadataKey, MetadataValue},
|
|
||||||
transport::Endpoint,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
GrpcAdapterError, GrpcRequest, GrpcResponse, GrpcWindowRequest, GrpcWindowResponse,
|
|
||||||
codec::JsonCodec,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct GrpcAdapter;
|
|
||||||
|
|
||||||
impl GrpcAdapter {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
&self,
|
|
||||||
target: &GrpcTarget,
|
|
||||||
request: &GrpcRequest,
|
|
||||||
) -> Result<GrpcResponse, GrpcAdapterError> {
|
|
||||||
let method = resolve_method(target)?;
|
|
||||||
if method.is_client_streaming() || method.is_server_streaming() {
|
|
||||||
return Err(GrpcAdapterError::UnsupportedMethodKind {
|
|
||||||
service: format!("{}.{}", target.package, target.service),
|
|
||||||
method: target.method.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut grpc = connect(target, request.timeout_ms).await?;
|
|
||||||
let path = build_path(&method)?;
|
|
||||||
let codec = JsonCodec::new(method.input(), method.output());
|
|
||||||
let mut tonic_request = Request::new(request.body.clone());
|
|
||||||
|
|
||||||
apply_headers(&mut tonic_request, &request.headers)?;
|
|
||||||
|
|
||||||
let response = grpc.unary(tonic_request, path, codec).await?;
|
|
||||||
let headers = normalize_headers(response.metadata());
|
|
||||||
let body = response.into_inner();
|
|
||||||
|
|
||||||
Ok(GrpcResponse {
|
|
||||||
status_code: 200,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute_window(
|
|
||||||
&self,
|
|
||||||
target: &GrpcTarget,
|
|
||||||
request: &GrpcWindowRequest,
|
|
||||||
) -> Result<GrpcWindowResponse, GrpcAdapterError> {
|
|
||||||
let method = resolve_method(target)?;
|
|
||||||
if method.is_client_streaming() || !method.is_server_streaming() {
|
|
||||||
return Err(GrpcAdapterError::UnsupportedStreamingMethodKind {
|
|
||||||
service: format!("{}.{}", target.package, target.service),
|
|
||||||
method: target.method.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut grpc = connect(target, request.request.timeout_ms).await?;
|
|
||||||
let path = build_path(&method)?;
|
|
||||||
let codec = JsonCodec::new(method.input(), method.output());
|
|
||||||
let mut tonic_request = Request::new(request.request.body.clone());
|
|
||||||
|
|
||||||
apply_headers(&mut tonic_request, &request.request.headers)?;
|
|
||||||
|
|
||||||
let response = grpc.server_streaming(tonic_request, path, codec).await?;
|
|
||||||
let headers = normalize_headers(response.metadata());
|
|
||||||
let mut stream = response.into_inner();
|
|
||||||
let deadline = Instant::now() + Duration::from_millis(request.window_duration_ms);
|
|
||||||
let mut items = Vec::new();
|
|
||||||
let mut done = true;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if request
|
|
||||||
.max_items
|
|
||||||
.is_some_and(|limit| items.len() >= limit as usize)
|
|
||||||
{
|
|
||||||
done = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_message = match timeout_at(deadline, stream.next()).await {
|
|
||||||
Ok(next_message) => next_message,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(next_message) = next_message else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = next_message?;
|
|
||||||
items.push(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(GrpcWindowResponse {
|
|
||||||
status_code: 200,
|
|
||||||
headers,
|
|
||||||
body: json!({
|
|
||||||
"items": items,
|
|
||||||
"done": done,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect(
|
|
||||||
target: &GrpcTarget,
|
|
||||||
timeout_ms: u64,
|
|
||||||
) -> Result<Grpc<tonic::transport::Channel>, GrpcAdapterError> {
|
|
||||||
let endpoint = Endpoint::from_shared(target.server_addr.clone())?
|
|
||||||
.timeout(Duration::from_millis(timeout_ms));
|
|
||||||
let channel = endpoint.connect().await?;
|
|
||||||
let mut grpc = Grpc::new(channel);
|
|
||||||
grpc.ready()
|
|
||||||
.await
|
|
||||||
.map_err(|error| GrpcAdapterError::Status {
|
|
||||||
code: tonic::Code::Unavailable,
|
|
||||||
message: error.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(grpc)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_path(
|
|
||||||
method: &MethodDescriptor,
|
|
||||||
) -> Result<tonic::codegen::http::uri::PathAndQuery, GrpcAdapterError> {
|
|
||||||
let service_name = method.parent_service().full_name().to_owned();
|
|
||||||
let method_name = method.name().to_owned();
|
|
||||||
tonic::codegen::http::uri::PathAndQuery::from_str(&format!("/{service_name}/{method_name}"))
|
|
||||||
.map_err(|_| GrpcAdapterError::InvalidMethodPath {
|
|
||||||
service: service_name,
|
|
||||||
method: method_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_method(target: &GrpcTarget) -> Result<MethodDescriptor, GrpcAdapterError> {
|
|
||||||
let bytes = STANDARD
|
|
||||||
.decode(&target.descriptor_set_b64)
|
|
||||||
.map_err(|_| GrpcAdapterError::InvalidDescriptorEncoding)?;
|
|
||||||
let descriptor_set = FileDescriptorSet::decode(bytes.as_slice())
|
|
||||||
.map_err(|_| GrpcAdapterError::InvalidDescriptorSet)?;
|
|
||||||
let pool = DescriptorPool::from_file_descriptor_set(descriptor_set)
|
|
||||||
.map_err(|_| GrpcAdapterError::InvalidDescriptorPool)?;
|
|
||||||
let full_service_name = if target.package.is_empty() {
|
|
||||||
target.service.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}.{}", target.package, target.service)
|
|
||||||
};
|
|
||||||
let service = pool
|
|
||||||
.get_service_by_name(&full_service_name)
|
|
||||||
.ok_or_else(|| GrpcAdapterError::ServiceNotFound {
|
|
||||||
service: full_service_name.clone(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
service
|
|
||||||
.methods()
|
|
||||||
.find(|method| method.name() == target.method)
|
|
||||||
.ok_or_else(|| GrpcAdapterError::MethodNotFound {
|
|
||||||
service: full_service_name,
|
|
||||||
method: target.method.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_headers(
|
|
||||||
request: &mut Request<serde_json::Value>,
|
|
||||||
headers: &BTreeMap<String, String>,
|
|
||||||
) -> Result<(), GrpcAdapterError> {
|
|
||||||
for (key, value) in headers {
|
|
||||||
let metadata_key =
|
|
||||||
MetadataKey::from_str(key).map_err(|source| GrpcAdapterError::InvalidMetadataKey {
|
|
||||||
key: key.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let metadata_value = MetadataValue::from_str(value).map_err(|source| {
|
|
||||||
GrpcAdapterError::InvalidMetadataValue {
|
|
||||||
key: key.clone(),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
request.metadata_mut().insert(metadata_key, metadata_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_headers(metadata: &tonic::metadata::MetadataMap) -> BTreeMap<String, String> {
|
|
||||||
metadata
|
|
||||||
.iter()
|
|
||||||
.filter_map(|entry| match entry {
|
|
||||||
KeyAndValueRef::Ascii(key, value) => {
|
|
||||||
Some((key.as_str().to_owned(), value.to_str().ok()?.to_owned()))
|
|
||||||
}
|
|
||||||
KeyAndValueRef::Binary(_, _) => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crank_core::{DescriptorId, GrpcTarget};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::{GrpcAdapter, GrpcAdapterError, GrpcRequest, GrpcWindowRequest, test_support};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn executes_unary_grpc_request() {
|
|
||||||
let server_addr = test_support::spawn_unary_echo_server().await;
|
|
||||||
let adapter = GrpcAdapter::new();
|
|
||||||
let target = GrpcTarget {
|
|
||||||
server_addr,
|
|
||||||
package: "echo".to_owned(),
|
|
||||||
service: "EchoService".to_owned(),
|
|
||||||
method: "UnaryEcho".to_owned(),
|
|
||||||
read_only: false,
|
|
||||||
descriptor_ref: DescriptorId::new("desc_echo"),
|
|
||||||
descriptor_set_b64: test_support::descriptor_set_b64(),
|
|
||||||
};
|
|
||||||
let request = GrpcRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
body: json!({ "message": "hello" }),
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = adapter.execute(&target, &request).await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.body, json!({ "message": "hello" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn collects_server_stream_messages_with_window_bounds() {
|
|
||||||
let server_addr = test_support::spawn_unary_echo_server().await;
|
|
||||||
let adapter = GrpcAdapter::new();
|
|
||||||
let target = GrpcTarget {
|
|
||||||
server_addr,
|
|
||||||
package: "echo".to_owned(),
|
|
||||||
service: "EchoService".to_owned(),
|
|
||||||
method: "ServerEcho".to_owned(),
|
|
||||||
read_only: false,
|
|
||||||
descriptor_ref: DescriptorId::new("desc_echo"),
|
|
||||||
descriptor_set_b64: test_support::descriptor_set_b64(),
|
|
||||||
};
|
|
||||||
let request = GrpcWindowRequest {
|
|
||||||
request: GrpcRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
body: json!({ "message": "hello" }),
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
},
|
|
||||||
window_duration_ms: 1_000,
|
|
||||||
max_items: Some(2),
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = adapter.execute_window(&target, &request).await.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
response.body,
|
|
||||||
json!({
|
|
||||||
"items": [
|
|
||||||
{ "message": "hello-one" },
|
|
||||||
{ "message": "hello-two" }
|
|
||||||
],
|
|
||||||
"done": false
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejects_unary_method_in_window_mode() {
|
|
||||||
let server_addr = test_support::spawn_unary_echo_server().await;
|
|
||||||
let adapter = GrpcAdapter::new();
|
|
||||||
let target = GrpcTarget {
|
|
||||||
server_addr,
|
|
||||||
package: "echo".to_owned(),
|
|
||||||
service: "EchoService".to_owned(),
|
|
||||||
method: "UnaryEcho".to_owned(),
|
|
||||||
read_only: false,
|
|
||||||
descriptor_ref: DescriptorId::new("desc_echo"),
|
|
||||||
descriptor_set_b64: test_support::descriptor_set_b64(),
|
|
||||||
};
|
|
||||||
let request = GrpcWindowRequest {
|
|
||||||
request: GrpcRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
body: json!({ "message": "hello" }),
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
},
|
|
||||||
window_duration_ms: 1_000,
|
|
||||||
max_items: Some(10),
|
|
||||||
};
|
|
||||||
|
|
||||||
let error = adapter.execute_window(&target, &request).await.unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
error,
|
|
||||||
GrpcAdapterError::UnsupportedStreamingMethodKind { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
use prost::Message;
|
|
||||||
use prost_reflect::{DynamicMessage, MessageDescriptor};
|
|
||||||
use tonic::{
|
|
||||||
Status,
|
|
||||||
codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct JsonCodec {
|
|
||||||
request_descriptor: MessageDescriptor,
|
|
||||||
response_descriptor: MessageDescriptor,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl JsonCodec {
|
|
||||||
pub fn new(
|
|
||||||
request_descriptor: MessageDescriptor,
|
|
||||||
response_descriptor: MessageDescriptor,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
request_descriptor,
|
|
||||||
response_descriptor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Codec for JsonCodec {
|
|
||||||
type Encode = serde_json::Value;
|
|
||||||
type Decode = serde_json::Value;
|
|
||||||
|
|
||||||
type Encoder = JsonEncoder;
|
|
||||||
type Decoder = JsonDecoder;
|
|
||||||
|
|
||||||
fn encoder(&mut self) -> Self::Encoder {
|
|
||||||
JsonEncoder(self.request_descriptor.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decoder(&mut self) -> Self::Decoder {
|
|
||||||
JsonDecoder(self.response_descriptor.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct JsonEncoder(MessageDescriptor);
|
|
||||||
|
|
||||||
impl Encoder for JsonEncoder {
|
|
||||||
type Item = serde_json::Value;
|
|
||||||
type Error = Status;
|
|
||||||
|
|
||||||
fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> {
|
|
||||||
let message = DynamicMessage::deserialize(self.0.clone(), item)
|
|
||||||
.map_err(|error| Status::invalid_argument(error.to_string()))?;
|
|
||||||
|
|
||||||
message.encode_raw(dst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct JsonDecoder(MessageDescriptor);
|
|
||||||
|
|
||||||
impl Decoder for JsonDecoder {
|
|
||||||
type Item = serde_json::Value;
|
|
||||||
type Error = Status;
|
|
||||||
|
|
||||||
fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
|
|
||||||
let mut message = DynamicMessage::new(self.0.clone());
|
|
||||||
message
|
|
||||||
.merge(src)
|
|
||||||
.map_err(|error| Status::internal(error.to_string()))?;
|
|
||||||
|
|
||||||
serde_json::to_value(&message)
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|error| Status::internal(error.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
use tonic::{
|
|
||||||
Code, Status,
|
|
||||||
metadata::errors::{InvalidMetadataKey, InvalidMetadataValue},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum GrpcAdapterError {
|
|
||||||
#[error("invalid descriptor set encoding")]
|
|
||||||
InvalidDescriptorEncoding,
|
|
||||||
#[error("invalid descriptor set")]
|
|
||||||
InvalidDescriptorSet,
|
|
||||||
#[error("invalid descriptor pool")]
|
|
||||||
InvalidDescriptorPool,
|
|
||||||
#[error("service {service} was not found in descriptor set")]
|
|
||||||
ServiceNotFound { service: String },
|
|
||||||
#[error("method {method} was not found in service {service}")]
|
|
||||||
MethodNotFound { service: String, method: String },
|
|
||||||
#[error("grpc method path is invalid for service {service} and method {method}")]
|
|
||||||
InvalidMethodPath { service: String, method: String },
|
|
||||||
#[error("grpc method {service}/{method} is not supported for unary execution")]
|
|
||||||
UnsupportedMethodKind { service: String, method: String },
|
|
||||||
#[error("grpc method {service}/{method} does not support server-stream execution")]
|
|
||||||
UnsupportedStreamingMethodKind { service: String, method: String },
|
|
||||||
#[error("invalid metadata key {key}")]
|
|
||||||
InvalidMetadataKey {
|
|
||||||
key: String,
|
|
||||||
#[source]
|
|
||||||
source: InvalidMetadataKey,
|
|
||||||
},
|
|
||||||
#[error("invalid metadata value for key {key}")]
|
|
||||||
InvalidMetadataValue {
|
|
||||||
key: String,
|
|
||||||
#[source]
|
|
||||||
source: InvalidMetadataValue,
|
|
||||||
},
|
|
||||||
#[error("transport endpoint is invalid")]
|
|
||||||
InvalidEndpoint(#[from] tonic::transport::Error),
|
|
||||||
#[error("stream collection window expired before grpc stream completed")]
|
|
||||||
WindowExpired,
|
|
||||||
#[error("grpc status {code}: {message}")]
|
|
||||||
Status { code: Code, message: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Status> for GrpcAdapterError {
|
|
||||||
fn from(value: Status) -> Self {
|
|
||||||
Self::Status {
|
|
||||||
code: value.code(),
|
|
||||||
message: value.message().to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
mod client;
|
|
||||||
mod codec;
|
|
||||||
mod error;
|
|
||||||
mod model;
|
|
||||||
|
|
||||||
pub use client::GrpcAdapter;
|
|
||||||
pub use error::GrpcAdapterError;
|
|
||||||
pub use model::{GrpcRequest, GrpcResponse, GrpcWindowRequest, GrpcWindowResponse};
|
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-support"))]
|
|
||||||
pub mod test_support;
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
|
||||||
pub struct GrpcRequest {
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub body: Value,
|
|
||||||
pub timeout_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct GrpcResponse {
|
|
||||||
pub status_code: u16,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub body: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
|
||||||
pub struct GrpcWindowRequest {
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub request: GrpcRequest,
|
|
||||||
pub window_duration_ms: u64,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_items: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct GrpcWindowResponse {
|
|
||||||
pub status_code: u16,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub body: Value,
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
|
||||||
use futures_util::stream;
|
|
||||||
use std::sync::{
|
|
||||||
Arc,
|
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tonic::{Request, Response, Status, transport::Server};
|
|
||||||
|
|
||||||
pub mod echo {
|
|
||||||
tonic::include_proto!("echo");
|
|
||||||
pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("echo_descriptor");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct EchoServiceImpl;
|
|
||||||
|
|
||||||
#[tonic::async_trait]
|
|
||||||
impl echo::echo_service_server::EchoService for EchoServiceImpl {
|
|
||||||
async fn unary_echo(
|
|
||||||
&self,
|
|
||||||
request: Request<echo::EchoRequest>,
|
|
||||||
) -> Result<Response<echo::EchoResponse>, Status> {
|
|
||||||
Ok(Response::new(echo::EchoResponse {
|
|
||||||
message: request.into_inner().message,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerEchoStream = std::pin::Pin<
|
|
||||||
Box<dyn futures_util::Stream<Item = Result<echo::EchoResponse, Status>> + Send>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
async fn server_echo(
|
|
||||||
&self,
|
|
||||||
request: Request<echo::EchoRequest>,
|
|
||||||
) -> Result<Response<Self::ServerEchoStream>, Status> {
|
|
||||||
let message = request.into_inner().message;
|
|
||||||
let events = vec![
|
|
||||||
Ok(echo::EchoResponse {
|
|
||||||
message: format!("{message}-one"),
|
|
||||||
}),
|
|
||||||
Ok(echo::EchoResponse {
|
|
||||||
message: format!("{message}-two"),
|
|
||||||
}),
|
|
||||||
Ok(echo::EchoResponse {
|
|
||||||
message: format!("{message}-three"),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
Ok(Response::new(Box::pin(stream::iter(events))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn spawn_unary_echo_server() -> String {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let incoming = tonic::transport::server::TcpIncoming::from(listener);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
Server::builder()
|
|
||||||
.add_service(echo::echo_service_server::EchoServiceServer::new(
|
|
||||||
EchoServiceImpl,
|
|
||||||
))
|
|
||||||
.serve_with_incoming(incoming)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
format!("http://{}", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn spawn_metadata_echo_server() -> String {
|
|
||||||
#[derive(Default)]
|
|
||||||
struct MetadataEchoServiceImpl;
|
|
||||||
|
|
||||||
#[tonic::async_trait]
|
|
||||||
impl echo::echo_service_server::EchoService for MetadataEchoServiceImpl {
|
|
||||||
async fn unary_echo(
|
|
||||||
&self,
|
|
||||||
request: Request<echo::EchoRequest>,
|
|
||||||
) -> Result<Response<echo::EchoResponse>, Status> {
|
|
||||||
let message = request.get_ref().message.clone();
|
|
||||||
let request_id = request
|
|
||||||
.metadata()
|
|
||||||
.get("x-request-id")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let correlation_id = request
|
|
||||||
.metadata()
|
|
||||||
.get("x-correlation-id")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(Response::new(echo::EchoResponse {
|
|
||||||
message: format!("{}|{}|{}", message, request_id, correlation_id),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerEchoStream = std::pin::Pin<
|
|
||||||
Box<dyn futures_util::Stream<Item = Result<echo::EchoResponse, Status>> + Send>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
async fn server_echo(
|
|
||||||
&self,
|
|
||||||
_request: Request<echo::EchoRequest>,
|
|
||||||
) -> Result<Response<Self::ServerEchoStream>, Status> {
|
|
||||||
Ok(Response::new(Box::pin(stream::empty())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let incoming = tonic::transport::server::TcpIncoming::from(listener);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
Server::builder()
|
|
||||||
.add_service(echo::echo_service_server::EchoServiceServer::new(
|
|
||||||
MetadataEchoServiceImpl,
|
|
||||||
))
|
|
||||||
.serve_with_incoming(incoming)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
format!("http://{}", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn spawn_counting_unary_echo_server(request_count: Arc<AtomicUsize>) -> String {
|
|
||||||
struct CountingEchoServiceImpl {
|
|
||||||
request_count: Arc<AtomicUsize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tonic::async_trait]
|
|
||||||
impl echo::echo_service_server::EchoService for CountingEchoServiceImpl {
|
|
||||||
async fn unary_echo(
|
|
||||||
&self,
|
|
||||||
request: Request<echo::EchoRequest>,
|
|
||||||
) -> Result<Response<echo::EchoResponse>, Status> {
|
|
||||||
let message = request.into_inner().message;
|
|
||||||
let count = self.request_count.fetch_add(1, Ordering::SeqCst) + 1;
|
|
||||||
|
|
||||||
Ok(Response::new(echo::EchoResponse {
|
|
||||||
message: format!("{message}-{count}"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerEchoStream = std::pin::Pin<
|
|
||||||
Box<dyn futures_util::Stream<Item = Result<echo::EchoResponse, Status>> + Send>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
async fn server_echo(
|
|
||||||
&self,
|
|
||||||
_request: Request<echo::EchoRequest>,
|
|
||||||
) -> Result<Response<Self::ServerEchoStream>, Status> {
|
|
||||||
Ok(Response::new(Box::pin(stream::empty())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let incoming = tonic::transport::server::TcpIncoming::from(listener);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
Server::builder()
|
|
||||||
.add_service(echo::echo_service_server::EchoServiceServer::new(
|
|
||||||
CountingEchoServiceImpl { request_count },
|
|
||||||
))
|
|
||||||
.serve_with_incoming(incoming)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
format!("http://{}", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn descriptor_set_b64() -> String {
|
|
||||||
STANDARD.encode(echo::FILE_DESCRIPTOR_SET)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "crank-adapter-soap"
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
crank-core = { path = "../crank-core" }
|
|
||||||
crank-mapping = { path = "../crank-mapping" }
|
|
||||||
reqwest.workspace = true
|
|
||||||
roxmltree.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
tokio.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
axum.workspace = true
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
use std::{collections::BTreeMap, time::Duration};
|
|
||||||
|
|
||||||
use crank_core::{SoapTarget, SoapVersion};
|
|
||||||
use crank_mapping::JsonPath;
|
|
||||||
use reqwest::{
|
|
||||||
Client,
|
|
||||||
header::{HeaderMap, HeaderName, HeaderValue},
|
|
||||||
};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
use crate::{SoapAdapterError, SoapRequest, SoapResponse, xml};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct SoapAdapter {
|
|
||||||
client: Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SoapAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SoapAdapter {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
client: Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute(
|
|
||||||
&self,
|
|
||||||
target: &SoapTarget,
|
|
||||||
request: &SoapRequest,
|
|
||||||
) -> Result<SoapResponse, SoapAdapterError> {
|
|
||||||
let endpoint = target
|
|
||||||
.endpoint_override
|
|
||||||
.as_deref()
|
|
||||||
.ok_or(SoapAdapterError::MissingEndpoint)?;
|
|
||||||
let headers = build_headers(target, request)?;
|
|
||||||
let envelope_headers = resolve_envelope_headers(target, request)?;
|
|
||||||
let envelope = xml::build_envelope(
|
|
||||||
&target.operation_name,
|
|
||||||
target.metadata.namespaces.first().map(String::as_str),
|
|
||||||
&request.body,
|
|
||||||
envelope_namespace(target.soap_version),
|
|
||||||
target.binding_style,
|
|
||||||
&envelope_headers,
|
|
||||||
);
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.post(endpoint)
|
|
||||||
.headers(headers)
|
|
||||||
.body(envelope)
|
|
||||||
.timeout(Duration::from_millis(request.timeout_ms))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let status = response.status();
|
|
||||||
let headers = normalize_headers(response.headers());
|
|
||||||
let body_text = response.text().await?;
|
|
||||||
let body = xml::decode_envelope(&body_text)?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(SoapAdapterError::UnexpectedStatus {
|
|
||||||
status: status.as_u16(),
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SoapResponse {
|
|
||||||
status_code: status.as_u16(),
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_envelope_headers(
|
|
||||||
target: &SoapTarget,
|
|
||||||
request: &SoapRequest,
|
|
||||||
) -> Result<Vec<xml::SoapEnvelopeHeader>, SoapAdapterError> {
|
|
||||||
let context = json!({
|
|
||||||
"request": {
|
|
||||||
"body": request.body.clone(),
|
|
||||||
},
|
|
||||||
"mcp": request.body.clone(),
|
|
||||||
});
|
|
||||||
let mut rendered = Vec::new();
|
|
||||||
|
|
||||||
for header in &target.headers {
|
|
||||||
let Some(value) = resolve_header_value(header, &context, &request.body)? else {
|
|
||||||
if header.required {
|
|
||||||
return Err(SoapAdapterError::MissingRequiredHeader {
|
|
||||||
name: header.name.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
rendered.push(xml::SoapEnvelopeHeader {
|
|
||||||
name: header.name.clone(),
|
|
||||||
namespace_uri: header.namespace_uri.clone(),
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(rendered)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_header_value(
|
|
||||||
header: &crank_core::SoapHeaderConfig,
|
|
||||||
context: &Value,
|
|
||||||
body: &Value,
|
|
||||||
) -> Result<Option<Value>, SoapAdapterError> {
|
|
||||||
if let Some(path) = header.value_path.as_deref() {
|
|
||||||
let path = JsonPath::parse(path).map_err(|_| SoapAdapterError::InvalidHeaderValuePath {
|
|
||||||
path: path.to_owned(),
|
|
||||||
})?;
|
|
||||||
return Ok(path.read(context).cloned());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(match body {
|
|
||||||
Value::Object(map) => map.get(&header.name).cloned(),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_headers(
|
|
||||||
target: &SoapTarget,
|
|
||||||
request: &SoapRequest,
|
|
||||||
) -> Result<HeaderMap, SoapAdapterError> {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
let content_type = match target.soap_version {
|
|
||||||
SoapVersion::Soap11 => "text/xml; charset=utf-8".to_owned(),
|
|
||||||
SoapVersion::Soap12 => match target.soap_action.as_deref() {
|
|
||||||
Some(action) => format!(r#"application/soap+xml; charset=utf-8; action="{action}""#),
|
|
||||||
None => "application/soap+xml; charset=utf-8".to_owned(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_str(&content_type).map_err(|_| SoapAdapterError::MissingEndpoint)?,
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::ACCEPT,
|
|
||||||
HeaderValue::from_static("application/soap+xml, text/xml, application/xml"),
|
|
||||||
);
|
|
||||||
|
|
||||||
if matches!(target.soap_version, SoapVersion::Soap11) {
|
|
||||||
if let Some(action) = target.soap_action.as_deref() {
|
|
||||||
headers.insert(
|
|
||||||
HeaderName::from_static("soapaction"),
|
|
||||||
HeaderValue::from_str(action).map_err(|_| SoapAdapterError::MissingEndpoint)?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (name, value) in &request.headers {
|
|
||||||
let header_name =
|
|
||||||
HeaderName::try_from(name.as_str()).map_err(|_| SoapAdapterError::MissingEndpoint)?;
|
|
||||||
let header_value =
|
|
||||||
HeaderValue::try_from(value).map_err(|_| SoapAdapterError::MissingEndpoint)?;
|
|
||||||
headers.insert(header_name, header_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn envelope_namespace(version: SoapVersion) -> &'static str {
|
|
||||||
match version {
|
|
||||||
SoapVersion::Soap11 => "http://schemas.xmlsoap.org/soap/envelope/",
|
|
||||||
SoapVersion::Soap12 => "http://www.w3.org/2003/05/soap-envelope",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
|
|
||||||
headers
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(name, value)| {
|
|
||||||
value
|
|
||||||
.to_str()
|
|
||||||
.ok()
|
|
||||||
.map(|value| (name.as_str().to_owned(), value.to_owned()))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use axum::{Router, body::Bytes, routing::post};
|
|
||||||
use crank_core::{SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, Target};
|
|
||||||
use serde_json::json;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
use crate::{SoapAdapter, SoapRequest};
|
|
||||||
|
|
||||||
async fn spawn_server() -> String {
|
|
||||||
async fn handler(body: Bytes) -> String {
|
|
||||||
let text = String::from_utf8_lossy(&body);
|
|
||||||
assert!(text.contains("<email>user@example.com</email>"));
|
|
||||||
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
||||||
<soap:Body>
|
|
||||||
<CreateLeadResponse>
|
|
||||||
<id>lead_123</id>
|
|
||||||
<status>created</status>
|
|
||||||
</CreateLeadResponse>
|
|
||||||
</soap:Body>
|
|
||||||
</soap:Envelope>"#
|
|
||||||
.to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
let app = Router::new().route("/", post(handler));
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
format!("http://{}", address)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_target(endpoint: String) -> SoapTarget {
|
|
||||||
match Target::Soap(SoapTarget {
|
|
||||||
wsdl_ref: "sample_wsdl".into(),
|
|
||||||
service_name: "LeadService".to_owned(),
|
|
||||||
port_name: "LeadPort".to_owned(),
|
|
||||||
operation_name: "CreateLead".to_owned(),
|
|
||||||
endpoint_override: Some(endpoint),
|
|
||||||
soap_version: SoapVersion::Soap11,
|
|
||||||
soap_action: Some("urn:createLead".to_owned()),
|
|
||||||
binding_style: SoapBindingStyle::DocumentLiteral,
|
|
||||||
headers: Vec::new(),
|
|
||||||
fault_contract: None,
|
|
||||||
metadata: SoapOperationMetadata {
|
|
||||||
input_part_names: vec!["CreateLeadRequest".to_owned()],
|
|
||||||
output_part_names: vec!["CreateLeadResponse".to_owned()],
|
|
||||||
namespaces: vec!["urn:crm".to_owned()],
|
|
||||||
},
|
|
||||||
}) {
|
|
||||||
Target::Soap(target) => target,
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn executes_soap_request_response() {
|
|
||||||
let endpoint = spawn_server().await;
|
|
||||||
let adapter = SoapAdapter::new();
|
|
||||||
let response = adapter
|
|
||||||
.execute(
|
|
||||||
&test_target(endpoint),
|
|
||||||
&SoapRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
body: json!({ "email": "user@example.com" }),
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.status_code, 200);
|
|
||||||
assert_eq!(response.body["id"], "lead_123");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn renders_soap_headers_and_rpc_literal_children() {
|
|
||||||
async fn handler(body: Bytes) -> String {
|
|
||||||
let text = String::from_utf8_lossy(&body);
|
|
||||||
assert!(text.contains("<soap:Header>"));
|
|
||||||
assert!(
|
|
||||||
text.contains(
|
|
||||||
r#"<h:CorrelationId xmlns:h="urn:headers">corr-123</h:CorrelationId>"#
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert!(text.contains("<m:CreateLead"));
|
|
||||||
assert!(text.contains("<m:email>user@example.com</m:email>"));
|
|
||||||
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
||||||
<soap:Body>
|
|
||||||
<CreateLeadResponse>
|
|
||||||
<id>lead_rpc</id>
|
|
||||||
</CreateLeadResponse>
|
|
||||||
</soap:Body>
|
|
||||||
</soap:Envelope>"#
|
|
||||||
.to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
let app = Router::new().route("/", post(handler));
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let target = SoapTarget {
|
|
||||||
binding_style: SoapBindingStyle::RpcLiteral,
|
|
||||||
headers: vec![crank_core::SoapHeaderConfig {
|
|
||||||
name: "CorrelationId".to_owned(),
|
|
||||||
namespace_uri: Some("urn:headers".to_owned()),
|
|
||||||
required: true,
|
|
||||||
value_path: Some("$.request.body.correlation_id".to_owned()),
|
|
||||||
}],
|
|
||||||
..test_target(format!("http://{}", address))
|
|
||||||
};
|
|
||||||
|
|
||||||
let adapter = SoapAdapter::new();
|
|
||||||
let response = adapter
|
|
||||||
.execute(
|
|
||||||
&target,
|
|
||||||
&SoapRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
body: json!({
|
|
||||||
"email": "user@example.com",
|
|
||||||
"correlation_id": "corr-123"
|
|
||||||
}),
|
|
||||||
timeout_ms: 1_000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.body["id"], "lead_rpc");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
use serde_json::Value;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum SoapAdapterError {
|
|
||||||
#[error("soap endpoint is missing")]
|
|
||||||
MissingEndpoint,
|
|
||||||
#[error("invalid SOAP header value path {path}")]
|
|
||||||
InvalidHeaderValuePath { path: String },
|
|
||||||
#[error("required SOAP header {name} is missing")]
|
|
||||||
MissingRequiredHeader { name: String },
|
|
||||||
#[error("request failed")]
|
|
||||||
Transport(#[from] reqwest::Error),
|
|
||||||
#[error("invalid xml payload")]
|
|
||||||
InvalidXml(#[from] roxmltree::Error),
|
|
||||||
#[error("soap envelope body was not found")]
|
|
||||||
MissingBody,
|
|
||||||
#[error("soap response body was empty")]
|
|
||||||
EmptyBody,
|
|
||||||
#[error("soap endpoint returned status {status}")]
|
|
||||||
UnexpectedStatus { status: u16, body: Value },
|
|
||||||
#[error("soap fault: {message}")]
|
|
||||||
SoapFault {
|
|
||||||
code: Option<String>,
|
|
||||||
message: String,
|
|
||||||
detail: Option<Value>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
mod client;
|
|
||||||
mod error;
|
|
||||||
mod model;
|
|
||||||
mod wsdl;
|
|
||||||
mod xml;
|
|
||||||
|
|
||||||
pub use client::SoapAdapter;
|
|
||||||
pub use error::SoapAdapterError;
|
|
||||||
pub use model::{
|
|
||||||
SoapOperationSummary, SoapPortSummary, SoapRequest, SoapResponse, SoapServiceSummary,
|
|
||||||
};
|
|
||||||
pub use wsdl::inspect_wsdl;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SoapRequest {
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub body: Value,
|
|
||||||
pub timeout_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct SoapResponse {
|
|
||||||
pub status_code: u16,
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub body: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SoapOperationSummary {
|
|
||||||
pub operation_name: String,
|
|
||||||
pub soap_action: Option<String>,
|
|
||||||
pub binding_style: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SoapPortSummary {
|
|
||||||
pub port_name: String,
|
|
||||||
pub binding_name: String,
|
|
||||||
pub endpoint: Option<String>,
|
|
||||||
pub soap_version: String,
|
|
||||||
pub operations: Vec<SoapOperationSummary>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct SoapServiceSummary {
|
|
||||||
pub service_name: String,
|
|
||||||
pub ports: Vec<SoapPortSummary>,
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use roxmltree::Document;
|
|
||||||
|
|
||||||
use crate::{SoapAdapterError, SoapOperationSummary, SoapPortSummary, SoapServiceSummary};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct BindingSummary {
|
|
||||||
soap_version: String,
|
|
||||||
operations: Vec<SoapOperationSummary>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inspect_wsdl(payload: &[u8]) -> Result<Vec<SoapServiceSummary>, SoapAdapterError> {
|
|
||||||
let text = std::str::from_utf8(payload).map_err(|_| SoapAdapterError::EmptyBody)?;
|
|
||||||
let document = Document::parse(text)?;
|
|
||||||
let bindings = collect_bindings(&document);
|
|
||||||
|
|
||||||
let services = document
|
|
||||||
.descendants()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "service")
|
|
||||||
.map(|service| {
|
|
||||||
let service_name = service.attribute("name").unwrap_or_default().to_owned();
|
|
||||||
let ports = service
|
|
||||||
.children()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "port")
|
|
||||||
.map(|port| {
|
|
||||||
let port_name = port.attribute("name").unwrap_or_default().to_owned();
|
|
||||||
let binding_name = local_name(port.attribute("binding").unwrap_or_default());
|
|
||||||
let endpoint = port
|
|
||||||
.descendants()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "address")
|
|
||||||
.and_then(|node| node.attribute("location"))
|
|
||||||
.map(ToOwned::to_owned);
|
|
||||||
let binding = bindings
|
|
||||||
.get(&binding_name)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(BindingSummary {
|
|
||||||
soap_version: "soap_11".to_owned(),
|
|
||||||
operations: Vec::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
SoapPortSummary {
|
|
||||||
port_name,
|
|
||||||
binding_name,
|
|
||||||
endpoint,
|
|
||||||
soap_version: binding.soap_version,
|
|
||||||
operations: binding.operations,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
SoapServiceSummary {
|
|
||||||
service_name,
|
|
||||||
ports,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(services)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_bindings(document: &Document<'_>) -> BTreeMap<String, BindingSummary> {
|
|
||||||
document
|
|
||||||
.descendants()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "binding")
|
|
||||||
.map(|binding| {
|
|
||||||
let name = binding.attribute("name").unwrap_or_default().to_owned();
|
|
||||||
let soap_binding = binding
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "binding");
|
|
||||||
let soap_namespace = soap_binding
|
|
||||||
.and_then(|node| node.tag_name().namespace())
|
|
||||||
.unwrap_or("http://schemas.xmlsoap.org/wsdl/soap/");
|
|
||||||
let soap_version = if soap_namespace.contains("soap12") {
|
|
||||||
"soap_12"
|
|
||||||
} else {
|
|
||||||
"soap_11"
|
|
||||||
}
|
|
||||||
.to_owned();
|
|
||||||
let binding_style = soap_binding
|
|
||||||
.and_then(|node| node.attribute("style"))
|
|
||||||
.map(|value| match value {
|
|
||||||
"rpc" => "rpc_literal",
|
|
||||||
_ => "document_literal",
|
|
||||||
})
|
|
||||||
.unwrap_or("document_literal")
|
|
||||||
.to_owned();
|
|
||||||
let operations = binding
|
|
||||||
.children()
|
|
||||||
.filter(|node| node.is_element() && node.tag_name().name() == "operation")
|
|
||||||
.map(|operation| SoapOperationSummary {
|
|
||||||
operation_name: operation.attribute("name").unwrap_or_default().to_owned(),
|
|
||||||
soap_action: operation
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "operation")
|
|
||||||
.and_then(|node| node.attribute("soapAction"))
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
binding_style: operation
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "operation")
|
|
||||||
.and_then(|node| node.attribute("style"))
|
|
||||||
.map(|value| match value {
|
|
||||||
"rpc" => "rpc_literal",
|
|
||||||
_ => "document_literal",
|
|
||||||
})
|
|
||||||
.unwrap_or(binding_style.as_str())
|
|
||||||
.to_owned(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(
|
|
||||||
name,
|
|
||||||
BindingSummary {
|
|
||||||
soap_version,
|
|
||||||
operations,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn local_name(value: &str) -> String {
|
|
||||||
value.rsplit(':').next().unwrap_or(value).to_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::inspect_wsdl;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_services_ports_and_operations_from_wsdl() {
|
|
||||||
let services = inspect_wsdl(
|
|
||||||
br#"<?xml version="1.0"?>
|
|
||||||
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
|
|
||||||
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
|
|
||||||
xmlns:tns="urn:crm"
|
|
||||||
targetNamespace="urn:crm">
|
|
||||||
<binding name="LeadBinding" type="tns:LeadPortType">
|
|
||||||
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
|
|
||||||
<operation name="CreateLead">
|
|
||||||
<soap:operation soapAction="urn:createLead"/>
|
|
||||||
</operation>
|
|
||||||
</binding>
|
|
||||||
<service name="LeadService">
|
|
||||||
<port name="LeadPort" binding="tns:LeadBinding">
|
|
||||||
<soap:address location="https://soap.example.com/lead"/>
|
|
||||||
</port>
|
|
||||||
</service>
|
|
||||||
</definitions>"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(services[0].service_name, "LeadService");
|
|
||||||
assert_eq!(services[0].ports[0].port_name, "LeadPort");
|
|
||||||
assert_eq!(services[0].ports[0].soap_version, "soap_11");
|
|
||||||
assert_eq!(
|
|
||||||
services[0].ports[0].operations[0].operation_name,
|
|
||||||
"CreateLead"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
services[0].ports[0].operations[0].soap_action.as_deref(),
|
|
||||||
Some("urn:createLead")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
use crank_core::SoapBindingStyle;
|
|
||||||
use roxmltree::{Document, Node};
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
|
|
||||||
use crate::SoapAdapterError;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct SoapEnvelopeHeader {
|
|
||||||
pub name: String,
|
|
||||||
pub namespace_uri: Option<String>,
|
|
||||||
pub value: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_envelope(
|
|
||||||
operation_name: &str,
|
|
||||||
namespace: Option<&str>,
|
|
||||||
body: &Value,
|
|
||||||
envelope_namespace: &str,
|
|
||||||
binding_style: SoapBindingStyle,
|
|
||||||
headers: &[SoapEnvelopeHeader],
|
|
||||||
) -> String {
|
|
||||||
let mut xml = String::new();
|
|
||||||
xml.push_str(&format!(
|
|
||||||
r#"<soap:Envelope xmlns:soap="{envelope_namespace}">"#
|
|
||||||
));
|
|
||||||
if !headers.is_empty() {
|
|
||||||
xml.push_str("<soap:Header>");
|
|
||||||
for header in headers {
|
|
||||||
append_header(&mut xml, header);
|
|
||||||
}
|
|
||||||
xml.push_str("</soap:Header>");
|
|
||||||
}
|
|
||||||
xml.push_str("<soap:Body>");
|
|
||||||
match namespace {
|
|
||||||
Some(namespace) => xml.push_str(&format!(r#"<m:{operation_name}" xmlns:m="{namespace}">"#)),
|
|
||||||
None => xml.push_str(&format!("<{operation_name}>")),
|
|
||||||
}
|
|
||||||
let child_prefix = match binding_style {
|
|
||||||
SoapBindingStyle::DocumentLiteral => None,
|
|
||||||
SoapBindingStyle::RpcLiteral => namespace.map(|_| "m"),
|
|
||||||
};
|
|
||||||
append_value_children(&mut xml, body, child_prefix);
|
|
||||||
match namespace {
|
|
||||||
Some(_) => xml.push_str(&format!("</m:{operation_name}>")),
|
|
||||||
None => xml.push_str(&format!("</{operation_name}>")),
|
|
||||||
}
|
|
||||||
xml.push_str("</soap:Body></soap:Envelope>");
|
|
||||||
xml
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_envelope(payload: &str) -> Result<Value, SoapAdapterError> {
|
|
||||||
let document = Document::parse(payload)?;
|
|
||||||
let body = document
|
|
||||||
.descendants()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "Body")
|
|
||||||
.ok_or(SoapAdapterError::MissingBody)?;
|
|
||||||
|
|
||||||
let body_child = body
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element())
|
|
||||||
.ok_or(SoapAdapterError::EmptyBody)?;
|
|
||||||
|
|
||||||
if body_child.tag_name().name() == "Fault" {
|
|
||||||
return Err(parse_fault(body_child));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(node_to_json(body_child, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_fault(fault: Node<'_, '_>) -> SoapAdapterError {
|
|
||||||
let code = find_nested_text(fault, &["Code", "Value"])
|
|
||||||
.or_else(|| find_nested_text(fault, &["faultcode"]));
|
|
||||||
let message = find_nested_text(fault, &["Reason", "Text"])
|
|
||||||
.or_else(|| find_nested_text(fault, &["faultstring"]))
|
|
||||||
.unwrap_or_else(|| "SOAP fault".to_owned());
|
|
||||||
let detail = fault
|
|
||||||
.children()
|
|
||||||
.find(|node| node.is_element() && node.tag_name().name() == "Detail")
|
|
||||||
.map(|node| node_to_json(node, false));
|
|
||||||
|
|
||||||
SoapAdapterError::SoapFault {
|
|
||||||
code,
|
|
||||||
message,
|
|
||||||
detail,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_header(xml: &mut String, header: &SoapEnvelopeHeader) {
|
|
||||||
match header.namespace_uri.as_deref() {
|
|
||||||
Some(namespace_uri) => {
|
|
||||||
xml.push_str(&format!(
|
|
||||||
r#"<h:{} xmlns:h="{}">"#,
|
|
||||||
header.name, namespace_uri
|
|
||||||
));
|
|
||||||
append_header_value(xml, &header.value);
|
|
||||||
xml.push_str(&format!("</h:{}>", header.name));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
xml.push('<');
|
|
||||||
xml.push_str(&header.name);
|
|
||||||
xml.push('>');
|
|
||||||
append_header_value(xml, &header.value);
|
|
||||||
xml.push_str("</");
|
|
||||||
xml.push_str(&header.name);
|
|
||||||
xml.push('>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_header_value(xml: &mut String, value: &Value) {
|
|
||||||
match value {
|
|
||||||
Value::Null => {}
|
|
||||||
Value::Bool(value) => xml.push_str(if *value { "true" } else { "false" }),
|
|
||||||
Value::Number(value) => xml.push_str(&value.to_string()),
|
|
||||||
Value::String(value) => xml.push_str(&escape_xml(value)),
|
|
||||||
Value::Array(_) | Value::Object(_) => append_value_children(xml, value, None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_nested_text(node: Node<'_, '_>, path: &[&str]) -> Option<String> {
|
|
||||||
let mut current = node;
|
|
||||||
for segment in path {
|
|
||||||
current = current
|
|
||||||
.children()
|
|
||||||
.find(|child| child.is_element() && child.tag_name().name() == *segment)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
current
|
|
||||||
.text()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_value_children(xml: &mut String, value: &Value, namespace_prefix: Option<&str>) {
|
|
||||||
match value {
|
|
||||||
Value::Object(object) => {
|
|
||||||
for (key, value) in object {
|
|
||||||
append_named_value(xml, key, value, namespace_prefix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => append_named_value(xml, "value", other, namespace_prefix),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_named_value(xml: &mut String, name: &str, value: &Value, namespace_prefix: Option<&str>) {
|
|
||||||
match value {
|
|
||||||
Value::Array(items) => {
|
|
||||||
for item in items {
|
|
||||||
append_named_value(xml, name, item, namespace_prefix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::Object(object) => {
|
|
||||||
push_start_tag(xml, name, namespace_prefix);
|
|
||||||
for (child_name, child_value) in object {
|
|
||||||
append_named_value(xml, child_name, child_value, namespace_prefix);
|
|
||||||
}
|
|
||||||
push_end_tag(xml, name, namespace_prefix);
|
|
||||||
}
|
|
||||||
Value::Null => {
|
|
||||||
push_empty_tag(xml, name, namespace_prefix);
|
|
||||||
}
|
|
||||||
Value::Bool(value) => {
|
|
||||||
push_start_tag(xml, name, namespace_prefix);
|
|
||||||
xml.push_str(if *value { "true" } else { "false" });
|
|
||||||
push_end_tag(xml, name, namespace_prefix);
|
|
||||||
}
|
|
||||||
Value::Number(value) => {
|
|
||||||
push_start_tag(xml, name, namespace_prefix);
|
|
||||||
xml.push_str(&value.to_string());
|
|
||||||
push_end_tag(xml, name, namespace_prefix);
|
|
||||||
}
|
|
||||||
Value::String(value) => {
|
|
||||||
push_start_tag(xml, name, namespace_prefix);
|
|
||||||
xml.push_str(&escape_xml(value));
|
|
||||||
push_end_tag(xml, name, namespace_prefix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_start_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) {
|
|
||||||
xml.push('<');
|
|
||||||
if let Some(prefix) = namespace_prefix {
|
|
||||||
xml.push_str(prefix);
|
|
||||||
xml.push(':');
|
|
||||||
}
|
|
||||||
xml.push_str(name);
|
|
||||||
xml.push('>');
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_end_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) {
|
|
||||||
xml.push_str("</");
|
|
||||||
if let Some(prefix) = namespace_prefix {
|
|
||||||
xml.push_str(prefix);
|
|
||||||
xml.push(':');
|
|
||||||
}
|
|
||||||
xml.push_str(name);
|
|
||||||
xml.push('>');
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_empty_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) {
|
|
||||||
xml.push('<');
|
|
||||||
if let Some(prefix) = namespace_prefix {
|
|
||||||
xml.push_str(prefix);
|
|
||||||
xml.push(':');
|
|
||||||
}
|
|
||||||
xml.push_str(name);
|
|
||||||
xml.push_str("/>");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn escape_xml(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.replace('&', "&")
|
|
||||||
.replace('<', "<")
|
|
||||||
.replace('>', ">")
|
|
||||||
.replace('"', """)
|
|
||||||
.replace('\'', "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn node_to_json(node: Node<'_, '_>, _unwrap_root: bool) -> Value {
|
|
||||||
let children = node
|
|
||||||
.children()
|
|
||||||
.filter(|child| child.is_element())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
if children.is_empty() {
|
|
||||||
return node
|
|
||||||
.text()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(|value| Value::String(value.to_owned()))
|
|
||||||
.unwrap_or(Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut object = Map::new();
|
|
||||||
for child in children {
|
|
||||||
let key = child.tag_name().name().to_owned();
|
|
||||||
let value = node_to_json(child, false);
|
|
||||||
match object.get_mut(&key) {
|
|
||||||
Some(existing) => match existing {
|
|
||||||
Value::Array(array) => array.push(value),
|
|
||||||
other => {
|
|
||||||
let previous = other.take();
|
|
||||||
*other = Value::Array(vec![previous, value]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
object.insert(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Value::Object(object)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
|
|
||||||
use super::{SoapEnvelopeHeader, build_envelope, decode_envelope};
|
|
||||||
use crank_core::SoapBindingStyle;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builds_document_literal_envelope() {
|
|
||||||
let xml = build_envelope(
|
|
||||||
"CreateLead",
|
|
||||||
Some("urn:crm"),
|
|
||||||
&json!({"email":"user@example.com","source":"mcp"}),
|
|
||||||
"http://schemas.xmlsoap.org/soap/envelope/",
|
|
||||||
SoapBindingStyle::DocumentLiteral,
|
|
||||||
&[],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(xml.contains("<soap:Envelope"));
|
|
||||||
assert!(xml.contains("<m:CreateLead"));
|
|
||||||
assert!(xml.contains(r#"xmlns:m="urn:crm""#));
|
|
||||||
assert!(xml.contains("<email>user@example.com</email>"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decodes_wrapped_response_body() {
|
|
||||||
let value = decode_envelope(
|
|
||||||
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
|
||||||
<soap:Body>
|
|
||||||
<CreateLeadResponse>
|
|
||||||
<id>lead_123</id>
|
|
||||||
<status>created</status>
|
|
||||||
</CreateLeadResponse>
|
|
||||||
</soap:Body>
|
|
||||||
</soap:Envelope>"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(value["id"], "lead_123");
|
|
||||||
assert_eq!(value["status"], "created");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builds_rpc_literal_envelope_with_headers() {
|
|
||||||
let xml = build_envelope(
|
|
||||||
"CreateLead",
|
|
||||||
Some("urn:crm"),
|
|
||||||
&json!({"email":"user@example.com"}),
|
|
||||||
"http://schemas.xmlsoap.org/soap/envelope/",
|
|
||||||
SoapBindingStyle::RpcLiteral,
|
|
||||||
&[SoapEnvelopeHeader {
|
|
||||||
name: "CorrelationId".to_owned(),
|
|
||||||
namespace_uri: Some("urn:headers".to_owned()),
|
|
||||||
value: Value::String("corr-123".to_owned()),
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(xml.contains("<soap:Header>"));
|
|
||||||
assert!(
|
|
||||||
xml.contains(r#"<h:CorrelationId xmlns:h="urn:headers">corr-123</h:CorrelationId>"#)
|
|
||||||
);
|
|
||||||
assert!(xml.contains("<m:email>user@example.com</m:email>"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "crank-adapter-websocket"
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
crank-core = { path = "../crank-core" }
|
|
||||||
futures-util = "0.3"
|
|
||||||
reqwest.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
tokio = { workspace = true, features = ["net", "time"] }
|
|
||||||
tokio-tungstenite.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] }
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
use std::{collections::BTreeMap, time::Duration};
|
|
||||||
|
|
||||||
use crank_core::WebsocketTarget;
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
|
||||||
use serde_json::Value;
|
|
||||||
use tokio::time::{Instant, sleep};
|
|
||||||
use tokio_tungstenite::{
|
|
||||||
MaybeTlsStream, WebSocketStream, connect_async,
|
|
||||||
tungstenite::{
|
|
||||||
Error as TungsteniteError, Message,
|
|
||||||
client::IntoClientRequest,
|
|
||||||
protocol::{CloseFrame, frame::coding::CloseCode},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
HeartbeatPolicy, ReconnectPolicy, WebsocketAdapterError, WebsocketWindowRequest,
|
|
||||||
WebsocketWindowResponse,
|
|
||||||
};
|
|
||||||
|
|
||||||
enum WindowCollectionStatus {
|
|
||||||
WindowExpired,
|
|
||||||
MaxItemsReached,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct WebsocketAdapter;
|
|
||||||
|
|
||||||
impl Default for WebsocketAdapter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WebsocketAdapter {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn execute_window(
|
|
||||||
&self,
|
|
||||||
target: &WebsocketTarget,
|
|
||||||
request: &WebsocketWindowRequest,
|
|
||||||
) -> Result<WebsocketWindowResponse, WebsocketAdapterError> {
|
|
||||||
let started_at = Instant::now();
|
|
||||||
let deadline = started_at + Duration::from_millis(request.window_duration_ms);
|
|
||||||
let heartbeat = request
|
|
||||||
.heartbeat_interval_ms
|
|
||||||
.map(Duration::from_millis)
|
|
||||||
.map(|interval| crate::HeartbeatPolicy { interval });
|
|
||||||
let reconnect = ReconnectPolicy {
|
|
||||||
max_attempts: request.reconnect_max_attempts,
|
|
||||||
backoff: Duration::from_millis(request.reconnect_backoff_ms),
|
|
||||||
};
|
|
||||||
let mut attempts = 0_u32;
|
|
||||||
let mut items = Vec::new();
|
|
||||||
let mut connected_headers = BTreeMap::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let (mut stream, headers) = connect_websocket(target, request).await?;
|
|
||||||
if connected_headers.is_empty() {
|
|
||||||
connected_headers = headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
send_subscribe_message(&mut stream, target).await?;
|
|
||||||
|
|
||||||
let status = match collect_window(
|
|
||||||
&mut stream,
|
|
||||||
request.max_items,
|
|
||||||
deadline,
|
|
||||||
heartbeat.as_ref(),
|
|
||||||
&mut items,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(status) => status,
|
|
||||||
Err(WebsocketAdapterError::ClosedEarly) => {
|
|
||||||
if attempts >= reconnect.max_attempts {
|
|
||||||
return Err(WebsocketAdapterError::ReconnectExhausted);
|
|
||||||
}
|
|
||||||
attempts = attempts.saturating_add(1);
|
|
||||||
reconnect_if_needed(&reconnect, attempts).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
};
|
|
||||||
|
|
||||||
match status {
|
|
||||||
WindowCollectionStatus::WindowExpired => {
|
|
||||||
send_unsubscribe_message(&mut stream, target).await?;
|
|
||||||
return Ok(WebsocketWindowResponse {
|
|
||||||
status_code: 101,
|
|
||||||
headers: connected_headers,
|
|
||||||
body: serde_json::json!({
|
|
||||||
"items": items,
|
|
||||||
"done": false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
WindowCollectionStatus::MaxItemsReached => {
|
|
||||||
send_unsubscribe_message(&mut stream, target).await?;
|
|
||||||
return Ok(WebsocketWindowResponse {
|
|
||||||
status_code: 101,
|
|
||||||
headers: connected_headers,
|
|
||||||
body: serde_json::json!({
|
|
||||||
"items": items,
|
|
||||||
"done": true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
|
||||||
|
|
||||||
pub async fn connect_websocket(
|
|
||||||
target: &WebsocketTarget,
|
|
||||||
request: &WebsocketWindowRequest,
|
|
||||||
) -> Result<(WsStream, BTreeMap<String, String>), WebsocketAdapterError> {
|
|
||||||
let mut client_request = target.url.as_str().into_client_request().map_err(|_| {
|
|
||||||
WebsocketAdapterError::InvalidUrl {
|
|
||||||
url: target.url.clone(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let headers = build_headers(target, request)?;
|
|
||||||
for (name, value) in &headers {
|
|
||||||
client_request
|
|
||||||
.headers_mut()
|
|
||||||
.insert(name.clone(), value.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if !target.subprotocols.is_empty() {
|
|
||||||
let value = target.subprotocols.join(", ");
|
|
||||||
let header_value = HeaderValue::from_str(&value)
|
|
||||||
.map_err(|_| WebsocketAdapterError::InvalidSubprotocol { value })?;
|
|
||||||
client_request
|
|
||||||
.headers_mut()
|
|
||||||
.insert("Sec-WebSocket-Protocol", header_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (stream, response) = connect_async(client_request).await?;
|
|
||||||
let response_headers = response
|
|
||||||
.headers()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(name, value)| {
|
|
||||||
value
|
|
||||||
.to_str()
|
|
||||||
.ok()
|
|
||||||
.map(|value| (name.as_str().to_owned(), value.to_owned()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok((stream, response_headers))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_subscribe_message(
|
|
||||||
stream: &mut WsStream,
|
|
||||||
target: &WebsocketTarget,
|
|
||||||
) -> Result<(), WebsocketAdapterError> {
|
|
||||||
let Some(payload) = target.subscribe_message_template.as_ref() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = serde_json::to_string(payload)
|
|
||||||
.map_err(|_| WebsocketAdapterError::InvalidSubscribePayload)?;
|
|
||||||
stream.send(Message::Text(message.into())).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_unsubscribe_message(
|
|
||||||
stream: &mut WsStream,
|
|
||||||
target: &WebsocketTarget,
|
|
||||||
) -> Result<(), WebsocketAdapterError> {
|
|
||||||
let Some(payload) = target.unsubscribe_message_template.as_ref() else {
|
|
||||||
let _ = stream
|
|
||||||
.close(Some(CloseFrame {
|
|
||||||
code: CloseCode::Normal,
|
|
||||||
reason: "completed".into(),
|
|
||||||
}))
|
|
||||||
.await;
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let message = serde_json::to_string(payload)
|
|
||||||
.map_err(|_| WebsocketAdapterError::InvalidUnsubscribePayload)?;
|
|
||||||
if let Err(error) = stream.send(Message::Text(message.into())).await {
|
|
||||||
if !matches!(
|
|
||||||
error,
|
|
||||||
TungsteniteError::ConnectionClosed
|
|
||||||
| TungsteniteError::AlreadyClosed
|
|
||||||
| TungsteniteError::Protocol(
|
|
||||||
tokio_tungstenite::tungstenite::error::ProtocolError::SendAfterClosing
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return Err(error.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = stream
|
|
||||||
.close(Some(CloseFrame {
|
|
||||||
code: CloseCode::Normal,
|
|
||||||
reason: "completed".into(),
|
|
||||||
}))
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn collect_window(
|
|
||||||
stream: &mut WsStream,
|
|
||||||
max_items: Option<u32>,
|
|
||||||
deadline: Instant,
|
|
||||||
heartbeat: Option<&HeartbeatPolicy>,
|
|
||||||
items: &mut Vec<Value>,
|
|
||||||
) -> Result<WindowCollectionStatus, WebsocketAdapterError> {
|
|
||||||
let mut heartbeat_deadline = heartbeat.map(|policy| Instant::now() + policy.interval);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
return Ok(WindowCollectionStatus::WindowExpired);
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Instant::now();
|
|
||||||
let next_tick = heartbeat_deadline.unwrap_or(deadline);
|
|
||||||
let sleep_until = std::cmp::min(next_tick, deadline);
|
|
||||||
let wait = sleep_until.saturating_duration_since(now);
|
|
||||||
let timer = sleep(wait);
|
|
||||||
tokio::pin!(timer);
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = &mut timer => {
|
|
||||||
if heartbeat_deadline.is_some_and(|value| value <= Instant::now()) {
|
|
||||||
heartbeat_tick(stream).await?;
|
|
||||||
heartbeat_deadline = heartbeat.map(|policy| Instant::now() + policy.interval);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(WindowCollectionStatus::WindowExpired);
|
|
||||||
}
|
|
||||||
frame = read_next_frame(stream) => {
|
|
||||||
match frame? {
|
|
||||||
Some(value) => {
|
|
||||||
items.push(value);
|
|
||||||
if max_items.is_some_and(|limit| items.len() as u32 >= limit) {
|
|
||||||
return Ok(WindowCollectionStatus::MaxItemsReached);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => return Err(WebsocketAdapterError::ClosedEarly),
|
|
||||||
}
|
|
||||||
heartbeat_deadline = heartbeat.map(|policy| Instant::now() + policy.interval);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_next_frame(
|
|
||||||
stream: &mut WsStream,
|
|
||||||
) -> Result<Option<Value>, WebsocketAdapterError> {
|
|
||||||
loop {
|
|
||||||
let Some(frame) = stream.next().await else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
match frame? {
|
|
||||||
Message::Text(text) => return Ok(Some(decode_text_frame(text.as_ref())?)),
|
|
||||||
Message::Binary(_) => return Err(WebsocketAdapterError::InvalidFramePayload),
|
|
||||||
Message::Ping(payload) => {
|
|
||||||
stream.send(Message::Pong(payload)).await?;
|
|
||||||
}
|
|
||||||
Message::Pong(_) => {}
|
|
||||||
Message::Frame(_) => {}
|
|
||||||
Message::Close(_) => return Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_text_frame(text: &str) -> Result<Value, WebsocketAdapterError> {
|
|
||||||
serde_json::from_str(text).or_else(|_| Ok(Value::String(text.to_owned())))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn heartbeat_tick(stream: &mut WsStream) -> Result<(), WebsocketAdapterError> {
|
|
||||||
stream.send(Message::Ping(Vec::new().into())).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reconnect_if_needed(policy: &ReconnectPolicy, attempts: u32) {
|
|
||||||
if attempts == 0 || policy.backoff.is_zero() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep(policy.backoff).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_headers(
|
|
||||||
target: &WebsocketTarget,
|
|
||||||
request: &WebsocketWindowRequest,
|
|
||||||
) -> Result<HeaderMap, WebsocketAdapterError> {
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
|
|
||||||
for (name, value) in target.static_headers.iter().chain(request.headers.iter()) {
|
|
||||||
let header_name = HeaderName::try_from(name.as_str()).map_err(|_| {
|
|
||||||
WebsocketAdapterError::InvalidHeaderName {
|
|
||||||
header: name.clone(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let header_value = HeaderValue::try_from(value.as_str()).map_err(|_| {
|
|
||||||
WebsocketAdapterError::InvalidHeaderValue {
|
|
||||||
header: name.clone(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
headers.insert(header_name, header_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::{collections::BTreeMap, sync::Arc};
|
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use tokio::{net::TcpListener, sync::Mutex};
|
|
||||||
use tokio_tungstenite::{
|
|
||||||
accept_hdr_async,
|
|
||||||
tungstenite::handshake::server::{Request, Response},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{WebsocketAdapter, WebsocketWindowRequest};
|
|
||||||
use crank_core::WebsocketTarget;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn collects_window_messages_and_sends_subscribe_payload() {
|
|
||||||
let received = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let target_url = spawn_server(received.clone(), false).await;
|
|
||||||
let adapter = WebsocketAdapter::new();
|
|
||||||
let target = WebsocketTarget {
|
|
||||||
url: target_url,
|
|
||||||
subprotocols: vec!["events.v1".to_owned()],
|
|
||||||
subscribe_message_template: Some(json!({"type":"subscribe","topic":"metrics"})),
|
|
||||||
unsubscribe_message_template: Some(json!({"type":"unsubscribe"})),
|
|
||||||
static_headers: BTreeMap::from([("x-test-env".to_owned(), "ci".to_owned())]),
|
|
||||||
};
|
|
||||||
let response = adapter
|
|
||||||
.execute_window(
|
|
||||||
&target,
|
|
||||||
&WebsocketWindowRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
window_duration_ms: 1_000,
|
|
||||||
max_items: Some(3),
|
|
||||||
heartbeat_interval_ms: None,
|
|
||||||
reconnect_max_attempts: 0,
|
|
||||||
reconnect_backoff_ms: 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.status_code, 101);
|
|
||||||
assert_eq!(response.body["items"].as_array().unwrap().len(), 3);
|
|
||||||
assert_eq!(response.body["items"][0]["seq"], 1);
|
|
||||||
|
|
||||||
let received = received.lock().await.clone();
|
|
||||||
assert!(
|
|
||||||
received
|
|
||||||
.iter()
|
|
||||||
.any(|value| value == &json!({"type":"subscribe","topic":"metrics"}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconnects_when_socket_closes_early() {
|
|
||||||
let received = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let target_url = spawn_server(received, true).await;
|
|
||||||
let adapter = WebsocketAdapter::new();
|
|
||||||
let target = WebsocketTarget {
|
|
||||||
url: target_url,
|
|
||||||
subprotocols: Vec::new(),
|
|
||||||
subscribe_message_template: None,
|
|
||||||
unsubscribe_message_template: None,
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
};
|
|
||||||
let response = adapter
|
|
||||||
.execute_window(
|
|
||||||
&target,
|
|
||||||
&WebsocketWindowRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
window_duration_ms: 1_000,
|
|
||||||
max_items: Some(3),
|
|
||||||
heartbeat_interval_ms: None,
|
|
||||||
reconnect_max_attempts: 2,
|
|
||||||
reconnect_backoff_ms: 10,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.body["items"].as_array().unwrap().len(), 3);
|
|
||||||
assert_eq!(response.body["items"][2]["seq"], 3);
|
|
||||||
assert_eq!(response.body["done"], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconnects_after_partial_close_without_marking_done_early() {
|
|
||||||
let target_url = spawn_partial_close_server().await;
|
|
||||||
let adapter = WebsocketAdapter::new();
|
|
||||||
let target = WebsocketTarget {
|
|
||||||
url: target_url,
|
|
||||||
subprotocols: Vec::new(),
|
|
||||||
subscribe_message_template: None,
|
|
||||||
unsubscribe_message_template: None,
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
};
|
|
||||||
let response = adapter
|
|
||||||
.execute_window(
|
|
||||||
&target,
|
|
||||||
&WebsocketWindowRequest {
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
window_duration_ms: 1_000,
|
|
||||||
max_items: Some(3),
|
|
||||||
heartbeat_interval_ms: None,
|
|
||||||
reconnect_max_attempts: 2,
|
|
||||||
reconnect_backoff_ms: 10,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.body["items"].as_array().unwrap().len(), 3);
|
|
||||||
assert_eq!(response.body["items"][0]["seq"], 1);
|
|
||||||
assert_eq!(response.body["items"][2]["seq"], 3);
|
|
||||||
assert_eq!(response.body["done"], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn_server(received: Arc<Mutex<Vec<Value>>>, close_early: bool) -> String {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut accepted = 0_u32;
|
|
||||||
loop {
|
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
|
||||||
let received = received.clone();
|
|
||||||
accepted = accepted.saturating_add(1);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let websocket =
|
|
||||||
accept_hdr_async(stream, |request: &Request, mut response: Response| {
|
|
||||||
if let Some(value) = request.headers().get("x-test-env") {
|
|
||||||
assert_eq!(value, "ci");
|
|
||||||
}
|
|
||||||
if let Some(value) = request.headers().get("sec-websocket-protocol") {
|
|
||||||
response
|
|
||||||
.headers_mut()
|
|
||||||
.insert("sec-websocket-protocol", value.clone());
|
|
||||||
}
|
|
||||||
Ok(response)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let (mut sink, mut source) = websocket.split();
|
|
||||||
let mut sent = 0_u32;
|
|
||||||
|
|
||||||
if !close_early {
|
|
||||||
if let Some(message) = source.next().await {
|
|
||||||
let message = message.unwrap();
|
|
||||||
if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
|
|
||||||
if let Ok(value) = serde_json::from_str::<Value>(&text) {
|
|
||||||
received.lock().await.push(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloads = if close_early && accepted == 1 {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
vec![json!({"seq": 1}), json!({"seq": 2}), json!({"seq": 3})]
|
|
||||||
};
|
|
||||||
|
|
||||||
for payload in payloads {
|
|
||||||
sink.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
||||||
payload.to_string().into(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
sent += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if sent < 3 {
|
|
||||||
let _ = sink.close().await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
format!("ws://{}", addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spawn_partial_close_server() -> String {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut accepted = 0_u32;
|
|
||||||
loop {
|
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
|
||||||
accepted = accepted.saturating_add(1);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let websocket =
|
|
||||||
accept_hdr_async(stream, |_request: &Request, response: Response| {
|
|
||||||
Ok(response)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let (mut sink, _source) = websocket.split();
|
|
||||||
let payloads = if accepted == 1 {
|
|
||||||
vec![json!({"seq": 1})]
|
|
||||||
} else {
|
|
||||||
vec![json!({"seq": 2}), json!({"seq": 3})]
|
|
||||||
};
|
|
||||||
|
|
||||||
for payload in payloads {
|
|
||||||
sink.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
||||||
payload.to_string().into(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = sink.close().await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
format!("ws://{}", addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
use serde_json::Value;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum WebsocketAdapterError {
|
|
||||||
#[error("invalid websocket url: {url}")]
|
|
||||||
InvalidUrl { url: String },
|
|
||||||
#[error("invalid websocket header name {header}")]
|
|
||||||
InvalidHeaderName { header: String },
|
|
||||||
#[error("invalid websocket header value for {header}")]
|
|
||||||
InvalidHeaderValue { header: String },
|
|
||||||
#[error("invalid websocket subprotocol {value}")]
|
|
||||||
InvalidSubprotocol { value: String },
|
|
||||||
#[error("websocket connect failed")]
|
|
||||||
Connect(#[from] tokio_tungstenite::tungstenite::Error),
|
|
||||||
#[error("websocket window expired before collecting any items")]
|
|
||||||
WindowExpired,
|
|
||||||
#[error("websocket stream produced malformed frame payload")]
|
|
||||||
InvalidFramePayload,
|
|
||||||
#[error("websocket endpoint returned close frame before collection completed")]
|
|
||||||
ClosedEarly,
|
|
||||||
#[error("websocket reconnect policy exhausted")]
|
|
||||||
ReconnectExhausted,
|
|
||||||
#[error("websocket upstream returned invalid subscribe payload")]
|
|
||||||
InvalidSubscribePayload,
|
|
||||||
#[error("websocket upstream returned invalid unsubscribe payload")]
|
|
||||||
InvalidUnsubscribePayload,
|
|
||||||
#[error("websocket upstream status {status}")]
|
|
||||||
UnexpectedStatus { status: u16, body: Value },
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
mod client;
|
|
||||||
mod error;
|
|
||||||
mod session;
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub use client::WebsocketAdapter;
|
|
||||||
pub use error::WebsocketAdapterError;
|
|
||||||
pub use session::{HeartbeatPolicy, ReconnectPolicy};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
|
||||||
pub struct WebsocketWindowRequest {
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub window_duration_ms: u64,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_items: Option<u32>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub heartbeat_interval_ms: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub reconnect_max_attempts: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub reconnect_backoff_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct WebsocketWindowResponse {
|
|
||||||
pub status_code: u16,
|
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
pub body: Value,
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ReconnectPolicy {
|
|
||||||
pub max_attempts: u32,
|
|
||||||
pub backoff: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ReconnectPolicy {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_attempts: 0,
|
|
||||||
backoff: Duration::from_millis(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct HeartbeatPolicy {
|
|
||||||
pub interval: Duration,
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "crank-proto"
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
crank-core = { path = "../crank-core" }
|
|
||||||
crank-schema = { path = "../crank-schema" }
|
|
||||||
prost.workspace = true
|
|
||||||
prost-reflect.workspace = true
|
|
||||||
prost-types.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod to_schema;
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use crank_schema::{Schema, SchemaKind};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
errors::ProtoError,
|
|
||||||
model::{
|
|
||||||
ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoMessage,
|
|
||||||
ProtoOneof, ProtoScalarKind,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn message_to_schema(message: &ProtoMessage) -> Result<Schema, ProtoError> {
|
|
||||||
let mut fields = BTreeMap::new();
|
|
||||||
|
|
||||||
for field in &message.fields {
|
|
||||||
let (name, schema) = convert_field(field)?;
|
|
||||||
fields.insert(name, schema);
|
|
||||||
}
|
|
||||||
|
|
||||||
for oneof in &message.oneofs {
|
|
||||||
let (name, schema) = convert_oneof(oneof)?;
|
|
||||||
fields.insert(name, schema);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(object_schema(fields))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_field(field: &ProtoField) -> Result<(String, Schema), ProtoError> {
|
|
||||||
let mut schema = convert_field_type(&field.field_type)?;
|
|
||||||
|
|
||||||
schema.required = matches!(field.cardinality, ProtoFieldCardinality::Required);
|
|
||||||
schema.nullable = false;
|
|
||||||
|
|
||||||
if matches!(field.cardinality, ProtoFieldCardinality::Repeated) {
|
|
||||||
schema = Schema {
|
|
||||||
kind: SchemaKind::Array,
|
|
||||||
description: None,
|
|
||||||
required: false,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::new(),
|
|
||||||
items: Some(Box::new(schema)),
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((field.name.clone(), schema))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_field_type(field_type: &ProtoFieldType) -> Result<Schema, ProtoError> {
|
|
||||||
match field_type {
|
|
||||||
ProtoFieldType::Scalar { scalar } => Ok(scalar_schema(scalar)),
|
|
||||||
ProtoFieldType::Message { message } => message_to_schema(message),
|
|
||||||
ProtoFieldType::Enum { enumeration } => Ok(enum_schema(enumeration)),
|
|
||||||
ProtoFieldType::Map { map } => convert_map(map),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_map(map: &ProtoMapField) -> Result<Schema, ProtoError> {
|
|
||||||
let entry = object_schema(BTreeMap::from([
|
|
||||||
("key".to_owned(), scalar_schema(&map.key)),
|
|
||||||
("value".to_owned(), convert_field_type(&map.value)?),
|
|
||||||
]));
|
|
||||||
|
|
||||||
Ok(Schema {
|
|
||||||
kind: SchemaKind::Array,
|
|
||||||
description: None,
|
|
||||||
required: false,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::new(),
|
|
||||||
items: Some(Box::new(entry)),
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_oneof(oneof: &ProtoOneof) -> Result<(String, Schema), ProtoError> {
|
|
||||||
if oneof.variants.is_empty() {
|
|
||||||
return Err(ProtoError::EmptyOneof {
|
|
||||||
oneof_name: oneof.name.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut variants = Vec::with_capacity(oneof.variants.len());
|
|
||||||
|
|
||||||
for field in &oneof.variants {
|
|
||||||
let (field_name, field_schema) = convert_field(field)?;
|
|
||||||
variants.push(object_schema(BTreeMap::from([(field_name, field_schema)])));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
oneof.name.clone(),
|
|
||||||
Schema {
|
|
||||||
kind: SchemaKind::Oneof,
|
|
||||||
description: None,
|
|
||||||
required: false,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::new(),
|
|
||||||
items: None,
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn object_schema(fields: BTreeMap<String, Schema>) -> Schema {
|
|
||||||
Schema {
|
|
||||||
kind: SchemaKind::Object,
|
|
||||||
description: None,
|
|
||||||
required: true,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields,
|
|
||||||
items: None,
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enum_schema(enumeration: &ProtoEnum) -> Schema {
|
|
||||||
Schema {
|
|
||||||
kind: SchemaKind::Enum,
|
|
||||||
description: None,
|
|
||||||
required: false,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::new(),
|
|
||||||
items: None,
|
|
||||||
enum_values: enumeration.values.clone(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scalar_schema(kind: &ProtoScalarKind) -> Schema {
|
|
||||||
let schema_kind = match kind {
|
|
||||||
ProtoScalarKind::String | ProtoScalarKind::Bytes => SchemaKind::String,
|
|
||||||
ProtoScalarKind::Bool => SchemaKind::Boolean,
|
|
||||||
ProtoScalarKind::Int32
|
|
||||||
| ProtoScalarKind::Int64
|
|
||||||
| ProtoScalarKind::Uint32
|
|
||||||
| ProtoScalarKind::Uint64 => SchemaKind::Integer,
|
|
||||||
ProtoScalarKind::Float | ProtoScalarKind::Double => SchemaKind::Number,
|
|
||||||
};
|
|
||||||
|
|
||||||
Schema {
|
|
||||||
kind: schema_kind,
|
|
||||||
description: None,
|
|
||||||
required: false,
|
|
||||||
nullable: false,
|
|
||||||
default_value: None,
|
|
||||||
fields: BTreeMap::new(),
|
|
||||||
items: None,
|
|
||||||
enum_values: Vec::new(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crank_schema::SchemaKind;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoMessage,
|
|
||||||
ProtoMethod, ProtoOneof, ProtoScalarKind, ProtoService, message_to_schema,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn service_filters_unary_methods() {
|
|
||||||
let unary = ProtoMethod {
|
|
||||||
name: "GetLead".to_owned(),
|
|
||||||
input: empty_message("GetLeadRequest"),
|
|
||||||
output: empty_message("GetLeadResponse"),
|
|
||||||
client_streaming: false,
|
|
||||||
server_streaming: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let streaming = ProtoMethod {
|
|
||||||
name: "StreamLeads".to_owned(),
|
|
||||||
input: empty_message("StreamLeadsRequest"),
|
|
||||||
output: empty_message("StreamLeadsResponse"),
|
|
||||||
client_streaming: false,
|
|
||||||
server_streaming: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let service = ProtoService {
|
|
||||||
package: "crm.v1".to_owned(),
|
|
||||||
name: "LeadService".to_owned(),
|
|
||||||
methods: vec![unary.clone(), streaming],
|
|
||||||
};
|
|
||||||
|
|
||||||
let unary_methods = service.unary_methods().collect::<Vec<_>>();
|
|
||||||
|
|
||||||
assert_eq!(unary_methods.len(), 1);
|
|
||||||
assert_eq!(unary_methods[0].name, unary.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_nested_message_to_schema() {
|
|
||||||
let request = ProtoMessage {
|
|
||||||
name: "CreateLeadRequest".to_owned(),
|
|
||||||
fields: vec![ProtoField {
|
|
||||||
name: "lead".to_owned(),
|
|
||||||
json_name: "lead".to_owned(),
|
|
||||||
cardinality: ProtoFieldCardinality::Required,
|
|
||||||
field_type: ProtoFieldType::Message {
|
|
||||||
message: Box::new(ProtoMessage {
|
|
||||||
name: "Lead".to_owned(),
|
|
||||||
fields: vec![ProtoField {
|
|
||||||
name: "email".to_owned(),
|
|
||||||
json_name: "email".to_owned(),
|
|
||||||
cardinality: ProtoFieldCardinality::Required,
|
|
||||||
field_type: ProtoFieldType::Scalar {
|
|
||||||
scalar: ProtoScalarKind::String,
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
oneofs: Vec::new(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
oneofs: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let schema = message_to_schema(&request).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(schema.kind, SchemaKind::Object);
|
|
||||||
assert_eq!(
|
|
||||||
schema.field("lead").map(|field| field.kind.clone()),
|
|
||||||
Some(SchemaKind::Object)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
schema
|
|
||||||
.field("lead")
|
|
||||||
.and_then(|field| field.field("email"))
|
|
||||||
.map(|field| field.kind.clone()),
|
|
||||||
Some(SchemaKind::String)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
schema
|
|
||||||
.field("lead")
|
|
||||||
.and_then(|field| field.field("email"))
|
|
||||||
.map(|field| field.required),
|
|
||||||
Some(true)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn converts_enum_map_and_oneof_contracts() {
|
|
||||||
let response = ProtoMessage {
|
|
||||||
name: "GetLeadResponse".to_owned(),
|
|
||||||
fields: vec![
|
|
||||||
ProtoField {
|
|
||||||
name: "status".to_owned(),
|
|
||||||
json_name: "status".to_owned(),
|
|
||||||
cardinality: ProtoFieldCardinality::Optional,
|
|
||||||
field_type: ProtoFieldType::Enum {
|
|
||||||
enumeration: ProtoEnum {
|
|
||||||
name: "LeadStatus".to_owned(),
|
|
||||||
values: vec!["OPEN".to_owned(), "CLOSED".to_owned()],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ProtoField {
|
|
||||||
name: "attributes".to_owned(),
|
|
||||||
json_name: "attributes".to_owned(),
|
|
||||||
cardinality: ProtoFieldCardinality::Optional,
|
|
||||||
field_type: ProtoFieldType::Map {
|
|
||||||
map: ProtoMapField {
|
|
||||||
key: ProtoScalarKind::String,
|
|
||||||
value: Box::new(ProtoFieldType::Scalar {
|
|
||||||
scalar: ProtoScalarKind::String,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
oneofs: vec![ProtoOneof {
|
|
||||||
name: "contact".to_owned(),
|
|
||||||
variants: vec![
|
|
||||||
ProtoField {
|
|
||||||
name: "email".to_owned(),
|
|
||||||
json_name: "email".to_owned(),
|
|
||||||
cardinality: ProtoFieldCardinality::Optional,
|
|
||||||
field_type: ProtoFieldType::Scalar {
|
|
||||||
scalar: ProtoScalarKind::String,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ProtoField {
|
|
||||||
name: "phone".to_owned(),
|
|
||||||
json_name: "phone".to_owned(),
|
|
||||||
cardinality: ProtoFieldCardinality::Optional,
|
|
||||||
field_type: ProtoFieldType::Scalar {
|
|
||||||
scalar: ProtoScalarKind::String,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let schema = message_to_schema(&response).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
schema.field("status").map(|field| field.kind.clone()),
|
|
||||||
Some(SchemaKind::Enum)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
schema
|
|
||||||
.field("status")
|
|
||||||
.map(|field| field.enum_values.clone())
|
|
||||||
.unwrap(),
|
|
||||||
vec!["OPEN".to_owned(), "CLOSED".to_owned()]
|
|
||||||
);
|
|
||||||
|
|
||||||
let attributes = schema.field("attributes").unwrap();
|
|
||||||
assert_eq!(attributes.kind, SchemaKind::Array);
|
|
||||||
assert_eq!(
|
|
||||||
attributes
|
|
||||||
.items
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|entry| entry.field("key"))
|
|
||||||
.map(|field| field.kind.clone()),
|
|
||||||
Some(SchemaKind::String)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
attributes
|
|
||||||
.items
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|entry| entry.field("value"))
|
|
||||||
.map(|field| field.kind.clone()),
|
|
||||||
Some(SchemaKind::String)
|
|
||||||
);
|
|
||||||
|
|
||||||
let contact = schema.field("contact").unwrap();
|
|
||||||
assert_eq!(contact.kind, SchemaKind::Oneof);
|
|
||||||
assert_eq!(contact.variants.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
contact.variants[0]
|
|
||||||
.field("email")
|
|
||||||
.map(|field| field.kind.clone()),
|
|
||||||
Some(SchemaKind::String)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
contact.variants[1]
|
|
||||||
.field("phone")
|
|
||||||
.map(|field| field.kind.clone()),
|
|
||||||
Some(SchemaKind::String)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_empty_oneof() {
|
|
||||||
let message = ProtoMessage {
|
|
||||||
name: "BrokenMessage".to_owned(),
|
|
||||||
fields: Vec::new(),
|
|
||||||
oneofs: vec![ProtoOneof {
|
|
||||||
name: "selection".to_owned(),
|
|
||||||
variants: Vec::new(),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let error = message_to_schema(&message).unwrap_err();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
error,
|
|
||||||
crate::ProtoError::EmptyOneof {
|
|
||||||
oneof_name: "selection".to_owned(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_message(name: &str) -> ProtoMessage {
|
|
||||||
ProtoMessage {
|
|
||||||
name: name.to_owned(),
|
|
||||||
fields: Vec::new(),
|
|
||||||
oneofs: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
|
||||||
pub enum ProtoError {
|
|
||||||
#[error("oneof {oneof_name} must contain at least one variant")]
|
|
||||||
EmptyOneof { oneof_name: String },
|
|
||||||
#[error("descriptor set could not be decoded")]
|
|
||||||
InvalidDescriptorSet,
|
|
||||||
#[error("descriptor pool could not be built")]
|
|
||||||
InvalidDescriptorPool,
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
pub mod convert;
|
|
||||||
pub mod errors;
|
|
||||||
pub mod model;
|
|
||||||
|
|
||||||
use prost::Message;
|
|
||||||
use prost_reflect::{
|
|
||||||
Cardinality, DescriptorPool, EnumDescriptor, FieldDescriptor, Kind, MessageDescriptor,
|
|
||||||
MethodDescriptor, ServiceDescriptor,
|
|
||||||
};
|
|
||||||
use prost_types::FileDescriptorSet;
|
|
||||||
|
|
||||||
pub use convert::to_schema::message_to_schema;
|
|
||||||
pub use errors::ProtoError;
|
|
||||||
pub use model::{
|
|
||||||
ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoMessage,
|
|
||||||
ProtoMethod, ProtoOneof, ProtoScalarKind, ProtoService,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn services_from_descriptor_set_bytes(bytes: &[u8]) -> Result<Vec<ProtoService>, ProtoError> {
|
|
||||||
let descriptor_set =
|
|
||||||
FileDescriptorSet::decode(bytes).map_err(|_| ProtoError::InvalidDescriptorSet)?;
|
|
||||||
let pool = DescriptorPool::from_file_descriptor_set(descriptor_set)
|
|
||||||
.map_err(|_| ProtoError::InvalidDescriptorPool)?;
|
|
||||||
|
|
||||||
Ok(pool.services().map(service_from_descriptor).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn service_from_descriptor(service: ServiceDescriptor) -> ProtoService {
|
|
||||||
ProtoService {
|
|
||||||
package: service.parent_file().package_name().to_owned(),
|
|
||||||
name: service.name().to_owned(),
|
|
||||||
methods: service.methods().map(method_from_descriptor).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn method_from_descriptor(method: MethodDescriptor) -> ProtoMethod {
|
|
||||||
ProtoMethod {
|
|
||||||
name: method.name().to_owned(),
|
|
||||||
input: message_from_descriptor(method.input()),
|
|
||||||
output: message_from_descriptor(method.output()),
|
|
||||||
client_streaming: method.is_client_streaming(),
|
|
||||||
server_streaming: method.is_server_streaming(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn message_from_descriptor(message: MessageDescriptor) -> ProtoMessage {
|
|
||||||
let fields = message
|
|
||||||
.fields()
|
|
||||||
.filter(|field| field.containing_oneof().is_none())
|
|
||||||
.map(field_from_descriptor)
|
|
||||||
.collect();
|
|
||||||
let oneofs = message
|
|
||||||
.oneofs()
|
|
||||||
.map(|oneof| ProtoOneof {
|
|
||||||
name: oneof.name().to_owned(),
|
|
||||||
variants: oneof.fields().map(field_from_descriptor).collect(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
ProtoMessage {
|
|
||||||
name: message.name().to_owned(),
|
|
||||||
fields,
|
|
||||||
oneofs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_from_descriptor(field: FieldDescriptor) -> ProtoField {
|
|
||||||
let cardinality = if field.is_list() && !field.is_map() {
|
|
||||||
ProtoFieldCardinality::Repeated
|
|
||||||
} else {
|
|
||||||
match field.cardinality() {
|
|
||||||
Cardinality::Optional => ProtoFieldCardinality::Optional,
|
|
||||||
Cardinality::Required => ProtoFieldCardinality::Required,
|
|
||||||
Cardinality::Repeated => ProtoFieldCardinality::Repeated,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ProtoField {
|
|
||||||
name: field.name().to_owned(),
|
|
||||||
json_name: field.json_name().to_owned(),
|
|
||||||
cardinality,
|
|
||||||
field_type: field_type_from_descriptor(field),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_type_from_descriptor(field: FieldDescriptor) -> ProtoFieldType {
|
|
||||||
if field.is_map() {
|
|
||||||
return map_field_from_descriptor(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
match field.kind() {
|
|
||||||
Kind::Bool => scalar_field(ProtoScalarKind::Bool),
|
|
||||||
Kind::String => scalar_field(ProtoScalarKind::String),
|
|
||||||
Kind::Bytes => scalar_field(ProtoScalarKind::Bytes),
|
|
||||||
Kind::Int32 | Kind::Sint32 | Kind::Sfixed32 => scalar_field(ProtoScalarKind::Int32),
|
|
||||||
Kind::Int64 | Kind::Sint64 | Kind::Sfixed64 => scalar_field(ProtoScalarKind::Int64),
|
|
||||||
Kind::Uint32 | Kind::Fixed32 => scalar_field(ProtoScalarKind::Uint32),
|
|
||||||
Kind::Uint64 | Kind::Fixed64 => scalar_field(ProtoScalarKind::Uint64),
|
|
||||||
Kind::Float => scalar_field(ProtoScalarKind::Float),
|
|
||||||
Kind::Double => scalar_field(ProtoScalarKind::Double),
|
|
||||||
Kind::Message(message) => ProtoFieldType::Message {
|
|
||||||
message: Box::new(message_from_descriptor(message)),
|
|
||||||
},
|
|
||||||
Kind::Enum(enumeration) => ProtoFieldType::Enum {
|
|
||||||
enumeration: enum_from_descriptor(enumeration),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_field_from_descriptor(field: FieldDescriptor) -> ProtoFieldType {
|
|
||||||
let message = match field.kind() {
|
|
||||||
Kind::Message(message) => message,
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
let key_field = message.map_entry_key_field();
|
|
||||||
let value_field = message.map_entry_value_field();
|
|
||||||
let key = match field_type_from_descriptor(key_field) {
|
|
||||||
ProtoFieldType::Scalar { scalar } => scalar,
|
|
||||||
_ => ProtoScalarKind::String,
|
|
||||||
};
|
|
||||||
|
|
||||||
ProtoFieldType::Map {
|
|
||||||
map: ProtoMapField {
|
|
||||||
key,
|
|
||||||
value: Box::new(field_type_from_descriptor(value_field)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scalar_field(scalar: ProtoScalarKind) -> ProtoFieldType {
|
|
||||||
ProtoFieldType::Scalar { scalar }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enum_from_descriptor(enumeration: EnumDescriptor) -> ProtoEnum {
|
|
||||||
ProtoEnum {
|
|
||||||
name: enumeration.name().to_owned(),
|
|
||||||
values: enumeration
|
|
||||||
.values()
|
|
||||||
.map(|value| value.name().to_owned())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::model::ProtoMessage;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ProtoScalarKind {
|
|
||||||
String,
|
|
||||||
Bool,
|
|
||||||
Int32,
|
|
||||||
Int64,
|
|
||||||
Uint32,
|
|
||||||
Uint64,
|
|
||||||
Float,
|
|
||||||
Double,
|
|
||||||
Bytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ProtoEnum {
|
|
||||||
pub name: String,
|
|
||||||
pub values: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ProtoMapField {
|
|
||||||
pub key: ProtoScalarKind,
|
|
||||||
pub value: Box<ProtoFieldType>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
pub enum ProtoFieldType {
|
|
||||||
Scalar { scalar: ProtoScalarKind },
|
|
||||||
Message { message: Box<ProtoMessage> },
|
|
||||||
Enum { enumeration: ProtoEnum },
|
|
||||||
Map { map: ProtoMapField },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ProtoFieldCardinality {
|
|
||||||
Optional,
|
|
||||||
Required,
|
|
||||||
Repeated,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ProtoField {
|
|
||||||
pub name: String,
|
|
||||||
pub json_name: String,
|
|
||||||
pub cardinality: ProtoFieldCardinality,
|
|
||||||
pub field_type: ProtoFieldType,
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::model::ProtoField;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ProtoOneof {
|
|
||||||
pub name: String,
|
|
||||||
pub variants: Vec<ProtoField>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ProtoMessage {
|
|
||||||
pub name: String,
|
|
||||||
pub fields: Vec<ProtoField>,
|
|
||||||
pub oneofs: Vec<ProtoOneof>,
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::model::ProtoMessage;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ProtoMethod {
|
|
||||||
pub name: String,
|
|
||||||
pub input: ProtoMessage,
|
|
||||||
pub output: ProtoMessage,
|
|
||||||
pub client_streaming: bool,
|
|
||||||
pub server_streaming: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProtoMethod {
|
|
||||||
pub fn is_unary(&self) -> bool {
|
|
||||||
!self.client_streaming && !self.server_streaming
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
mod field;
|
|
||||||
mod message;
|
|
||||||
mod method;
|
|
||||||
mod service;
|
|
||||||
|
|
||||||
pub use field::{
|
|
||||||
ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoScalarKind,
|
|
||||||
};
|
|
||||||
pub use message::{ProtoMessage, ProtoOneof};
|
|
||||||
pub use method::ProtoMethod;
|
|
||||||
pub use service::ProtoService;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::model::ProtoMethod;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ProtoService {
|
|
||||||
pub package: String,
|
|
||||||
pub name: String,
|
|
||||||
pub methods: Vec<ProtoMethod>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProtoService {
|
|
||||||
pub fn unary_methods(&self) -> impl Iterator<Item = &ProtoMethod> {
|
|
||||||
self.methods.iter().filter(|method| method.is_unary())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -74,8 +74,6 @@ just staging-note-block <domain> <deploy-sha> "codex + operator"
|
|||||||
- `/usage`
|
- `/usage`
|
||||||
- `/workspace-setup`
|
- `/workspace-setup`
|
||||||
- `/settings`
|
- `/settings`
|
||||||
- `/stream-sessions`
|
|
||||||
- `/async-jobs`
|
|
||||||
- `/wizard/`
|
- `/wizard/`
|
||||||
|
|
||||||
Для каждой страницы зафиксировать:
|
Для каждой страницы зафиксировать:
|
||||||
@@ -142,8 +140,6 @@ just staging-note-block <domain> <deploy-sha> "codex + operator"
|
|||||||
- `window` tool;
|
- `window` tool;
|
||||||
- `session` tool;
|
- `session` tool;
|
||||||
- `async_job` tool;
|
- `async_job` tool;
|
||||||
- `/stream-sessions`;
|
|
||||||
- `/async-jobs`.
|
|
||||||
|
|
||||||
Если WebSocket/SOAP upstream на стенде отсутствуют:
|
Если WebSocket/SOAP upstream на стенде отсутствуют:
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
- `crates/crank-core`
|
- `crates/crank-core`
|
||||||
- `crates/crank-schema`
|
- `crates/crank-schema`
|
||||||
- `crates/crank-mapping`
|
- `crates/crank-mapping`
|
||||||
- `crates/crank-proto`
|
|
||||||
- `crates/crank-registry`
|
- `crates/crank-registry`
|
||||||
- `crates/crank-runtime`
|
- `crates/crank-runtime`
|
||||||
- `crates/crank-adapter-rest`
|
- `crates/crank-adapter-rest`
|
||||||
@@ -129,11 +128,9 @@
|
|||||||
|
|
||||||
Основные блокеры:
|
Основные блокеры:
|
||||||
|
|
||||||
1. Root workspace все еще содержит premium adapter crates.
|
1. `admin-api` и `mcp-server` test/dev wiring еще не полностью очищены от legacy streaming/premium references.
|
||||||
2. `admin-api` и `mcp-server` test/dev wiring все еще используют premium crates.
|
2. `workspace-setup`, `settings`, `catalog`, `usage`, `agents` и `i18n` все еще содержат отдельные premium protocol strings и copy.
|
||||||
3. UI build все еще включает premium wizard modules и premium step templates.
|
3. часть docs все еще требует final Community pass после физического удаления premium crates и standalone streaming pages.
|
||||||
4. `workspace-setup`, `settings`, `catalog`, `usage`, `agents` и `i18n` все еще содержат premium protocol surface.
|
|
||||||
5. `examples/` и часть docs все еще содержат premium protocol materials.
|
|
||||||
|
|
||||||
## 6. Порядок очистки перед public import
|
## 6. Порядок очистки перед public import
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,6 @@ curl -I https://<domain>/html/wizard/
|
|||||||
- `/usage`
|
- `/usage`
|
||||||
- `/workspace-setup`
|
- `/workspace-setup`
|
||||||
- `/settings`
|
- `/settings`
|
||||||
- `/stream-sessions`
|
|
||||||
- `/async-jobs`
|
|
||||||
- `/wizard/`
|
- `/wizard/`
|
||||||
|
|
||||||
Для каждой страницы:
|
Для каждой страницы:
|
||||||
@@ -209,9 +207,6 @@ Smoke считается успешным, если:
|
|||||||
На deployed стенде проверить:
|
На deployed стенде проверить:
|
||||||
|
|
||||||
- `window` tool выполняется и не возвращает бесконечный поток;
|
- `window` tool выполняется и не возвращает бесконечный поток;
|
||||||
- `session` tool создает записи в `/stream-sessions`;
|
|
||||||
- `async_job` tool создает записи в `/async-jobs`;
|
|
||||||
- UI pages `/stream-sessions` и `/async-jobs` не пустые после вызовов.
|
|
||||||
|
|
||||||
Если есть real upstream:
|
Если есть real upstream:
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,7 @@ flowchart LR
|
|||||||
CORE[crank-core]
|
CORE[crank-core]
|
||||||
SCHEMA[crank-schema]
|
SCHEMA[crank-schema]
|
||||||
MAP[crank-mapping]
|
MAP[crank-mapping]
|
||||||
PROTO[crank-proto]
|
|
||||||
REST[adapter-rest]
|
REST[adapter-rest]
|
||||||
GQL[adapter-graphql]
|
|
||||||
GRPC[adapter-grpc]
|
|
||||||
DB[(PostgreSQL)]
|
DB[(PostgreSQL)]
|
||||||
STORE[(Artifact Storage)]
|
STORE[(Artifact Storage)]
|
||||||
OBS[(Usage and Logs)]
|
OBS[(Usage and Logs)]
|
||||||
@@ -39,7 +36,6 @@ flowchart LR
|
|||||||
MCP --> RUN
|
MCP --> RUN
|
||||||
ADMIN --> REG
|
ADMIN --> REG
|
||||||
ADMIN --> RUN
|
ADMIN --> RUN
|
||||||
ADMIN --> PROTO
|
|
||||||
REG --> DB
|
REG --> DB
|
||||||
REG --> CORE
|
REG --> CORE
|
||||||
REG --> SCHEMA
|
REG --> SCHEMA
|
||||||
@@ -48,10 +44,6 @@ flowchart LR
|
|||||||
RUN --> SCHEMA
|
RUN --> SCHEMA
|
||||||
RUN --> MAP
|
RUN --> MAP
|
||||||
RUN --> REST
|
RUN --> REST
|
||||||
RUN --> GQL
|
|
||||||
RUN --> GRPC
|
|
||||||
GRPC --> PROTO
|
|
||||||
PROTO --> STORE
|
|
||||||
ADMIN --> STORE
|
ADMIN --> STORE
|
||||||
REG --> OBS
|
REG --> OBS
|
||||||
ADMIN --> OBS
|
ADMIN --> OBS
|
||||||
|
|||||||
@@ -29,13 +29,8 @@
|
|||||||
crank-registry/
|
crank-registry/
|
||||||
crank-runtime/
|
crank-runtime/
|
||||||
crank-adapter-rest/
|
crank-adapter-rest/
|
||||||
crank-adapter-graphql/
|
|
||||||
crank-adapter-grpc/
|
|
||||||
crank-adapter-websocket/
|
|
||||||
crank-adapter-soap/
|
|
||||||
crank-mapping/
|
crank-mapping/
|
||||||
crank-schema/
|
crank-schema/
|
||||||
crank-proto/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Поверх существующих crates должны появиться новые логические поддомены:
|
Поверх существующих crates должны появиться новые логические поддомены:
|
||||||
@@ -87,15 +82,7 @@
|
|||||||
- input/output mapping;
|
- input/output mapping;
|
||||||
- draft inference из samples.
|
- draft inference из samples.
|
||||||
|
|
||||||
### 4.4. `crank-proto`
|
### 4.4. `crank-registry`
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- работа с `.proto` и descriptor set;
|
|
||||||
- извлечение services, methods и message schemas;
|
|
||||||
- преобразование protobuf metadata во внутренние типы.
|
|
||||||
|
|
||||||
### 4.5. `crank-registry`
|
|
||||||
|
|
||||||
Назначение:
|
Назначение:
|
||||||
|
|
||||||
@@ -109,7 +96,7 @@
|
|||||||
- logs и usage aggregates;
|
- logs и usage aggregates;
|
||||||
- metadata по sample artifacts и descriptors.
|
- metadata по sample artifacts и descriptors.
|
||||||
|
|
||||||
### 4.6. `crank-runtime`
|
### 4.5. `crank-runtime`
|
||||||
|
|
||||||
Назначение:
|
Назначение:
|
||||||
|
|
||||||
@@ -124,23 +111,13 @@
|
|||||||
- `crank-runtime` должен собираться как `REST-only` base даже без premium adapter crates;
|
- `crank-runtime` должен собираться как `REST-only` base даже без premium adapter crates;
|
||||||
- premium protocol adapters подключаются через feature seams, а не как безусловная зависимость Community runtime.
|
- premium protocol adapters подключаются через feature seams, а не как безусловная зависимость Community runtime.
|
||||||
|
|
||||||
### 4.7. Protocol adapters
|
### 4.6. Protocol adapters
|
||||||
|
|
||||||
- `crank-adapter-rest`
|
- `crank-adapter-rest`
|
||||||
- `crank-adapter-graphql`
|
|
||||||
- `crank-adapter-grpc`
|
|
||||||
- `crank-adapter-websocket`
|
|
||||||
- `crank-adapter-soap`
|
|
||||||
|
|
||||||
Каждый adapter знает только свой протокол.
|
Community runtime в этом репозитории поддерживает только `REST`.
|
||||||
|
|
||||||
Для open-core split это означает:
|
### 4.7. `apps/admin-api`
|
||||||
|
|
||||||
- `crank-adapter-rest` остается обязательной частью Community runtime;
|
|
||||||
- `crank-adapter-graphql`, `crank-adapter-grpc`, `crank-adapter-websocket`, `crank-adapter-soap`
|
|
||||||
должны подключаться как отделяемые protocol modules.
|
|
||||||
|
|
||||||
### 4.8. `apps/admin-api`
|
|
||||||
|
|
||||||
Должен содержать сервисные группы:
|
Должен содержать сервисные группы:
|
||||||
|
|
||||||
@@ -156,7 +133,7 @@
|
|||||||
- `usage`
|
- `usage`
|
||||||
- `streaming`
|
- `streaming`
|
||||||
|
|
||||||
### 4.9. `apps/mcp-server`
|
### 4.8. `apps/mcp-server`
|
||||||
|
|
||||||
Назначение:
|
Назначение:
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
# GraphQL
|
|
||||||
|
|
||||||
## 1. Роль протокола в проекте
|
|
||||||
|
|
||||||
GraphQL поддерживается как отдельный тип интеграции, но на слое MCP намеренно ограничивается. Цель платформы не в том, чтобы дать LLM универсальный доступ ко всему GraphQL endpoint, а в том, чтобы превратить конкретный GraphQL-запрос в узкий и предсказуемый MCP tool.
|
|
||||||
|
|
||||||
## 2. Что поддерживается в целевом продукте
|
|
||||||
|
|
||||||
- `query`
|
|
||||||
- `mutation`
|
|
||||||
- один GraphQL endpoint на operation
|
|
||||||
- фиксированный `query_template`
|
|
||||||
- фиксированный `selection set`
|
|
||||||
- загрузка примера выходного `JSON`
|
|
||||||
- схема переменных
|
|
||||||
- variables mapping
|
|
||||||
- response extraction из `data`
|
|
||||||
- разбор `errors`
|
|
||||||
- auth и headers
|
|
||||||
- автогенерация чернового mapping
|
|
||||||
- ручная донастройка через `JSONPath`
|
|
||||||
- тестовый вызов перед публикацией
|
|
||||||
|
|
||||||
## 3. Что отложено
|
|
||||||
|
|
||||||
- `subscription`
|
|
||||||
- универсальный GraphQL explorer для LLM
|
|
||||||
- передача произвольного GraphQL-документа от LLM
|
|
||||||
- визуальный конструктор сложных selection set
|
|
||||||
- обязательная зависимость от introspection
|
|
||||||
- автоматическое построение любого запроса по полной GraphQL schema
|
|
||||||
|
|
||||||
`subscription` допускается только как future scope после появления отдельного websocket/subscription adapter и controlled streaming lifecycle.
|
|
||||||
|
|
||||||
## 4. Ключевое архитектурное ограничение
|
|
||||||
|
|
||||||
Платформа не должна публиковать в MCP общий GraphQL tool, который умеет получать любые поля и принимать любые параметры в зависимости от намерения LLM.
|
|
||||||
|
|
||||||
Правильная модель только одна:
|
|
||||||
|
|
||||||
- один tool;
|
|
||||||
- один конкретный `query` или `mutation`;
|
|
||||||
- один заранее зафиксированный `selection set`;
|
|
||||||
- фиксированный набор входных параметров;
|
|
||||||
- один предсказуемый формат ответа.
|
|
||||||
|
|
||||||
Иными словами, на MCP-слое GraphQL сознательно сужается до модели, близкой к RPC или REST operation. Это делается потому, что LLM должен работать с понятным контрактом, а не конструировать произвольный GraphQL-запрос на лету.
|
|
||||||
|
|
||||||
## 5. Внутренняя модель GraphQL operation
|
|
||||||
|
|
||||||
GraphQL operation должна включать:
|
|
||||||
|
|
||||||
- `endpoint`
|
|
||||||
- `operation_type`
|
|
||||||
- `operation_name`
|
|
||||||
- `query_template`
|
|
||||||
- `variables_schema`
|
|
||||||
- `input_mapping`
|
|
||||||
- `response_path`
|
|
||||||
- `error_policy`
|
|
||||||
- `headers`
|
|
||||||
- `auth_profile`
|
|
||||||
|
|
||||||
## 6. Как оператор настраивает GraphQL operation
|
|
||||||
|
|
||||||
1. Указывает GraphQL endpoint.
|
|
||||||
2. Выбирает `query` или `mutation`.
|
|
||||||
3. Задает имя операции.
|
|
||||||
4. Вставляет готовый шаблон запроса.
|
|
||||||
5. Описывает переменные, которые разрешено передавать в эту операцию.
|
|
||||||
6. При необходимости загружает пример JSON-ответа.
|
|
||||||
7. Система строит черновую схему ответа и стартовый mapping.
|
|
||||||
8. Настраивает маппинг `MCP input -> GraphQL variables`.
|
|
||||||
9. Указывает `response_path`, по которому извлекается полезный результат из `data`.
|
|
||||||
10. При необходимости уточняет mapping через `JSONPath`.
|
|
||||||
11. Выполняет тест.
|
|
||||||
12. Публикует operation как MCP tool.
|
|
||||||
|
|
||||||
## 7. Поведение runtime
|
|
||||||
|
|
||||||
При выполнении GraphQL operation runtime должен:
|
|
||||||
|
|
||||||
1. Валидировать вход по фиксированной схеме переменных.
|
|
||||||
2. Применить input mapping.
|
|
||||||
3. Собрать GraphQL payload вида `query + variables`.
|
|
||||||
4. Выполнить HTTP request.
|
|
||||||
5. Отдельно разобрать `data` и `errors`.
|
|
||||||
6. Применить output mapping или `response_path`.
|
|
||||||
7. Вернуть нормализованный результат.
|
|
||||||
|
|
||||||
## 8. Критические нюансы
|
|
||||||
|
|
||||||
- HTTP `200 OK` не означает успешное выполнение, если в теле присутствует `errors`.
|
|
||||||
- структура ответа зависит от `selection set`, значит она должна быть фиксирована заранее;
|
|
||||||
- GraphQL endpoint обычно один, поэтому операция определяется не URL, а телом запроса;
|
|
||||||
- variables должны быть строго ограничены, иначе один tool станет слишком широким и плохо управляемым;
|
|
||||||
- `subscription` не входит в текущий scope, потому что требует отдельной lifecycle-модели, близкой к `session` mode, и отдельного transport adapter.
|
|
||||||
- `JSONPath` используется для точечного извлечения вложенных данных из `data` и для управления структурой итогового ответа.
|
|
||||||
|
|
||||||
## 9. Почему GraphQL не считается "почти REST"
|
|
||||||
|
|
||||||
GraphQL похож на REST только тем, что часто передается по HTTP. Но с точки зрения платформы это другой тип контракта:
|
|
||||||
|
|
||||||
- смысл операции задается не endpoint, а запросом;
|
|
||||||
- ответ зависит от `selection set`;
|
|
||||||
- ошибки живут в теле ответа, а не только в HTTP status;
|
|
||||||
- одна и та же точка входа может обслуживать много операций.
|
|
||||||
|
|
||||||
Поэтому GraphQL в системе должен иметь отдельный адаптер и отдельную конфигурационную модель.
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# gRPC
|
|
||||||
|
|
||||||
## 1. Роль протокола в проекте
|
|
||||||
|
|
||||||
gRPC поддерживается как третий основной протокол платформы в управляемой форме. Цель состоит не в том, чтобы покрыть все возможности gRPC, а в том, чтобы представить unary и bounded server-streaming методы как MCP tools с предсказуемым жизненным циклом.
|
|
||||||
|
|
||||||
## 2. Что поддерживается в целевом продукте
|
|
||||||
|
|
||||||
- unary RPC
|
|
||||||
- bounded server-streaming через execution modes `window`, `session`, `async_job`
|
|
||||||
- загрузка `.proto`
|
|
||||||
- загрузка descriptor set
|
|
||||||
- загрузка примеров JSON для MCP input/output при необходимости
|
|
||||||
- извлечение `services`, `methods`, request/response messages
|
|
||||||
- отображение входных и выходных параметров в UI
|
|
||||||
- mapping `MCP input -> protobuf request`
|
|
||||||
- mapping `protobuf response -> MCP output`
|
|
||||||
- автогенерация чернового mapping
|
|
||||||
- ручная донастройка через `JSONPath`
|
|
||||||
- вызов метода по descriptor metadata
|
|
||||||
- auth/transport settings на уровне соединения
|
|
||||||
- тестовый вызов перед публикацией
|
|
||||||
|
|
||||||
## 3. Что отложено
|
|
||||||
|
|
||||||
- `client streaming`
|
|
||||||
- `bidirectional streaming`
|
|
||||||
- обязательная поддержка server reflection
|
|
||||||
- генерация нового Rust-кода под каждый загруженный `.proto`
|
|
||||||
- сложные сценарии с долгоживущими сессиями вызовов
|
|
||||||
|
|
||||||
## 4. Ключевое архитектурное ограничение
|
|
||||||
|
|
||||||
В проекте поддерживаются unary-методы и bounded server-streaming, потому что MCP tool в этой архитектуре должен оставаться управляемым.
|
|
||||||
|
|
||||||
Это означает:
|
|
||||||
|
|
||||||
- один request message;
|
|
||||||
- один bounded response или управляемая session/job-семантика;
|
|
||||||
- явно ограниченный lifecycle stream-сессии.
|
|
||||||
|
|
||||||
Streaming gRPC не публикуется как бесконечный raw stream. Он допускается только там, где runtime умеет bounded-ить, агрегировать и завершать результат.
|
|
||||||
|
|
||||||
## 5. Внутренняя модель gRPC operation
|
|
||||||
|
|
||||||
gRPC operation должна включать:
|
|
||||||
|
|
||||||
- `server_addr`
|
|
||||||
- `package`
|
|
||||||
- `service`
|
|
||||||
- `method`
|
|
||||||
- `descriptor_ref`
|
|
||||||
- `descriptor_set_b64`
|
|
||||||
- `input_schema`
|
|
||||||
- `output_schema`
|
|
||||||
- `input_mapping`
|
|
||||||
- `output_mapping`
|
|
||||||
- `execution_config`
|
|
||||||
- `tool_description`
|
|
||||||
|
|
||||||
## 6. Как оператор настраивает gRPC operation
|
|
||||||
|
|
||||||
1. Загружает `.proto` или descriptor set.
|
|
||||||
2. Система извлекает список services и methods.
|
|
||||||
3. Оператор выбирает конкретный unary- или server-streaming метод.
|
|
||||||
4. UI показывает структуру request message и response message.
|
|
||||||
5. При необходимости загружает примеры JSON для MCP input/output.
|
|
||||||
6. Система строит черновую схему, стартовый mapping и runtime-ready snapshot descriptor set для выбранного метода.
|
|
||||||
7. Оператор задает или уточняет входные MCP-параметры.
|
|
||||||
8. Настраивает маппинг во входные protobuf fields.
|
|
||||||
9. Настраивает маппинг из response fields в MCP output.
|
|
||||||
10. При необходимости уточняет mapping через `JSONPath`.
|
|
||||||
11. Выполняет тест.
|
|
||||||
12. Публикует operation как MCP tool.
|
|
||||||
|
|
||||||
## 7. Поведение runtime
|
|
||||||
|
|
||||||
При выполнении gRPC operation runtime должен:
|
|
||||||
|
|
||||||
1. Валидировать MCP input по нормализованной схеме.
|
|
||||||
2. Применить input mapping.
|
|
||||||
3. Построить protobuf request message из JSON.
|
|
||||||
4. Для unary выполнить unary RPC вызов.
|
|
||||||
5. Для server-streaming собрать bounded окно или session step.
|
|
||||||
6. Преобразовать protobuf response или stream items в нормализованный JSON.
|
|
||||||
7. Применить output mapping.
|
|
||||||
8. Вернуть итоговый результат.
|
|
||||||
|
|
||||||
## 8. Критические нюансы
|
|
||||||
|
|
||||||
- `.proto` и descriptor handling должны быть отделены от runtime-вызова;
|
|
||||||
- protobuf discovery не должен жить внутри gRPC adapter;
|
|
||||||
- runtime использует сохраненный `descriptor_set_b64`, а не исходный `.proto`;
|
|
||||||
- `oneof`, `enum`, `repeated`, `map` и well-known types требуют отдельной нормализации;
|
|
||||||
- `map` на слое нормализованной schema модели представляется как `array` объектов вида `{ key, value }`;
|
|
||||||
- `oneof` на слое нормализованной schema модели представляется как `oneof` с вариантами-объектами, каждый из которых содержит одно допустимое поле;
|
|
||||||
- схема сообщения должна быть представлена в UI как обычная форма полей, а не как сырой protobuf descriptor;
|
|
||||||
- пользователь не должен видеть внутреннюю сложность protobuf-контракта больше, чем это нужно для настройки operation.
|
|
||||||
- `JSONPath` используется как единый способ точечной адресации вложенных полей при настройке mapping поверх нормализованной JSON-модели.
|
|
||||||
|
|
||||||
## 9. Почему gRPC ограничивается controlled streaming
|
|
||||||
|
|
||||||
Причина не только в сложности реализации. Главное ограничение архитектурное:
|
|
||||||
|
|
||||||
- MCP tool моделируется как завершенный вызов;
|
|
||||||
- LLM работает с запросом и конечным ответом;
|
|
||||||
- UI платформы построен вокруг формы входа и формы выхода;
|
|
||||||
- streaming требует отдельной session-модели, buffering, cancellation и состояния.
|
|
||||||
|
|
||||||
Поэтому поддержка gRPC в Crank ограничивается unary и bounded server-streaming. Client-streaming и bidi остаются вне scope до появления полноценной interactive session model.
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# SOAP
|
|
||||||
|
|
||||||
## 1. Роль протокола в проекте
|
|
||||||
|
|
||||||
SOAP поддерживается как enterprise-oriented upstream protocol для интеграций с системами, где REST уже не является стандартом де-факто.
|
|
||||||
|
|
||||||
Типичные домены:
|
|
||||||
|
|
||||||
- ERP;
|
|
||||||
- banking;
|
|
||||||
- insurance;
|
|
||||||
- government and B2B gateways;
|
|
||||||
- legacy enterprise systems;
|
|
||||||
- ESB-oriented internal APIs.
|
|
||||||
|
|
||||||
## 2. Что поддерживается в целевом продукте
|
|
||||||
|
|
||||||
- SOAP 1.1 и SOAP 1.2;
|
|
||||||
- WSDL upload/import;
|
|
||||||
- выбор `service`, `port` и `operation`;
|
|
||||||
- document/literal first;
|
|
||||||
- XML schema extraction из WSDL/XSD;
|
|
||||||
- input/output schema generation;
|
|
||||||
- mapping `MCP JSON -> SOAP body`;
|
|
||||||
- mapping `SOAP response -> normalized JSON`;
|
|
||||||
- SOAP headers config;
|
|
||||||
- basic auth, bearer auth и auth profiles;
|
|
||||||
- test run;
|
|
||||||
- publish as MCP tool.
|
|
||||||
|
|
||||||
## 3. Что не входит в текущий продуктовый scope
|
|
||||||
|
|
||||||
- полный стек WS-* расширений;
|
|
||||||
- MTOM attachments;
|
|
||||||
- arbitrary XML transformation engine;
|
|
||||||
- full schema authoring inside UI;
|
|
||||||
- server-side SOAP hosting.
|
|
||||||
|
|
||||||
## 4. Ключевое архитектурное ограничение
|
|
||||||
|
|
||||||
SOAP не является просто HTTP POST с XML body.
|
|
||||||
|
|
||||||
Для платформы SOAP operation определяется:
|
|
||||||
|
|
||||||
- WSDL model;
|
|
||||||
- service/port/operation binding;
|
|
||||||
- envelope structure;
|
|
||||||
- namespaces;
|
|
||||||
- optional SOAPAction;
|
|
||||||
- fault model;
|
|
||||||
- XML schema contract.
|
|
||||||
|
|
||||||
Поэтому SOAP должен иметь отдельный adapter и отдельную конфигурационную модель.
|
|
||||||
|
|
||||||
## 5. Типовые use cases
|
|
||||||
|
|
||||||
- enterprise CRM/ERP operations;
|
|
||||||
- payment and settlement integrations;
|
|
||||||
- policy and claims systems;
|
|
||||||
- regulated B2B data exchange;
|
|
||||||
- legacy internal service contracts.
|
|
||||||
|
|
||||||
## 6. Внутренняя модель SOAP operation
|
|
||||||
|
|
||||||
SOAP operation должна включать:
|
|
||||||
|
|
||||||
- `wsdl_ref`
|
|
||||||
- `service_name`
|
|
||||||
- `port_name`
|
|
||||||
- `operation_name`
|
|
||||||
- `endpoint_override`
|
|
||||||
- `soap_version`
|
|
||||||
- `soap_action`
|
|
||||||
- `input_schema`
|
|
||||||
- `output_schema`
|
|
||||||
- `input_mapping`
|
|
||||||
- `output_mapping`
|
|
||||||
- `header_config`
|
|
||||||
- `execution_config`
|
|
||||||
- `tool_description`
|
|
||||||
|
|
||||||
Code-level model:
|
|
||||||
|
|
||||||
- `crank_core::SoapTarget`
|
|
||||||
- `crank_core::SoapProtocolOptions`
|
|
||||||
- `crank_core::SoapVersion`
|
|
||||||
- `crank_core::SoapBindingStyle`
|
|
||||||
- `crank_core::SoapHeaderConfig`
|
|
||||||
- `crank_core::SoapFaultContract`
|
|
||||||
- `crank_core::SoapOperationMetadata`
|
|
||||||
- `crank_schema::XmlNodeKind`
|
|
||||||
- `crank_schema::XmlQualifiedName`
|
|
||||||
- `crank_schema::XmlSchemaBinding`
|
|
||||||
|
|
||||||
Execution constraints:
|
|
||||||
|
|
||||||
- `Protocol::Soap` поддерживает только `unary` и `async_job`;
|
|
||||||
- `window` и `session` для SOAP не поддерживаются;
|
|
||||||
- runtime foundation поддерживает `unary` SOAP request-response execution;
|
|
||||||
- `window` и `session` execution по-прежнему отклоняются как unsupported mode.
|
|
||||||
|
|
||||||
## 7. Как оператор настраивает SOAP operation
|
|
||||||
|
|
||||||
1. Загружает WSDL.
|
|
||||||
2. При необходимости загружает supporting XSD.
|
|
||||||
3. Система извлекает services, ports и operations из WSDL.
|
|
||||||
4. Оператор выбирает service, port и operation.
|
|
||||||
5. UI показывает input/output schema в JSON-oriented виде.
|
|
||||||
6. Оператор настраивает mapping `MCP input -> SOAP body`.
|
|
||||||
7. При необходимости задает SOAP headers и auth profile.
|
|
||||||
8. Настраивает output mapping и fault handling.
|
|
||||||
9. Выполняет test run.
|
|
||||||
10. Публикует operation как MCP tool.
|
|
||||||
|
|
||||||
Foundation routes:
|
|
||||||
|
|
||||||
- `POST /operations/{operation_id}/descriptors/wsdl`
|
|
||||||
- `POST /operations/{operation_id}/descriptors/xsd`
|
|
||||||
- `GET /operations/{operation_id}/soap/services`
|
|
||||||
|
|
||||||
Current UI foundation:
|
|
||||||
|
|
||||||
- protocol selection card on wizard step 1;
|
|
||||||
- dedicated SOAP step 3 panel;
|
|
||||||
- WSDL upload;
|
|
||||||
- optional XSD upload;
|
|
||||||
- backend-driven inspection of `service -> port -> operation`;
|
|
||||||
- applying selected binding metadata into the operation draft target.
|
|
||||||
|
|
||||||
## 8. Поведение runtime
|
|
||||||
|
|
||||||
При выполнении SOAP operation runtime должен:
|
|
||||||
|
|
||||||
1. Валидировать MCP input.
|
|
||||||
2. Построить XML envelope.
|
|
||||||
3. Подставить namespaces, `Content-Type` и `SOAPAction`.
|
|
||||||
4. Выполнить HTTP request.
|
|
||||||
5. Разобрать SOAP response и SOAP Fault.
|
|
||||||
6. Нормализовать XML result в JSON.
|
|
||||||
7. Применить output mapping.
|
|
||||||
8. Вернуть итоговый результат.
|
|
||||||
|
|
||||||
## 9. Критические нюансы
|
|
||||||
|
|
||||||
- WSDL import должен быть отделен от runtime call;
|
|
||||||
- XML namespaces должны быть first-class частью конфигурации;
|
|
||||||
- SOAP Fault нельзя сводить только к HTTP error;
|
|
||||||
- input/output schema должны отображаться в UI как нормализованные поля, а не как raw XML;
|
|
||||||
- auth и headers должны настраиваться отдельно от body mapping.
|
|
||||||
|
|
||||||
## 10. Почему SOAP должен входить в product scope
|
|
||||||
|
|
||||||
Если Crank позиционируется как enterprise integration platform, отсутствие SOAP оставляет большую часть legacy и regulated environments вне продукта.
|
|
||||||
|
|
||||||
Поэтому SOAP должен быть частью общей protocol strategy наравне с:
|
|
||||||
|
|
||||||
- REST
|
|
||||||
- GraphQL
|
|
||||||
- gRPC
|
|
||||||
- WebSocket
|
|
||||||
|
|
||||||
Но при этом он должен оставаться request-response oriented adapter, а не ломать общую execution model.
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# WebSocket
|
|
||||||
|
|
||||||
## 1. Роль протокола в проекте
|
|
||||||
|
|
||||||
WebSocket поддерживается как полноценный upstream protocol для realtime, push-oriented и stateful integrations. Он не заменяет downstream MCP transport, а работает за Crank proxy.
|
|
||||||
|
|
||||||
Иными словами:
|
|
||||||
|
|
||||||
- downstream: MCP `Streamable HTTP`;
|
|
||||||
- upstream: WebSocket;
|
|
||||||
- Crank нормализует WebSocket lifecycle в bounded MCP tool semantics.
|
|
||||||
|
|
||||||
## 2. Что поддерживается в целевом продукте
|
|
||||||
|
|
||||||
- клиентское WebSocket подключение к внешнему upstream;
|
|
||||||
- custom headers и auth profiles;
|
|
||||||
- handshake config;
|
|
||||||
- optional subprotocol selection;
|
|
||||||
- subscription payload или start message;
|
|
||||||
- bounded `window` mode;
|
|
||||||
- `session` mode с `start/poll/stop`;
|
|
||||||
- `async_job` mode для control-plane и progress channels;
|
|
||||||
- JSON message parsing;
|
|
||||||
- text-frame based event collection;
|
|
||||||
- mapping `MCP input -> subscribe payload`;
|
|
||||||
- mapping `WebSocket message -> normalized JSON`;
|
|
||||||
- heartbeat/keepalive config;
|
|
||||||
- reconnect policy для controlled session modes.
|
|
||||||
|
|
||||||
## 3. Что не входит в текущий продуктовый scope
|
|
||||||
|
|
||||||
- raw binary frame passthrough в LLM;
|
|
||||||
- arbitrary bidirectional conversation tunnel;
|
|
||||||
- full message bus semantics;
|
|
||||||
- generic browser-like socket inspector;
|
|
||||||
- guaranteed resumability across arbitrary upstream websocket providers.
|
|
||||||
|
|
||||||
## 4. Ключевое архитектурное ограничение
|
|
||||||
|
|
||||||
WebSocket не публикуется как "живой канал в чат". Он должен быть выражен в одной из execution models:
|
|
||||||
|
|
||||||
- `window`
|
|
||||||
- `session`
|
|
||||||
- `async_job`
|
|
||||||
|
|
||||||
Недопустимо:
|
|
||||||
|
|
||||||
- бесконечно проксировать frames напрямую в MCP client;
|
|
||||||
- скрывать жизненный цикл сокета за одним неопределенным tool call;
|
|
||||||
- смешивать downstream SSE transport и upstream WebSocket semantics в одну абстракцию.
|
|
||||||
|
|
||||||
## 5. Типовые use cases
|
|
||||||
|
|
||||||
### 5.1. Monitoring and telemetry
|
|
||||||
|
|
||||||
- realtime метрики;
|
|
||||||
- device telemetry;
|
|
||||||
- market data;
|
|
||||||
- anomaly events.
|
|
||||||
|
|
||||||
### 5.2. Event subscriptions
|
|
||||||
|
|
||||||
- alert streams;
|
|
||||||
- queue events;
|
|
||||||
- workflow transitions;
|
|
||||||
- security events.
|
|
||||||
|
|
||||||
### 5.3. Stateful control channels
|
|
||||||
|
|
||||||
- rollout progress;
|
|
||||||
- remote task status;
|
|
||||||
- infrastructure control plane updates;
|
|
||||||
- long-running remote sessions.
|
|
||||||
|
|
||||||
## 6. Внутренняя модель WebSocket operation
|
|
||||||
|
|
||||||
WebSocket operation должна включать:
|
|
||||||
|
|
||||||
- `url`
|
|
||||||
- `headers`
|
|
||||||
- `subprotocols`
|
|
||||||
- `subscribe_message_template`
|
|
||||||
- `unsubscribe_message_template`
|
|
||||||
- `input_mapping`
|
|
||||||
- `output_mapping`
|
|
||||||
- `execution_config`
|
|
||||||
- `tool_description`
|
|
||||||
|
|
||||||
Protocol-specific runtime tuning хранится отдельно в `ProtocolOptions.websocket`:
|
|
||||||
|
|
||||||
- `heartbeat_interval_ms`
|
|
||||||
- `reconnect_max_attempts`
|
|
||||||
- `reconnect_backoff_ms`
|
|
||||||
|
|
||||||
## 7. Как оператор настраивает WebSocket operation
|
|
||||||
|
|
||||||
1. Указывает URL WebSocket upstream.
|
|
||||||
2. При необходимости выбирает auth profile и headers.
|
|
||||||
3. Указывает subprotocol или оставляет пустым.
|
|
||||||
4. Выбирает execution mode: `window`, `session` или `async_job`.
|
|
||||||
5. Настраивает subscribe payload.
|
|
||||||
6. Указывает правила извлечения items, status и cursor.
|
|
||||||
7. Настраивает aggregation limits.
|
|
||||||
8. Выполняет test run.
|
|
||||||
9. Публикует operation как MCP tool или tool family.
|
|
||||||
|
|
||||||
## 7.1. Текущий UI foundation
|
|
||||||
|
|
||||||
Текущий wizard support для WebSocket уже включает:
|
|
||||||
|
|
||||||
- выбор `WebSocket` как protocol family на шаге 1;
|
|
||||||
- step 2 reuse общего upstream/auth selector;
|
|
||||||
- отдельный step 3 для:
|
|
||||||
- socket path override;
|
|
||||||
- subprotocol list;
|
|
||||||
- subscribe message template;
|
|
||||||
- unsubscribe message template;
|
|
||||||
- `heartbeat_interval_ms`;
|
|
||||||
- `reconnect_max_attempts`;
|
|
||||||
- `reconnect_backoff_ms`.
|
|
||||||
|
|
||||||
На текущем этапе wizard сериализует:
|
|
||||||
|
|
||||||
- `Target::Websocket`
|
|
||||||
- `ExecutionConfig.protocol_options.websocket`
|
|
||||||
|
|
||||||
Это дает оператору usable foundation для `window`, `session` и `async_job` flows even before deeper inspection/test-run polish.
|
|
||||||
|
|
||||||
## 8. Поведение runtime
|
|
||||||
|
|
||||||
При выполнении WebSocket operation runtime должен:
|
|
||||||
|
|
||||||
1. Валидировать MCP input.
|
|
||||||
2. Построить subscribe payload.
|
|
||||||
3. Открыть WebSocket connection.
|
|
||||||
4. Пройти handshake и auth.
|
|
||||||
5. Отправить subscribe message.
|
|
||||||
6. Собрать bounded window или session step.
|
|
||||||
7. Нормализовать messages в JSON.
|
|
||||||
8. Применить output mapping.
|
|
||||||
9. Закрыть соединение или сохранить session state.
|
|
||||||
|
|
||||||
## 9. Критические нюансы
|
|
||||||
|
|
||||||
- upstream WebSocket может быть stateful и требовать explicit unsubscribe;
|
|
||||||
- heartbeat и reconnect должны быть управляемыми через config, а не захардкоженными;
|
|
||||||
- session state должен хранить cursor, last_event и subscription metadata;
|
|
||||||
- disconnect downstream MCP client не означает cancel upstream session;
|
|
||||||
- runtime обязан уметь cleanly завершать orphaned connections.
|
|
||||||
|
|
||||||
## 10. Почему WebSocket должен быть отдельным adapter
|
|
||||||
|
|
||||||
WebSocket нельзя свести к "почти SSE" или "почти HTTP", потому что:
|
|
||||||
|
|
||||||
- transport двунаправленный;
|
|
||||||
- lifecycle connection stateful;
|
|
||||||
- есть handshake, heartbeat и reconnect;
|
|
||||||
- подписка часто задается сообщением, а не URL;
|
|
||||||
- semantics событий и прогресса отличаются от request-response вызова.
|
|
||||||
@@ -361,11 +361,6 @@ where
|
|||||||
- mapping rule helpers;
|
- mapping rule helpers;
|
||||||
- execution helpers.
|
- execution helpers.
|
||||||
|
|
||||||
### В `crank-proto`
|
|
||||||
|
|
||||||
- metadata conversion helpers;
|
|
||||||
- descriptor lookup helpers.
|
|
||||||
|
|
||||||
### В `crank-registry`
|
### В `crank-registry`
|
||||||
|
|
||||||
- service methods, а не методы на доменных структурах;
|
- service methods, а не методы на доменных структурах;
|
||||||
|
|||||||
@@ -1,641 +0,0 @@
|
|||||||
# Streaming Admin API
|
|
||||||
|
|
||||||
## 1. Назначение документа
|
|
||||||
|
|
||||||
Этот документ фиксирует точные HTTP-контракты для потоковой модели Crank.
|
|
||||||
|
|
||||||
Он дополняет:
|
|
||||||
|
|
||||||
- [admin-api.md](/home/a.tolmachev/code/rust/mcpaas/docs/admin-api.md)
|
|
||||||
- [streaming-mcp-plan.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-mcp-plan.md)
|
|
||||||
- [streaming-ui-contract.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-ui-contract.md)
|
|
||||||
- [streaming-runtime-design.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-runtime-design.md)
|
|
||||||
|
|
||||||
Цель документа:
|
|
||||||
|
|
||||||
- зафиксировать DTO;
|
|
||||||
- зафиксировать route groups;
|
|
||||||
- зафиксировать валидацию;
|
|
||||||
- зафиксировать expected error model;
|
|
||||||
- зафиксировать page-to-endpoint contract для streaming configuration и test-runs.
|
|
||||||
|
|
||||||
## 2. Общие принципы
|
|
||||||
|
|
||||||
- все ресурсы являются `workspace-scoped`;
|
|
||||||
- streaming configuration является частью `operation version`;
|
|
||||||
- `session` и `async_job` state не редактируются напрямую из UI;
|
|
||||||
- test-runs могут создавать временные sessions и jobs, но не публикуют их как runtime resources;
|
|
||||||
- transport errors и validation errors разделяются;
|
|
||||||
- лимиты и safety-параметры валидируются на сервере, а не только в UI.
|
|
||||||
|
|
||||||
Базовый префикс:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/api/admin/workspaces/{workspace_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Основные ресурсы
|
|
||||||
|
|
||||||
- `streaming-presets`
|
|
||||||
- `streaming-validation`
|
|
||||||
- `stream-test-runs`
|
|
||||||
- `stream-sessions`
|
|
||||||
- `async-jobs`
|
|
||||||
- `protocol-capabilities`
|
|
||||||
|
|
||||||
## 4. Общие DTO
|
|
||||||
|
|
||||||
### 4.1. `ExecutionMode`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mode": "unary"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Допустимые значения:
|
|
||||||
|
|
||||||
- `unary`
|
|
||||||
- `window`
|
|
||||||
- `session`
|
|
||||||
- `async_job`
|
|
||||||
|
|
||||||
### 4.2. `StreamingConfig`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mode": "window",
|
|
||||||
"transport_behavior": "server_stream",
|
|
||||||
"window_duration_ms": 5000,
|
|
||||||
"poll_interval_ms": 2000,
|
|
||||||
"upstream_timeout_ms": 10000,
|
|
||||||
"idle_timeout_ms": 30000,
|
|
||||||
"max_session_lifetime_ms": 300000,
|
|
||||||
"max_items": 200,
|
|
||||||
"max_bytes": 131072,
|
|
||||||
"aggregation_mode": "summary_plus_samples",
|
|
||||||
"summary_path": "$.summary",
|
|
||||||
"items_path": "$.items",
|
|
||||||
"cursor_path": "$.cursor",
|
|
||||||
"status_path": "$.status",
|
|
||||||
"done_path": "$.done",
|
|
||||||
"redacted_paths": [
|
|
||||||
"$.items[*].token",
|
|
||||||
"$.summary.secret"
|
|
||||||
],
|
|
||||||
"truncate_item_fields": true,
|
|
||||||
"max_field_length": 512,
|
|
||||||
"drop_duplicates": true,
|
|
||||||
"sampling_rate": 1.0,
|
|
||||||
"tool_family": {
|
|
||||||
"start_tool_name": "cluster_events_start",
|
|
||||||
"poll_tool_name": "cluster_events_poll",
|
|
||||||
"stop_tool_name": "cluster_events_stop",
|
|
||||||
"status_tool_name": "deploy_status",
|
|
||||||
"result_tool_name": "deploy_result",
|
|
||||||
"cancel_tool_name": "deploy_cancel"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3. `StreamingValidationError`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": "streaming_validation_error",
|
|
||||||
"message": "Streaming configuration is invalid",
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"field": "window_duration_ms",
|
|
||||||
"reason": "must_be_positive"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"field": "max_items",
|
|
||||||
"reason": "must_not_exceed_workspace_limit"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4. `ProtocolCapability`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"protocol": "grpc",
|
|
||||||
"supports_execution_modes": [
|
|
||||||
"unary",
|
|
||||||
"window",
|
|
||||||
"session",
|
|
||||||
"async_job"
|
|
||||||
],
|
|
||||||
"supports_transport_behaviors": [
|
|
||||||
"request_response",
|
|
||||||
"server_stream"
|
|
||||||
],
|
|
||||||
"supports_auth_kinds": [
|
|
||||||
"none",
|
|
||||||
"bearer",
|
|
||||||
"basic",
|
|
||||||
"api_key_header",
|
|
||||||
"api_key_query"
|
|
||||||
],
|
|
||||||
"supports_upload_artifacts": [
|
|
||||||
"proto",
|
|
||||||
"descriptor_set"
|
|
||||||
],
|
|
||||||
"supports_cursor_path": true,
|
|
||||||
"supports_done_path": true,
|
|
||||||
"supports_aggregation_mode": [
|
|
||||||
"raw_items",
|
|
||||||
"summary_only",
|
|
||||||
"summary_plus_samples",
|
|
||||||
"stats",
|
|
||||||
"latest_state"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Capabilities endpoints
|
|
||||||
|
|
||||||
### `GET /api/admin/workspaces/{workspace_id}/protocol-capabilities`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- отдать UI полную capability matrix;
|
|
||||||
- убрать protocol-specific hardcode из frontend.
|
|
||||||
|
|
||||||
Ответ:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"protocol": "rest",
|
|
||||||
"supports_execution_modes": ["unary", "window", "session", "async_job"],
|
|
||||||
"supports_transport_behaviors": ["request_response", "server_stream"],
|
|
||||||
"supports_auth_kinds": ["none", "bearer", "basic", "api_key_header", "api_key_query"],
|
|
||||||
"supports_upload_artifacts": [],
|
|
||||||
"supports_cursor_path": true,
|
|
||||||
"supports_done_path": true,
|
|
||||||
"supports_aggregation_mode": ["raw_items", "summary_only", "summary_plus_samples", "stats", "latest_state"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Streaming validation endpoints
|
|
||||||
|
|
||||||
### `POST /api/admin/workspaces/{workspace_id}/streaming/validate`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- проверить streaming config до сохранения operation;
|
|
||||||
- вернуть protocol-aware ошибки.
|
|
||||||
|
|
||||||
Тело:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"protocol": "websocket",
|
|
||||||
"target": {},
|
|
||||||
"execution_config": {
|
|
||||||
"streaming": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Успех:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"valid": true,
|
|
||||||
"warnings": [
|
|
||||||
{
|
|
||||||
"field": "max_bytes",
|
|
||||||
"code": "may_truncate_large_event_payloads",
|
|
||||||
"message": "Large event payloads may be truncated"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ошибка:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"valid": false,
|
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"field": "transport_behavior",
|
|
||||||
"code": "unsupported_transport_behavior"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Streaming presets endpoints
|
|
||||||
|
|
||||||
### `GET /api/admin/workspaces/{workspace_id}/streaming-presets`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- отдать рекомендованные UI presets.
|
|
||||||
|
|
||||||
Ответ:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"preset_id": "logs_window_5s",
|
|
||||||
"display_name": "Logs Window 5s",
|
|
||||||
"protocols": ["rest", "grpc", "websocket"],
|
|
||||||
"streaming": {
|
|
||||||
"mode": "window",
|
|
||||||
"window_duration_ms": 5000,
|
|
||||||
"max_items": 100,
|
|
||||||
"max_bytes": 65536,
|
|
||||||
"aggregation_mode": "summary_plus_samples"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Streaming operation test-runs
|
|
||||||
|
|
||||||
### `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- выполнить `unary`, `window`, `session` или `async_job` test-run для draft version;
|
|
||||||
- показать оператору runtime behavior до publish.
|
|
||||||
|
|
||||||
Тело:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": 4,
|
|
||||||
"input": {
|
|
||||||
"service": "billing",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"version": 4,
|
|
||||||
"input": {
|
|
||||||
"service": "billing",
|
|
||||||
"level": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Успех для `unary`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"mode": "unary",
|
|
||||||
"request_preview": {
|
|
||||||
"service": "billing",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"response_preview": {
|
|
||||||
"summary": "completed"
|
|
||||||
},
|
|
||||||
"errors": [],
|
|
||||||
"window": null,
|
|
||||||
"stream_session": null,
|
|
||||||
"async_job": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Успех для `window`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"mode": "window",
|
|
||||||
"request_preview": {
|
|
||||||
"service": "billing",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"response_preview": {
|
|
||||||
"summary": {},
|
|
||||||
"items": []
|
|
||||||
},
|
|
||||||
"errors": [],
|
|
||||||
"window": {
|
|
||||||
"window_complete": true,
|
|
||||||
"truncated": false,
|
|
||||||
"has_more": false,
|
|
||||||
"cursor": null
|
|
||||||
},
|
|
||||||
"stream_session": null,
|
|
||||||
"async_job": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Успех для `session`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"mode": "session",
|
|
||||||
"request_preview": {
|
|
||||||
"service": "billing",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"response_preview": {
|
|
||||||
"summary": {},
|
|
||||||
"items": []
|
|
||||||
},
|
|
||||||
"errors": [],
|
|
||||||
"window": null,
|
|
||||||
"stream_session": {
|
|
||||||
"session_id": "sess_01j0stream",
|
|
||||||
"status": "running",
|
|
||||||
"expires_at": "2026-04-06T12:05:00Z",
|
|
||||||
"poll_after_ms": 2000,
|
|
||||||
"preview": {
|
|
||||||
"summary": {},
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"async_job": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Успех для `async_job`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"mode": "async_job",
|
|
||||||
"request_preview": {
|
|
||||||
"service": "billing",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"response_preview": {
|
|
||||||
"job": "started"
|
|
||||||
},
|
|
||||||
"errors": [],
|
|
||||||
"window": null,
|
|
||||||
"stream_session": null,
|
|
||||||
"async_job": {
|
|
||||||
"job_id": "job_01j0deploy",
|
|
||||||
"status": "running",
|
|
||||||
"progress": {
|
|
||||||
"pct": 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Ошибка:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": false,
|
|
||||||
"mode": "window",
|
|
||||||
"request_preview": {
|
|
||||||
"service": "billing",
|
|
||||||
"level": "error"
|
|
||||||
},
|
|
||||||
"response_preview": null,
|
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"code": "runtime_test_failure",
|
|
||||||
"message": "upstream timeout"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"window": null,
|
|
||||||
"stream_session": null,
|
|
||||||
"async_job": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Примечания:
|
|
||||||
|
|
||||||
- endpoint не использует отдельные `stream-test-runs/*` subresources;
|
|
||||||
- session и async-job test-runs создают обычные runtime resources в `stream_sessions` и `async_jobs`;
|
|
||||||
- дальнейшее наблюдение идет через `GET /stream-sessions`, `GET /stream-sessions/{session_id}`, `POST /stream-sessions/{session_id}/stop`, `GET /async-jobs`, `GET /async-jobs/{job_id}`, `GET /async-jobs/{job_id}/result`, `POST /async-jobs/{job_id}/cancel`.
|
|
||||||
|
|
||||||
## 9. Runtime session resources
|
|
||||||
|
|
||||||
### `GET /api/admin/workspaces/{workspace_id}/stream-sessions`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- список активных и недавних session resources для observability/debug UI.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
|
|
||||||
- `operation_id`
|
|
||||||
- `agent_id`
|
|
||||||
- `status`
|
|
||||||
- `page`
|
|
||||||
- `page_size`
|
|
||||||
|
|
||||||
Ответ:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "sess_01j0stream",
|
|
||||||
"operation_id": "op_01j0",
|
|
||||||
"agent_id": "agent_01j0",
|
|
||||||
"mode": "session",
|
|
||||||
"status": "running",
|
|
||||||
"expires_at": "2026-04-06T12:05:00Z",
|
|
||||||
"last_poll_at": "2026-04-06T12:00:10Z",
|
|
||||||
"created_at": "2026-04-06T12:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"page": 1,
|
|
||||||
"page_size": 20,
|
|
||||||
"total": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/admin/workspaces/{workspace_id}/stream-sessions/{session_id}`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- detail session metadata;
|
|
||||||
- без возврата полного raw state.
|
|
||||||
|
|
||||||
### `POST /api/admin/workspaces/{workspace_id}/stream-sessions/{session_id}/stop`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- административно остановить runtime session.
|
|
||||||
|
|
||||||
### `DELETE /api/admin/workspaces/{workspace_id}/stream-sessions/{session_id}`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- hard cleanup завершенной session;
|
|
||||||
- доступен только для `stopped`, `failed`, `expired`.
|
|
||||||
|
|
||||||
## 10. Async job resources
|
|
||||||
|
|
||||||
### `GET /api/admin/workspaces/{workspace_id}/async-jobs`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- список активных и недавних job resources.
|
|
||||||
|
|
||||||
### `GET /api/admin/workspaces/{workspace_id}/async-jobs/{job_id}`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- status/progress/result metadata.
|
|
||||||
|
|
||||||
### `POST /api/admin/workspaces/{workspace_id}/async-jobs/{job_id}/cancel`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- административная отмена long-running job.
|
|
||||||
|
|
||||||
### `GET /api/admin/workspaces/{workspace_id}/async-jobs/{job_id}/result`
|
|
||||||
|
|
||||||
Назначение:
|
|
||||||
|
|
||||||
- получить финальный нормализованный результат.
|
|
||||||
|
|
||||||
## 11. Integration into operation contracts
|
|
||||||
|
|
||||||
`streaming` не является отдельным top-level resource для опубликованной операции. Он живет внутри:
|
|
||||||
|
|
||||||
- `OperationVersionDocument.execution_config.streaming`
|
|
||||||
|
|
||||||
и входит в:
|
|
||||||
|
|
||||||
- `POST /operations`
|
|
||||||
- `PATCH /operations/{operation_id}`
|
|
||||||
- `POST /operations/{operation_id}/versions`
|
|
||||||
- `GET /operations/{operation_id}/versions/{version}`
|
|
||||||
|
|
||||||
### 11.1. `OperationVersionDocument.execution_config.streaming`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"execution_config": {
|
|
||||||
"timeout_ms": 10000,
|
|
||||||
"retries": 0,
|
|
||||||
"auth_profile_ref": "auth_profile_01j0",
|
|
||||||
"streaming": {
|
|
||||||
"mode": "window",
|
|
||||||
"transport_behavior": "server_stream",
|
|
||||||
"window_duration_ms": 5000,
|
|
||||||
"upstream_timeout_ms": 10000,
|
|
||||||
"max_items": 100,
|
|
||||||
"max_bytes": 65536,
|
|
||||||
"aggregation_mode": "summary_plus_samples",
|
|
||||||
"summary_path": "$.summary",
|
|
||||||
"items_path": "$.items",
|
|
||||||
"cursor_path": "$.cursor",
|
|
||||||
"status_path": "$.status",
|
|
||||||
"done_path": "$.done",
|
|
||||||
"redacted_paths": [],
|
|
||||||
"truncate_item_fields": true,
|
|
||||||
"max_field_length": 512,
|
|
||||||
"drop_duplicates": true,
|
|
||||||
"sampling_rate": 1.0,
|
|
||||||
"tool_family": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12. Validation rules
|
|
||||||
|
|
||||||
### 12.1. Общие
|
|
||||||
|
|
||||||
- `mode=unary` запрещает `tool_family`;
|
|
||||||
- `max_items` > 0;
|
|
||||||
- `max_bytes` > 0;
|
|
||||||
- `window_duration_ms` > 0 для `window`;
|
|
||||||
- `idle_timeout_ms` обязателен для `session`;
|
|
||||||
- `max_session_lifetime_ms` обязателен для `session`;
|
|
||||||
- `tool_family.start_tool_name/poll_tool_name/stop_tool_name` обязательны для `session`;
|
|
||||||
- `tool_family.start_tool_name/status_tool_name/result_tool_name/cancel_tool_name` обязательны для `async_job`.
|
|
||||||
|
|
||||||
### 12.2. Protocol-aware
|
|
||||||
|
|
||||||
REST:
|
|
||||||
|
|
||||||
- `transport_behavior=server_stream` допустим только при streaming-capable target.
|
|
||||||
|
|
||||||
GraphQL:
|
|
||||||
|
|
||||||
- `window`, `session`, `async_job` пока запрещены;
|
|
||||||
- `subscription` невалиден как target type.
|
|
||||||
|
|
||||||
gRPC:
|
|
||||||
|
|
||||||
- `transport_behavior=server_stream` допустим только для server-streaming method.
|
|
||||||
|
|
||||||
WebSocket:
|
|
||||||
|
|
||||||
- `mode=unary` запрещен;
|
|
||||||
- требуется `subscribe_message_template` для `session` и `window`.
|
|
||||||
|
|
||||||
SOAP:
|
|
||||||
|
|
||||||
- `mode=session` запрещен в первой волне;
|
|
||||||
- `mode=window` допустим только при наличии polling-style status operation family;
|
|
||||||
- `mode=async_job` требует status/result contract.
|
|
||||||
|
|
||||||
## 13. Error model
|
|
||||||
|
|
||||||
Коды:
|
|
||||||
|
|
||||||
- `streaming_validation_error`
|
|
||||||
- `unsupported_execution_mode`
|
|
||||||
- `unsupported_transport_behavior`
|
|
||||||
- `stream_window_timeout`
|
|
||||||
- `stream_window_truncated`
|
|
||||||
- `stream_session_expired`
|
|
||||||
- `stream_session_not_found`
|
|
||||||
- `async_job_not_found`
|
|
||||||
- `async_job_not_ready`
|
|
||||||
- `async_job_cancelled`
|
|
||||||
- `protocol_capability_mismatch`
|
|
||||||
|
|
||||||
### 13.1. Ошибка несовместимого protocol mode
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": "protocol_capability_mismatch",
|
|
||||||
"message": "websocket target does not support unary execution mode",
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"field": "execution_config.streaming.mode",
|
|
||||||
"reason": "unsupported_for_protocol"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 14. Route-to-service mapping
|
|
||||||
|
|
||||||
Ожидаемые service handlers в `apps/admin-api`:
|
|
||||||
|
|
||||||
- `list_protocol_capabilities`
|
|
||||||
- `list_streaming_presets`
|
|
||||||
- `validate_streaming_config`
|
|
||||||
- `start_stream_test_run`
|
|
||||||
- `poll_stream_test_run`
|
|
||||||
- `stop_stream_test_run`
|
|
||||||
- `get_stream_test_result`
|
|
||||||
- `list_stream_sessions`
|
|
||||||
- `get_stream_session`
|
|
||||||
- `stop_stream_session`
|
|
||||||
- `delete_stream_session`
|
|
||||||
- `list_async_jobs`
|
|
||||||
- `get_async_job`
|
|
||||||
- `cancel_async_job`
|
|
||||||
- `get_async_job_result`
|
|
||||||
|
|
||||||
Это не route names, а service-level функции orchestration.
|
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
# Streaming Implementation Specification
|
|
||||||
|
|
||||||
## 1. Назначение документа
|
|
||||||
|
|
||||||
Этот документ переводит streaming architecture в execution-oriented plan, пригодный для агентной разработки.
|
|
||||||
|
|
||||||
Он отвечает на вопросы:
|
|
||||||
|
|
||||||
- в каком порядке реализовывать фичи;
|
|
||||||
- какие файлы менять в каждом срезе;
|
|
||||||
- какие новые модули создавать;
|
|
||||||
- какие тесты обязательны;
|
|
||||||
- какие риски и зависимости есть между срезами;
|
|
||||||
- какой Definition of Done нужен для каждого шага.
|
|
||||||
|
|
||||||
Документ дополняет:
|
|
||||||
|
|
||||||
- [streaming-mcp-plan.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-mcp-plan.md)
|
|
||||||
- [streaming-admin-api.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-admin-api.md)
|
|
||||||
- [streaming-runtime-design.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-runtime-design.md)
|
|
||||||
- [streaming-ui-contract.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-ui-contract.md)
|
|
||||||
- [protocol-capability-matrix.md](/home/a.tolmachev/code/rust/mcpaas/docs/protocol-capability-matrix.md)
|
|
||||||
|
|
||||||
## 2. Принципы агентной реализации
|
|
||||||
|
|
||||||
- один vertical slice на ветку;
|
|
||||||
- docs first, code second;
|
|
||||||
- не смешивать transport alignment, core model, registry, runtime и UI в одном срезе;
|
|
||||||
- каждый срез должен быть протестирован отдельно;
|
|
||||||
- stateful streaming нельзя вводить через скрытые side effects;
|
|
||||||
- session/job lifecycle должен появляться в коде и API явно.
|
|
||||||
|
|
||||||
## 3. Dependency graph
|
|
||||||
|
|
||||||
Порядок реализации:
|
|
||||||
|
|
||||||
1. `feat/mcp-streamable-http-alignment`
|
|
||||||
2. `feat/streaming-core-model`
|
|
||||||
3. `feat/stream-session-store`
|
|
||||||
4. `feat/runtime-window-mode`
|
|
||||||
5. `feat/rest-sse-adapter`
|
|
||||||
6. `feat/grpc-server-streaming-adapter`
|
|
||||||
7. `feat/session-and-job-tools`
|
|
||||||
8. `feat/streaming-ui-config`
|
|
||||||
9. `feat/streaming-e2e`
|
|
||||||
10. `feat/websocket-upstream-adapter`
|
|
||||||
11. `feat/soap-architecture-and-core-model`
|
|
||||||
12. `feat/soap-adapter-foundation`
|
|
||||||
|
|
||||||
Почему именно так:
|
|
||||||
|
|
||||||
- сначала transport correctness;
|
|
||||||
- потом core types;
|
|
||||||
- потом persistence;
|
|
||||||
- потом bounded runtime;
|
|
||||||
- потом protocol adapters;
|
|
||||||
- потом MCP tool publishing;
|
|
||||||
- потом UI;
|
|
||||||
- потом e2e;
|
|
||||||
- потом расширение protocol platform.
|
|
||||||
|
|
||||||
## 4. Slice 1: `feat/mcp-streamable-http-alignment`
|
|
||||||
|
|
||||||
### 4.1. Цель
|
|
||||||
|
|
||||||
Довести `apps/mcp-server` до полного и явного соответствия MCP `Streamable HTTP`.
|
|
||||||
|
|
||||||
### 4.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [apps/mcp-server/src/app.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/app.rs)
|
|
||||||
- [apps/mcp-server/src/jsonrpc.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/jsonrpc.rs)
|
|
||||||
- [apps/mcp-server/src/session.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/session.rs)
|
|
||||||
- [apps/mcp-server/src/main.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/main.rs)
|
|
||||||
|
|
||||||
Добавить при необходимости:
|
|
||||||
|
|
||||||
- `apps/mcp-server/src/transport.rs`
|
|
||||||
- `apps/mcp-server/src/sse.rs`
|
|
||||||
|
|
||||||
### 4.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- parse `Accept` корректно для `application/json` и `text/event-stream`;
|
|
||||||
- `GET` endpoint перестает быть `405` и становится SSE-capable transport stream;
|
|
||||||
- `POST` может отвечать JSON или SSE в зависимости от negotiated transport;
|
|
||||||
- `Mcp-Session-Id` создается, читается и валидируется явно;
|
|
||||||
- `MCP-Protocol-Version` валидируется и возвращается явно;
|
|
||||||
- `DELETE` корректно завершает transport session;
|
|
||||||
- disconnect не трактуется как cancel operation.
|
|
||||||
|
|
||||||
### 4.4. Тесты
|
|
||||||
|
|
||||||
Обязательно добавить:
|
|
||||||
|
|
||||||
- initialize через JSON response;
|
|
||||||
- initialize через SSE response;
|
|
||||||
- `GET` SSE handshake;
|
|
||||||
- повторное использование `Mcp-Session-Id`;
|
|
||||||
- invalid protocol version;
|
|
||||||
- invalid accept header;
|
|
||||||
- session close through `DELETE`.
|
|
||||||
|
|
||||||
### 4.5. DoD
|
|
||||||
|
|
||||||
- `mcp-server` ведет себя согласно spec;
|
|
||||||
- transport tests зелёные;
|
|
||||||
- docs и code names совпадают.
|
|
||||||
|
|
||||||
## 5. Slice 2: `feat/streaming-core-model`
|
|
||||||
|
|
||||||
### 5.1. Цель
|
|
||||||
|
|
||||||
Ввести доменные типы streaming execution.
|
|
||||||
|
|
||||||
### 5.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [crates/crank-core/src/lib.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/lib.rs)
|
|
||||||
- [crates/crank-core/src/operation.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/operation.rs)
|
|
||||||
- [crates/crank-core/src/ids.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/ids.rs)
|
|
||||||
- [crates/crank-core/src/protocol.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/protocol.rs)
|
|
||||||
|
|
||||||
Добавить:
|
|
||||||
|
|
||||||
- `crates/crank-core/src/streaming.rs`
|
|
||||||
- `crates/crank-core/src/stream_session.rs`
|
|
||||||
|
|
||||||
### 5.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- добавить `ExecutionMode`;
|
|
||||||
- добавить `TransportBehavior`;
|
|
||||||
- добавить `AggregationMode`;
|
|
||||||
- добавить `StreamingConfig`;
|
|
||||||
- добавить `StreamSessionId`, `AsyncJobId`;
|
|
||||||
- добавить `StreamSession`, `AsyncJobHandle`, `StreamStatus`, `JobStatus`;
|
|
||||||
- вшить `streaming: Option<StreamingConfig>` в `ExecutionConfig`.
|
|
||||||
|
|
||||||
### 5.4. Тесты
|
|
||||||
|
|
||||||
- serde roundtrip для `StreamingConfig`;
|
|
||||||
- validation rules для `ExecutionMode`;
|
|
||||||
- protocol-aware validation helpers.
|
|
||||||
|
|
||||||
### 5.5. DoD
|
|
||||||
|
|
||||||
- core types стабильны;
|
|
||||||
- экспортированы через `lib.rs`;
|
|
||||||
- naming не спорит с docs.
|
|
||||||
|
|
||||||
## 6. Slice 3: `feat/stream-session-store`
|
|
||||||
|
|
||||||
### 6.1. Цель
|
|
||||||
|
|
||||||
Добавить persistent store для `stream_sessions` и `async_jobs`.
|
|
||||||
|
|
||||||
### 6.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [crates/crank-registry/src/lib.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/lib.rs)
|
|
||||||
- [crates/crank-registry/src/model.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/model.rs)
|
|
||||||
- [crates/crank-registry/src/migrations.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/migrations.rs)
|
|
||||||
- [crates/crank-registry/src/postgres.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/postgres.rs)
|
|
||||||
|
|
||||||
### 6.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- таблицы `stream_sessions`, `async_jobs`;
|
|
||||||
- record types;
|
|
||||||
- create/get/update/close/list/delete-expired methods;
|
|
||||||
- filters and paging;
|
|
||||||
- optimistic transitions для state updates.
|
|
||||||
|
|
||||||
### 6.4. Тесты
|
|
||||||
|
|
||||||
- migration test;
|
|
||||||
- create/get/update/close session;
|
|
||||||
- create/get/update/cancel async job;
|
|
||||||
- cleanup expired rows;
|
|
||||||
- invalid transition rejection.
|
|
||||||
|
|
||||||
### 6.5. DoD
|
|
||||||
|
|
||||||
- storage API соответствует runtime design;
|
|
||||||
- transitions не допускают silent corruption.
|
|
||||||
|
|
||||||
## 7. Slice 4: `feat/runtime-window-mode`
|
|
||||||
|
|
||||||
### 7.1. Цель
|
|
||||||
|
|
||||||
Ввести bounded `window` mode до session/job complexity.
|
|
||||||
|
|
||||||
### 7.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [crates/crank-runtime/src/lib.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/lib.rs)
|
|
||||||
- [crates/crank-runtime/src/model.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/model.rs)
|
|
||||||
- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs)
|
|
||||||
- [crates/crank-runtime/src/error.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/error.rs)
|
|
||||||
|
|
||||||
Добавить:
|
|
||||||
|
|
||||||
- `crates/crank-runtime/src/streaming.rs`
|
|
||||||
- `crates/crank-runtime/src/aggregation.rs`
|
|
||||||
- `crates/crank-runtime/src/redaction.rs`
|
|
||||||
|
|
||||||
### 7.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- `execute_window_operation`;
|
|
||||||
- bounded item/byte limiting;
|
|
||||||
- `truncated`, `window_complete`, `has_more`;
|
|
||||||
- summary building;
|
|
||||||
- redaction and truncation;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### 7.4. Тесты
|
|
||||||
|
|
||||||
- raw items mode;
|
|
||||||
- summary only mode;
|
|
||||||
- summary plus samples;
|
|
||||||
- max item truncation;
|
|
||||||
- max byte truncation;
|
|
||||||
- redaction;
|
|
||||||
- timeout handling.
|
|
||||||
|
|
||||||
### 7.5. DoD
|
|
||||||
|
|
||||||
- unary execution не ломается;
|
|
||||||
- window mode работает для synthetic adapter-level fixtures.
|
|
||||||
|
|
||||||
## 8. Slice 5: `feat/rest-sse-adapter`
|
|
||||||
|
|
||||||
### 8.1. Цель
|
|
||||||
|
|
||||||
Добавить bounded REST SSE support.
|
|
||||||
|
|
||||||
### 8.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- текущий crate [crates/crank-adapter-rest](/home/a.tolmachev/code/rust/mcpaas/crates/crank-adapter-rest)
|
|
||||||
- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs)
|
|
||||||
|
|
||||||
Ожидаемые файлы:
|
|
||||||
|
|
||||||
- `src/lib.rs`
|
|
||||||
- `src/error.rs`
|
|
||||||
- `src/client.rs`
|
|
||||||
- `src/sse.rs`
|
|
||||||
|
|
||||||
### 8.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- SSE connect;
|
|
||||||
- event parsing;
|
|
||||||
- bounded collect window;
|
|
||||||
- session start/poll/stop hooks, если adapter slice сразу включает stateful mode;
|
|
||||||
- proper close.
|
|
||||||
|
|
||||||
### 8.4. Тесты
|
|
||||||
|
|
||||||
- local SSE upstream fixture;
|
|
||||||
- collect N events;
|
|
||||||
- timeout without events;
|
|
||||||
- malformed event handling;
|
|
||||||
- reconnect not enabled by default.
|
|
||||||
|
|
||||||
## 9. Slice 6: `feat/grpc-server-streaming-adapter`
|
|
||||||
|
|
||||||
### 9.1. Цель
|
|
||||||
|
|
||||||
Добавить bounded gRPC server-streaming.
|
|
||||||
|
|
||||||
### 9.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [crates/crank-adapter-grpc](/home/a.tolmachev/code/rust/mcpaas/crates/crank-adapter-grpc)
|
|
||||||
- [crates/crank-proto](/home/a.tolmachev/code/rust/mcpaas/crates/crank-proto)
|
|
||||||
- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs)
|
|
||||||
|
|
||||||
### 9.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- identify unary vs server-streaming method from descriptors;
|
|
||||||
- invoke server stream;
|
|
||||||
- collect bounded window;
|
|
||||||
- decode stream items to normalized JSON;
|
|
||||||
- integrate with window mode.
|
|
||||||
|
|
||||||
### 9.4. Тесты
|
|
||||||
|
|
||||||
- local grpc fixture with server-streaming;
|
|
||||||
- descriptor-backed invocation;
|
|
||||||
- bounded collection;
|
|
||||||
- timeout;
|
|
||||||
- malformed item handling.
|
|
||||||
|
|
||||||
## 10. Slice 7: `feat/session-and-job-tools`
|
|
||||||
|
|
||||||
### 10.1. Цель
|
|
||||||
|
|
||||||
Ввести `session` и `async_job` как first-class runtime and MCP constructs.
|
|
||||||
|
|
||||||
### 10.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs)
|
|
||||||
- [apps/mcp-server/src/app.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/app.rs)
|
|
||||||
- [apps/mcp-server/src/catalog.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/catalog.rs)
|
|
||||||
- [apps/mcp-server/src/session.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/session.rs)
|
|
||||||
- [crates/crank-registry/src/postgres.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/postgres.rs)
|
|
||||||
|
|
||||||
### 10.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- runtime methods for start/poll/stop and start/status/result/cancel;
|
|
||||||
- MCP tool-family generation;
|
|
||||||
- binding-level tool-name derivation;
|
|
||||||
- JSON-RPC call routing for tool families;
|
|
||||||
- session/job observability.
|
|
||||||
|
|
||||||
### 10.4. Тесты
|
|
||||||
|
|
||||||
- tool listing shows generated family;
|
|
||||||
- `session start -> poll -> stop`;
|
|
||||||
- `async_job start -> status -> result -> cancel`;
|
|
||||||
- invalid session id;
|
|
||||||
- expired session;
|
|
||||||
- result before ready.
|
|
||||||
|
|
||||||
## 11. Slice 8: `feat/streaming-ui-config`
|
|
||||||
|
|
||||||
### 11.1. Цель
|
|
||||||
|
|
||||||
Добавить UI для streaming configuration.
|
|
||||||
|
|
||||||
### 11.2. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- `apps/ui/html/wizard/*.html`
|
|
||||||
- `apps/ui/js/wizard.js`
|
|
||||||
- `apps/ui/js/api.js`
|
|
||||||
- `apps/ui/js/i18n.js`
|
|
||||||
- CSS файлы wizard/settings/pages по необходимости
|
|
||||||
|
|
||||||
Добавить:
|
|
||||||
|
|
||||||
- `apps/ui/js/streaming-form.js`
|
|
||||||
- `apps/ui/js/stream-test-run.js`
|
|
||||||
- `apps/ui/html/stream-sessions.html`
|
|
||||||
- `apps/ui/html/async-jobs.html`
|
|
||||||
|
|
||||||
### 11.3. Что должно быть сделано
|
|
||||||
|
|
||||||
- protocol capabilities fetch;
|
|
||||||
- execution mode selector;
|
|
||||||
- protocol-specific field visibility;
|
|
||||||
- validation wiring;
|
|
||||||
- test-run UX for window/session/async job;
|
|
||||||
- sessions/jobs views;
|
|
||||||
- localization.
|
|
||||||
|
|
||||||
### 11.4. Тесты
|
|
||||||
|
|
||||||
- Playwright:
|
|
||||||
- configure REST window;
|
|
||||||
- configure gRPC streaming;
|
|
||||||
- validation error render;
|
|
||||||
- session test flow;
|
|
||||||
- async job test flow.
|
|
||||||
|
|
||||||
## 12. Slice 9: `feat/streaming-e2e`
|
|
||||||
|
|
||||||
### 12.1. Цель
|
|
||||||
|
|
||||||
Зафиксировать end-to-end behavior на живом тестовом стеке.
|
|
||||||
|
|
||||||
### 12.2. Что должно быть сделано
|
|
||||||
|
|
||||||
- добавить local fixtures:
|
|
||||||
- REST SSE server
|
|
||||||
- gRPC server-streaming server
|
|
||||||
- WebSocket event server
|
|
||||||
- SOAP fixture, если adapter уже готов
|
|
||||||
- добавить smoke scenarios;
|
|
||||||
- включить их в CI.
|
|
||||||
|
|
||||||
### 12.3. Тесты
|
|
||||||
|
|
||||||
- tool call end-to-end through mcp-server;
|
|
||||||
- usage/logging on streaming calls;
|
|
||||||
- session cleanup;
|
|
||||||
- async job cleanup.
|
|
||||||
|
|
||||||
## 13. Slice 10: `feat/websocket-upstream-adapter`
|
|
||||||
|
|
||||||
### 13.1. Файлы
|
|
||||||
|
|
||||||
Добавить:
|
|
||||||
|
|
||||||
- `crates/crank-adapter-websocket/Cargo.toml`
|
|
||||||
- `crates/crank-adapter-websocket/src/lib.rs`
|
|
||||||
- `crates/crank-adapter-websocket/src/error.rs`
|
|
||||||
- `crates/crank-adapter-websocket/src/client.rs`
|
|
||||||
- `crates/crank-adapter-websocket/src/session.rs`
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [crates/crank-core/src/protocol.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/protocol.rs)
|
|
||||||
- [crates/crank-core/src/operation.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/operation.rs)
|
|
||||||
- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs)
|
|
||||||
|
|
||||||
### 13.2. DoD
|
|
||||||
|
|
||||||
- bounded WebSocket window;
|
|
||||||
- session support;
|
|
||||||
- reconnect and heartbeat policy;
|
|
||||||
- capability matrix exposed in admin-api and UI.
|
|
||||||
|
|
||||||
## 14. Slice 11: `feat/soap-architecture-and-core-model`
|
|
||||||
|
|
||||||
### 14.1. Файлы
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- [crates/crank-core/src/protocol.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/protocol.rs)
|
|
||||||
- [crates/crank-core/src/operation.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/operation.rs)
|
|
||||||
- [crates/crank-schema](/home/a.tolmachev/code/rust/mcpaas/crates/crank-schema)
|
|
||||||
|
|
||||||
Добавить:
|
|
||||||
|
|
||||||
- `crates/crank-core/src/soap.rs`
|
|
||||||
|
|
||||||
### 14.2. DoD
|
|
||||||
|
|
||||||
- SOAP target model;
|
|
||||||
- WSDL/XSD-derived metadata model;
|
|
||||||
- XML normalization strategy documented in code-level types.
|
|
||||||
|
|
||||||
## 15. Slice 12: `feat/soap-adapter-foundation`
|
|
||||||
|
|
||||||
### 15.1. Файлы
|
|
||||||
|
|
||||||
Добавить:
|
|
||||||
|
|
||||||
- `crates/crank-adapter-soap/Cargo.toml`
|
|
||||||
- `crates/crank-adapter-soap/src/lib.rs`
|
|
||||||
- `crates/crank-adapter-soap/src/error.rs`
|
|
||||||
- `crates/crank-adapter-soap/src/wsdl.rs`
|
|
||||||
- `crates/crank-adapter-soap/src/xml.rs`
|
|
||||||
- `crates/crank-adapter-soap/src/client.rs`
|
|
||||||
|
|
||||||
Изменить:
|
|
||||||
|
|
||||||
- `apps/admin-api`
|
|
||||||
- `apps/ui`
|
|
||||||
- `crates/crank-runtime`
|
|
||||||
|
|
||||||
### 15.2. DoD
|
|
||||||
|
|
||||||
- WSDL import;
|
|
||||||
- service/port/operation selection;
|
|
||||||
- SOAP request-response execution;
|
|
||||||
- fault normalization;
|
|
||||||
- UI test-run support.
|
|
||||||
|
|
||||||
## 16. State transition tables
|
|
||||||
|
|
||||||
## 16.1. `StreamSession`
|
|
||||||
|
|
||||||
Allowed:
|
|
||||||
|
|
||||||
- `created -> running`
|
|
||||||
- `running -> running`
|
|
||||||
- `running -> stopped`
|
|
||||||
- `running -> expired`
|
|
||||||
- `running -> failed`
|
|
||||||
- `stopped -> deleted`
|
|
||||||
- `expired -> deleted`
|
|
||||||
- `failed -> deleted`
|
|
||||||
|
|
||||||
Forbidden:
|
|
||||||
|
|
||||||
- `stopped -> running`
|
|
||||||
- `expired -> running`
|
|
||||||
- `deleted -> *`
|
|
||||||
|
|
||||||
## 16.2. `AsyncJob`
|
|
||||||
|
|
||||||
Allowed:
|
|
||||||
|
|
||||||
- `created -> running`
|
|
||||||
- `running -> running`
|
|
||||||
- `running -> completed`
|
|
||||||
- `running -> failed`
|
|
||||||
- `running -> cancelled`
|
|
||||||
- `completed -> expired`
|
|
||||||
- `failed -> expired`
|
|
||||||
- `cancelled -> expired`
|
|
||||||
|
|
||||||
Forbidden:
|
|
||||||
|
|
||||||
- `completed -> running`
|
|
||||||
- `cancelled -> running`
|
|
||||||
|
|
||||||
## 17. Sequence outlines
|
|
||||||
|
|
||||||
## 17.1. Window
|
|
||||||
|
|
||||||
1. MCP client calls tool.
|
|
||||||
2. `mcp-server` resolves tool binding.
|
|
||||||
3. `runtime.execute_window_operation`.
|
|
||||||
4. adapter collects bounded upstream data.
|
|
||||||
5. runtime aggregates and truncates.
|
|
||||||
6. `mcp-server` returns final JSON-RPC result.
|
|
||||||
|
|
||||||
## 17.2. Session
|
|
||||||
|
|
||||||
1. MCP client calls `{tool}_start`.
|
|
||||||
2. runtime starts upstream session and persists `StreamSession`.
|
|
||||||
3. client calls `{tool}_poll`.
|
|
||||||
4. runtime loads session and collects next bounded chunk.
|
|
||||||
5. client calls `{tool}_stop`.
|
|
||||||
6. runtime closes upstream and marks session stopped.
|
|
||||||
|
|
||||||
## 17.3. Async Job
|
|
||||||
|
|
||||||
1. MCP client calls `{tool}_start`.
|
|
||||||
2. runtime starts long-running job and persists `AsyncJob`.
|
|
||||||
3. client calls `{tool}_status`.
|
|
||||||
4. runtime returns current progress.
|
|
||||||
5. client calls `{tool}_result`.
|
|
||||||
6. runtime returns final normalized result if ready.
|
|
||||||
|
|
||||||
## 18. Acceptance checklist per slice
|
|
||||||
|
|
||||||
Перед merge каждого slice агент обязан проверить:
|
|
||||||
|
|
||||||
- docs updated if contract changed;
|
|
||||||
- `just fmt-check`
|
|
||||||
- `just check`
|
|
||||||
- `just clippy`
|
|
||||||
- `just test`
|
|
||||||
- relevant Playwright/e2e if UI touched;
|
|
||||||
- no hidden feature flags without docs;
|
|
||||||
- no unsupported combinations exposed in UI.
|
|
||||||
|
|
||||||
## 19. Branch and commit policy
|
|
||||||
|
|
||||||
Ожидаемые branch names:
|
|
||||||
|
|
||||||
- `feat/mcp-streamable-http-alignment`
|
|
||||||
- `feat/streaming-core-model`
|
|
||||||
- `feat/stream-session-store`
|
|
||||||
- `feat/runtime-window-mode`
|
|
||||||
- `feat/rest-sse-adapter`
|
|
||||||
- `feat/grpc-server-streaming-adapter`
|
|
||||||
- `feat/session-and-job-tools`
|
|
||||||
- `feat/streaming-ui-config`
|
|
||||||
- `feat/streaming-e2e`
|
|
||||||
- `feat/websocket-upstream-adapter`
|
|
||||||
- `feat/soap-architecture-and-core-model`
|
|
||||||
- `feat/soap-adapter-foundation`
|
|
||||||
|
|
||||||
Ожидаемые commit classes:
|
|
||||||
|
|
||||||
- `docs: ...`
|
|
||||||
- `feat: ...`
|
|
||||||
- `test: ...`
|
|
||||||
- `refactor: ...`
|
|
||||||
- `fix: ...`
|
|
||||||
|
|
||||||
## 20. Practical rule for agents
|
|
||||||
|
|
||||||
Агент не должен брать следующий slice, пока не выполнены DoD и тесты предыдущего slice.
|
|
||||||
|
|
||||||
Если slice требует новый contract:
|
|
||||||
|
|
||||||
1. обновить docs;
|
|
||||||
2. обновить tests;
|
|
||||||
3. обновить code;
|
|
||||||
4. только потом переходить дальше.
|
|
||||||
@@ -1,702 +0,0 @@
|
|||||||
# Streaming MCP Plan
|
|
||||||
|
|
||||||
## 1. Назначение документа
|
|
||||||
|
|
||||||
Этот документ фиксирует целевую архитектуру потоковой обработки в Crank. Он описывает:
|
|
||||||
|
|
||||||
- какие transport- и upstream-протоколы поддерживаются;
|
|
||||||
- какие streaming-сценарии считаются допустимыми;
|
|
||||||
- какие ограничения обязательны для безопасности и управляемости;
|
|
||||||
- какие сущности, API, UI-поля и runtime-механизмы нужно добавить;
|
|
||||||
- в каком порядке это реализовывать.
|
|
||||||
|
|
||||||
Документ синхронизирован с MCP transport specification `2025-06-18`, где `Streamable HTTP` определен как основной HTTP transport, а SSE допускается как часть `POST`-response и как отдельный `GET`-stream.
|
|
||||||
|
|
||||||
## 2. Базовое архитектурное решение
|
|
||||||
|
|
||||||
Crank поддерживает streaming не как бесконечный текстовый поток в чат, а как управляемую инструментальную модель поверх MCP tools.
|
|
||||||
|
|
||||||
Принцип:
|
|
||||||
|
|
||||||
- transport может быть long-lived;
|
|
||||||
- tool contract обязан оставаться ограниченным и управляемым;
|
|
||||||
- upstream stream всегда преобразуется в bounded result, session poll или async job;
|
|
||||||
- оператор в UI настраивает не "трубу", а режим сбора, агрегации и завершения.
|
|
||||||
|
|
||||||
## 3. Поддерживаемые transport- и upstream-протоколы
|
|
||||||
|
|
||||||
### 3.1. Downstream MCP transport
|
|
||||||
|
|
||||||
Поддерживается:
|
|
||||||
|
|
||||||
- `Streamable HTTP` как канонический network transport;
|
|
||||||
- HTTP `POST` с ответом `application/json` или `text/event-stream`;
|
|
||||||
- HTTP `GET` для server-to-client SSE stream;
|
|
||||||
- несколько SSE streams одновременно в рамках одного MCP session;
|
|
||||||
- `Mcp-Session-Id` и `MCP-Protocol-Version` headers;
|
|
||||||
- resumable SSE streams как опциональная возможность.
|
|
||||||
|
|
||||||
Не является текущим приоритетом:
|
|
||||||
|
|
||||||
- `stdio` как обязательная часть продукта;
|
|
||||||
- собственные нестандартные transport-режимы поверх MCP.
|
|
||||||
|
|
||||||
### 3.2. Upstream protocols
|
|
||||||
|
|
||||||
Поддерживаются:
|
|
||||||
|
|
||||||
- REST unary;
|
|
||||||
- REST SSE в bounded режимах;
|
|
||||||
- GraphQL `query` и `mutation`;
|
|
||||||
- gRPC unary;
|
|
||||||
- gRPC server-streaming в bounded режимах;
|
|
||||||
- WebSocket в bounded режимах.
|
|
||||||
|
|
||||||
Отложено:
|
|
||||||
|
|
||||||
- GraphQL `subscription`;
|
|
||||||
- gRPC client-streaming;
|
|
||||||
- gRPC bidirectional streaming;
|
|
||||||
- arbitrary websocket passthrough;
|
|
||||||
- raw infinite stream forwarding в MCP client.
|
|
||||||
|
|
||||||
### 3.3. Protocol capability matrix
|
|
||||||
|
|
||||||
| Protocol | Unary | Window | Session | Async Job | Notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| REST | Yes | Yes | Limited | Yes | SSE and long-poll sources are supported in controlled form |
|
|
||||||
| GraphQL | Yes | No | No | Limited | `query` and `mutation` only; `subscription` is future scope |
|
|
||||||
| gRPC | Yes | Yes | Yes | Yes | server-streaming only; client/bidi deferred |
|
|
||||||
| WebSocket | No | Yes | Yes | Yes | upstream adapter only; not downstream MCP transport |
|
|
||||||
| SOAP | Yes | Limited | Limited | Yes | primarily request-response enterprise workflows |
|
|
||||||
|
|
||||||
## 4. Поддерживаемые streaming modes
|
|
||||||
|
|
||||||
Crank поддерживает четыре режима выполнения operation.
|
|
||||||
|
|
||||||
### 4.1. `unary`
|
|
||||||
|
|
||||||
Обычный request-response вызов.
|
|
||||||
|
|
||||||
Подходит для:
|
|
||||||
|
|
||||||
- REST;
|
|
||||||
- GraphQL `query` и `mutation`;
|
|
||||||
- gRPC unary;
|
|
||||||
- SOAP.
|
|
||||||
|
|
||||||
### 4.2. `window`
|
|
||||||
|
|
||||||
Runtime открывает upstream stream или repeatedly polls upstream source, собирает данные в пределах окна и возвращает один bounded ответ.
|
|
||||||
|
|
||||||
Параметры:
|
|
||||||
|
|
||||||
- `window_duration_ms`
|
|
||||||
- `max_items`
|
|
||||||
- `max_bytes`
|
|
||||||
- `upstream_timeout_ms`
|
|
||||||
- `aggregation_mode`
|
|
||||||
|
|
||||||
Подходит для:
|
|
||||||
|
|
||||||
- логи за период;
|
|
||||||
- метрики за период;
|
|
||||||
- event window;
|
|
||||||
- SSE stream snapshot;
|
|
||||||
- gRPC server-stream window;
|
|
||||||
- WebSocket event window.
|
|
||||||
|
|
||||||
### 4.3. `session`
|
|
||||||
|
|
||||||
Runtime создает stream session, после чего данные читаются по шагам через session-oriented tool family.
|
|
||||||
|
|
||||||
Обязательные операции:
|
|
||||||
|
|
||||||
- `start`
|
|
||||||
- `poll`
|
|
||||||
- `stop`
|
|
||||||
|
|
||||||
Подходит для:
|
|
||||||
|
|
||||||
- follow logs;
|
|
||||||
- telemetry follow;
|
|
||||||
- alert/event feed;
|
|
||||||
- контроль длительных stream-подписок;
|
|
||||||
- WebSocket subscriptions.
|
|
||||||
|
|
||||||
### 4.4. `async_job`
|
|
||||||
|
|
||||||
Runtime запускает long-running upstream operation и возвращает `job_id`.
|
|
||||||
|
|
||||||
Обязательные операции:
|
|
||||||
|
|
||||||
- `start`
|
|
||||||
- `status`
|
|
||||||
- `result`
|
|
||||||
- `cancel`
|
|
||||||
|
|
||||||
Подходит для:
|
|
||||||
|
|
||||||
- import/export;
|
|
||||||
- deploy/reindex;
|
|
||||||
- batch processing;
|
|
||||||
- инфраструктурные control-plane действия;
|
|
||||||
- SOAP workflows with deferred status polling.
|
|
||||||
|
|
||||||
## 5. Бизнес-кейсы
|
|
||||||
|
|
||||||
### 5.1. Logs Window
|
|
||||||
|
|
||||||
LLM запрашивает:
|
|
||||||
|
|
||||||
- ошибки сервиса за последние `30s`;
|
|
||||||
- top errors за последние `100` записей;
|
|
||||||
- логи по конкретному `correlation_id`.
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- собирает bounded окно;
|
|
||||||
- агрегирует counts, уровни, sample lines;
|
|
||||||
- возвращает summary плюс ограниченный список items.
|
|
||||||
|
|
||||||
### 5.2. Metrics Window
|
|
||||||
|
|
||||||
LLM запрашивает:
|
|
||||||
|
|
||||||
- latency/error summary по сервису;
|
|
||||||
- CPU/memory snapshot;
|
|
||||||
- anomaly summary за окно.
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- читает поток метрик;
|
|
||||||
- агрегирует min/max/avg/p95 или anomaly set;
|
|
||||||
- возвращает компактный JSON.
|
|
||||||
|
|
||||||
### 5.3. Event Feed
|
|
||||||
|
|
||||||
LLM запрашивает:
|
|
||||||
|
|
||||||
- audit events за период;
|
|
||||||
- queue events по фильтру;
|
|
||||||
- security alerts за окно.
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- фильтрует events;
|
|
||||||
- ограничивает количество;
|
|
||||||
- возвращает items plus cursor.
|
|
||||||
|
|
||||||
### 5.4. Long-running Operation Status
|
|
||||||
|
|
||||||
LLM запускает:
|
|
||||||
|
|
||||||
- reindex;
|
|
||||||
- import job;
|
|
||||||
- rollout;
|
|
||||||
- repair task.
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- создает job handle;
|
|
||||||
- возвращает `job_id`;
|
|
||||||
- позволяет дальше получать status/result/cancel.
|
|
||||||
|
|
||||||
### 5.5. Control Plane Follow
|
|
||||||
|
|
||||||
LLM инициирует:
|
|
||||||
|
|
||||||
- reboot;
|
|
||||||
- rollout;
|
|
||||||
- node drain;
|
|
||||||
- workflow transition.
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- стартует действие;
|
|
||||||
- пишет progress в session/job state;
|
|
||||||
- возвращает snapshots по `poll`.
|
|
||||||
|
|
||||||
### 5.6. WebSocket Realtime Feeds
|
|
||||||
|
|
||||||
LLM запрашивает:
|
|
||||||
|
|
||||||
- realtime alert snapshot;
|
|
||||||
- device telemetry window;
|
|
||||||
- market data slice;
|
|
||||||
- status feed по subscription channel.
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- открывает upstream WebSocket;
|
|
||||||
- подписывается на channel;
|
|
||||||
- собирает bounded окно или session step;
|
|
||||||
- возвращает summary и limited items.
|
|
||||||
|
|
||||||
### 5.7. SOAP Enterprise Operations
|
|
||||||
|
|
||||||
LLM запрашивает:
|
|
||||||
|
|
||||||
- создание/поиск сущности в ERP;
|
|
||||||
- запуск enterprise workflow;
|
|
||||||
- получение статуса batch operation;
|
|
||||||
- B2B request через SOAP gateway.
|
|
||||||
|
|
||||||
Runtime:
|
|
||||||
|
|
||||||
- строит SOAP envelope из MCP input;
|
|
||||||
- вызывает enterprise endpoint;
|
|
||||||
- нормализует XML response или SOAP Fault;
|
|
||||||
- возвращает JSON-oriented output.
|
|
||||||
|
|
||||||
## 6. Функциональные требования
|
|
||||||
|
|
||||||
### 6.1. Общие
|
|
||||||
|
|
||||||
- operation должна явно указывать `execution_mode`;
|
|
||||||
- streaming operation обязана быть bounded;
|
|
||||||
- runtime обязан поддерживать timeout, max items и max bytes;
|
|
||||||
- tool output обязан иметь предсказуемую схему;
|
|
||||||
- stream/session/job state должен быть наблюдаемым и логируемым;
|
|
||||||
- cancel/stop должен быть явной операцией, а не побочным эффектом disconnect.
|
|
||||||
|
|
||||||
### 6.2. Для `window`
|
|
||||||
|
|
||||||
- задать размер окна;
|
|
||||||
- задать лимит items;
|
|
||||||
- задать лимит bytes;
|
|
||||||
- выбрать aggregation mode;
|
|
||||||
- вернуть `truncated` и `window_complete` flags;
|
|
||||||
- поддерживать optional cursor для следующего окна.
|
|
||||||
|
|
||||||
### 6.3. Для `session`
|
|
||||||
|
|
||||||
- создать `session_id`;
|
|
||||||
- поддерживать `poll`;
|
|
||||||
- поддерживать `stop`;
|
|
||||||
- хранить курсор и session status;
|
|
||||||
- иметь `idle_timeout`;
|
|
||||||
- иметь `max_session_lifetime`;
|
|
||||||
- удалять expired sessions.
|
|
||||||
|
|
||||||
### 6.4. Для `async_job`
|
|
||||||
|
|
||||||
- создать `job_id`;
|
|
||||||
- хранить progress, status и final result metadata;
|
|
||||||
- поддерживать `cancel`;
|
|
||||||
- поддерживать retrieval последнего готового результата.
|
|
||||||
|
|
||||||
## 7. Нефункциональные требования
|
|
||||||
|
|
||||||
### 7.1. Безопасность
|
|
||||||
|
|
||||||
- никакого неограниченного passthrough потока;
|
|
||||||
- обязательные лимиты по времени, items и bytes;
|
|
||||||
- обязательный redact layer для secret-bearing полей;
|
|
||||||
- audit trail на `start`, `poll`, `stop`, `cancel`;
|
|
||||||
- session и job identifiers должны быть криптографически стойкими.
|
|
||||||
|
|
||||||
### 7.2. Производительность
|
|
||||||
|
|
||||||
- bounded memory per session;
|
|
||||||
- bounded upstream read buffer;
|
|
||||||
- ограничение числа параллельных sessions и jobs на workspace и agent;
|
|
||||||
- backpressure при медленных клиентах;
|
|
||||||
- возможность early cut-off после достижения лимита.
|
|
||||||
|
|
||||||
### 7.3. Надежность
|
|
||||||
|
|
||||||
- TTL и cleanup для sessions/jobs;
|
|
||||||
- устойчивость к disconnect downstream client;
|
|
||||||
- poll должен быть идемпотентным;
|
|
||||||
- long-running upstream action не должен считаться отмененным из-за SSE disconnect;
|
|
||||||
- resumability для downstream SSE допускается как следующая волна, но не является обязательной в первой реализации.
|
|
||||||
|
|
||||||
### 7.4. UX
|
|
||||||
|
|
||||||
- оператор должен видеть, что operation является `unary`, `window`, `session` или `async_job`;
|
|
||||||
- UI должен явно показывать все лимиты и режим агрегации;
|
|
||||||
- результат тестового вызова должен показывать `truncated`, `window_complete`, `has_more`, `status`.
|
|
||||||
|
|
||||||
## 8. UI contract
|
|
||||||
|
|
||||||
### 8.1. Новый блок `Execution mode`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `mode`: `unary | window | session | async_job`
|
|
||||||
- `transport_behavior`: `request_response | server_stream`
|
|
||||||
|
|
||||||
### 8.2. Блок `Collection limits`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `window_duration_ms`
|
|
||||||
- `poll_interval_ms`
|
|
||||||
- `upstream_timeout_ms`
|
|
||||||
- `idle_timeout_ms`
|
|
||||||
- `max_session_lifetime_ms`
|
|
||||||
- `max_items`
|
|
||||||
- `max_bytes`
|
|
||||||
|
|
||||||
### 8.3. Блок `Aggregation`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `aggregation_mode`: `raw_items | summary_only | summary_plus_samples | stats | latest_state`
|
|
||||||
- `summary_path`
|
|
||||||
- `items_path`
|
|
||||||
- `cursor_path`
|
|
||||||
- `status_path`
|
|
||||||
- `done_path`
|
|
||||||
|
|
||||||
### 8.4. Блок `Safety`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `truncate_item_fields`
|
|
||||||
- `max_field_length`
|
|
||||||
- `redacted_paths`
|
|
||||||
- `drop_duplicates`
|
|
||||||
- `sampling_rate`
|
|
||||||
|
|
||||||
### 8.5. Block `Tool family`
|
|
||||||
|
|
||||||
Для `session`:
|
|
||||||
|
|
||||||
- `start_tool_name`
|
|
||||||
- `poll_tool_name`
|
|
||||||
- `stop_tool_name`
|
|
||||||
|
|
||||||
Для `async_job`:
|
|
||||||
|
|
||||||
- `start_tool_name`
|
|
||||||
- `status_tool_name`
|
|
||||||
- `result_tool_name`
|
|
||||||
- `cancel_tool_name`
|
|
||||||
|
|
||||||
## 9. Domain model changes
|
|
||||||
|
|
||||||
### 9.1. `ExecutionMode`
|
|
||||||
|
|
||||||
Новый enum:
|
|
||||||
|
|
||||||
- `Unary`
|
|
||||||
- `Window`
|
|
||||||
- `Session`
|
|
||||||
- `AsyncJob`
|
|
||||||
|
|
||||||
### 9.2. `StreamingConfig`
|
|
||||||
|
|
||||||
Новая часть `execution_config`:
|
|
||||||
|
|
||||||
- `mode`
|
|
||||||
- `window_duration_ms`
|
|
||||||
- `poll_interval_ms`
|
|
||||||
- `upstream_timeout_ms`
|
|
||||||
- `idle_timeout_ms`
|
|
||||||
- `max_session_lifetime_ms`
|
|
||||||
- `max_items`
|
|
||||||
- `max_bytes`
|
|
||||||
- `aggregation_mode`
|
|
||||||
- `items_path`
|
|
||||||
- `summary_path`
|
|
||||||
- `cursor_path`
|
|
||||||
- `status_path`
|
|
||||||
- `done_path`
|
|
||||||
- `redacted_paths`
|
|
||||||
|
|
||||||
### 9.3. `StreamSession`
|
|
||||||
|
|
||||||
Новая runtime/store сущность:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `workspace_id`
|
|
||||||
- `agent_id`
|
|
||||||
- `operation_id`
|
|
||||||
- `protocol`
|
|
||||||
- `mode`
|
|
||||||
- `status`
|
|
||||||
- `cursor`
|
|
||||||
- `state_json`
|
|
||||||
- `expires_at`
|
|
||||||
- `last_poll_at`
|
|
||||||
- `created_at`
|
|
||||||
- `closed_at`
|
|
||||||
|
|
||||||
### 9.4. `AsyncJobHandle`
|
|
||||||
|
|
||||||
Новая runtime/store сущность:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `workspace_id`
|
|
||||||
- `agent_id`
|
|
||||||
- `operation_id`
|
|
||||||
- `status`
|
|
||||||
- `progress_json`
|
|
||||||
- `result_json`
|
|
||||||
- `error_json`
|
|
||||||
- `expires_at`
|
|
||||||
- `created_at`
|
|
||||||
- `updated_at`
|
|
||||||
- `finished_at`
|
|
||||||
|
|
||||||
## 10. MCP publishing model for streaming tools
|
|
||||||
|
|
||||||
### 10.1. Unary and Window
|
|
||||||
|
|
||||||
`unary` и `window` публикуются как один MCP tool:
|
|
||||||
|
|
||||||
- один input contract;
|
|
||||||
- один bounded result;
|
|
||||||
- transport может использовать `application/json` или SSE response stream до финального JSON-RPC response.
|
|
||||||
|
|
||||||
### 10.2. Session
|
|
||||||
|
|
||||||
`session` публикуется как tool family:
|
|
||||||
|
|
||||||
- `{tool}_start`
|
|
||||||
- `{tool}_poll`
|
|
||||||
- `{tool}_stop`
|
|
||||||
|
|
||||||
Причина:
|
|
||||||
|
|
||||||
- lifecycle становится явным;
|
|
||||||
- LLM получает контролируемую state machine;
|
|
||||||
- runtime не скрывает долговременное состояние за одним "магическим" вызовом.
|
|
||||||
|
|
||||||
### 10.3. Async Job
|
|
||||||
|
|
||||||
`async_job` публикуется как tool family:
|
|
||||||
|
|
||||||
- `{tool}_start`
|
|
||||||
- `{tool}_status`
|
|
||||||
- `{tool}_result`
|
|
||||||
- `{tool}_cancel`
|
|
||||||
|
|
||||||
## 11. Module decomposition and responsibilities
|
|
||||||
|
|
||||||
### 11.1. `crank-core`
|
|
||||||
|
|
||||||
Новые модули:
|
|
||||||
|
|
||||||
- `streaming`
|
|
||||||
- `stream_session`
|
|
||||||
|
|
||||||
Новые типы:
|
|
||||||
|
|
||||||
- `ExecutionMode`
|
|
||||||
- `StreamingConfig`
|
|
||||||
- `AggregationMode`
|
|
||||||
- `StreamSession`
|
|
||||||
- `AsyncJobHandle`
|
|
||||||
- `StreamStatus`
|
|
||||||
- `JobStatus`
|
|
||||||
|
|
||||||
### 11.2. `crank-registry`
|
|
||||||
|
|
||||||
Новые обязанности:
|
|
||||||
|
|
||||||
- хранение `stream_sessions`;
|
|
||||||
- хранение `async_jobs`;
|
|
||||||
- cleanup expired rows;
|
|
||||||
- optimistic updates on poll/stop/cancel.
|
|
||||||
|
|
||||||
Ожидаемые функции:
|
|
||||||
|
|
||||||
- `create_stream_session`
|
|
||||||
- `get_stream_session`
|
|
||||||
- `advance_stream_session`
|
|
||||||
- `close_stream_session`
|
|
||||||
- `create_async_job`
|
|
||||||
- `get_async_job`
|
|
||||||
- `update_async_job_status`
|
|
||||||
- `cancel_async_job`
|
|
||||||
- `delete_expired_stream_sessions`
|
|
||||||
|
|
||||||
### 11.3. `crank-runtime`
|
|
||||||
|
|
||||||
Новые orchestration функции:
|
|
||||||
|
|
||||||
- `execute_unary_operation`
|
|
||||||
- `execute_window_operation`
|
|
||||||
- `start_stream_session`
|
|
||||||
- `poll_stream_session`
|
|
||||||
- `stop_stream_session`
|
|
||||||
- `start_async_job`
|
|
||||||
- `get_async_job_status`
|
|
||||||
- `get_async_job_result`
|
|
||||||
- `cancel_async_job`
|
|
||||||
|
|
||||||
### 11.4. Protocol adapters
|
|
||||||
|
|
||||||
REST:
|
|
||||||
|
|
||||||
- unary HTTP;
|
|
||||||
- bounded SSE collection;
|
|
||||||
- bounded long-poll collection.
|
|
||||||
|
|
||||||
GraphQL:
|
|
||||||
|
|
||||||
- `query` и `mutation`;
|
|
||||||
- `subscription` отложен на отдельную protocol wave.
|
|
||||||
|
|
||||||
gRPC:
|
|
||||||
|
|
||||||
- unary;
|
|
||||||
- bounded server-stream collection;
|
|
||||||
- client/bidi отложены на отдельную protocol wave.
|
|
||||||
|
|
||||||
WebSocket:
|
|
||||||
|
|
||||||
- bounded event collection;
|
|
||||||
- subscribe/poll/stop orchestration;
|
|
||||||
- heartbeat and reconnect policy.
|
|
||||||
|
|
||||||
SOAP:
|
|
||||||
|
|
||||||
- WSDL-driven request/response adapter;
|
|
||||||
- XML normalization;
|
|
||||||
- SOAP Fault normalization;
|
|
||||||
- future WS-Security expansion.
|
|
||||||
|
|
||||||
### 11.5. `apps/mcp-server`
|
|
||||||
|
|
||||||
Новые обязанности:
|
|
||||||
|
|
||||||
- корректно вести `Streamable HTTP` lifecycle;
|
|
||||||
- принимать `POST` с `Accept: application/json, text/event-stream`;
|
|
||||||
- отдавать `application/json` или `text/event-stream`;
|
|
||||||
- поддерживать `GET` SSE stream для server-to-client messages и notifications;
|
|
||||||
- вести `Mcp-Session-Id`;
|
|
||||||
- публиковать tool families для `session` и `async_job`.
|
|
||||||
|
|
||||||
### 11.6. `apps/admin-api`
|
|
||||||
|
|
||||||
Новые обязанности:
|
|
||||||
|
|
||||||
- CRUD и versioning для streaming config;
|
|
||||||
- тестовые window/session/job runs;
|
|
||||||
- UI-oriented validation ошибок для streaming fields.
|
|
||||||
|
|
||||||
### 11.7. `apps/ui`
|
|
||||||
|
|
||||||
- конфиг execution mode;
|
|
||||||
- конфиг limits/aggregation/safety;
|
|
||||||
- test-run screen для bounded window/session/job behavior;
|
|
||||||
- отдельные предупреждения про truncation и timeouts.
|
|
||||||
|
|
||||||
## 12. Границы текущей продуктовой волны
|
|
||||||
|
|
||||||
В первой продуктовой волне входит:
|
|
||||||
|
|
||||||
- `Streamable HTTP` и SSE на MCP transport;
|
|
||||||
- `window` mode;
|
|
||||||
- `async_job` mode;
|
|
||||||
- REST SSE;
|
|
||||||
- gRPC server streaming;
|
|
||||||
- tool family generation;
|
|
||||||
- bounded session/job state.
|
|
||||||
|
|
||||||
Во второй продуктовой волне:
|
|
||||||
|
|
||||||
- WebSocket upstream adapter;
|
|
||||||
- SOAP adapter foundation;
|
|
||||||
- richer session tooling;
|
|
||||||
- expanded protocol smoke suite.
|
|
||||||
|
|
||||||
Отложено:
|
|
||||||
|
|
||||||
- GraphQL subscriptions;
|
|
||||||
- gRPC client streaming;
|
|
||||||
- gRPC bidirectional streaming;
|
|
||||||
- raw infinite stream passthrough;
|
|
||||||
- guaranteed resumability across all stream types;
|
|
||||||
- generic websocket proxy mode.
|
|
||||||
|
|
||||||
## 13. Порядок реализации
|
|
||||||
|
|
||||||
### 13.1. `feat/streaming-mcp-architecture`
|
|
||||||
|
|
||||||
- зафиксировать docs;
|
|
||||||
- обновить protocol support matrix;
|
|
||||||
- синхронизировать `TASKS.md`.
|
|
||||||
|
|
||||||
### 13.2. `feat/mcp-streamable-http-alignment`
|
|
||||||
|
|
||||||
- довести `mcp-server` до полного соответствия `Streamable HTTP`;
|
|
||||||
- session headers;
|
|
||||||
- GET SSE;
|
|
||||||
- protocol version header validation;
|
|
||||||
- explicit cancel behavior.
|
|
||||||
|
|
||||||
### 13.3. `feat/streaming-core-model`
|
|
||||||
|
|
||||||
- ввести `ExecutionMode`, `StreamingConfig`, `StreamSession`, `AsyncJobHandle`.
|
|
||||||
|
|
||||||
### 13.4. `feat/stream-session-store`
|
|
||||||
|
|
||||||
- таблицы `stream_sessions` и `async_jobs`;
|
|
||||||
- cleanup;
|
|
||||||
- optimistic state transitions.
|
|
||||||
|
|
||||||
### 13.5. `feat/runtime-window-mode`
|
|
||||||
|
|
||||||
- bounded collection для `window`;
|
|
||||||
- `truncated`, `window_complete`, `has_more`.
|
|
||||||
|
|
||||||
### 13.6. `feat/rest-sse-adapter`
|
|
||||||
|
|
||||||
- поддержка REST SSE upstream.
|
|
||||||
|
|
||||||
### 13.7. `feat/grpc-server-streaming-adapter`
|
|
||||||
|
|
||||||
- поддержка bounded gRPC server-streaming.
|
|
||||||
|
|
||||||
### 13.8. `feat/session-and-job-tools`
|
|
||||||
|
|
||||||
- генерация tool families;
|
|
||||||
- `start/poll/stop`;
|
|
||||||
- `start/status/result/cancel`.
|
|
||||||
|
|
||||||
### 13.9. `feat/websocket-upstream-adapter`
|
|
||||||
|
|
||||||
- bounded WebSocket collection;
|
|
||||||
- subscribe/unsubscribe templates;
|
|
||||||
- heartbeat/reconnect policy;
|
|
||||||
- session integration.
|
|
||||||
|
|
||||||
### 13.10. `feat/soap-architecture-and-core-model`
|
|
||||||
|
|
||||||
- WSDL/XSD-driven domain model;
|
|
||||||
- SOAP execution config;
|
|
||||||
- XML normalization strategy.
|
|
||||||
|
|
||||||
### 13.11. `feat/soap-adapter-foundation`
|
|
||||||
|
|
||||||
- runtime SOAP adapter;
|
|
||||||
- envelope builder;
|
|
||||||
- fault normalization;
|
|
||||||
- test-run support.
|
|
||||||
|
|
||||||
### 13.12. `feat/streaming-ui-config`
|
|
||||||
|
|
||||||
- новый execution mode selector;
|
|
||||||
- limits/aggregation/safety blocks;
|
|
||||||
- test-run UX.
|
|
||||||
|
|
||||||
### 13.13. `feat/streaming-e2e`
|
|
||||||
|
|
||||||
- публичные smoke targets;
|
|
||||||
- e2e сценарии;
|
|
||||||
- manual regression plan.
|
|
||||||
|
|
||||||
## 14. Практический итог
|
|
||||||
|
|
||||||
Crank должен поддерживать streaming как полнофункциональный MCP proxy, но в управляемой форме:
|
|
||||||
|
|
||||||
- transport-level SSE и `Streamable HTTP` поддерживаются;
|
|
||||||
- upstream streaming поддерживается там, где его можно bounded-ить;
|
|
||||||
- tool contract остается контролируемым;
|
|
||||||
- UI настраивает лимиты, aggregation и lifecycle;
|
|
||||||
- платформа не превращается в бесконечную data pipe.
|
|
||||||
@@ -1,580 +0,0 @@
|
|||||||
# Streaming Runtime Design
|
|
||||||
|
|
||||||
## 1. Назначение документа
|
|
||||||
|
|
||||||
Этот документ фиксирует, как потоковая модель должна быть разложена по crates, traits, services и функциям.
|
|
||||||
|
|
||||||
Цель:
|
|
||||||
|
|
||||||
- избежать god-services;
|
|
||||||
- зафиксировать boundaries между `core`, `registry`, `runtime`, protocol adapters и `mcp-server`;
|
|
||||||
- расписать ожидаемые функции и их ответственность до уровня инженерной спецификации.
|
|
||||||
|
|
||||||
## 2. Общая схема слоев
|
|
||||||
|
|
||||||
### 2.1. `crank-core`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- типы `ExecutionMode`, `StreamingConfig`, `AggregationMode`;
|
|
||||||
- типы `StreamSession`, `AsyncJobHandle`, `StreamStatus`, `JobStatus`;
|
|
||||||
- validation helpers без инфраструктуры.
|
|
||||||
|
|
||||||
### 2.2. `crank-registry`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- persistence session/job state;
|
|
||||||
- optimistic transitions;
|
|
||||||
- cleanup expired state.
|
|
||||||
|
|
||||||
### 2.3. `crank-runtime`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- orchestration execution modes;
|
|
||||||
- dispatch в protocol adapter;
|
|
||||||
- bounded collection;
|
|
||||||
- aggregation;
|
|
||||||
- state transitions;
|
|
||||||
- invocation logging.
|
|
||||||
|
|
||||||
### 2.4. Protocol adapters
|
|
||||||
|
|
||||||
Отвечают за:
|
|
||||||
|
|
||||||
- protocol-specific upstream behavior;
|
|
||||||
- connect/request/collect/close;
|
|
||||||
- преобразование upstream payload в нормализованный JSON.
|
|
||||||
|
|
||||||
### 2.5. `apps/mcp-server`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- downstream MCP transport;
|
|
||||||
- tool-family generation;
|
|
||||||
- JSON-RPC lifecycle;
|
|
||||||
- transport/session routing.
|
|
||||||
|
|
||||||
### 2.6. `apps/admin-api`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- config CRUD;
|
|
||||||
- test-runs;
|
|
||||||
- validation;
|
|
||||||
- UI-oriented metadata and previews.
|
|
||||||
|
|
||||||
## 3. Core domain types
|
|
||||||
|
|
||||||
## 3.1. `ExecutionMode`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum ExecutionMode {
|
|
||||||
Unary,
|
|
||||||
Window,
|
|
||||||
Session,
|
|
||||||
AsyncJob,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- `fn is_stateful(&self) -> bool`
|
|
||||||
- `fn requires_tool_family(&self) -> bool`
|
|
||||||
|
|
||||||
## 3.2. `AggregationMode`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub enum AggregationMode {
|
|
||||||
RawItems,
|
|
||||||
SummaryOnly,
|
|
||||||
SummaryPlusSamples,
|
|
||||||
Stats,
|
|
||||||
LatestState,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- `fn needs_items(&self) -> bool`
|
|
||||||
- `fn needs_summary(&self) -> bool`
|
|
||||||
|
|
||||||
## 3.3. `StreamingConfig`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct StreamingConfig {
|
|
||||||
pub mode: ExecutionMode,
|
|
||||||
pub transport_behavior: TransportBehavior,
|
|
||||||
pub window_duration_ms: Option<u64>,
|
|
||||||
pub poll_interval_ms: Option<u64>,
|
|
||||||
pub upstream_timeout_ms: Option<u64>,
|
|
||||||
pub idle_timeout_ms: Option<u64>,
|
|
||||||
pub max_session_lifetime_ms: Option<u64>,
|
|
||||||
pub max_items: Option<u32>,
|
|
||||||
pub max_bytes: Option<u32>,
|
|
||||||
pub aggregation_mode: AggregationMode,
|
|
||||||
pub summary_path: Option<String>,
|
|
||||||
pub items_path: Option<String>,
|
|
||||||
pub cursor_path: Option<String>,
|
|
||||||
pub status_path: Option<String>,
|
|
||||||
pub done_path: Option<String>,
|
|
||||||
pub redacted_paths: Vec<String>,
|
|
||||||
pub truncate_item_fields: bool,
|
|
||||||
pub max_field_length: Option<u32>,
|
|
||||||
pub drop_duplicates: bool,
|
|
||||||
pub sampling_rate: Option<f64>,
|
|
||||||
pub tool_family: ToolFamilyConfig,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- `fn validate_common(&self) -> Result<(), StreamingConfigError>`
|
|
||||||
- `fn validate_for_protocol(&self, protocol: Protocol) -> Result<(), StreamingConfigError>`
|
|
||||||
- `fn effective_max_items(&self, limits: &StreamingLimits) -> u32`
|
|
||||||
- `fn effective_max_bytes(&self, limits: &StreamingLimits) -> u32`
|
|
||||||
- `fn effective_timeout(&self, defaults: &StreamingDefaults) -> Duration`
|
|
||||||
|
|
||||||
## 3.4. `StreamSession`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct StreamSession {
|
|
||||||
pub id: StreamSessionId,
|
|
||||||
pub workspace_id: WorkspaceId,
|
|
||||||
pub agent_id: Option<AgentId>,
|
|
||||||
pub operation_id: OperationId,
|
|
||||||
pub protocol: Protocol,
|
|
||||||
pub mode: ExecutionMode,
|
|
||||||
pub status: StreamStatus,
|
|
||||||
pub cursor: Option<Value>,
|
|
||||||
pub state: Value,
|
|
||||||
pub expires_at: DateTime<Utc>,
|
|
||||||
pub last_poll_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub closed_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- `fn is_expired(&self, now: DateTime<Utc>) -> bool`
|
|
||||||
- `fn can_poll(&self, now: DateTime<Utc>) -> bool`
|
|
||||||
- `fn mark_polled(&mut self, now: DateTime<Utc>)`
|
|
||||||
- `fn mark_closed(&mut self, now: DateTime<Utc>)`
|
|
||||||
|
|
||||||
## 3.5. `AsyncJobHandle`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct AsyncJobHandle {
|
|
||||||
pub id: AsyncJobId,
|
|
||||||
pub workspace_id: WorkspaceId,
|
|
||||||
pub agent_id: Option<AgentId>,
|
|
||||||
pub operation_id: OperationId,
|
|
||||||
pub status: JobStatus,
|
|
||||||
pub progress: Value,
|
|
||||||
pub result: Option<Value>,
|
|
||||||
pub error: Option<Value>,
|
|
||||||
pub expires_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
pub finished_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Методы:
|
|
||||||
|
|
||||||
- `fn is_finished(&self) -> bool`
|
|
||||||
- `fn can_cancel(&self) -> bool`
|
|
||||||
- `fn mark_finished(&mut self, now: DateTime<Utc>, result: Value)`
|
|
||||||
- `fn mark_failed(&mut self, now: DateTime<Utc>, error: Value)`
|
|
||||||
|
|
||||||
## 4. Registry contracts
|
|
||||||
|
|
||||||
## 4.1. `StreamSessionRepository`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait StreamSessionRepository {
|
|
||||||
async fn create_stream_session(&self, session: NewStreamSession) -> Result<StreamSession, RegistryError>;
|
|
||||||
async fn get_stream_session(&self, id: &StreamSessionId) -> Result<Option<StreamSession>, RegistryError>;
|
|
||||||
async fn update_stream_session_state(&self, update: StreamSessionStateUpdate) -> Result<StreamSession, RegistryError>;
|
|
||||||
async fn close_stream_session(&self, id: &StreamSessionId, now: DateTime<Utc>) -> Result<(), RegistryError>;
|
|
||||||
async fn list_stream_sessions(&self, filter: StreamSessionFilter) -> Result<Page<StreamSession>, RegistryError>;
|
|
||||||
async fn delete_expired_stream_sessions(&self, now: DateTime<Utc>) -> Result<u64, RegistryError>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4.2. `AsyncJobRepository`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait AsyncJobRepository {
|
|
||||||
async fn create_async_job(&self, job: NewAsyncJob) -> Result<AsyncJobHandle, RegistryError>;
|
|
||||||
async fn get_async_job(&self, id: &AsyncJobId) -> Result<Option<AsyncJobHandle>, RegistryError>;
|
|
||||||
async fn update_async_job_status(&self, update: AsyncJobStatusUpdate) -> Result<AsyncJobHandle, RegistryError>;
|
|
||||||
async fn cancel_async_job(&self, id: &AsyncJobId, now: DateTime<Utc>) -> Result<(), RegistryError>;
|
|
||||||
async fn list_async_jobs(&self, filter: AsyncJobFilter) -> Result<Page<AsyncJobHandle>, RegistryError>;
|
|
||||||
async fn delete_expired_async_jobs(&self, now: DateTime<Utc>) -> Result<u64, RegistryError>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4.3. Storage rules
|
|
||||||
|
|
||||||
- update methods должны использовать optimistic concurrency, если state transitions конфликтуют;
|
|
||||||
- `poll` не должен silently reopen closed session;
|
|
||||||
- cleanup job должен быть отдельным service;
|
|
||||||
- session/job state не должен хранить необрезанные raw payloads без лимитов.
|
|
||||||
|
|
||||||
## 5. Runtime contracts
|
|
||||||
|
|
||||||
## 5.1. `ProtocolAdapter`
|
|
||||||
|
|
||||||
Базовый trait не должен пытаться описать все streaming cases в одном методе.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ProtocolAdapter {
|
|
||||||
async fn execute_unary(&self, ctx: UnaryExecutionContext) -> Result<NormalizedResponse, AdapterError>;
|
|
||||||
async fn execute_window(&self, ctx: WindowExecutionContext) -> Result<WindowExecutionResult, AdapterError>;
|
|
||||||
async fn start_session(&self, ctx: SessionStartContext) -> Result<SessionStartResult, AdapterError>;
|
|
||||||
async fn poll_session(&self, ctx: SessionPollContext) -> Result<SessionPollResult, AdapterError>;
|
|
||||||
async fn stop_session(&self, ctx: SessionStopContext) -> Result<(), AdapterError>;
|
|
||||||
async fn start_async_job(&self, ctx: AsyncJobStartContext) -> Result<AsyncJobStartResult, AdapterError>;
|
|
||||||
async fn get_async_job_status(&self, ctx: AsyncJobStatusContext) -> Result<AsyncJobStatusResult, AdapterError>;
|
|
||||||
async fn get_async_job_result(&self, ctx: AsyncJobResultContext) -> Result<NormalizedResponse, AdapterError>;
|
|
||||||
async fn cancel_async_job(&self, ctx: AsyncJobCancelContext) -> Result<(), AdapterError>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Не каждый adapter обязан поддерживать все методы. Capability mismatch должен проверяться выше.
|
|
||||||
|
|
||||||
## 5.2. `OperationExecutor`
|
|
||||||
|
|
||||||
Главная orchestration точка в `crank-runtime`.
|
|
||||||
|
|
||||||
Ожидаемые функции:
|
|
||||||
|
|
||||||
- `execute_unary_operation`
|
|
||||||
- `execute_window_operation`
|
|
||||||
- `start_stream_session`
|
|
||||||
- `poll_stream_session`
|
|
||||||
- `stop_stream_session`
|
|
||||||
- `start_async_job`
|
|
||||||
- `get_async_job_status`
|
|
||||||
- `get_async_job_result`
|
|
||||||
- `cancel_async_job`
|
|
||||||
|
|
||||||
### `execute_unary_operation`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- schema validation;
|
|
||||||
- auth resolution;
|
|
||||||
- adapter dispatch;
|
|
||||||
- output mapping;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### `execute_window_operation`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- schema validation;
|
|
||||||
- auth resolution;
|
|
||||||
- adapter dispatch в `execute_window`;
|
|
||||||
- aggregation;
|
|
||||||
- truncation flags;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### `start_stream_session`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- schema validation;
|
|
||||||
- auth resolution;
|
|
||||||
- adapter `start_session`;
|
|
||||||
- registry `create_stream_session`;
|
|
||||||
- initial preview result;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### `poll_stream_session`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- load session from registry;
|
|
||||||
- expiration check;
|
|
||||||
- adapter `poll_session`;
|
|
||||||
- registry `update_stream_session_state`;
|
|
||||||
- output mapping;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### `stop_stream_session`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- load session;
|
|
||||||
- adapter `stop_session`;
|
|
||||||
- registry close;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### `start_async_job`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- schema validation;
|
|
||||||
- adapter `start_async_job`;
|
|
||||||
- registry `create_async_job`;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### `get_async_job_status`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- registry load;
|
|
||||||
- adapter `get_async_job_status`, если upstream status is live;
|
|
||||||
- registry update;
|
|
||||||
- normalized status result.
|
|
||||||
|
|
||||||
### `get_async_job_result`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- job readiness check;
|
|
||||||
- adapter `get_async_job_result`, если result lazy-loaded;
|
|
||||||
- output mapping;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
### `cancel_async_job`
|
|
||||||
|
|
||||||
Отвечает за:
|
|
||||||
|
|
||||||
- adapter cancel;
|
|
||||||
- registry cancel transition;
|
|
||||||
- observability write.
|
|
||||||
|
|
||||||
## 5.3. Aggregation services
|
|
||||||
|
|
||||||
Нужно отделить aggregation от adapters.
|
|
||||||
|
|
||||||
Отдельные services:
|
|
||||||
|
|
||||||
- `WindowAggregator`
|
|
||||||
- `SummaryBuilder`
|
|
||||||
- `CursorTracker`
|
|
||||||
- `PayloadLimiter`
|
|
||||||
- `RedactionService`
|
|
||||||
|
|
||||||
### `WindowAggregator`
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `collect_items`
|
|
||||||
- `apply_item_limit`
|
|
||||||
- `apply_byte_limit`
|
|
||||||
- `mark_truncated`
|
|
||||||
|
|
||||||
### `SummaryBuilder`
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `build_summary`
|
|
||||||
- `build_stats_summary`
|
|
||||||
- `build_latest_state_summary`
|
|
||||||
- `build_summary_plus_samples`
|
|
||||||
|
|
||||||
### `PayloadLimiter`
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `truncate_item_fields`
|
|
||||||
- `truncate_bytes`
|
|
||||||
- `enforce_max_items`
|
|
||||||
|
|
||||||
### `RedactionService`
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `redact_paths`
|
|
||||||
- `redact_object`
|
|
||||||
- `redact_item`
|
|
||||||
|
|
||||||
## 6. Adapter responsibilities
|
|
||||||
|
|
||||||
## 6.1. REST adapter
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `execute_rest_unary`
|
|
||||||
- `execute_rest_window`
|
|
||||||
- `start_rest_session`
|
|
||||||
- `poll_rest_session`
|
|
||||||
- `stop_rest_session`
|
|
||||||
- `start_rest_async_job`
|
|
||||||
- `get_rest_async_job_status`
|
|
||||||
- `get_rest_async_job_result`
|
|
||||||
- `cancel_rest_async_job`
|
|
||||||
|
|
||||||
Дополнительные helpers:
|
|
||||||
|
|
||||||
- `open_sse_stream`
|
|
||||||
- `collect_sse_window`
|
|
||||||
- `parse_sse_event`
|
|
||||||
- `close_sse_stream`
|
|
||||||
|
|
||||||
## 6.2. GraphQL adapter
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `execute_graphql_unary`
|
|
||||||
|
|
||||||
Отдельно отложено:
|
|
||||||
|
|
||||||
- `execute_graphql_subscription_window`
|
|
||||||
- `start_graphql_subscription_session`
|
|
||||||
|
|
||||||
## 6.3. gRPC adapter
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `execute_grpc_unary`
|
|
||||||
- `execute_grpc_window`
|
|
||||||
- `start_grpc_session`
|
|
||||||
- `poll_grpc_session`
|
|
||||||
- `stop_grpc_session`
|
|
||||||
- `start_grpc_async_job`
|
|
||||||
- `get_grpc_async_job_status`
|
|
||||||
- `get_grpc_async_job_result`
|
|
||||||
- `cancel_grpc_async_job`
|
|
||||||
|
|
||||||
Helpers:
|
|
||||||
|
|
||||||
- `invoke_unary_method`
|
|
||||||
- `open_server_stream`
|
|
||||||
- `collect_server_stream_window`
|
|
||||||
- `decode_stream_item`
|
|
||||||
|
|
||||||
## 6.4. WebSocket adapter
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `execute_websocket_window`
|
|
||||||
- `start_websocket_session`
|
|
||||||
- `poll_websocket_session`
|
|
||||||
- `stop_websocket_session`
|
|
||||||
- `start_websocket_async_job`
|
|
||||||
- `get_websocket_async_job_status`
|
|
||||||
- `cancel_websocket_async_job`
|
|
||||||
|
|
||||||
Helpers:
|
|
||||||
|
|
||||||
- `connect_websocket`
|
|
||||||
- `send_subscribe_message`
|
|
||||||
- `send_unsubscribe_message`
|
|
||||||
- `read_next_frame`
|
|
||||||
- `decode_text_frame`
|
|
||||||
- `heartbeat_tick`
|
|
||||||
- `reconnect_if_needed`
|
|
||||||
|
|
||||||
## 6.5. SOAP adapter
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `execute_soap_unary`
|
|
||||||
- `start_soap_async_job`
|
|
||||||
- `get_soap_async_job_status`
|
|
||||||
- `get_soap_async_job_result`
|
|
||||||
- `cancel_soap_async_job`
|
|
||||||
|
|
||||||
Helpers:
|
|
||||||
|
|
||||||
- `render_soap_envelope`
|
|
||||||
- `render_soap_headers`
|
|
||||||
- `parse_soap_envelope`
|
|
||||||
- `parse_soap_fault`
|
|
||||||
- `normalize_xml_value`
|
|
||||||
|
|
||||||
## 7. MCP server design
|
|
||||||
|
|
||||||
## 7.1. Route handlers
|
|
||||||
|
|
||||||
Ожидаемые handlers:
|
|
||||||
|
|
||||||
- `handle_initialize`
|
|
||||||
- `handle_notifications_initialized`
|
|
||||||
- `handle_ping`
|
|
||||||
- `handle_tools_list`
|
|
||||||
- `handle_tools_call`
|
|
||||||
- `handle_get_sse_stream`
|
|
||||||
- `handle_delete_session`
|
|
||||||
|
|
||||||
## 7.2. Tool-family generation
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `build_unary_tool_definition`
|
|
||||||
- `build_window_tool_definition`
|
|
||||||
- `build_session_tool_family`
|
|
||||||
- `build_async_job_tool_family`
|
|
||||||
|
|
||||||
Для `session`:
|
|
||||||
|
|
||||||
- `build_session_start_tool`
|
|
||||||
- `build_session_poll_tool`
|
|
||||||
- `build_session_stop_tool`
|
|
||||||
|
|
||||||
Для `async_job`:
|
|
||||||
|
|
||||||
- `build_async_job_start_tool`
|
|
||||||
- `build_async_job_status_tool`
|
|
||||||
- `build_async_job_result_tool`
|
|
||||||
- `build_async_job_cancel_tool`
|
|
||||||
|
|
||||||
## 7.3. Transport session management
|
|
||||||
|
|
||||||
Функции:
|
|
||||||
|
|
||||||
- `ensure_protocol_version`
|
|
||||||
- `resolve_transport_mode`
|
|
||||||
- `ensure_mcp_session`
|
|
||||||
- `attach_session_headers`
|
|
||||||
- `stream_sse_event`
|
|
||||||
- `close_transport_session`
|
|
||||||
|
|
||||||
## 8. Admin API service design
|
|
||||||
|
|
||||||
Ожидаемые service функции:
|
|
||||||
|
|
||||||
- `validate_streaming_config`
|
|
||||||
- `list_protocol_capabilities`
|
|
||||||
- `list_streaming_presets`
|
|
||||||
- `start_stream_test_run`
|
|
||||||
- `poll_stream_test_run`
|
|
||||||
- `stop_stream_test_run`
|
|
||||||
- `get_stream_test_result`
|
|
||||||
- `list_stream_sessions`
|
|
||||||
- `get_stream_session`
|
|
||||||
- `stop_stream_session`
|
|
||||||
- `delete_stream_session`
|
|
||||||
- `list_async_jobs`
|
|
||||||
- `get_async_job`
|
|
||||||
- `cancel_async_job`
|
|
||||||
- `get_async_job_result`
|
|
||||||
|
|
||||||
## 9. Cleanup jobs
|
|
||||||
|
|
||||||
Отдельные jobs:
|
|
||||||
|
|
||||||
- `expire_stream_sessions`
|
|
||||||
- `expire_async_jobs`
|
|
||||||
- `reap_orphaned_transport_sessions`
|
|
||||||
- `compact_stream_payloads`, если будет нужен storage optimization layer
|
|
||||||
|
|
||||||
## 10. Design rules
|
|
||||||
|
|
||||||
- adapters не агрегируют product-level summaries;
|
|
||||||
- runtime не знает деталей transport framing;
|
|
||||||
- `mcp-server` не знает про upstream protocols;
|
|
||||||
- `admin-api` не управляет live protocol connections напрямую;
|
|
||||||
- session/job lifecycle должен быть явным в names и contracts;
|
|
||||||
- cancellation не должна зависеть от TCP disconnect;
|
|
||||||
- bounded limits должны применяться раньше, чем payload попадет в final result.
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
# Streaming UI Contract
|
|
||||||
|
|
||||||
## 1. Назначение документа
|
|
||||||
|
|
||||||
Этот документ фиксирует точный UI-контракт для настройки потоковых операций.
|
|
||||||
|
|
||||||
Цель:
|
|
||||||
|
|
||||||
- определить экраны и блоки wizard;
|
|
||||||
- перечислить все поля;
|
|
||||||
- перечислить валидации;
|
|
||||||
- определить protocol-specific visibility rules;
|
|
||||||
- зафиксировать UX states, warnings и system messages.
|
|
||||||
|
|
||||||
## 2. Основные экраны
|
|
||||||
|
|
||||||
Потоковая конфигурация живет в:
|
|
||||||
|
|
||||||
- `Operations Wizard`
|
|
||||||
- `Operation Detail`
|
|
||||||
- `Test Run`
|
|
||||||
- `Agent Tool Preview`
|
|
||||||
- `Logs` / `Usage` observability surfaces
|
|
||||||
|
|
||||||
Дополнительно админские страницы:
|
|
||||||
|
|
||||||
- `Stream Sessions`
|
|
||||||
- `Async Jobs`
|
|
||||||
|
|
||||||
Для test-run UX:
|
|
||||||
|
|
||||||
- wizard status block должен различать `unary`, `window`, `session`, `async_job`;
|
|
||||||
- `window` показывает bounded flags `window_complete`, `truncated`, `has_more`;
|
|
||||||
- `session` показывает `session_id`, `poll_after_ms` и переводит оператора к странице `Stream Sessions`;
|
|
||||||
- `async_job` показывает `job_id` и переводит оператора к странице `Async Jobs`;
|
|
||||||
- request/response preview textareas остаются общими для всех режимов.
|
|
||||||
|
|
||||||
## 3. Wizard information architecture
|
|
||||||
|
|
||||||
## 3.1. Shared wizard structure
|
|
||||||
|
|
||||||
Шаги:
|
|
||||||
|
|
||||||
1. `Protocol`
|
|
||||||
2. `Upstream`
|
|
||||||
3. `Request / Subscription`
|
|
||||||
4. `Execution Mode`
|
|
||||||
5. `Input Schema`
|
|
||||||
6. `Output / Aggregation`
|
|
||||||
7. `Safety and Limits`
|
|
||||||
8. `Tool Family`
|
|
||||||
9. `Test Run`
|
|
||||||
10. `Publish`
|
|
||||||
|
|
||||||
## 3.2. Step `Execution Mode`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `Mode`
|
|
||||||
- `Unary`
|
|
||||||
- `Window`
|
|
||||||
- `Session`
|
|
||||||
- `Async Job`
|
|
||||||
- `Transport behavior`
|
|
||||||
- `Request / response`
|
|
||||||
- `Server stream`
|
|
||||||
|
|
||||||
Подсказки:
|
|
||||||
|
|
||||||
- `Unary` для обычных request-response integrations.
|
|
||||||
- `Window` для bounded snapshots из stream или feed.
|
|
||||||
- `Session` для follow-style tools с `start/poll/stop`.
|
|
||||||
- `Async Job` для long-running operations с `status/result/cancel`.
|
|
||||||
|
|
||||||
Валидации:
|
|
||||||
|
|
||||||
- `Transport behavior=server_stream` не может использоваться с protocol, который его не поддерживает.
|
|
||||||
- `WebSocket` не может быть `Unary`.
|
|
||||||
- `SOAP` в первой волне не может быть `Session`.
|
|
||||||
|
|
||||||
## 3.3. Step `Input Schema`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `Input schema source`
|
|
||||||
- manual
|
|
||||||
- sample-derived
|
|
||||||
- descriptor/wsdl-derived
|
|
||||||
- `Input fields`
|
|
||||||
- `Required fields`
|
|
||||||
- `Defaults`
|
|
||||||
- `Mapping preview`
|
|
||||||
|
|
||||||
## 3.4. Step `Output / Aggregation`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `Aggregation mode`
|
|
||||||
- `Raw items`
|
|
||||||
- `Summary only`
|
|
||||||
- `Summary + samples`
|
|
||||||
- `Stats`
|
|
||||||
- `Latest state`
|
|
||||||
- `Items path`
|
|
||||||
- `Summary path`
|
|
||||||
- `Cursor path`
|
|
||||||
- `Status path`
|
|
||||||
- `Done path`
|
|
||||||
- `Output mapping`
|
|
||||||
|
|
||||||
Валидации:
|
|
||||||
|
|
||||||
- `Items path` обязателен для `Raw items` и `Summary + samples`.
|
|
||||||
- `Summary path` обязателен для `Summary only`, `Summary + samples`, `Stats`, `Latest state`.
|
|
||||||
- `Done path` обязателен для `Async Job`, если upstream status не выражается отдельным field set.
|
|
||||||
|
|
||||||
## 3.5. Step `Safety and Limits`
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `Window duration`
|
|
||||||
- `Poll interval`
|
|
||||||
- `Upstream timeout`
|
|
||||||
- `Idle timeout`
|
|
||||||
- `Session lifetime`
|
|
||||||
- `Max items`
|
|
||||||
- `Max bytes`
|
|
||||||
- `Max field length`
|
|
||||||
- `Drop duplicates`
|
|
||||||
- `Sampling rate`
|
|
||||||
- `Redacted paths`
|
|
||||||
|
|
||||||
Валидации:
|
|
||||||
|
|
||||||
- `Window duration` обязателен для `Window`.
|
|
||||||
- `Poll interval` обязателен для `Session`.
|
|
||||||
- `Idle timeout` обязателен для `Session`.
|
|
||||||
- `Session lifetime` обязателен для `Session`.
|
|
||||||
- `Max items` > 0.
|
|
||||||
- `Max bytes` > 0.
|
|
||||||
- `Sampling rate` > 0 and <= 1.
|
|
||||||
|
|
||||||
## 3.6. Step `Tool Family`
|
|
||||||
|
|
||||||
Показывается только для:
|
|
||||||
|
|
||||||
- `Session`
|
|
||||||
- `Async Job`
|
|
||||||
|
|
||||||
Для `Session`:
|
|
||||||
|
|
||||||
- `Start tool name`
|
|
||||||
- `Poll tool name`
|
|
||||||
- `Stop tool name`
|
|
||||||
|
|
||||||
Для `Async Job`:
|
|
||||||
|
|
||||||
- `Start tool name`
|
|
||||||
- `Status tool name`
|
|
||||||
- `Result tool name`
|
|
||||||
- `Cancel tool name`
|
|
||||||
|
|
||||||
Валидации:
|
|
||||||
|
|
||||||
- имена обязательны;
|
|
||||||
- имена должны быть уникальны в пределах agent;
|
|
||||||
- имена не должны конфликтовать с already bound tools.
|
|
||||||
|
|
||||||
## 4. Protocol-specific UI
|
|
||||||
|
|
||||||
## 4.1. REST
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `Base URL`
|
|
||||||
- `HTTP method`
|
|
||||||
- `Path template`
|
|
||||||
- `Headers`
|
|
||||||
- `Query mapping`
|
|
||||||
- `Body mapping`
|
|
||||||
- `SSE enabled`
|
|
||||||
- `SSE event filter`
|
|
||||||
|
|
||||||
Visibility:
|
|
||||||
|
|
||||||
- `SSE enabled` показывается только если method/endpoint допускают stream use case;
|
|
||||||
- `Window` и `Session` доступны, если оператор включает stream behavior.
|
|
||||||
|
|
||||||
## 4.2. GraphQL
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `Endpoint`
|
|
||||||
- `Operation type`
|
|
||||||
- `Operation name`
|
|
||||||
- `Query template`
|
|
||||||
- `Variables schema`
|
|
||||||
- `Response path`
|
|
||||||
|
|
||||||
Visibility:
|
|
||||||
|
|
||||||
- `Window`, `Session`, `Async Job` скрыты в текущей продуктовой волне;
|
|
||||||
- `Subscription` UI не показывается.
|
|
||||||
|
|
||||||
## 4.3. gRPC
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `Server address`
|
|
||||||
- `Package`
|
|
||||||
- `Service`
|
|
||||||
- `Method`
|
|
||||||
- `Descriptor source`
|
|
||||||
- `Stream kind`
|
|
||||||
- `Unary`
|
|
||||||
- `Server streaming`
|
|
||||||
|
|
||||||
Visibility:
|
|
||||||
|
|
||||||
- `Window`, `Session`, `Async Job` доступны только для `Server streaming`;
|
|
||||||
- `Client streaming` и `Bidirectional` не показываются вообще.
|
|
||||||
|
|
||||||
## 4.4. WebSocket
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `WebSocket URL`
|
|
||||||
- `Subprotocols`
|
|
||||||
- `Connect timeout`
|
|
||||||
- `Heartbeat interval`
|
|
||||||
- `Subscribe message template`
|
|
||||||
- `Unsubscribe message template`
|
|
||||||
- `Message envelope path`
|
|
||||||
|
|
||||||
Visibility:
|
|
||||||
|
|
||||||
- `Unary` не показывается;
|
|
||||||
- `Window`, `Session`, `Async Job` доступны всегда;
|
|
||||||
- `Subscribe message template` обязательно для `Window` и `Session`.
|
|
||||||
|
|
||||||
## 4.5. SOAP
|
|
||||||
|
|
||||||
Поля:
|
|
||||||
|
|
||||||
- `WSDL source`
|
|
||||||
- `Service`
|
|
||||||
- `Port`
|
|
||||||
- `Operation`
|
|
||||||
- `SOAP version`
|
|
||||||
- `SOAPAction`
|
|
||||||
- `Endpoint override`
|
|
||||||
- `Header config`
|
|
||||||
|
|
||||||
Visibility:
|
|
||||||
|
|
||||||
- `Session` скрыт;
|
|
||||||
- `Window` скрыт по умолчанию и включается только для polling-style enterprise workflows;
|
|
||||||
- `Unary` и `Async Job` доступны.
|
|
||||||
|
|
||||||
## 5. Test Run UX
|
|
||||||
|
|
||||||
## 5.1. Window
|
|
||||||
|
|
||||||
UI должен показывать:
|
|
||||||
|
|
||||||
- `Status`
|
|
||||||
- `Duration`
|
|
||||||
- `Items count`
|
|
||||||
- `Bytes count`
|
|
||||||
- `Window complete`
|
|
||||||
- `Truncated`
|
|
||||||
- `Has more`
|
|
||||||
- `Summary`
|
|
||||||
- `Items preview`
|
|
||||||
- `Cursor`
|
|
||||||
|
|
||||||
Кнопки:
|
|
||||||
|
|
||||||
- `Run window test`
|
|
||||||
- `Repeat`
|
|
||||||
- `Save config`
|
|
||||||
|
|
||||||
## 5.2. Session
|
|
||||||
|
|
||||||
UI должен показывать:
|
|
||||||
|
|
||||||
- `Session id`
|
|
||||||
- `Status`
|
|
||||||
- `Expires at`
|
|
||||||
- `Poll after`
|
|
||||||
- `Summary preview`
|
|
||||||
- `Items preview`
|
|
||||||
|
|
||||||
Кнопки:
|
|
||||||
|
|
||||||
- `Start session`
|
|
||||||
- `Poll next chunk`
|
|
||||||
- `Stop session`
|
|
||||||
|
|
||||||
## 5.3. Async Job
|
|
||||||
|
|
||||||
UI должен показывать:
|
|
||||||
|
|
||||||
- `Job id`
|
|
||||||
- `Status`
|
|
||||||
- `Progress`
|
|
||||||
- `Started at`
|
|
||||||
- `Finished at`
|
|
||||||
- `Result preview`
|
|
||||||
|
|
||||||
Кнопки:
|
|
||||||
|
|
||||||
- `Start job`
|
|
||||||
- `Refresh status`
|
|
||||||
- `Get result`
|
|
||||||
- `Cancel job`
|
|
||||||
|
|
||||||
## 6. Page-level states
|
|
||||||
|
|
||||||
Каждый streaming-aware экран обязан поддерживать:
|
|
||||||
|
|
||||||
- `idle`
|
|
||||||
- `validating`
|
|
||||||
- `saving`
|
|
||||||
- `testing`
|
|
||||||
- `running`
|
|
||||||
- `completed`
|
|
||||||
- `failed`
|
|
||||||
- `stopped`
|
|
||||||
- `expired`
|
|
||||||
|
|
||||||
## 7. Warnings and messages
|
|
||||||
|
|
||||||
## 7.1. Validation warnings
|
|
||||||
|
|
||||||
- `This mode will truncate responses above the configured byte limit.`
|
|
||||||
- `Current protocol does not support the selected execution mode.`
|
|
||||||
- `Session lifetime is shorter than idle timeout.`
|
|
||||||
- `Aggregation mode summary_only hides raw items from the final tool output.`
|
|
||||||
- `WebSocket reconnect may duplicate events if upstream does not provide cursor semantics.`
|
|
||||||
- `SOAP async mode requires a separate status/result contract.`
|
|
||||||
|
|
||||||
## 7.2. Confirmations
|
|
||||||
|
|
||||||
- `Stop current session?`
|
|
||||||
- `Cancel running job?`
|
|
||||||
- `Switching execution mode will reset protocol-specific fields.`
|
|
||||||
|
|
||||||
## 7.3. Errors
|
|
||||||
|
|
||||||
- `Streaming configuration is invalid.`
|
|
||||||
- `Test session expired.`
|
|
||||||
- `Upstream did not return any messages within the configured window.`
|
|
||||||
- `The collected stream payload was truncated by size limits.`
|
|
||||||
- `This protocol does not support the selected execution mode.`
|
|
||||||
|
|
||||||
## 8. Stream Sessions page
|
|
||||||
|
|
||||||
Колонки:
|
|
||||||
|
|
||||||
- `Session ID`
|
|
||||||
- `Operation`
|
|
||||||
- `Agent`
|
|
||||||
- `Mode`
|
|
||||||
- `Status`
|
|
||||||
- `Created`
|
|
||||||
- `Last poll`
|
|
||||||
- `Expires`
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- `Open`
|
|
||||||
- `Stop`
|
|
||||||
- `Delete`
|
|
||||||
|
|
||||||
Detail view:
|
|
||||||
|
|
||||||
- `Session metadata`
|
|
||||||
- `Cursor preview`
|
|
||||||
- `State summary`
|
|
||||||
- `Recent events summary`
|
|
||||||
|
|
||||||
## 9. Async Jobs page
|
|
||||||
|
|
||||||
Колонки:
|
|
||||||
|
|
||||||
- `Job ID`
|
|
||||||
- `Operation`
|
|
||||||
- `Agent`
|
|
||||||
- `Status`
|
|
||||||
- `Progress`
|
|
||||||
- `Created`
|
|
||||||
- `Updated`
|
|
||||||
- `Finished`
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
|
|
||||||
- `Open`
|
|
||||||
- `Cancel`
|
|
||||||
- `Get result`
|
|
||||||
|
|
||||||
Detail view:
|
|
||||||
|
|
||||||
- `Job metadata`
|
|
||||||
- `Progress payload`
|
|
||||||
- `Result preview`
|
|
||||||
- `Error preview`
|
|
||||||
|
|
||||||
## 10. Exact frontend adapters
|
|
||||||
|
|
||||||
Ожидаемые frontend modules:
|
|
||||||
|
|
||||||
- `streaming-form.js`
|
|
||||||
- `streaming-validation.js`
|
|
||||||
- `stream-test-run.js`
|
|
||||||
- `stream-sessions.js`
|
|
||||||
- `async-jobs.js`
|
|
||||||
|
|
||||||
Ожидаемые frontend functions:
|
|
||||||
|
|
||||||
- `loadProtocolCapabilities()`
|
|
||||||
- `applyStreamingPreset()`
|
|
||||||
- `validateStreamingConfig()`
|
|
||||||
- `serializeStreamingConfig()`
|
|
||||||
- `deserializeStreamingConfig()`
|
|
||||||
- `startWindowTest()`
|
|
||||||
- `startSessionTest()`
|
|
||||||
- `pollSessionTest()`
|
|
||||||
- `stopSessionTest()`
|
|
||||||
- `startAsyncJobTest()`
|
|
||||||
- `refreshAsyncJobStatus()`
|
|
||||||
- `loadAsyncJobResult()`
|
|
||||||
|
|
||||||
## 11. Shared UI rules
|
|
||||||
|
|
||||||
- UI никогда не должен предлагать unsupported mode;
|
|
||||||
- UI должен строить availability по `protocol-capabilities`, а не по hardcoded if-else;
|
|
||||||
- UI должен явно объяснять, почему поле скрыто или disabled;
|
|
||||||
- UI должен всегда показывать итоговую tool topology:
|
|
||||||
- `1 tool`
|
|
||||||
- `3 tools start/poll/stop`
|
|
||||||
- `4 tools start/status/result/cancel`
|
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
|
|
||||||
- `crank-schema`
|
- `crank-schema`
|
||||||
- `crank-mapping`
|
- `crank-mapping`
|
||||||
- `crank-proto`
|
|
||||||
- небольшие части `crank-core`
|
- небольшие части `crank-core`
|
||||||
|
|
||||||
Что проверять:
|
Что проверять:
|
||||||
@@ -28,8 +27,6 @@
|
|||||||
- `JSONPath` parsing;
|
- `JSONPath` parsing;
|
||||||
- применение input/output mapping;
|
- применение input/output mapping;
|
||||||
- генерацию чернового mapping;
|
- генерацию чернового mapping;
|
||||||
- protobuf -> schema normalization;
|
|
||||||
- JSON -> protobuf и protobuf -> JSON conversion.
|
|
||||||
|
|
||||||
### 2.2. Integration tests
|
### 2.2. Integration tests
|
||||||
|
|
||||||
@@ -50,11 +47,9 @@
|
|||||||
|
|
||||||
### 2.3. Adapter tests
|
### 2.3. Adapter tests
|
||||||
|
|
||||||
Отдельно для каждого протокола:
|
Отдельно для Community-протокола:
|
||||||
|
|
||||||
- REST adapter;
|
- REST adapter;
|
||||||
- GraphQL adapter;
|
|
||||||
- gRPC unary adapter.
|
|
||||||
|
|
||||||
Что проверять:
|
Что проверять:
|
||||||
|
|
||||||
@@ -68,8 +63,6 @@
|
|||||||
Минимально нужны сценарии:
|
Минимально нужны сценарии:
|
||||||
|
|
||||||
- создать REST operation -> протестировать -> опубликовать -> вызвать как MCP tool;
|
- создать REST operation -> протестировать -> опубликовать -> вызвать как MCP tool;
|
||||||
- создать GraphQL operation -> протестировать -> опубликовать -> вызвать как MCP tool;
|
|
||||||
- загрузить `.proto` или descriptor set -> создать gRPC unary operation -> протестировать -> опубликовать -> вызвать как MCP tool.
|
|
||||||
|
|
||||||
## 3. Что должно быть покрыто обязательно
|
## 3. Что должно быть покрыто обязательно
|
||||||
|
|
||||||
@@ -83,10 +76,8 @@
|
|||||||
|
|
||||||
### Обязательно до первого демо
|
### Обязательно до первого демо
|
||||||
|
|
||||||
- хотя бы один end-to-end сценарий для каждого из трех протоколов;
|
- хотя бы один end-to-end сценарий для REST;
|
||||||
- negative tests на invalid `JSONPath`;
|
- negative tests на invalid `JSONPath`;
|
||||||
- negative tests на invalid protobuf descriptor;
|
|
||||||
- negative tests на GraphQL errors при HTTP `200`.
|
|
||||||
|
|
||||||
## 4. Формат тестовых данных
|
## 4. Формат тестовых данных
|
||||||
|
|
||||||
@@ -94,7 +85,6 @@
|
|||||||
|
|
||||||
- JSON fixtures для sample input/output;
|
- JSON fixtures для sample input/output;
|
||||||
- YAML golden files для export/import;
|
- YAML golden files для export/import;
|
||||||
- `.proto` и descriptor fixtures для gRPC;
|
|
||||||
- snapshot tests для generated draft.
|
- snapshot tests для generated draft.
|
||||||
|
|
||||||
## 5. Техническая стратегия
|
## 5. Техническая стратегия
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "country_lookup_countries",
|
|
||||||
"display_name": "Lookup Country",
|
|
||||||
"category": "geo",
|
|
||||||
"protocol": "graphql",
|
|
||||||
"target": {
|
|
||||||
"kind": "graphql",
|
|
||||||
"endpoint": "https://countries.trevorblades.com/",
|
|
||||||
"operation_type": "query",
|
|
||||||
"operation_name": "GetCountry",
|
|
||||||
"query_template": "query GetCountry($code: ID!) { country(code: $code) { code name capital emoji currency languages { code name } } }",
|
|
||||||
"response_path": "$.response.body.data.country"
|
|
||||||
},
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": true,
|
|
||||||
"fields": {
|
|
||||||
"code": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"output_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": true,
|
|
||||||
"fields": {
|
|
||||||
"code": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"capital": {
|
|
||||||
"type": "string",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"emoji": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"currency": {
|
|
||||||
"type": "string",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"type": "array",
|
|
||||||
"required": true,
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"required": true,
|
|
||||||
"fields": {
|
|
||||||
"code": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"input_mapping": {
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"source": "$.mcp.code",
|
|
||||||
"target": "$.request.variables.code",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"output_mapping": {
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"source": "$.response.data.code",
|
|
||||||
"target": "$.output.code",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.data.name",
|
|
||||||
"target": "$.output.name",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.data.capital",
|
|
||||||
"target": "$.output.capital",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.data.emoji",
|
|
||||||
"target": "$.output.emoji",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.data.currency",
|
|
||||||
"target": "$.output.currency",
|
|
||||||
"required": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.data.languages",
|
|
||||||
"target": "$.output.languages",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_config": {
|
|
||||||
"timeout_ms": 10000,
|
|
||||||
"headers": {}
|
|
||||||
},
|
|
||||||
"tool_description": {
|
|
||||||
"title": "Lookup Country",
|
|
||||||
"description": "Fetch country metadata from the public Countries GraphQL API.",
|
|
||||||
"tags": [
|
|
||||||
"graphql",
|
|
||||||
"geo",
|
|
||||||
"smoke"
|
|
||||||
],
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"input": {
|
|
||||||
"code": "BR"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "grpcb_add_sum",
|
|
||||||
"display_name": "Add Numbers (grpcb.in)",
|
|
||||||
"category": "math",
|
|
||||||
"protocol": "grpc",
|
|
||||||
"target": {
|
|
||||||
"kind": "grpc",
|
|
||||||
"server_addr": "http://grpcb.in:9000",
|
|
||||||
"package": "addsvc",
|
|
||||||
"service": "Add",
|
|
||||||
"method": "Sum",
|
|
||||||
"descriptor_ref": "desc_addsvc_grpcb",
|
|
||||||
"descriptor_set_b64": "CqQKCgxhZGRzdmMucHJvdG8SBmFkZHN2YyIoCgpTdW1SZXF1ZXN0EgwKAWEYASABKANSAWESDAoBYhgCIAEoA1IBYiIqCghTdW1SZXBseRIMCgF2GAEgASgDUgF2EhAKA2VychgCIAEoCVIDZXJyIisKDUNvbmNhdFJlcXVlc3QSDAoBYRgBIAEoCVIBYRIMCgFiGAIgASgJUgFiIi0KC0NvbmNhdFJlcGx5EgwKAXYYASABKAlSAXYSEAoDZXJyGAIgASgJUgNlcnIybAoDQWRkEi0KA1N1bRISLmFkZHN2Yy5TdW1SZXF1ZXN0GhAuYWRkc3ZjLlN1bVJlcGx5IgASNgoGQ29uY2F0EhUuYWRkc3ZjLkNvbmNhdFJlcXVlc3QaEy5hZGRzdmMuQ29uY2F0UmVwbHkiAErjBwoGEgQAACMBCggKAQwSAwAAEgoICgECEgMCAA8KKQoCBgASBAUACwEaHSBUaGUgQWRkIHNlcnZpY2UgZGVmaW5pdGlvbi4KCgoKAwYAARIDBQgLCiEKBAYAAgASAwcCLBoUIFN1bXMgdHdvIGludGVnZXJzLgoKDAoFBgACAAESAwcGCQoMCgUGAAIAAhIDBwsVCgwKBQYAAgADEgMHICgKJwoEBgACARIDCgI1GhogQ29uY2F0ZW5hdGVzIHR3byBzdHJpbmdzCgoMCgUGAAIBARIDCgYMCgwKBQYAAgECEgMKDhsKDAoFBgACAQMSAwomMQo2CgIEABIEDgARARoqIFRoZSBzdW0gcmVxdWVzdCBjb250YWlucyB0d28gcGFyYW1ldGVycy4KCgoKAwQAARIDDggSCgsKBAQAAgASAw8CDgoMCgUEAAIABRIDDwIHCgwKBQQAAgABEgMPCAkKDAoFBAACAAMSAw8MDQoLCgQEAAIBEgMQAg4KDAoFBAACAQUSAxACBwoMCgUEAAIBARIDEAgJCgwKBQQAAgEDEgMQDA0KRgoCBAESBBQAFwEaOiBUaGUgc3VtIHJlc3BvbnNlIGNvbnRhaW5zIHRoZSByZXN1bHQgb2YgdGhlIGNhbGN1bGF0aW9uLgoKCgoDBAEBEgMUCBAKCwoEBAECABIDFQIOCgwKBQQBAgAFEgMVAgcKDAoFBAECAAESAxUICQoMCgUEAQIAAxIDFQwNCgsKBAQBAgESAxYCEQoMCgUEAQIBBRIDFgIICgwKBQQBAgEBEgMWCQwKDAoFBAECAQMSAxYPEAo5CgIEAhIEGgAdARotIFRoZSBDb25jYXQgcmVxdWVzdCBjb250YWlucyB0d28gcGFyYW1ldGVycy4KCgoKAwQCARIDGggVCgsKBAQCAgASAxsCDwoMCgUEAgIABRIDGwIICgwKBQQCAgABEgMbCQoKDAoFBAICAAMSAxsNDgoLCgQEAgIBEgMcAg8KDAoFBAICAQUSAxwCCAoMCgUEAgIBARIDHAkKCgwKBQQCAgEDEgMcDQ4KSwoCBAMSBCAAIwEaPyBUaGUgQ29uY2F0IHJlc3BvbnNlIGNvbnRhaW5zIHRoZSByZXN1bHQgb2YgdGhlIGNvbmNhdGVuYXRpb24uCgoKCgMEAwESAyAIEwoLCgQEAwIAEgMhAg8KDAoFBAMCAAUSAyECCAoMCgUEAwIAARIDIQkKCgwKBQQDAgADEgMhDQ4KCwoEBAMCARIDIgIRCgwKBQQDAgEFEgMiAggKDAoFBAMCAQESAyIJDAoMCgUEAwIBAxIDIg8QYgZwcm90bzM="
|
|
||||||
},
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": true,
|
|
||||||
"fields": {
|
|
||||||
"a": {
|
|
||||||
"type": "integer",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"b": {
|
|
||||||
"type": "integer",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"output_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": true,
|
|
||||||
"fields": {
|
|
||||||
"sum": {
|
|
||||||
"type": "integer",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"error_message": {
|
|
||||||
"type": "string",
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"input_mapping": {
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"source": "$.mcp.a",
|
|
||||||
"target": "$.request.grpc.a",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.mcp.b",
|
|
||||||
"target": "$.request.grpc.b",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"output_mapping": {
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"source": "$.response.data.v",
|
|
||||||
"target": "$.output.sum",
|
|
||||||
"required": true,
|
|
||||||
"transform": {
|
|
||||||
"kind": "to_number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.data.err",
|
|
||||||
"target": "$.output.error_message",
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_config": {
|
|
||||||
"timeout_ms": 5000,
|
|
||||||
"headers": {},
|
|
||||||
"protocol_options": {
|
|
||||||
"grpc": {
|
|
||||||
"use_tls": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tool_description": {
|
|
||||||
"title": "Add Numbers (grpcb.in)",
|
|
||||||
"description": "Call the public grpcb.in addsvc.Add/Sum unary RPC through Crank.",
|
|
||||||
"tags": [
|
|
||||||
"grpc",
|
|
||||||
"math",
|
|
||||||
"smoke"
|
|
||||||
],
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"input": {
|
|
||||||
"a": 7,
|
|
||||||
"b": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package addsvc;
|
|
||||||
|
|
||||||
service Add {
|
|
||||||
rpc Sum (SumRequest) returns (SumReply) {}
|
|
||||||
rpc Concat (ConcatRequest) returns (ConcatReply) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
message SumRequest {
|
|
||||||
int64 a = 1;
|
|
||||||
int64 b = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SumReply {
|
|
||||||
int64 v = 1;
|
|
||||||
string err = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ConcatRequest {
|
|
||||||
string a = 1;
|
|
||||||
string b = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ConcatReply {
|
|
||||||
string v = 1;
|
|
||||||
string err = 2;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user