community: remove premium protocols and streaming surface

This commit is contained in:
a.tolmachev
2026-05-07 20:44:58 +00:00
parent 202c886793
commit 29f971c22d
78 changed files with 21 additions and 8838 deletions
Generated
-426
View File
@@ -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"
-1
View File
@@ -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",
+3 -3
View File
@@ -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
+1 -18
View File
@@ -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 \
+2 -15
View File
@@ -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 -118
View File
@@ -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)))
}
-1
View File
@@ -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
+1 -18
View File
@@ -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 \
-106
View File
@@ -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>
-111
View File
@@ -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
View File
@@ -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>
-21
View File
@@ -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)
-232
View File
@@ -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();
});
-2
View File
@@ -9,8 +9,6 @@
secrets: '/secrets',
logs: '/logs',
usage: '/usage',
streamSessions: '/stream-sessions',
asyncJobs: '/async-jobs',
settings: '/settings',
workspaceSetup: '/workspace-setup',
wizard: '/wizard/'
-211
View File
@@ -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();
});
-16
View File
@@ -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;
}
-14
View File
@@ -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, '../..');
-31
View File
@@ -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
-14
View File
@@ -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;
-234
View File
@@ -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', 'Создать') },
];
-125
View File
@@ -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');
});
-18
View File
@@ -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
-264
View File
@@ -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"
}
}
}))
}
}
-24
View File
@@ -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> },
}
-7
View File
@@ -1,7 +0,0 @@
mod client;
mod error;
mod model;
pub use client::GraphqlAdapter;
pub use error::GraphqlAdapterError;
pub use model::{GraphqlRequest, GraphqlResponse};
-22
View File
@@ -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,
}
-30
View File
@@ -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
-17
View File
@@ -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;
}
-314
View File
@@ -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 { .. }
));
}
}
-72
View File
@@ -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()))
}
}
-52
View File
@@ -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(),
}
}
}
-11
View File
@@ -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;
-37
View File
@@ -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)
}
-19
View File
@@ -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
-326
View File
@@ -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");
}
}
-28
View File
@@ -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>,
},
}
-12
View File
@@ -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;
-40
View File
@@ -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>,
}
-165
View File
@@ -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")
);
}
}
-319
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
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>"));
}
}
-19
View File
@@ -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 },
}
-35
View File
@@ -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,
}
-15
View File
@@ -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
View File
@@ -1 +0,0 @@
pub mod to_schema;
-381
View File
@@ -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(),
}
}
}
-11
View File
@@ -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,
}
-142
View File
@@ -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(),
}
}
-54
View File
@@ -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,
}
-16
View File
@@ -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>,
}
-18
View File
@@ -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
}
}
-11
View File
@@ -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;
-16
View File
@@ -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())
}
}
-4
View File
@@ -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 на стенде отсутствуют:
+3 -6
View File
@@ -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
-5
View File
@@ -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:
-8
View File
@@ -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
+6 -29
View File
@@ -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`
Назначение:
-109
View File
@@ -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 в системе должен иметь отдельный адаптер и отдельную конфигурационную модель.
-110
View File
@@ -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.
-162
View File
@@ -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.
-159
View File
@@ -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 вызова.
-5
View File
@@ -361,11 +361,6 @@ where
- mapping rule helpers;
- execution helpers.
### В `crank-proto`
- metadata conversion helpers;
- descriptor lookup helpers.
### В `crank-registry`
- service methods, а не методы на доменных структурах;
-641
View File
@@ -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.
-584
View File
@@ -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. только потом переходить дальше.
-702
View File
@@ -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.
-580
View File
@@ -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.
-442
View File
@@ -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`
+2 -12
View File
@@ -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;
}