community: remove premium protocols and streaming surface
This commit is contained in:
Generated
-426
@@ -380,27 +380,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crank-adapter-rest"
|
||||
version = "0.1.0"
|
||||
@@ -438,19 +417,6 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-proto"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"crank-core",
|
||||
"crank-schema",
|
||||
"prost",
|
||||
"prost-reflect",
|
||||
"prost-types",
|
||||
"serde",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-registry"
|
||||
version = "0.1.0"
|
||||
@@ -627,16 +593,6 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "etcetera"
|
||||
version = "0.8.0"
|
||||
@@ -671,12 +627,6 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -688,12 +638,6 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -852,25 +796,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -991,7 +916,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1021,19 +945,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1202,15 +1113,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1276,12 +1178,6 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -1337,7 +1233,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"crank-adapter-grpc",
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"crank-registry",
|
||||
@@ -1390,12 +1285,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1479,15 +1368,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -1543,37 +1423,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -1674,136 +1523,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
@@ -1820,26 +1539,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2017,18 +1716,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -2129,19 +1816,6 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -2226,16 +1900,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -2686,19 +2350,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
@@ -2844,74 +2495,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -2920,12 +2503,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3035,12 +2615,6 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
||||
@@ -5,7 +5,6 @@ members = [
|
||||
"crates/crank-core",
|
||||
"crates/crank-schema",
|
||||
"crates/crank-mapping",
|
||||
"crates/crank-proto",
|
||||
"crates/crank-registry",
|
||||
"crates/crank-runtime",
|
||||
"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
|
||||
- pending:
|
||||
- implement the whitelist from `docs/community-source-whitelist.md`
|
||||
- make Community workspace compile and run without premium protocol crates
|
||||
- make Community UI export stop depending on premium wizard and streaming surfaces
|
||||
- only after that, perform a clean whitelist-based force-push into `crank-community`
|
||||
- make Community copy/docs pass finish the remaining premium string cleanup in `catalog`, `agents`, `settings`, `workspace-setup`, and `i18n`
|
||||
- decide whether legacy streaming internals in `admin-api` stay dormant until `enterprise/cloud` extraction or move out before the next Community release cut
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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-schema/Cargo.toml crates/crank-schema/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-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-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 \
|
||||
apps/admin-api/src \
|
||||
@@ -26,27 +19,17 @@ RUN mkdir -p \
|
||||
crates/crank-core/src \
|
||||
crates/crank-schema/src \
|
||||
crates/crank-mapping/src \
|
||||
crates/crank-proto/src \
|
||||
crates/crank-registry/src \
|
||||
crates/crank-runtime/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/mcp-server/src/main.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-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-runtime/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
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::{
|
||||
capabilities::get_capabilities,
|
||||
machine_auth::{issue_agent_token, issue_one_time_agent_token},
|
||||
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
||||
streaming::list_protocol_capabilities,
|
||||
operations::{
|
||||
archive_operation, create_operation, create_version, delete_operation,
|
||||
export_operation, generate_draft, get_operation, get_operation_version,
|
||||
@@ -33,10 +34,6 @@ use crate::{
|
||||
upload_output_json,
|
||||
},
|
||||
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},
|
||||
},
|
||||
state::AppState,
|
||||
@@ -131,17 +128,7 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.route("/usage", get(get_usage))
|
||||
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
||||
.route("/usage/agents/{agent_id}", get(get_agent_usage))
|
||||
.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));
|
||||
.route("/protocol-capabilities", get(list_protocol_capabilities));
|
||||
|
||||
let workspace_root_router = Router::new()
|
||||
.route("/capabilities", get(get_capabilities))
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
routes::access::WorkspacePath,
|
||||
service::{AsyncJobsQuery, StreamSessionsQuery},
|
||||
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(
|
||||
Path(_path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
@@ -31,107 +18,3 @@ pub async fn list_protocol_capabilities(
|
||||
"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
|
||||
|
||||
[dev-dependencies]
|
||||
crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
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-schema/Cargo.toml crates/crank-schema/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-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-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 \
|
||||
apps/admin-api/src \
|
||||
@@ -26,27 +19,17 @@ RUN mkdir -p \
|
||||
crates/crank-core/src \
|
||||
crates/crank-schema/src \
|
||||
crates/crank-mapping/src \
|
||||
crates/crank-proto/src \
|
||||
crates/crank-registry/src \
|
||||
crates/crank-runtime/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/mcp-server/src/main.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-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-runtime/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
|
||||
&& printf 'pub fn placeholder() {}\n' > crates/crank-adapter-rest/src/lib.rs
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--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>
|
||||
</button>
|
||||
<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
|
||||
class="dropdown-item"
|
||||
:class="{ selected: filterProtocol === 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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -376,27 +376,6 @@
|
||||
getProtocolCapabilities: function(workspaceId) {
|
||||
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) {
|
||||
return get(
|
||||
'/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',
|
||||
logs: '/logs',
|
||||
usage: '/usage',
|
||||
streamSessions: '/stream-sessions',
|
||||
asyncJobs: '/async-jobs',
|
||||
settings: '/settings',
|
||||
workspaceSetup: '/workspace-setup',
|
||||
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;
|
||||
}
|
||||
|
||||
location = /stream-sessions {
|
||||
try_files /html/stream-sessions.html =404;
|
||||
}
|
||||
|
||||
location = /async-jobs {
|
||||
try_files /html/async-jobs.html =404;
|
||||
}
|
||||
|
||||
location = /settings {
|
||||
try_files /html/settings.html =404;
|
||||
}
|
||||
@@ -91,14 +83,6 @@ server {
|
||||
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 {
|
||||
return 302 /settings;
|
||||
}
|
||||
|
||||
@@ -77,18 +77,6 @@ const BUNDLES = {
|
||||
'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': {
|
||||
files: [
|
||||
'js/diagnostics.js',
|
||||
@@ -220,11 +208,9 @@ async function main() {
|
||||
rewriteHtml('html/login.html', manifest, '..');
|
||||
rewriteHtml('html/agents.html', manifest, '..');
|
||||
rewriteHtml('html/api-keys.html', manifest, '..');
|
||||
rewriteHtml('html/async-jobs.html', manifest, '..');
|
||||
rewriteHtml('html/logs.html', manifest, '..');
|
||||
rewriteHtml('html/secrets.html', manifest, '..');
|
||||
rewriteHtml('html/settings.html', manifest, '..');
|
||||
rewriteHtml('html/stream-sessions.html', manifest, '..');
|
||||
rewriteHtml('html/usage.html', manifest, '..');
|
||||
rewriteHtml('html/workspace-setup.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
|
||||
rm -f "$TMP_DIR/ui-server.pid"
|
||||
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
|
||||
kill_port_processes "$UI_PORT"
|
||||
kill_port_processes "$ADMIN_PORT"
|
||||
kill_port_processes "$MCP_PORT"
|
||||
kill_port_processes "$STREAM_FIXTURE_PORT"
|
||||
kill_port_processes "${GRPC_FIXTURE_BIND##*:}"
|
||||
}
|
||||
|
||||
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_DEMO_SEED="true"
|
||||
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"
|
||||
|
||||
(
|
||||
@@ -153,24 +140,6 @@ until curl -fsS "http://127.0.0.1:$MCP_PORT/health" >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
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"
|
||||
node scripts/playwright-ui-server.js >"$LOG_DIR/ui-server.log" 2>&1
|
||||
|
||||
@@ -53,12 +53,6 @@ function mapRoute(urlPath) {
|
||||
if (urlPath === '/usage') {
|
||||
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') {
|
||||
return path.join(ROOT_DIR, 'html', 'settings.html');
|
||||
}
|
||||
@@ -171,14 +165,6 @@ const server = http.createServer((request, response) => {
|
||||
redirect(response, '/usage');
|
||||
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') {
|
||||
redirect(response, '/settings');
|
||||
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: '/workspace-setup', heading: localized('Workspace', 'Воркспейс') },
|
||||
{ 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', 'Создать') },
|
||||
];
|
||||
|
||||
|
||||
@@ -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`
|
||||
- `/workspace-setup`
|
||||
- `/settings`
|
||||
- `/stream-sessions`
|
||||
- `/async-jobs`
|
||||
- `/wizard/`
|
||||
|
||||
Для каждой страницы зафиксировать:
|
||||
@@ -142,8 +140,6 @@ just staging-note-block <domain> <deploy-sha> "codex + operator"
|
||||
- `window` tool;
|
||||
- `session` tool;
|
||||
- `async_job` tool;
|
||||
- `/stream-sessions`;
|
||||
- `/async-jobs`.
|
||||
|
||||
Если WebSocket/SOAP upstream на стенде отсутствуют:
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
- `crates/crank-core`
|
||||
- `crates/crank-schema`
|
||||
- `crates/crank-mapping`
|
||||
- `crates/crank-proto`
|
||||
- `crates/crank-registry`
|
||||
- `crates/crank-runtime`
|
||||
- `crates/crank-adapter-rest`
|
||||
@@ -129,11 +128,9 @@
|
||||
|
||||
Основные блокеры:
|
||||
|
||||
1. Root workspace все еще содержит premium adapter crates.
|
||||
2. `admin-api` и `mcp-server` test/dev wiring все еще используют premium crates.
|
||||
3. UI build все еще включает premium wizard modules и premium step templates.
|
||||
4. `workspace-setup`, `settings`, `catalog`, `usage`, `agents` и `i18n` все еще содержат premium protocol surface.
|
||||
5. `examples/` и часть docs все еще содержат premium protocol materials.
|
||||
1. `admin-api` и `mcp-server` test/dev wiring еще не полностью очищены от legacy streaming/premium references.
|
||||
2. `workspace-setup`, `settings`, `catalog`, `usage`, `agents` и `i18n` все еще содержат отдельные premium protocol strings и copy.
|
||||
3. часть docs все еще требует final Community pass после физического удаления premium crates и standalone streaming pages.
|
||||
|
||||
## 6. Порядок очистки перед public import
|
||||
|
||||
|
||||
@@ -152,8 +152,6 @@ curl -I https://<domain>/html/wizard/
|
||||
- `/usage`
|
||||
- `/workspace-setup`
|
||||
- `/settings`
|
||||
- `/stream-sessions`
|
||||
- `/async-jobs`
|
||||
- `/wizard/`
|
||||
|
||||
Для каждой страницы:
|
||||
@@ -209,9 +207,6 @@ Smoke считается успешным, если:
|
||||
На deployed стенде проверить:
|
||||
|
||||
- `window` tool выполняется и не возвращает бесконечный поток;
|
||||
- `session` tool создает записи в `/stream-sessions`;
|
||||
- `async_job` tool создает записи в `/async-jobs`;
|
||||
- UI pages `/stream-sessions` и `/async-jobs` не пустые после вызовов.
|
||||
|
||||
Если есть real upstream:
|
||||
|
||||
|
||||
@@ -26,10 +26,7 @@ flowchart LR
|
||||
CORE[crank-core]
|
||||
SCHEMA[crank-schema]
|
||||
MAP[crank-mapping]
|
||||
PROTO[crank-proto]
|
||||
REST[adapter-rest]
|
||||
GQL[adapter-graphql]
|
||||
GRPC[adapter-grpc]
|
||||
DB[(PostgreSQL)]
|
||||
STORE[(Artifact Storage)]
|
||||
OBS[(Usage and Logs)]
|
||||
@@ -39,7 +36,6 @@ flowchart LR
|
||||
MCP --> RUN
|
||||
ADMIN --> REG
|
||||
ADMIN --> RUN
|
||||
ADMIN --> PROTO
|
||||
REG --> DB
|
||||
REG --> CORE
|
||||
REG --> SCHEMA
|
||||
@@ -48,10 +44,6 @@ flowchart LR
|
||||
RUN --> SCHEMA
|
||||
RUN --> MAP
|
||||
RUN --> REST
|
||||
RUN --> GQL
|
||||
RUN --> GRPC
|
||||
GRPC --> PROTO
|
||||
PROTO --> STORE
|
||||
ADMIN --> STORE
|
||||
REG --> OBS
|
||||
ADMIN --> OBS
|
||||
|
||||
@@ -29,13 +29,8 @@
|
||||
crank-registry/
|
||||
crank-runtime/
|
||||
crank-adapter-rest/
|
||||
crank-adapter-graphql/
|
||||
crank-adapter-grpc/
|
||||
crank-adapter-websocket/
|
||||
crank-adapter-soap/
|
||||
crank-mapping/
|
||||
crank-schema/
|
||||
crank-proto/
|
||||
```
|
||||
|
||||
Поверх существующих crates должны появиться новые логические поддомены:
|
||||
@@ -87,15 +82,7 @@
|
||||
- input/output mapping;
|
||||
- draft inference из samples.
|
||||
|
||||
### 4.4. `crank-proto`
|
||||
|
||||
Назначение:
|
||||
|
||||
- работа с `.proto` и descriptor set;
|
||||
- извлечение services, methods и message schemas;
|
||||
- преобразование protobuf metadata во внутренние типы.
|
||||
|
||||
### 4.5. `crank-registry`
|
||||
### 4.4. `crank-registry`
|
||||
|
||||
Назначение:
|
||||
|
||||
@@ -109,7 +96,7 @@
|
||||
- logs и usage aggregates;
|
||||
- metadata по sample artifacts и descriptors.
|
||||
|
||||
### 4.6. `crank-runtime`
|
||||
### 4.5. `crank-runtime`
|
||||
|
||||
Назначение:
|
||||
|
||||
@@ -124,23 +111,13 @@
|
||||
- `crank-runtime` должен собираться как `REST-only` base даже без premium adapter crates;
|
||||
- premium protocol adapters подключаются через feature seams, а не как безусловная зависимость Community runtime.
|
||||
|
||||
### 4.7. Protocol adapters
|
||||
### 4.6. Protocol adapters
|
||||
|
||||
- `crank-adapter-rest`
|
||||
- `crank-adapter-graphql`
|
||||
- `crank-adapter-grpc`
|
||||
- `crank-adapter-websocket`
|
||||
- `crank-adapter-soap`
|
||||
|
||||
Каждый adapter знает только свой протокол.
|
||||
Community runtime в этом репозитории поддерживает только `REST`.
|
||||
|
||||
Для open-core split это означает:
|
||||
|
||||
- `crank-adapter-rest` остается обязательной частью Community runtime;
|
||||
- `crank-adapter-graphql`, `crank-adapter-grpc`, `crank-adapter-websocket`, `crank-adapter-soap`
|
||||
должны подключаться как отделяемые protocol modules.
|
||||
|
||||
### 4.8. `apps/admin-api`
|
||||
### 4.7. `apps/admin-api`
|
||||
|
||||
Должен содержать сервисные группы:
|
||||
|
||||
@@ -156,7 +133,7 @@
|
||||
- `usage`
|
||||
- `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;
|
||||
- execution helpers.
|
||||
|
||||
### В `crank-proto`
|
||||
|
||||
- metadata conversion helpers;
|
||||
- descriptor lookup helpers.
|
||||
|
||||
### В `crank-registry`
|
||||
|
||||
- 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-mapping`
|
||||
- `crank-proto`
|
||||
- небольшие части `crank-core`
|
||||
|
||||
Что проверять:
|
||||
@@ -28,8 +27,6 @@
|
||||
- `JSONPath` parsing;
|
||||
- применение input/output mapping;
|
||||
- генерацию чернового mapping;
|
||||
- protobuf -> schema normalization;
|
||||
- JSON -> protobuf и protobuf -> JSON conversion.
|
||||
|
||||
### 2.2. Integration tests
|
||||
|
||||
@@ -50,11 +47,9 @@
|
||||
|
||||
### 2.3. Adapter tests
|
||||
|
||||
Отдельно для каждого протокола:
|
||||
Отдельно для Community-протокола:
|
||||
|
||||
- REST adapter;
|
||||
- GraphQL adapter;
|
||||
- gRPC unary adapter.
|
||||
|
||||
Что проверять:
|
||||
|
||||
@@ -68,8 +63,6 @@
|
||||
Минимально нужны сценарии:
|
||||
|
||||
- создать REST operation -> протестировать -> опубликовать -> вызвать как MCP tool;
|
||||
- создать GraphQL operation -> протестировать -> опубликовать -> вызвать как MCP tool;
|
||||
- загрузить `.proto` или descriptor set -> создать gRPC unary operation -> протестировать -> опубликовать -> вызвать как MCP tool.
|
||||
|
||||
## 3. Что должно быть покрыто обязательно
|
||||
|
||||
@@ -83,10 +76,8 @@
|
||||
|
||||
### Обязательно до первого демо
|
||||
|
||||
- хотя бы один end-to-end сценарий для каждого из трех протоколов;
|
||||
- хотя бы один end-to-end сценарий для REST;
|
||||
- negative tests на invalid `JSONPath`;
|
||||
- negative tests на invalid protobuf descriptor;
|
||||
- negative tests на GraphQL errors при HTTP `200`.
|
||||
|
||||
## 4. Формат тестовых данных
|
||||
|
||||
@@ -94,7 +85,6 @@
|
||||
|
||||
- JSON fixtures для sample input/output;
|
||||
- YAML golden files для export/import;
|
||||
- `.proto` и descriptor fixtures для gRPC;
|
||||
- snapshot tests для generated draft.
|
||||
|
||||
## 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