merge: streaming transport and protocol platform
This commit is contained in:
@@ -12,6 +12,7 @@ CRANK_MCP_BIND=0.0.0.0:3002
|
||||
CRANK_MCP_REFRESH_MS=5000
|
||||
CRANK_LOG_LEVEL=info
|
||||
CRANK_SECRET_PROVIDER=env
|
||||
CRANK_MASTER_KEY=change-me-master-key
|
||||
CRANK_SESSION_SECRET=change-me-session-secret
|
||||
CRANK_PASSWORD_PEPPER=change-me-password-pepper
|
||||
CRANK_SESSION_TTL_HOURS=24
|
||||
|
||||
Generated
+234
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"axum-extra",
|
||||
"base64",
|
||||
"crank-adapter-grpc",
|
||||
"crank-adapter-soap",
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"crank-proto",
|
||||
@@ -33,6 +34,41 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -249,6 +285,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -317,6 +363,7 @@ dependencies = [
|
||||
"base64",
|
||||
"crank-core",
|
||||
"crank-proto",
|
||||
"futures-util",
|
||||
"prost",
|
||||
"prost-reflect",
|
||||
"prost-types",
|
||||
@@ -336,6 +383,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"crank-core",
|
||||
"futures-util",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -343,6 +391,35 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-adapter-soap"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-adapter-websocket"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"crank-core",
|
||||
"futures-util",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-core"
|
||||
version = "0.1.0"
|
||||
@@ -396,17 +473,24 @@ dependencies = [
|
||||
name = "crank-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"axum",
|
||||
"base64",
|
||||
"crank-adapter-graphql",
|
||||
"crank-adapter-grpc",
|
||||
"crank-adapter-rest",
|
||||
"crank-adapter-soap",
|
||||
"crank-adapter-websocket",
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"crank-schema",
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -457,9 +541,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -636,6 +736,17 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
@@ -656,6 +767,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
@@ -713,6 +825,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -1038,6 +1160,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1175,6 +1306,7 @@ dependencies = [
|
||||
"crank-registry",
|
||||
"crank-runtime",
|
||||
"crank-schema",
|
||||
"futures-util",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1256,6 +1388,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "2.10.1"
|
||||
@@ -1360,6 +1498,18 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -1762,6 +1912,7 @@ dependencies = [
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -1781,12 +1932,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
@@ -1805,6 +1958,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -2013,6 +2172,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2383,6 +2553,22 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -2581,6 +2767,25 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@@ -2626,6 +2831,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
@@ -2650,6 +2865,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -2800,6 +3021,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
|
||||
@@ -11,6 +11,8 @@ members = [
|
||||
"crates/crank-adapter-rest",
|
||||
"crates/crank-adapter-graphql",
|
||||
"crates/crank-adapter-grpc",
|
||||
"crates/crank-adapter-websocket",
|
||||
"crates/crank-adapter-soap",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
@@ -21,6 +23,7 @@ rust-version = "1.85"
|
||||
version = "0.1.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
axum = "0.8"
|
||||
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||
@@ -31,6 +34,7 @@ prost-types = "0.14"
|
||||
protoc-bin-vendored = "3"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] }
|
||||
roxmltree = "0.20"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
@@ -45,4 +49,5 @@ tonic-build = "0.14"
|
||||
tonic-prost-build = "0.14"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
uuid = { version = "1", features = ["serde", "v7"] }
|
||||
|
||||
@@ -8,7 +8,7 @@ Crank - платформа для публикации внешних API в в
|
||||
|
||||
- Разработать MCP server на Rust.
|
||||
- Поддержать динамическое добавление интеграций через UI или конфигурацию.
|
||||
- Обеспечить единый сценарий работы оператора для REST, GraphQL и gRPC.
|
||||
- Обеспечить единый сценарий работы оператора для REST, GraphQL, gRPC, WebSocket и SOAP.
|
||||
- Нормализовать внешние протоколы в единую внутреннюю модель операции.
|
||||
- Ограничивать набор tools на уровне конкретного агента, а не отдавать один глобальный каталог.
|
||||
- Поддержать workspace-изоляцию, platform access и observability.
|
||||
@@ -20,7 +20,10 @@ Crank - платформа для публикации внешних API в в
|
||||
- `Agent` как curated MCP surface для LLM.
|
||||
- Поддержка REST для `GET`, `POST`, `PUT`, `PATCH` и `DELETE`.
|
||||
- Поддержка GraphQL для `query` и `mutation`.
|
||||
- Поддержка только unary-методов gRPC.
|
||||
- Поддержка unary и bounded server-streaming для gRPC.
|
||||
- Поддержка WebSocket upstream integrations в bounded execution modes.
|
||||
- Поддержка SOAP/WSDL enterprise integrations.
|
||||
- Поддержка controlled streaming modes поверх MCP `Streamable HTTP`.
|
||||
- Platform API keys и membership layer.
|
||||
- Observability: invocation logs, usage aggregates, latency/error metrics.
|
||||
- Импорт и экспорт operation-конфигураций в `YAML`.
|
||||
@@ -40,11 +43,21 @@ Crank - платформа для публикации внешних API в в
|
||||
- `docs/diagrams.md` - диаграммы компонентов, сущностей и БД.
|
||||
- `docs/mcp-interface.md` - модель MCP transport и agent-scoped publishing.
|
||||
- `docs/testing-strategy.md` - стратегия тестирования.
|
||||
- `docs/manual-regression-checklist.md` - post-integration regression baseline и ручной smoke checklist.
|
||||
- `docs/runtime-config.md` - конфигурация окружения.
|
||||
- `docs/deployment.md` - деплой, reverse proxy и CI/CD.
|
||||
- `docs/deploy-and-staging-smoke.md` - канонический post-deploy smoke pass для staging/production-like окружения.
|
||||
- `docs/authenticated-staging-pass.md` - browser-authenticated pass для UI flows, secrets, wizard и protocol smoke на стенде.
|
||||
- `docs/staging-regression-notes.md` - журнал реальных замечаний и результатов post-deploy проверок на стенде.
|
||||
- `docs/demo-runbook.md` - демонстрационный сценарий.
|
||||
- `docs/public-smoke-targets.md` - готовые публичные upstream-сервисы и payload-ы для smoke-проверки MCP.
|
||||
- `docs/secrets-auth-plan.md` - целевая модель upstream secrets, auth profiles и пошаговый план реализации.
|
||||
- `docs/streaming-mcp-plan.md` - целевая модель MCP transport streaming, upstream streaming и поэтапный план реализации.
|
||||
- `docs/streaming-admin-api.md` - точные HTTP-контракты и DTO для streaming configuration, sessions и jobs.
|
||||
- `docs/streaming-runtime-design.md` - функция-за-функцией разложенная streaming runtime architecture.
|
||||
- `docs/streaming-ui-contract.md` - точный UI-контракт для streaming configuration и test flows.
|
||||
- `docs/protocol-capability-matrix.md` - capability matrix по всем protocol families и execution modes.
|
||||
- `docs/streaming-implementation-spec.md` - execution-oriented план реализации по срезам, файлам, тестам и DoD.
|
||||
- `docs/rust-design.md` - правила распределения поведения в Rust.
|
||||
- `docs/development-rules.md` - правила разработки и workflow.
|
||||
- `docs/rust-code-rules.md` - Rust-specific coding rules.
|
||||
@@ -52,6 +65,8 @@ Crank - платформа для публикации внешних API в в
|
||||
- `docs/protocols/rest.md` - требования и ограничения для REST.
|
||||
- `docs/protocols/graphql.md` - требования и ограничения для GraphQL.
|
||||
- `docs/protocols/grpc.md` - требования и ограничения для gRPC.
|
||||
- `docs/protocols/websocket.md` - требования и ограничения для WebSocket.
|
||||
- `docs/protocols/soap.md` - требования и ограничения для SOAP.
|
||||
|
||||
## Ключевая идея продукта
|
||||
|
||||
@@ -88,8 +103,10 @@ Crank - платформа для публикации внешних API в в
|
||||
- REST
|
||||
- GraphQL
|
||||
- gRPC
|
||||
- WebSocket
|
||||
- SOAP
|
||||
|
||||
`SOAP` сознательно не входит в текущий scope.
|
||||
Все пять протокольных семейств входят в целевой product scope. Разница только в очередности реализации.
|
||||
|
||||
## Frontend e2e
|
||||
|
||||
@@ -114,3 +131,23 @@ npm run e2e
|
||||
```bash
|
||||
just ui-e2e
|
||||
```
|
||||
|
||||
Для post-deploy smoke:
|
||||
|
||||
```bash
|
||||
just staging-smoke https://<domain>
|
||||
```
|
||||
|
||||
Для browser-authenticated smoke на реальном стенде:
|
||||
|
||||
```bash
|
||||
export CRANK_STAGING_ADMIN_EMAIL=owner@example.com
|
||||
export CRANK_STAGING_ADMIN_PASSWORD=secret
|
||||
just authenticated-staging-smoke https://<domain>
|
||||
```
|
||||
|
||||
Чтобы быстро подготовить запись для `docs/staging-regression-notes.md`:
|
||||
|
||||
```bash
|
||||
just staging-note-block <domain> <deploy-sha> "codex + operator"
|
||||
```
|
||||
|
||||
@@ -2,24 +2,19 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/secrets-auth-plan`
|
||||
### `feat/websocket-test-run-polish`
|
||||
|
||||
Status: completed
|
||||
Status: pending
|
||||
|
||||
DoD:
|
||||
- Docs describe the target secret store and auth profile model
|
||||
- Backend, runtime, and UI gaps are captured as vertical slices
|
||||
- TASKS and implementation plan reflect the new sequence
|
||||
- WebSocket wizard test-run shows reconnect / bounded-result semantics clearly
|
||||
- WebSocket test-run flow is covered by local fixtures and Playwright
|
||||
- `TASKS.md` stays aligned with the actual streaming branch state
|
||||
|
||||
## Next
|
||||
|
||||
- `feat/secret-store-foundation`
|
||||
- `feat/live-authenticated-staging-entry`
|
||||
|
||||
## Backlog
|
||||
|
||||
- `feat/secret-store-foundation`
|
||||
- `feat/auth-profile-secret-resolution`
|
||||
- `feat/runtime-upstream-auth`
|
||||
- `feat/secrets-ui`
|
||||
- `feat/wizard-auth-selector`
|
||||
- `feat/manual-regression-pass`
|
||||
- `feat/live-authenticated-staging-entry`
|
||||
|
||||
@@ -11,6 +11,7 @@ axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] }
|
||||
crank-adapter-soap = { path = "../../crates/crank-adapter-soap" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-proto = { path = "../../crates/crank-proto" }
|
||||
|
||||
+687
-8
@@ -24,8 +24,14 @@ use crate::{
|
||||
operations::{
|
||||
archive_operation, create_operation, create_version, delete_operation,
|
||||
export_operation, generate_draft, get_operation, get_operation_version,
|
||||
list_grpc_services, list_operations, publish_operation, run_test, update_operation,
|
||||
upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor,
|
||||
list_grpc_services, list_operations, list_soap_services, publish_operation, run_test,
|
||||
update_operation, upload_descriptor_set, upload_input_json, upload_output_json,
|
||||
upload_proto_descriptor, upload_wsdl_descriptor, upload_xsd_descriptor,
|
||||
},
|
||||
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},
|
||||
},
|
||||
@@ -72,10 +78,22 @@ pub fn build_app(state: AppState) -> Router {
|
||||
"/operations/{operation_id}/descriptors/descriptor-set",
|
||||
post(upload_descriptor_set),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/descriptors/wsdl",
|
||||
post(upload_wsdl_descriptor),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/descriptors/xsd",
|
||||
post(upload_xsd_descriptor),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/grpc/services",
|
||||
get(list_grpc_services),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/soap/services",
|
||||
get(list_soap_services),
|
||||
)
|
||||
.route(
|
||||
"/operations/{operation_id}/drafts/generate",
|
||||
post(generate_draft),
|
||||
@@ -99,6 +117,12 @@ pub fn build_app(state: AppState) -> Router {
|
||||
get(list_auth_profiles).post(create_auth_profile),
|
||||
)
|
||||
.route("/auth-profiles/{auth_profile_id}", get(get_auth_profile))
|
||||
.route("/secrets", get(list_secrets).post(create_secret))
|
||||
.route(
|
||||
"/secrets/{secret_id}",
|
||||
get(get_secret).delete(delete_secret),
|
||||
)
|
||||
.route("/secrets/{secret_id}/rotate", post(rotate_secret))
|
||||
.route("/members", get(list_memberships))
|
||||
.route(
|
||||
"/members/{user_id}",
|
||||
@@ -126,7 +150,18 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.route("/logs/{log_id}", get(get_log))
|
||||
.route("/usage", get(get_usage))
|
||||
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
||||
.route("/usage/agents/{agent_id}", get(get_agent_usage));
|
||||
.route("/usage/agents/{agent_id}", get(get_agent_usage))
|
||||
.route("/protocol-capabilities", get(list_protocol_capabilities))
|
||||
.route("/stream-sessions", get(list_stream_sessions))
|
||||
.route("/stream-sessions/{session_id}", get(get_stream_session))
|
||||
.route(
|
||||
"/stream-sessions/{session_id}/stop",
|
||||
post(stop_stream_session),
|
||||
)
|
||||
.route("/async-jobs", get(list_async_jobs))
|
||||
.route("/async-jobs/{job_id}", get(get_async_job))
|
||||
.route("/async-jobs/{job_id}/cancel", post(cancel_async_job))
|
||||
.route("/async-jobs/{job_id}/result", get(get_async_job_result));
|
||||
|
||||
let workspace_root_router = Router::new()
|
||||
.route("/workspaces", get(list_workspaces).post(create_workspace))
|
||||
@@ -186,11 +221,14 @@ mod tests {
|
||||
use axum::{Json, Router, routing::post};
|
||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||
use crank_core::{
|
||||
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
||||
MembershipRole, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
||||
DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget,
|
||||
GrpcTarget, HttpMethod, MembershipRole, Protocol, RestTarget, SecretKind, SoapBindingStyle,
|
||||
SoapOperationMetadata, SoapTarget, SoapVersion, Target, ToolDescription, TransportBehavior,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_runtime::SecretCrypto;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use serial_test::serial;
|
||||
@@ -209,6 +247,7 @@ mod tests {
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
const TEST_MASTER_KEY: &str = "test-master-key";
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
@@ -981,7 +1020,12 @@ mod tests {
|
||||
async fn seeds_demo_assets_for_live_ui() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("demo_seed");
|
||||
let service = AdminService::new(registry.clone(), storage_root, test_auth_settings());
|
||||
let service = AdminService::new(
|
||||
registry.clone(),
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
|
||||
service.bootstrap_admin_user().await.unwrap();
|
||||
service.seed_demo_assets().await.unwrap();
|
||||
@@ -1398,6 +1442,248 @@ mod tests {
|
||||
assert_eq!(test_run["response_preview"]["message"], "hello");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn returns_window_metadata_for_streaming_test_runs() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("grpc_window_test_run");
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_grpc_window_operation_payload(
|
||||
&server_addr,
|
||||
"echo_window_runtime",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let test_run = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "message": "hello" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(test_run["ok"], true);
|
||||
assert_eq!(test_run["mode"], "window");
|
||||
assert!(test_run["window"].is_object());
|
||||
assert!(test_run["window"]["window_complete"].is_boolean());
|
||||
assert!(test_run["window"]["truncated"].is_boolean());
|
||||
assert!(test_run["window"]["has_more"].is_boolean());
|
||||
assert!(test_run["response_preview"]["items"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn starts_stream_session_from_streaming_test_runs() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("grpc_session_test_run");
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_grpc_session_operation_payload(
|
||||
&server_addr,
|
||||
"echo_session_runtime",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let test_run = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "message": "hello" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let session_id = test_run["stream_session"]["session_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let session = client
|
||||
.get(format!("{base_url}/stream-sessions/{session_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(test_run["ok"], true);
|
||||
assert_eq!(test_run["mode"], "session");
|
||||
assert_eq!(test_run["stream_session"]["status"], "running");
|
||||
assert!(test_run["response_preview"]["preview"]["items"].is_array());
|
||||
assert_eq!(session["id"], session_id);
|
||||
assert_eq!(session["status"], "running");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn starts_async_job_from_streaming_test_runs() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("async_job_test_run");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_rest_async_job_operation_payload(
|
||||
&upstream_base_url,
|
||||
"crm_async_job_runtime",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let test_run = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let job_id = test_run["async_job"]["job_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let mut job = Value::Null;
|
||||
for _ in 0..20 {
|
||||
job = client
|
||||
.get(format!("{base_url}/async-jobs/{job_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
if job["status"] == "completed" {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
}
|
||||
|
||||
let result = client
|
||||
.get(format!("{base_url}/async-jobs/{job_id}/result"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(test_run["ok"], true);
|
||||
assert_eq!(test_run["mode"], "async_job");
|
||||
assert_eq!(test_run["async_job"]["status"], "running");
|
||||
assert_eq!(job["status"], "completed");
|
||||
assert_eq!(result["id"], "lead_123");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn uploads_wsdl_and_tests_soap_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("soap_runtime");
|
||||
let endpoint = spawn_soap_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
.json(&test_soap_operation_payload(
|
||||
&endpoint,
|
||||
"crm_create_lead_soap",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
let uploaded = client
|
||||
.post(format!(
|
||||
"{base_url}/operations/{operation_id}/descriptors/wsdl"
|
||||
))
|
||||
.header("x-file-name", "lead.wsdl")
|
||||
.body(SOAP_TEST_WSDL)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let services = client
|
||||
.get(format!(
|
||||
"{base_url}/operations/{operation_id}/soap/services"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let test_run = client
|
||||
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
|
||||
.json(&json!({
|
||||
"version": 1,
|
||||
"input": { "email": "user@example.com" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(uploaded["version"], 1);
|
||||
assert_eq!(services["services"][0]["service_name"], "LeadService");
|
||||
assert_eq!(services["services"][0]["ports"][0]["port_name"], "LeadPort");
|
||||
assert_eq!(
|
||||
services["services"][0]["ports"][0]["operations"][0]["operation_name"],
|
||||
"CreateLead"
|
||||
);
|
||||
assert_eq!(test_run["ok"], true);
|
||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_auth_profiles_and_yaml_upsert() {
|
||||
@@ -1406,6 +1692,18 @@ mod tests {
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
let secret = client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-api-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "super-secret-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = assert_success_json(secret).await;
|
||||
let secret_id = secret["id"].as_str().unwrap();
|
||||
|
||||
let auth_profile = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
@@ -1415,7 +1713,7 @@ mod tests {
|
||||
"config": {
|
||||
"api_key_header": {
|
||||
"header_name": "X-Api-Key",
|
||||
"secret_ref": "secret://crm/api-key"
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -1457,11 +1755,177 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(auth_profile["kind"], "api_key_header");
|
||||
assert_eq!(
|
||||
auth_profile["config"]["api_key_header"]["secret_id"],
|
||||
secret_id
|
||||
);
|
||||
assert_eq!(imported["operation_id"], operation_id);
|
||||
assert_eq!(imported["version"], 2);
|
||||
assert_eq!(imported["import_mode"], "upsert");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_workspace_secrets_without_exposing_plaintext() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("secrets");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-api-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "super-secret-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let created = assert_success_json(created).await;
|
||||
let secret_id = created["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let listed = client
|
||||
.get(format!("{base_url}/secrets"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let listed = assert_success_json(listed).await;
|
||||
|
||||
let fetched = client
|
||||
.get(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let fetched = assert_success_json(fetched).await;
|
||||
|
||||
let rotated = client
|
||||
.post(format!("{base_url}/secrets/{secret_id}/rotate"))
|
||||
.json(&json!({
|
||||
"value": { "token": "rotated-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let rotated = assert_success_json(rotated).await;
|
||||
|
||||
let deleted = client
|
||||
.delete(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let deleted = assert_success_json(deleted).await;
|
||||
|
||||
let missing = client
|
||||
.get(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let missing_status = missing.status();
|
||||
let missing = missing.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(created["name"], "crm-api-token");
|
||||
assert_eq!(created["kind"], "token");
|
||||
assert_eq!(created["current_version"], 1);
|
||||
assert!(created.get("value").is_none());
|
||||
assert_eq!(listed["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(fetched["id"], secret_id);
|
||||
assert!(fetched.get("value").is_none());
|
||||
assert_eq!(rotated["current_version"], 2);
|
||||
assert_eq!(deleted["ok"], true);
|
||||
assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(missing["error"]["code"], "not_found");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_auth_profile_with_missing_secret() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("missing_secret_auth");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let response = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "crm-header",
|
||||
"kind": "api_key_header",
|
||||
"config": {
|
||||
"api_key_header": {
|
||||
"header_name": "X-Api-Key",
|
||||
"secret_id": "secret_missing"
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["error"]["code"], "not_found");
|
||||
assert_eq!(
|
||||
body["error"]["message"],
|
||||
"secret secret_missing was not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn rejects_deleting_secret_referenced_by_auth_profile() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("secret_references");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let secret = client
|
||||
.post(format!("{base_url}/secrets"))
|
||||
.json(&json!({
|
||||
"name": "crm-api-token",
|
||||
"kind": SecretKind::Token,
|
||||
"value": { "token": "super-secret-token" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = assert_success_json(secret).await;
|
||||
let secret_id = secret["id"].as_str().unwrap();
|
||||
|
||||
let auth_profile = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
.json(&json!({
|
||||
"name": "crm-header",
|
||||
"kind": "api_key_header",
|
||||
"config": {
|
||||
"api_key_header": {
|
||||
"header_name": "X-Api-Key",
|
||||
"secret_id": secret_id
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let auth_profile = assert_success_json(auth_profile).await;
|
||||
let auth_profile_id = auth_profile["id"].as_str().unwrap();
|
||||
|
||||
let response = client
|
||||
.delete(format!("{base_url}/secrets/{secret_id}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response.json::<Value>().await.unwrap();
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::CONFLICT);
|
||||
assert_eq!(body["error"]["code"], "conflict");
|
||||
assert_eq!(
|
||||
body["error"]["message"],
|
||||
format!("secret {secret_id} is referenced by auth profile {auth_profile_id}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn roundtrips_graphql_operation_through_yaml_upsert() {
|
||||
@@ -1644,7 +2108,12 @@ mod tests {
|
||||
|
||||
fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router {
|
||||
build_app(AppState {
|
||||
service: AdminService::new(registry, storage_root, test_auth_settings()),
|
||||
service: AdminService::new(
|
||||
registry,
|
||||
storage_root,
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1672,6 +2141,18 @@ mod tests {
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
async fn spawn_soap_server() -> String {
|
||||
let app = Router::new().route("/", post(soap_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 spawn_admin_api(app: Router) -> TestServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
@@ -1760,6 +2241,23 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn soap_handler(body: String) -> (axum::http::StatusCode, String) {
|
||||
assert!(body.contains("<email>user@example.com</email>"));
|
||||
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn test_registry() -> PostgresRegistry {
|
||||
let database_url = env::var("TEST_DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:5432/crank".to_owned());
|
||||
@@ -1823,6 +2321,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_secret_crypto() -> SecretCrypto {
|
||||
SecretCrypto::new(TEST_MASTER_KEY).unwrap()
|
||||
}
|
||||
|
||||
fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload {
|
||||
OperationPayload {
|
||||
name: name.to_owned(),
|
||||
@@ -1865,6 +2367,7 @@ mod tests {
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create Lead".to_owned(),
|
||||
@@ -1920,6 +2423,7 @@ mod tests {
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create Lead GraphQL".to_owned(),
|
||||
@@ -1974,6 +2478,7 @@ mod tests {
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Unary Echo gRPC".to_owned(),
|
||||
@@ -1984,6 +2489,180 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_grpc_window_operation_payload(server_addr: &str, name: &str) -> OperationPayload {
|
||||
let mut payload = test_grpc_operation_payload(server_addr, name);
|
||||
payload.display_name = "Server Echo Window".to_owned();
|
||||
payload.target = Target::Grpc(GrpcTarget {
|
||||
server_addr: server_addr.to_owned(),
|
||||
package: "echo".to_owned(),
|
||||
service: "EchoService".to_owned(),
|
||||
method: "ServerEcho".to_owned(),
|
||||
descriptor_ref: DescriptorId::new("desc_echo_stream"),
|
||||
descriptor_set_b64: grpc_test_support::descriptor_set_b64(),
|
||||
});
|
||||
payload.execution_config.streaming = Some(crank_core::StreamingConfig {
|
||||
mode: ExecutionMode::Window,
|
||||
transport_behavior: TransportBehavior::ServerStream,
|
||||
window_duration_ms: Some(100),
|
||||
poll_interval_ms: None,
|
||||
upstream_timeout_ms: Some(1_000),
|
||||
idle_timeout_ms: None,
|
||||
max_session_lifetime_ms: None,
|
||||
max_items: Some(2),
|
||||
max_bytes: None,
|
||||
aggregation_mode: crank_core::AggregationMode::RawItems,
|
||||
summary_path: None,
|
||||
items_path: Some("$.response.body.items".to_owned()),
|
||||
cursor_path: None,
|
||||
status_path: None,
|
||||
done_path: Some("$.response.body.done".to_owned()),
|
||||
redacted_paths: Vec::new(),
|
||||
truncate_item_fields: false,
|
||||
max_field_length: None,
|
||||
drop_duplicates: false,
|
||||
sampling_rate: None,
|
||||
tool_family: crank_core::ToolFamilyConfig::default(),
|
||||
});
|
||||
payload
|
||||
}
|
||||
|
||||
fn test_grpc_session_operation_payload(server_addr: &str, name: &str) -> OperationPayload {
|
||||
let mut payload = test_grpc_window_operation_payload(server_addr, name);
|
||||
payload.display_name = "Server Echo Session".to_owned();
|
||||
if let Some(streaming) = payload.execution_config.streaming.as_mut() {
|
||||
streaming.mode = ExecutionMode::Session;
|
||||
streaming.poll_interval_ms = Some(1_000);
|
||||
streaming.max_session_lifetime_ms = Some(60_000);
|
||||
streaming.tool_family = crank_core::ToolFamilyConfig {
|
||||
start_tool_name: Some("echo_session_start".to_owned()),
|
||||
poll_tool_name: Some("echo_session_poll".to_owned()),
|
||||
stop_tool_name: Some("echo_session_stop".to_owned()),
|
||||
status_tool_name: None,
|
||||
result_tool_name: None,
|
||||
cancel_tool_name: None,
|
||||
};
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn test_rest_async_job_operation_payload(base_url: &str, name: &str) -> OperationPayload {
|
||||
let mut payload = test_operation_payload(base_url, name);
|
||||
payload.display_name = "Create Lead Async Job".to_owned();
|
||||
payload.execution_config.streaming = Some(crank_core::StreamingConfig {
|
||||
mode: ExecutionMode::AsyncJob,
|
||||
transport_behavior: TransportBehavior::DeferredResult,
|
||||
window_duration_ms: None,
|
||||
poll_interval_ms: Some(1_000),
|
||||
upstream_timeout_ms: Some(1_000),
|
||||
idle_timeout_ms: None,
|
||||
max_session_lifetime_ms: Some(300_000),
|
||||
max_items: None,
|
||||
max_bytes: None,
|
||||
aggregation_mode: crank_core::AggregationMode::SummaryPlusSamples,
|
||||
summary_path: None,
|
||||
items_path: None,
|
||||
cursor_path: None,
|
||||
status_path: None,
|
||||
done_path: None,
|
||||
redacted_paths: Vec::new(),
|
||||
truncate_item_fields: false,
|
||||
max_field_length: None,
|
||||
drop_duplicates: false,
|
||||
sampling_rate: None,
|
||||
tool_family: crank_core::ToolFamilyConfig {
|
||||
start_tool_name: Some("crm_async_start".to_owned()),
|
||||
poll_tool_name: None,
|
||||
stop_tool_name: None,
|
||||
status_tool_name: Some("crm_async_status".to_owned()),
|
||||
result_tool_name: Some("crm_async_result".to_owned()),
|
||||
cancel_tool_name: Some("crm_async_cancel".to_owned()),
|
||||
},
|
||||
});
|
||||
payload
|
||||
}
|
||||
|
||||
fn test_soap_operation_payload(endpoint: &str, name: &str) -> OperationPayload {
|
||||
OperationPayload {
|
||||
name: name.to_owned(),
|
||||
display_name: "Create Lead SOAP".to_owned(),
|
||||
category: "sales".to_owned(),
|
||||
protocol: Protocol::Soap,
|
||||
target: 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.to_owned()),
|
||||
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()],
|
||||
},
|
||||
}),
|
||||
input_schema: object_schema("email"),
|
||||
output_schema: object_schema("id"),
|
||||
input_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
output_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body.id".to_owned(),
|
||||
target: "$.output.id".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 1_000,
|
||||
retry_policy: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: None,
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create Lead SOAP".to_owned(),
|
||||
description: "Creates a CRM lead through SOAP".to_owned(),
|
||||
tags: vec!["crm".to_owned(), "soap".to_owned()],
|
||||
examples: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SOAP_TEST_WSDL: &str = r#"<?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>"#;
|
||||
|
||||
fn object_schema(field_name: &str) -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
|
||||
@@ -139,6 +139,15 @@ impl From<RegistryError> for ApiError {
|
||||
RegistryError::PlatformApiKeyNotFound { key_id } => {
|
||||
Self::not_found(format!("platform api key {key_id} was not found"))
|
||||
}
|
||||
RegistryError::SecretNotFound { secret_id } => {
|
||||
Self::not_found(format!("secret {secret_id} was not found"))
|
||||
}
|
||||
RegistryError::StreamSessionNotFound { session_id } => {
|
||||
Self::not_found(format!("stream session {session_id} was not found"))
|
||||
}
|
||||
RegistryError::AsyncJobNotFound { job_id } => {
|
||||
Self::not_found(format!("async job {job_id} was not found"))
|
||||
}
|
||||
RegistryError::InvocationLogNotFound { log_id } => {
|
||||
Self::not_found(format!("invocation log {log_id} was not found"))
|
||||
}
|
||||
@@ -171,6 +180,17 @@ impl From<RegistryError> for ApiError {
|
||||
RegistryError::WorkspaceSlugAlreadyExists { slug } => {
|
||||
Self::conflict(format!("workspace with slug {slug} already exists"))
|
||||
}
|
||||
RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict(
|
||||
format!("secret with name {name} already exists in workspace {workspace_id}"),
|
||||
),
|
||||
RegistryError::SecretReferencedByAuthProfile {
|
||||
secret_id,
|
||||
auth_profile_id,
|
||||
} => Self::conflict(format!(
|
||||
"secret {secret_id} is referenced by auth profile {auth_profile_id}"
|
||||
)),
|
||||
RegistryError::InvalidStreamSessionTransition { .. }
|
||||
| RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()),
|
||||
RegistryError::UserEmailAlreadyExists { email } => {
|
||||
Self::conflict(format!("user with email {email} already exists"))
|
||||
}
|
||||
@@ -224,7 +244,18 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str {
|
||||
RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error",
|
||||
RuntimeError::GrpcAdapter(_) => "runtime_grpc_error",
|
||||
RuntimeError::RestAdapter(_) => "runtime_rest_error",
|
||||
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
|
||||
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error",
|
||||
RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"runtime_secret_error"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
service::AdminService,
|
||||
state::AppState,
|
||||
};
|
||||
use crank_runtime::SecretCrypto;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -51,7 +52,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.unwrap_or_else(|_| "Crank Owner".into()),
|
||||
},
|
||||
};
|
||||
let service = AdminService::new(registry, storage_root, auth_settings);
|
||||
let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?;
|
||||
let service = AdminService::new(registry, storage_root, auth_settings, secret_crypto);
|
||||
service.bootstrap_admin_user().await?;
|
||||
if env_flag("CRANK_DEMO_SEED") {
|
||||
service.seed_demo_assets().await?;
|
||||
|
||||
@@ -4,6 +4,8 @@ pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
pub mod secrets;
|
||||
pub mod streaming;
|
||||
pub mod workspaces;
|
||||
|
||||
use axum::Json;
|
||||
|
||||
@@ -269,6 +269,46 @@ pub async fn upload_descriptor_set(
|
||||
Ok(Json(json!(descriptor)))
|
||||
}
|
||||
|
||||
pub async fn upload_wsdl_descriptor(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let source_name = header_file_name(&headers);
|
||||
let descriptor = state
|
||||
.service
|
||||
.upload_wsdl_file(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
source_name.as_deref(),
|
||||
body.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!(descriptor)))
|
||||
}
|
||||
|
||||
pub async fn upload_xsd_descriptor(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let source_name = header_file_name(&headers);
|
||||
let descriptor = state
|
||||
.service
|
||||
.upload_xsd_file(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
source_name.as_deref(),
|
||||
body.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!(descriptor)))
|
||||
}
|
||||
|
||||
pub async fn list_grpc_services(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
@@ -285,6 +325,22 @@ pub async fn list_grpc_services(
|
||||
Ok(Json(json!({ "services": services })))
|
||||
}
|
||||
|
||||
pub async fn list_soap_services(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
Query(query): Query<ExportQuery>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let services = state
|
||||
.service
|
||||
.list_soap_services(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.operation_id.as_str().into(),
|
||||
query.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "services": services })))
|
||||
}
|
||||
|
||||
pub async fn generate_draft(
|
||||
Path(path): Path<WorkspaceOperationPath>,
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{RotateSecretPayload, SecretPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceSecretPath {
|
||||
pub workspace_id: String,
|
||||
pub secret_id: String,
|
||||
}
|
||||
|
||||
pub async fn list_secrets(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_secrets(&path.workspace_id.as_str().into())
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_secret(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<SecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.create_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn get_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.get_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn rotate_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<RotateSecretPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let secret = state
|
||||
.service
|
||||
.rotate_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
Some(&session.user.id),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(secret)))
|
||||
}
|
||||
|
||||
pub async fn delete_secret(
|
||||
Path(path): Path<WorkspaceSecretPath>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
state
|
||||
.service
|
||||
.delete_secret(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.secret_id.as_str().into(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, 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>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
Ok(Json(json!({
|
||||
"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)))
|
||||
}
|
||||
+1235
-20
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,8 @@ impl LocalArtifactStorage {
|
||||
DescriptorKind::ReflectionSnapshot => {
|
||||
source_name.unwrap_or("reflection.bin").to_owned()
|
||||
}
|
||||
DescriptorKind::WsdlUpload => source_name.unwrap_or("service.wsdl").to_owned(),
|
||||
DescriptorKind::XsdUpload => source_name.unwrap_or("schema.xsd").to_owned(),
|
||||
};
|
||||
let path = self
|
||||
.root
|
||||
|
||||
@@ -12,6 +12,7 @@ crank-core = { path = "../../crates/crank-core" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
futures-util = "0.3"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
+1627
-128
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@ struct CatalogKey {
|
||||
struct CachedCatalog {
|
||||
loaded_at: Option<Instant>,
|
||||
tools: Vec<PublishedAgentTool>,
|
||||
tools_by_name: HashMap<String, PublishedAgentTool>,
|
||||
}
|
||||
|
||||
impl PublishedToolCatalog {
|
||||
@@ -50,20 +49,6 @@ impl PublishedToolCatalog {
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_tool(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<Option<PublishedAgentTool>, RegistryError> {
|
||||
self.refresh_if_stale(workspace_slug, agent_slug).await?;
|
||||
let guard = self.cached.read().await;
|
||||
Ok(guard
|
||||
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
||||
.and_then(|entry| entry.tools_by_name.get(tool_name))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn refresh_if_stale(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
@@ -92,11 +77,6 @@ impl PublishedToolCatalog {
|
||||
Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let tools_by_name = tools
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|tool| (tool.tool_name.clone(), tool))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut guard = self.cached.write().await;
|
||||
let previous_count = guard
|
||||
.get(&key)
|
||||
@@ -108,7 +88,6 @@ impl PublishedToolCatalog {
|
||||
CachedCatalog {
|
||||
loaded_at: Some(Instant::now()),
|
||||
tools,
|
||||
tools_by_name,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+1052
-15
File diff suppressed because it is too large
Load Diff
+70
-1
@@ -61,7 +61,18 @@
|
||||
.login-sub {
|
||||
font-size: 13.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-note {
|
||||
margin-bottom: 24px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -103,13 +114,31 @@
|
||||
}
|
||||
|
||||
.field-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.field-link:hover { text-decoration: underline; }
|
||||
|
||||
.field-link.is-disabled,
|
||||
.field-link:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.field-link.is-disabled:hover,
|
||||
.field-link:disabled:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-signin {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
@@ -184,9 +213,49 @@
|
||||
|
||||
.btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); }
|
||||
|
||||
.btn-sso:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-sso:disabled:hover {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-inline-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
text-align: center;
|
||||
margin-top: 28px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-footer-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: not-allowed;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -1550,3 +1550,174 @@
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-select {
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.resource-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.resource-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.resource-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.resource-card-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.resource-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.resource-meta-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-meta-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.resource-meta-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-detail-block {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.resource-detail-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.resource-detail-pre {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(12, 17, 24, 0.8);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.resource-pill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.resource-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.resource-status-pill.active,
|
||||
.resource-status-pill.running,
|
||||
.resource-status-pill.succeeded {
|
||||
border-color: rgba(63, 185, 80, 0.28);
|
||||
background: rgba(63, 185, 80, 0.12);
|
||||
color: #71dd8a;
|
||||
}
|
||||
|
||||
.resource-status-pill.idle,
|
||||
.resource-status-pill.pending {
|
||||
border-color: rgba(110, 118, 129, 0.28);
|
||||
background: rgba(110, 118, 129, 0.12);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.resource-status-pill.failed,
|
||||
.resource-status-pill.cancelled,
|
||||
.resource-status-pill.stopped {
|
||||
border-color: rgba(248, 81, 73, 0.28);
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
color: #ff8a83;
|
||||
}
|
||||
|
||||
.resource-status-pill.completed {
|
||||
border-color: rgba(56, 189, 248, 0.28);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: #79d8ff;
|
||||
}
|
||||
|
||||
@@ -142,3 +142,64 @@
|
||||
.role-admin { background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); color: var(--accent); }
|
||||
.role-member { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); }
|
||||
.role-viewer { background: rgba(88,166,255,0.07); border-color: var(--blue-border); color: var(--blue); }
|
||||
|
||||
.planned-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.planned-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.24);
|
||||
background: rgba(245, 158, 11, 0.09);
|
||||
color: var(--amber);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.planned-badge.small {
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.planned-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.planned-item {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.planned-item-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.planned-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.planned-item-desc {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,38 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.checkbox-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.checkbox-pill:hover {
|
||||
border-color: var(--bg-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkbox-pill input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-pill:has(input:checked) {
|
||||
border-color: rgba(13, 148, 136, 0.35);
|
||||
background: rgba(13, 148, 136, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
STEP SIDEBAR
|
||||
══════════════════════════════════════════════════ */
|
||||
@@ -1326,6 +1358,11 @@
|
||||
color: #58a6ff;
|
||||
border: 1px solid rgba(88, 166, 255, 0.2);
|
||||
}
|
||||
.upstream-auth-badge.auth-basic {
|
||||
background: rgba(240, 173, 78, 0.12);
|
||||
color: #f0ad4e;
|
||||
border: 1px solid rgba(240, 173, 78, 0.2);
|
||||
}
|
||||
.upstream-auth-badge.auth-apikey {
|
||||
background: rgba(188, 140, 255, 0.1);
|
||||
color: #bc8cff;
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link active" 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>
|
||||
@@ -90,6 +91,7 @@
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link active" 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>
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
<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 active" 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>
|
||||
@@ -106,6 +107,7 @@
|
||||
<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 active" 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>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!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="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/ui-feedback.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</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" style="display:none">
|
||||
<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>
|
||||
<div class="nav-kbd"><svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg><span>⌘K</span></div>
|
||||
<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">
|
||||
<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="js/nav.js"></script>
|
||||
<script src="js/async-jobs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+12
-3
@@ -26,6 +26,7 @@
|
||||
<div class="login-box">
|
||||
<div class="login-heading" data-i18n="login.title">Sign in</div>
|
||||
<div class="login-sub" data-i18n="login.subtitle">Welcome back to your workspace</div>
|
||||
<div class="login-note" data-i18n="login.password_only">This build supports password sign-in only. Password reset, Google SSO and self-service access requests are not wired yet.</div>
|
||||
|
||||
<div class="login-error" id="login-error" data-i18n="login.error.invalid">
|
||||
Invalid email or password. Please try again.
|
||||
@@ -41,7 +42,10 @@
|
||||
<label class="field-label" for="password" data-i18n="login.password">Password</label>
|
||||
<input class="field-input" type="password" id="password" placeholder="••••••••" autocomplete="current-password">
|
||||
<div class="field-footer">
|
||||
<a href="#" class="field-link" data-i18n="login.forgot">Forgot password?</a>
|
||||
<button class="field-link is-disabled" type="button" disabled>
|
||||
<span data-i18n="login.forgot">Forgot password?</span>
|
||||
<span class="login-inline-badge" data-i18n="login.planned_badge">Planned</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +58,7 @@
|
||||
<div class="login-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<button class="btn-sso" type="button">
|
||||
<button class="btn-sso" type="button" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
@@ -62,12 +66,17 @@
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
<span data-i18n="login.sso_short">Google SSO</span>
|
||||
<span class="login-inline-badge" data-i18n="login.planned_badge">Planned</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
<span data-i18n="login.request_prefix">Don't have an account?</span> <a href="#" data-i18n="login.request_link">Request access</a>
|
||||
<span data-i18n="login.request_prefix">Don't have an account?</span>
|
||||
<button class="login-footer-action" type="button" disabled>
|
||||
<span data-i18n="login.request_link">Request access</span>
|
||||
<span class="login-inline-badge" data-i18n="login.planned_badge">Planned</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="version-badge">Crank v0.9.0-beta</div>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<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 active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -87,6 +88,7 @@
|
||||
<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 active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<!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 — Secrets</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">
|
||||
<style>
|
||||
.secret-value-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.secret-inline-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.secret-ref-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.secret-ref-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.secret-ref-pill strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/ui-feedback.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
<script src="js/workspace.js"></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" style="display:none">
|
||||
<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 active" 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>
|
||||
<div class="nav-kbd"><svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg><span>⌘K</span></div>
|
||||
<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">
|
||||
<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 active" 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="secrets.title">Secrets</h1>
|
||||
<p class="page-subtitle" data-i18n="secrets.subtitle">Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-primary" id="btn-create-secret" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="secrets.new">Create secret</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong data-i18n="secrets.callout.title">Secret plaintext is write-only.</strong>
|
||||
<span data-i18n="secrets.callout.body">Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.active.title">Workspace secrets</div>
|
||||
<div class="section-card-subtitle" id="secrets-summary"></div>
|
||||
</div>
|
||||
<div class="filter-bar-search" style="max-width: 240px; margin: 0;">
|
||||
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="secret-search" data-i18n-ph="secrets.search" placeholder="Filter secrets…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-top: 0;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="secrets.th.name">Name</th>
|
||||
<th data-i18n="secrets.th.kind">Kind</th>
|
||||
<th data-i18n="secrets.th.version">Version</th>
|
||||
<th data-i18n="secrets.th.last_used">Last used</th>
|
||||
<th data-i18n="secrets.th.used_by">Used by</th>
|
||||
<th data-i18n="secrets.th.status">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="secrets-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.profiles.title">Auth profile references</div>
|
||||
<div class="section-card-subtitle" id="secret-profiles-summary" data-i18n="secrets.profiles.subtitle">Profiles resolve secrets at runtime before upstream execution.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="resource-list" id="auth-profiles-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="secret-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="secret-modal-title" data-i18n="secrets.modal.create_title">Create secret</span>
|
||||
<button class="modal-close" id="secret-modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.name">Secret name</label>
|
||||
<input class="field-input" id="secret-name" type="text" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="secrets.modal.name_hint">Use a stable operator-facing label. Plaintext values are never returned after storage.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.kind">Secret kind</label>
|
||||
<select class="field-select" id="secret-kind">
|
||||
<option value="token" data-i18n="secrets.kind.token">Token</option>
|
||||
<option value="username_password" data-i18n="secrets.kind.username_password">Username/password</option>
|
||||
<option value="header" data-i18n="secrets.kind.header">Header value</option>
|
||||
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
|
||||
</select>
|
||||
<div class="field-hint" id="secret-kind-hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="secret-value-grid" id="secret-value-fields">
|
||||
<div class="field-group" data-kind-field="string">
|
||||
<label class="field-label" id="secret-string-label" data-i18n="secrets.modal.value">Secret value</label>
|
||||
<input class="field-input" id="secret-string-value" type="text" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row" data-kind-field="basic" style="display:none;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.username">Username</label>
|
||||
<input class="field-input" id="secret-username" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.password">Password</label>
|
||||
<input class="field-input" id="secret-password" type="password" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group" data-kind-field="json" style="display:none;">
|
||||
<label class="field-label" data-i18n="secrets.modal.json_payload">JSON payload</label>
|
||||
<textarea class="field-textarea code-textarea" id="secret-json-value" rows="8" spellcheck="false"></textarea>
|
||||
<div class="field-hint" data-i18n="secrets.modal.json_hint">Useful for generic secret payloads. The value must be valid JSON.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-inline-note" id="secret-modal-note" data-i18n="secrets.modal.create_note">Creation stores encrypted plaintext and returns metadata only.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="secret-modal-cancel-btn" type="button" data-i18n="secrets.modal.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="secret-modal-submit-btn" type="button" data-i18n="secrets.modal.create_action">Create secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/nav.js"></script>
|
||||
<script src="js/secrets.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,6 +49,7 @@
|
||||
<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>
|
||||
@@ -87,6 +88,7 @@
|
||||
<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>
|
||||
@@ -274,7 +276,10 @@
|
||||
|
||||
<div style="padding: 4px 20px 16px;">
|
||||
<div class="settings-section-divider" style="margin-top:0;"></div>
|
||||
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;" data-i18n="settings.security.capabilities_title">Planned security capabilities</div>
|
||||
<div class="planned-section-title">
|
||||
<span data-i18n="settings.security.capabilities_title">Planned security capabilities</span>
|
||||
<span class="planned-badge" data-i18n="settings.planned_badge">Planned</span>
|
||||
</div>
|
||||
<div class="callout warning" style="margin-bottom:0;">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
|
||||
@@ -305,7 +310,10 @@
|
||||
<div id="section-notifications" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.notifications.title">Notification preferences</div>
|
||||
<div class="planned-section-title" style="margin-bottom:0;">
|
||||
<span class="section-card-title" data-i18n="settings.notifications.title">Notification preferences</span>
|
||||
<span class="planned-badge" data-i18n="settings.planned_badge">Planned</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 8px 20px 16px;">
|
||||
<div class="callout info" style="margin-bottom:14px;">
|
||||
@@ -319,15 +327,24 @@
|
||||
</div>
|
||||
<div class="planned-list">
|
||||
<div class="planned-item">
|
||||
<div class="planned-item-title" data-i18n="settings.notifications.spike_title">Operation error spike</div>
|
||||
<div class="planned-item-head">
|
||||
<div class="planned-item-title" data-i18n="settings.notifications.spike_title">Operation error spike</div>
|
||||
<span class="planned-badge small" data-i18n="settings.planned_badge">Planned</span>
|
||||
</div>
|
||||
<div class="planned-item-desc" data-i18n="settings.notifications.spike_body">Alert when error rate exceeds a rolling threshold for a published tool.</div>
|
||||
</div>
|
||||
<div class="planned-item">
|
||||
<div class="planned-item-title" data-i18n="settings.notifications.latency_title">Upstream latency degradation</div>
|
||||
<div class="planned-item-head">
|
||||
<div class="planned-item-title" data-i18n="settings.notifications.latency_title">Upstream latency degradation</div>
|
||||
<span class="planned-badge small" data-i18n="settings.planned_badge">Planned</span>
|
||||
</div>
|
||||
<div class="planned-item-desc" data-i18n="settings.notifications.latency_body">Notify operators when p99 latency rises sharply above the baseline.</div>
|
||||
</div>
|
||||
<div class="planned-item">
|
||||
<div class="planned-item-title" data-i18n="settings.notifications.digest_title">Quota and usage digests</div>
|
||||
<div class="planned-item-head">
|
||||
<div class="planned-item-title" data-i18n="settings.notifications.digest_title">Quota and usage digests</div>
|
||||
<span class="planned-badge small" data-i18n="settings.planned_badge">Planned</span>
|
||||
</div>
|
||||
<div class="planned-item-desc" data-i18n="settings.notifications.digest_body">Deliver periodic summaries for quota consumption, errors and workspace activity.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<!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="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/ui-feedback.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</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" style="display:none">
|
||||
<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>
|
||||
<div class="nav-kbd"><svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg><span>⌘K</span></div>
|
||||
<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">
|
||||
<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="js/nav.js"></script>
|
||||
<script src="js/stream-sessions.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,6 +49,7 @@
|
||||
<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 active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
@@ -87,6 +88,7 @@
|
||||
<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 active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<a href="/" class="nav-link active" 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>
|
||||
@@ -93,6 +94,7 @@
|
||||
<a href="/" class="mobile-nav-link active" 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>
|
||||
@@ -235,6 +237,43 @@
|
||||
|
||||
</div><!-- /wizard-shell -->
|
||||
|
||||
<script src="../../js/streaming-form.js"></script>
|
||||
<script src="../../js/stream-test-run.js"></script>
|
||||
<div class="modal-overlay" id="quick-secret-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" data-i18n="wizard.step2.quick_secret_title">Quick create secret</span>
|
||||
<button class="modal-close" id="quick-secret-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" style="display:grid; gap:14px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_name">Secret name</label>
|
||||
<input class="field-input" id="quick-secret-name" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_kind">Secret kind</label>
|
||||
<select class="field-select" id="quick-secret-kind">
|
||||
<option value="token" data-i18n="secrets.kind.token">Token</option>
|
||||
<option value="header" data-i18n="secrets.kind.header">Header value</option>
|
||||
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_value">Secret value</label>
|
||||
<input class="field-input" id="quick-secret-value" type="text" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="wizard.step2.quick_secret_hint">The plaintext is encrypted immediately and will not be returned again.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="quick-secret-cancel-btn" type="button" data-i18n="btn.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="quick-secret-submit-btn" type="button" data-i18n="wizard.step2.quick_secret_submit">Create secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../../js/wizard.js"></script>
|
||||
<script src="../../js/nav.js"></script>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- Protocol selection cards -->
|
||||
<div class="protocol-grid">
|
||||
|
||||
<div class="protocol-card rest selected" role="radio" aria-checked="true" tabindex="0">
|
||||
<div class="protocol-card rest selected" data-protocol="rest" role="radio" aria-checked="true" tabindex="0">
|
||||
<div class="protocol-card-check">
|
||||
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protocol-card graphql" role="radio" aria-checked="false" tabindex="0">
|
||||
<div class="protocol-card graphql" data-protocol="graphql" role="radio" aria-checked="false" tabindex="0">
|
||||
<div class="protocol-card-check">
|
||||
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protocol-card grpc" role="radio" aria-checked="false" tabindex="0">
|
||||
<div class="protocol-card grpc" data-protocol="grpc" role="radio" aria-checked="false" tabindex="0">
|
||||
<div class="protocol-card-check">
|
||||
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
@@ -76,6 +76,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protocol-card websocket" data-protocol="websocket" role="radio" aria-checked="false" tabindex="0">
|
||||
<div class="protocol-card-check">
|
||||
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
<div class="protocol-icon-wrap">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 8h16M4 16h16"/>
|
||||
<path d="M7 5l-3 3 3 3M17 13l3 3-3 3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="protocol-card-name" data-i18n="wizard.step1.websocket_name">WebSocket</div>
|
||||
<div class="protocol-card-tagline" data-i18n="wizard.step1.websocket_tagline">Stateful event streams and push-oriented integrations normalized into bounded MCP streaming tools.</div>
|
||||
<div class="protocol-card-tags">
|
||||
<span class="protocol-tag">WS</span>
|
||||
<span class="protocol-tag" data-i18n="wizard.streaming.mode.window">Window</span>
|
||||
<span class="protocol-tag" data-i18n="wizard.streaming.mode.session">Session</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="protocol-card soap" data-protocol="soap" role="radio" aria-checked="false" tabindex="0">
|
||||
<div class="protocol-card-check">
|
||||
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
<div class="protocol-icon-wrap">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 5h16v14H4z"/>
|
||||
<path d="M8 5V3h8v2"/>
|
||||
<path d="M7 10h10M7 14h6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="protocol-card-name" data-i18n="wizard.step1.soap_name">SOAP</div>
|
||||
<div class="protocol-card-tagline" data-i18n="wizard.step1.soap_tagline">Enterprise WSDL/XSD service contracts normalized into MCP tools with structured XML mapping.</div>
|
||||
<div class="protocol-card-tags">
|
||||
<span class="protocol-tag">WSDL</span>
|
||||
<span class="protocol-tag">XML</span>
|
||||
<span class="protocol-tag" data-i18n="wizard.step1.unary">Unary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /protocol-grid -->
|
||||
|
||||
|
||||
|
||||
@@ -84,21 +84,88 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.base_url">Base URL</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input input-mono" id="new-upstream-url" type="text" placeholder="https://api.example.com" autocomplete="off" spellcheck="false">
|
||||
<div class="form-hint" data-i18n="wizard.step2.base_url_hint">Root URL — no trailing slash. Supports ${secrets.*}.</div>
|
||||
<div class="form-hint" data-i18n="wizard.step2.base_url_hint">Root URL without a trailing slash. Auth is configured separately below.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.auth_headers">Auth headers</label>
|
||||
<label class="form-label" data-i18n="wizard.step2.auth_selector">Upstream auth</label>
|
||||
<select class="form-select" id="new-upstream-auth-mode" onchange="updateUpstreamAuthUi()">
|
||||
<option value="none" data-i18n="wizard.step2.auth_mode.none">No auth</option>
|
||||
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Use existing auth profile</option>
|
||||
<option value="create" data-i18n="wizard.step2.auth_mode.create">Create auth profile now</option>
|
||||
</select>
|
||||
<div class="form-hint" data-i18n="wizard.step2.auth_selector_hint">Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="upstream-auth-existing-group" style="display:none;">
|
||||
<label class="form-label" data-i18n="wizard.step2.auth_profile">Auth profile</label>
|
||||
<select class="form-select" id="new-upstream-auth-profile"></select>
|
||||
<div class="form-hint" id="new-upstream-auth-profile-hint" data-i18n="wizard.step2.auth_profile_hint">Selected profile will be resolved at runtime and attached through `execution_config.auth_profile_ref`.</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" id="upstream-auth-create-group" style="display:none; margin-top: 6px;">
|
||||
<div class="config-card-header">
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step2.create_profile_title">Create auth profile</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step2.create_profile_subtitle">Create a reusable profile backed by encrypted workspace secrets.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 14px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.profile_name">Profile name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input" id="new-auth-profile-name" type="text" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row" style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Auth kind</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<select class="form-select" id="new-auth-profile-kind" onchange="updateAuthProfileCreateUi()">
|
||||
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer token</option>
|
||||
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Basic auth</option>
|
||||
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API key header</option>
|
||||
<option value="api_key_query" data-i18n="wizard.step2.auth_kind.api_key_query">API key query</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="auth-profile-header-name-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.header_name">Header name</label>
|
||||
<input class="form-input input-mono" id="new-auth-profile-header-name" type="text" value="Authorization" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="auth-profile-query-param-group" style="display:none;">
|
||||
<label class="form-label" data-i18n="wizard.step2.query_param">Query param</label>
|
||||
<input class="form-input input-mono" id="new-auth-profile-query-param" type="text" value="api_key" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row" id="auth-profile-basic-secret-row" style="display:none; grid-template-columns: 1fr 1fr;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.username_secret">Username secret</label>
|
||||
<select class="form-select" id="new-auth-profile-username-secret"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.password_secret">Password secret</label>
|
||||
<select class="form-select" id="new-auth-profile-password-secret"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="auth-profile-single-secret-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.secret_value">Secret</label>
|
||||
<select class="form-select" id="new-auth-profile-secret-id"></select>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<button class="btn-ghost-sm" onclick="openQuickSecretModal(event)" data-i18n="wizard.step2.quick_secret">Quick create secret</button>
|
||||
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Open secrets page</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.static_headers">Static headers (optional)</label>
|
||||
<div class="code-block" style="border-radius: 6px;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">json / auth-headers</span>
|
||||
<span class="code-toolbar-label">json / static-headers</span>
|
||||
</div>
|
||||
<textarea class="form-textarea code-textarea" id="new-upstream-auth-headers" rows="7">{
|
||||
"Authorization": "${secrets.API_KEY}"
|
||||
<textarea class="form-textarea code-textarea" id="new-upstream-static-headers" rows="5">{
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="wizard.step2.auth_headers_hint">These headers are sent on every request to this upstream. Use secrets for credentials.</div>
|
||||
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:4px;">
|
||||
<button class="btn-primary-sm" onclick="saveNewUpstream(event)" data-i18n="wizard.step2.save_upstream">Save upstream</button>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<div id="step-panel-3-soap" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-i18n-html="wizard.step_of">Step 3 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step3.soap.title">WSDL and SOAP binding</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step3.soap.subtitle">Upload the WSDL/XSD bundle, inspect services and choose the exact service, port and operation that Crank should expose as an MCP tool.</p>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3.5 2.5h6l3 3v8a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-10a1 1 0 0 1 1-1z"/>
|
||||
<path d="M9.5 2.5v3h3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step3.soap.wsdl_title">Contract files</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step3.soap.wsdl_subtitle">WSDL is required. Supporting XSD files are optional and can be uploaded before discovery.</div>
|
||||
</div>
|
||||
<span class="config-card-optional" style="color:var(--error,#f87171);" data-i18n="wizard.required">Required</span>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<div class="form-row">
|
||||
<input id="wizard-soap-wsdl-input" type="file" accept=".wsdl,.xml,text/xml,application/wsdl+xml" style="display:none">
|
||||
<button id="wizard-select-soap-wsdl" class="btn-ghost-sm" type="button" data-i18n="wizard.step3.soap.choose_wsdl">Choose WSDL</button>
|
||||
<span id="wizard-soap-wsdl-name" style="color: var(--text-muted); font-size: 12px;" data-i18n="wizard.step3.soap.no_wsdl">No WSDL selected</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input id="wizard-soap-xsd-input" type="file" accept=".xsd,.xml,text/xml,application/xml" style="display:none">
|
||||
<button id="wizard-select-soap-xsd" class="btn-ghost-sm" type="button" data-i18n="wizard.step3.soap.choose_xsd">Choose XSD</button>
|
||||
<span id="wizard-soap-xsd-name" style="color: var(--text-muted); font-size: 12px;" data-i18n="wizard.step3.soap.no_xsd">No XSD selected</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button id="wizard-upload-soap-contracts" class="btn-primary-sm" type="button" data-i18n="wizard.step3.soap.upload_discover">Upload and inspect services</button>
|
||||
</div>
|
||||
<div class="form-hint" id="wizard-soap-services-summary"></div>
|
||||
<div id="wizard-soap-services-list" style="display:grid; gap: 10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.5 5.5h11M5.5 2.5v3M10.5 2.5v3M4 8.5h8v5H4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step3.soap.binding_title">Selected binding</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step3.soap.binding_subtitle">The chosen service, port and operation become the target binding for this operation draft.</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.optional">Optional</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.soap.service_name">Service</label>
|
||||
<input id="soap-service-name" class="form-input input-mono" type="text" placeholder="LeadService">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.soap.port_name">Port</label>
|
||||
<input id="soap-port-name" class="form-input input-mono" type="text" placeholder="LeadPort">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.soap.operation_name">Operation</label>
|
||||
<input id="soap-operation-name" class="form-input input-mono" type="text" placeholder="CreateLead">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.soap.version">SOAP version</label>
|
||||
<select id="soap-version" class="form-select">
|
||||
<option value="soap_11">SOAP 1.1</option>
|
||||
<option value="soap_12">SOAP 1.2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.soap.action">SOAPAction</label>
|
||||
<input id="soap-action" class="form-input input-mono" type="text" placeholder="urn:createLead">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.soap.binding_style">Binding style</label>
|
||||
<select id="soap-binding-style" class="form-select">
|
||||
<option value="document_literal" data-i18n="wizard.step3.soap.binding.document_literal">Document literal</option>
|
||||
<option value="rpc_literal" data-i18n="wizard.step3.soap.binding.rpc_literal">RPC literal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.soap.endpoint_override">Endpoint override</label>
|
||||
<input id="soap-endpoint-override" class="form-input input-mono" type="text" placeholder="https://soap.example.com/lead">
|
||||
<div class="form-hint" data-i18n="wizard.step3.soap.endpoint_override_hint">Defaults to the upstream base URL from step 2. Override only when the binding address differs from the selected upstream.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
<div id="step-panel-3-websocket" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-i18n-html="wizard.step_of">Step 3 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step3.websocket.title">WebSocket subscription</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step3.websocket.subtitle">Define the subscription handshake, the message templates and the connection tuning that Crank should use before collecting bounded WebSocket events.</p>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.5 8h11M4.5 5l-2 3 2 3M11.5 11l2-3-2-3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step3.websocket.connection_title">Connection</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step3.websocket.connection_subtitle">The upstream base URL from step 2 becomes the WebSocket endpoint unless you override it here.</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.optional">Optional</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.websocket.path">Socket path</label>
|
||||
<input id="websocket-path" class="form-input input-mono" type="text" placeholder="/telemetry">
|
||||
<div class="form-hint" data-i18n="wizard.step3.websocket.path_hint">Appended to the upstream URL from step 2. Leave empty if the upstream URL already points to the full WebSocket endpoint.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.websocket.subprotocols">Subprotocols</label>
|
||||
<textarea id="websocket-subprotocols" class="form-textarea code-textarea" rows="3" spellcheck="false" placeholder="graphql-transport-ws telemetry.v1"></textarea>
|
||||
<div class="form-hint" data-i18n="wizard.step3.websocket.subprotocols_hint">One subprotocol per line. Crank offers them during the handshake in the same order.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||
<path d="M5 7h6M5 10h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step3.websocket.messages_title">Subscription messages</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step3.websocket.messages_subtitle">Templates are sent after the socket opens. They can reference MCP input through the mapping step.</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.optional">Optional</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.websocket.subscribe_template">Subscribe template</label>
|
||||
<textarea id="websocket-subscribe-template" class="form-textarea code-textarea" rows="6" spellcheck="false" placeholder="{ "type": "subscribe", "topic": "telemetry" }"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.websocket.unsubscribe_template">Unsubscribe template</label>
|
||||
<textarea id="websocket-unsubscribe-template" class="form-textarea code-textarea" rows="6" spellcheck="false" placeholder="{ "type": "unsubscribe", "topic": "telemetry" }"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2.5v2M8 11.5v2M3.8 4.2l1.4 1.4M10.8 11.2l1.4 1.4M2.5 8h2M11.5 8h2M3.8 11.8l1.4-1.4M10.8 4.8l1.4-1.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step3.websocket.runtime_title">Connection tuning</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step3.websocket.runtime_subtitle">Used by the runtime adapter for heartbeats and reconnects in bounded session-oriented flows.</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.optional">Optional</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.websocket.heartbeat">Heartbeat interval (ms)</label>
|
||||
<input id="websocket-heartbeat-interval-ms" class="form-input" type="number" min="1" step="1" placeholder="5000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.websocket.reconnect_attempts">Reconnect attempts</label>
|
||||
<input id="websocket-reconnect-max-attempts" class="form-input" type="number" min="0" step="1" placeholder="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.websocket.reconnect_backoff">Reconnect backoff (ms)</label>
|
||||
<input id="websocket-reconnect-backoff-ms" class="form-input" type="number" min="0" step="1" placeholder="250">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
<span data-i18n-html="wizard.step_of">Step 5 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step5.title">Mapping and execution</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step5.subtitle">Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.</p>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step5.subtitle">Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 2.</p>
|
||||
</div>
|
||||
|
||||
<div class="section-divider" style="margin-bottom: 16px;">
|
||||
@@ -87,6 +87,164 @@ tls:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-streaming-config-card" class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 4h10M3 8h10M3 12h6"/>
|
||||
<circle cx="12" cy="12" r="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.streaming.title">Streaming execution</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.streaming.subtitle">Bounded stream, session and async job settings for MCP tool families.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.execution_mode">Execution mode</label>
|
||||
<select id="streaming-mode" class="form-select">
|
||||
<option value="unary">Unary</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.transport_behavior">Transport behavior</label>
|
||||
<select id="streaming-transport-behavior" class="form-select">
|
||||
<option value="request_response">Request / response</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.aggregation_mode">Aggregation mode</label>
|
||||
<select id="streaming-aggregation-mode" class="form-select">
|
||||
<option value="raw_items" data-i18n="wizard.streaming.aggregation.raw_items">Raw items</option>
|
||||
<option value="summary_only" data-i18n="wizard.streaming.aggregation.summary_only">Summary only</option>
|
||||
<option value="summary_plus_samples" data-i18n="wizard.streaming.aggregation.summary_plus_samples">Summary + samples</option>
|
||||
<option value="stats" data-i18n="wizard.streaming.aggregation.stats">Stats</option>
|
||||
<option value="latest_state" data-i18n="wizard.streaming.aggregation.latest_state">Latest state</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="streaming-mode-help" class="form-hint" data-i18n="wizard.streaming.help.unary">Unary keeps the existing request-response model. Other modes create bounded stream-aware MCP tools.</div>
|
||||
|
||||
<div id="streaming-limits-grid" class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.window_duration">Window duration (ms)</label>
|
||||
<input id="streaming-window-duration-ms" class="form-input" type="number" min="1" step="1" placeholder="5000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.poll_interval">Poll interval (ms)</label>
|
||||
<input id="streaming-poll-interval-ms" class="form-input" type="number" min="1" step="1" placeholder="2000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.upstream_timeout">Upstream timeout (ms)</label>
|
||||
<input id="streaming-upstream-timeout-ms" class="form-input" type="number" min="1" step="1" placeholder="10000">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.idle_timeout">Idle timeout (ms)</label>
|
||||
<input id="streaming-idle-timeout-ms" class="form-input" type="number" min="1" step="1" placeholder="30000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.session_lifetime">Session lifetime (ms)</label>
|
||||
<input id="streaming-session-lifetime-ms" class="form-input" type="number" min="1" step="1" placeholder="300000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.max_items">Max items</label>
|
||||
<input id="streaming-max-items" class="form-input" type="number" min="1" step="1" placeholder="100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.max_bytes">Max bytes</label>
|
||||
<input id="streaming-max-bytes" class="form-input" type="number" min="1" step="1" placeholder="65536">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.max_field_length">Max field length</label>
|
||||
<input id="streaming-max-field-length" class="form-input" type="number" min="1" step="1" placeholder="512">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.sampling_rate">Sampling rate</label>
|
||||
<input id="streaming-sampling-rate" class="form-input" type="number" min="0" max="1" step="0.1" placeholder="1.0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.items_path">Items path</label>
|
||||
<input id="streaming-items-path" class="form-input input-mono" type="text" placeholder="$.items">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.summary_path">Summary path</label>
|
||||
<input id="streaming-summary-path" class="form-input input-mono" type="text" placeholder="$.summary">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.done_path">Done path</label>
|
||||
<input id="streaming-done-path" class="form-input input-mono" type="text" placeholder="$.done">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.cursor_path">Cursor path</label>
|
||||
<input id="streaming-cursor-path" class="form-input input-mono" type="text" placeholder="$.cursor">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.status_path">Status path</label>
|
||||
<input id="streaming-status-path" class="form-input input-mono" type="text" placeholder="$.status">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.redacted_paths">Redacted paths</label>
|
||||
<textarea id="streaming-redacted-paths" class="form-textarea code-textarea" rows="4" spellcheck="false" placeholder="$.items[*].token $.summary.secret"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="checkbox-pill"><input id="streaming-truncate-item-fields" type="checkbox"> <span data-i18n="wizard.streaming.truncate_item_fields">Truncate item fields</span></label>
|
||||
<label class="checkbox-pill"><input id="streaming-drop-duplicates" type="checkbox"> <span data-i18n="wizard.streaming.drop_duplicates">Drop duplicates</span></label>
|
||||
</div>
|
||||
|
||||
<div id="streaming-tool-family-block" style="display:none;">
|
||||
<div class="section-divider" style="margin: 8px 0 16px;">
|
||||
<span class="section-divider-label" data-i18n="wizard.streaming.tool_family">Tool family</span>
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.start_tool_name">Start tool name</label>
|
||||
<input id="streaming-start-tool-name" class="form-input input-mono" type="text" placeholder="logs_follow_start">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.poll_tool_name">Poll tool name</label>
|
||||
<input id="streaming-poll-tool-name" class="form-input input-mono" type="text" placeholder="logs_follow_poll">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.stop_tool_name">Stop tool name</label>
|
||||
<input id="streaming-stop-tool-name" class="form-input input-mono" type="text" placeholder="logs_follow_stop">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row" id="streaming-async-tool-family-row" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.status_tool_name">Status tool name</label>
|
||||
<input id="streaming-status-tool-name" class="form-input input-mono" type="text" placeholder="deploy_status">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.result_tool_name">Result tool name</label>
|
||||
<input id="streaming-result-tool-name" class="form-input input-mono" type="text" placeholder="deploy_result">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.streaming.cancel_tool_name">Cancel tool name</label>
|
||||
<input id="streaming-cancel-tool-name" class="form-input input-mono" type="text" placeholder="deploy_cancel">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider" style="margin-bottom: 16px;">
|
||||
<span class="section-divider-label" data-i18n="wizard.step5.live_title">Live validation and publishing</span>
|
||||
<div class="section-divider-line"></div>
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
<option value="rest" selected>REST</option>
|
||||
<option value="graphql">GraphQL</option>
|
||||
<option value="grpc">gRPC</option>
|
||||
<option value="websocket">WebSocket</option>
|
||||
<option value="soap">SOAP</option>
|
||||
</select>
|
||||
<div class="form-hint" data-i18n="workspace_setup.protocol_hint">Pre-selected in the operation wizard.</div>
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -49,6 +49,7 @@
|
||||
<a href="/" class="nav-link active" 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>
|
||||
@@ -98,6 +99,7 @@
|
||||
<a href="/" class="mobile-nav-link active" 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>
|
||||
@@ -181,13 +183,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']" :key="p">
|
||||
<template x-for="p in ['rest','graphql','grpc','websocket','soap']" :key="p">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
:class="{ selected: filterProtocol === p }"
|
||||
@click="setProtocol(p)"
|
||||
>
|
||||
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : 'gRPC'"></span>
|
||||
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : p === 'grpc' ? 'gRPC' : p === 'websocket' ? 'WebSocket' : 'SOAP'"></span>
|
||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -416,12 +416,16 @@ document.addEventListener('alpine:init', function() {
|
||||
protocolBadge(operation) {
|
||||
if (operation.protocol === 'rest') return 'badge badge-rest';
|
||||
if (operation.protocol === 'graphql') return 'badge badge-graphql';
|
||||
if (operation.protocol === 'websocket') return 'badge badge-rest';
|
||||
if (operation.protocol === 'soap') return 'badge badge-rest';
|
||||
return 'badge badge-grpc';
|
||||
},
|
||||
|
||||
protocolLabel(operation) {
|
||||
if (operation.protocol === 'rest') return 'REST';
|
||||
if (operation.protocol === 'graphql') return 'GQL';
|
||||
if (operation.protocol === 'websocket') return 'WS';
|
||||
if (operation.protocol === 'soap') return 'SOAP';
|
||||
return 'gRPC';
|
||||
},
|
||||
|
||||
|
||||
@@ -257,12 +257,32 @@
|
||||
fileName
|
||||
);
|
||||
},
|
||||
uploadWsdlFile: function(workspaceId, operationId, content, fileName) {
|
||||
return postBytes(
|
||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/descriptors/wsdl',
|
||||
content,
|
||||
fileName
|
||||
);
|
||||
},
|
||||
uploadXsdFile: function(workspaceId, operationId, content, fileName) {
|
||||
return postBytes(
|
||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/descriptors/xsd',
|
||||
content,
|
||||
fileName
|
||||
);
|
||||
},
|
||||
listGrpcServices: function(workspaceId, operationId, version) {
|
||||
return get(
|
||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/grpc/services'
|
||||
+ query({ version: version })
|
||||
);
|
||||
},
|
||||
listSoapServices: function(workspaceId, operationId, version) {
|
||||
return get(
|
||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/soap/services'
|
||||
+ query({ version: version })
|
||||
);
|
||||
},
|
||||
generateDraft: function(workspaceId, operationId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/drafts/generate', payload || {});
|
||||
},
|
||||
@@ -323,6 +343,24 @@
|
||||
deletePlatformApiKey: function(workspaceId, keyId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||
},
|
||||
listSecrets: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets');
|
||||
},
|
||||
createSecret: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets', payload);
|
||||
},
|
||||
getSecret: function(workspaceId, secretId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId));
|
||||
},
|
||||
rotateSecret: function(workspaceId, secretId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId) + '/rotate', payload);
|
||||
},
|
||||
deleteSecret: function(workspaceId, secretId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId));
|
||||
},
|
||||
listAuthProfiles: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/auth-profiles');
|
||||
},
|
||||
listLogs: function(workspaceId, params) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params));
|
||||
},
|
||||
@@ -332,6 +370,30 @@
|
||||
getUsageOverview: function(workspaceId, params) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
|
||||
},
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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 renderEmpty(title, body) {
|
||||
list.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + title + '</div><div class="empty-state-text">' + body + '</div></div>';
|
||||
}
|
||||
|
||||
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.map(function(item) {
|
||||
var open = state.openId === item.id;
|
||||
var statusLabel = tKey('async_jobs.status.' + item.status);
|
||||
return [
|
||||
'<div class="resource-card" data-job-id="' + item.id + '">',
|
||||
' <div class="resource-card-header">',
|
||||
' <div>',
|
||||
' <div class="resource-card-title">' + item.id + '</div>',
|
||||
' <div class="resource-card-subtitle">' + tKey('async_jobs.operation', { operation: item.operation_id }) + '</div>',
|
||||
' <div class="resource-pill-row">',
|
||||
' <span class="resource-status-pill ' + String(item.status || '').toLowerCase() + '">' + statusLabel + '</span>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="resource-card-actions">',
|
||||
item.status === 'created' || item.status === 'running'
|
||||
? ' <button class="btn-secondary" data-action="cancel" data-job-id="' + item.id + '">' + tKey('async_jobs.cancel') + '</button>'
|
||||
: '',
|
||||
' <button class="btn-secondary" data-action="toggle" data-job-id="' + item.id + '">' + (open ? tKey('async_jobs.hide_details') : tKey('async_jobs.show_details')) + '</button>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="resource-meta-grid">',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.created') + '</div><div class="resource-meta-value">' + formatDateTime(item.created_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.updated') + '</div><div class="resource-meta-value">' + formatDateTime(item.updated_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.finished') + '</div><div class="resource-meta-value">' + formatDateTime(item.finished_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.expires') + '</div><div class="resource-meta-value">' + formatDateTime(item.expires_at) + '</div></div>',
|
||||
' </div>',
|
||||
open
|
||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.progress') + '</div><pre class="resource-detail-pre">' + formatJson(item.progress) + '</pre></div>'
|
||||
: '',
|
||||
open
|
||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.error_preview') + '</div><pre class="resource-detail-pre">' + formatJson(item.error) + '</pre></div>'
|
||||
: '',
|
||||
open && (item.status === 'completed' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'expired')
|
||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.result') + '</div><pre class="resource-detail-pre" data-result-for="' + item.id + '">' + tKey('async_jobs.result_loading') + '</pre></div>'
|
||||
: '',
|
||||
'</div>'
|
||||
].join('');
|
||||
}).join('');
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -399,6 +399,8 @@ document.addEventListener('alpine:init', function() {
|
||||
return operation.method ? ('REST · ' + operation.method) : 'REST';
|
||||
}
|
||||
if (operation.protocol === 'graphql') return 'GraphQL';
|
||||
if (operation.protocol === 'websocket') return 'WebSocket';
|
||||
if (operation.protocol === 'soap') return 'SOAP';
|
||||
return 'gRPC';
|
||||
},
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
login: '/login',
|
||||
agents: '/agents',
|
||||
apiKeys: '/api-keys',
|
||||
secrets: '/secrets',
|
||||
logs: '/logs',
|
||||
usage: '/usage',
|
||||
streamSessions: '/stream-sessions',
|
||||
asyncJobs: '/async-jobs',
|
||||
settings: '/settings',
|
||||
workspaceSetup: '/workspace-setup',
|
||||
wizard: '/wizard/'
|
||||
|
||||
+659
-8
@@ -9,6 +9,7 @@ var TRANSLATIONS = {
|
||||
'nav.operations': 'Operations',
|
||||
'nav.agents': 'Agents',
|
||||
'nav.apikeys': 'API Keys',
|
||||
'nav.secrets': 'Secrets',
|
||||
'nav.logs': 'Logs',
|
||||
'nav.usage': 'Usage',
|
||||
'nav.settings': 'Settings',
|
||||
@@ -157,6 +158,97 @@ var TRANSLATIONS = {
|
||||
'apikeys.action.delete': 'Delete',
|
||||
'apikeys.creating': 'Creating…',
|
||||
|
||||
// Secrets page
|
||||
'secrets.title': 'Secrets',
|
||||
'secrets.subtitle': 'Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.',
|
||||
'secrets.new': 'Create secret',
|
||||
'secrets.callout.title': 'Secret plaintext is write-only.',
|
||||
'secrets.callout.body': 'Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references.',
|
||||
'secrets.active.title': 'Workspace secrets',
|
||||
'secrets.active.subtitle': '{active} active · {total} total',
|
||||
'secrets.search': 'Filter secrets…',
|
||||
'secrets.th.name': 'NAME',
|
||||
'secrets.th.kind': 'KIND',
|
||||
'secrets.th.version': 'VERSION',
|
||||
'secrets.th.last_used': 'LAST USED',
|
||||
'secrets.th.used_by': 'USED BY',
|
||||
'secrets.th.status': 'STATUS',
|
||||
'secrets.kind.token': 'Token',
|
||||
'secrets.kind.username_password': 'Username/password',
|
||||
'secrets.kind.header': 'Header value',
|
||||
'secrets.kind.generic': 'Generic JSON',
|
||||
'secrets.status.active': 'Active',
|
||||
'secrets.status.disabled': 'Disabled',
|
||||
'secrets.last_used.never': 'Never',
|
||||
'secrets.used_by_count': '{count} profiles',
|
||||
'secrets.loading': 'Loading…',
|
||||
'secrets.empty.none': 'No secrets yet',
|
||||
'secrets.empty.search': 'No secrets match your search',
|
||||
'secrets.error.workspace': 'Workspace is not selected',
|
||||
'secrets.error.load': 'Failed to load secrets',
|
||||
'secrets.action.rotate': 'Rotate',
|
||||
'secrets.action.delete': 'Delete',
|
||||
'secrets.confirm.delete': 'Delete secret {name}? This cannot be undone while the secret is still referenced by an auth profile.',
|
||||
'secrets.toast.create_title': 'Secret created',
|
||||
'secrets.toast.create_message': 'The secret was stored encrypted. Plaintext cannot be viewed again through the API.',
|
||||
'secrets.toast.create_error_title': 'Secret creation failed',
|
||||
'secrets.toast.create_error_message': 'Failed to create secret',
|
||||
'secrets.toast.rotate_title': 'Secret rotated',
|
||||
'secrets.toast.rotate_message': 'A new secret version is now active.',
|
||||
'secrets.toast.rotate_error_title': 'Secret rotation failed',
|
||||
'secrets.toast.rotate_error_message': 'Failed to rotate secret',
|
||||
'secrets.toast.delete_title': 'Secret deleted',
|
||||
'secrets.toast.delete_message': '{name} was deleted.',
|
||||
'secrets.toast.delete_error_title': 'Secret deletion failed',
|
||||
'secrets.toast.delete_error_message': 'Failed to delete secret',
|
||||
'secrets.modal.create_title': 'Create secret',
|
||||
'secrets.modal.rotate_title': 'Rotate secret',
|
||||
'secrets.modal.name': 'Secret name',
|
||||
'secrets.modal.name_hint': 'Use a stable operator-facing label. Plaintext values are never returned after storage.',
|
||||
'secrets.modal.kind': 'Secret kind',
|
||||
'secrets.modal.value': 'Secret value',
|
||||
'secrets.modal.header_value': 'Header value',
|
||||
'secrets.modal.username': 'Username',
|
||||
'secrets.modal.password': 'Password',
|
||||
'secrets.modal.json_payload': 'JSON payload',
|
||||
'secrets.modal.json_hint': 'Useful for generic secret payloads. The value must be valid JSON.',
|
||||
'secrets.modal.create_note': 'Creation stores encrypted plaintext and returns metadata only.',
|
||||
'secrets.modal.rotate_note': 'Rotation creates a new encrypted version and keeps the same secret id.',
|
||||
'secrets.modal.cancel': 'Cancel',
|
||||
'secrets.modal.create_action': 'Create secret',
|
||||
'secrets.modal.rotate_action': 'Rotate secret',
|
||||
'secrets.modal.creating': 'Creating…',
|
||||
'secrets.modal.rotating': 'Rotating…',
|
||||
'secrets.hint.token': 'Best for bearer tokens and simple API key values.',
|
||||
'secrets.hint.username_password': 'Stores username and password as one encrypted payload.',
|
||||
'secrets.hint.header': 'Stores a raw header value. Header name is configured in the auth profile.',
|
||||
'secrets.hint.generic': 'Stores arbitrary JSON for advanced future integrations.',
|
||||
'secrets.validation.name_required': 'Secret name is required.',
|
||||
'secrets.validation.value_required': 'Secret value is required.',
|
||||
'secrets.validation.username_password_required': 'Username and password are both required.',
|
||||
'secrets.validation.json_invalid': 'Generic secret payload must be valid JSON.',
|
||||
'secrets.profiles.title': 'Auth profile references',
|
||||
'secrets.profiles.subtitle': 'Profiles resolve secrets at runtime before upstream execution.',
|
||||
'secrets.profiles.subtitle_count': '{count} auth profiles',
|
||||
'secrets.profiles.error_title': 'Unable to load auth profile references',
|
||||
'secrets.profiles.loading_title': 'Loading auth profile references…',
|
||||
'secrets.profiles.loading_body': 'Fetching auth profiles for the current workspace.',
|
||||
'secrets.profiles.empty_title': 'No auth profiles yet',
|
||||
'secrets.profiles.empty_body': 'Create auth profiles in the next slice to bind secrets to upstream authentication.',
|
||||
'secrets.profiles.references': 'Referenced secrets',
|
||||
'secrets.profiles.reference_count': '{count} refs',
|
||||
'secrets.profiles.meta.created': 'Created',
|
||||
'secrets.profiles.meta.updated': 'Updated',
|
||||
'secrets.profiles.summary_bearer': 'Bearer auth via {header} using {secret}.',
|
||||
'secrets.profiles.summary_basic': 'Basic auth using {username} and {password}.',
|
||||
'secrets.profiles.summary_api_key_header': 'API key header {header} using {secret}.',
|
||||
'secrets.profiles.summary_api_key_query': 'API key query param {param} using {secret}.',
|
||||
'secrets.profiles.summary_unknown': 'Unknown auth profile configuration.',
|
||||
'secrets.auth_kind.bearer': 'Bearer auth',
|
||||
'secrets.auth_kind.basic': 'Basic auth',
|
||||
'secrets.auth_kind.api_key_header': 'API key header',
|
||||
'secrets.auth_kind.api_key_query': 'API key query',
|
||||
|
||||
// Logs page
|
||||
'logs.title': 'Logs',
|
||||
'logs.subtitle': 'Real-time invocation log for all operations in this workspace.',
|
||||
@@ -315,6 +407,7 @@ var TRANSLATIONS = {
|
||||
'settings.notifications.title': 'Notification preferences',
|
||||
'settings.notifications.not_ready_title': 'Notifications are not wired yet.',
|
||||
'settings.notifications.not_ready_body': 'Alert routing and per-user preferences are planned, but this build does not persist or deliver notification settings.',
|
||||
'settings.planned_badge': 'Planned',
|
||||
'settings.notifications.spike_title': 'Operation error spike',
|
||||
'settings.notifications.spike_body': 'Alert when error rate exceeds a rolling threshold for a published tool.',
|
||||
'settings.notifications.latency_title': 'Upstream latency degradation',
|
||||
@@ -410,6 +503,8 @@ var TRANSLATIONS = {
|
||||
'workspace_setup.custom_protocol.rest': 'REST',
|
||||
'workspace_setup.custom_protocol.graphql': 'GraphQL',
|
||||
'workspace_setup.custom_protocol.grpc': 'gRPC',
|
||||
'workspace_setup.custom_protocol.websocket': 'WebSocket',
|
||||
'workspace_setup.custom_protocol.soap': 'SOAP',
|
||||
|
||||
// Wizard
|
||||
'wizard.back_catalog': 'Operations',
|
||||
@@ -444,6 +539,10 @@ var TRANSLATIONS = {
|
||||
'wizard.step1.graphql_tagline': 'Query and mutate a GraphQL schema. Supports queries and mutations over one fixed operation.',
|
||||
'wizard.step1.grpc_name': 'gRPC',
|
||||
'wizard.step1.grpc_tagline': 'High-performance binary RPC over HTTP/2. Ideal for low-latency internal services.',
|
||||
'wizard.step1.websocket_name': 'WebSocket',
|
||||
'wizard.step1.websocket_tagline': 'Stateful event streams and push-oriented integrations normalized into bounded MCP streaming tools.',
|
||||
'wizard.step1.soap_name': 'SOAP',
|
||||
'wizard.step1.soap_tagline': 'Enterprise WSDL/XSD service contracts normalized into MCP tools with structured XML mapping.',
|
||||
'wizard.step1.query': 'Query',
|
||||
'wizard.step1.mutation': 'Mutation',
|
||||
'wizard.step1.unary': 'Unary',
|
||||
@@ -460,14 +559,44 @@ var TRANSLATIONS = {
|
||||
'wizard.step2.change': 'Change',
|
||||
'wizard.step2.auth_none': 'No auth',
|
||||
'wizard.step2.auth_bearer': 'Bearer token',
|
||||
'wizard.step2.auth_basic': 'Basic auth',
|
||||
'wizard.step2.auth_apikey': 'API key',
|
||||
'wizard.step2.register_new': 'Register new upstream',
|
||||
'wizard.step2.name': 'Name',
|
||||
'wizard.step2.base_url': 'Base URL',
|
||||
'wizard.step2.unique_hint': 'Unique identifier for this upstream.',
|
||||
'wizard.step2.base_url_hint': 'Root URL — no trailing slash. Supports ${secrets.*}.',
|
||||
'wizard.step2.auth_headers': 'Auth headers',
|
||||
'wizard.step2.auth_headers_hint': 'These headers are sent on every request to this upstream. Use secrets for credentials.',
|
||||
'wizard.step2.base_url_hint': 'Root URL without a trailing slash. Auth is configured separately below.',
|
||||
'wizard.step2.auth_selector': 'Upstream auth',
|
||||
'wizard.step2.auth_selector_hint': 'Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.',
|
||||
'wizard.step2.auth_mode.none': 'No auth',
|
||||
'wizard.step2.auth_mode.existing': 'Use existing auth profile',
|
||||
'wizard.step2.auth_mode.create': 'Create auth profile now',
|
||||
'wizard.step2.auth_profile': 'Auth profile',
|
||||
'wizard.step2.auth_profile_hint': 'Selected profile will be resolved at runtime and attached through `execution_config.auth_profile_ref`.',
|
||||
'wizard.step2.auth_profile_empty': 'No auth profiles yet. Create one below or on the Secrets page.',
|
||||
'wizard.step2.create_profile_title': 'Create auth profile',
|
||||
'wizard.step2.create_profile_subtitle': 'Create a reusable profile backed by encrypted workspace secrets.',
|
||||
'wizard.step2.profile_name': 'Profile name',
|
||||
'wizard.step2.profile_kind': 'Auth kind',
|
||||
'wizard.step2.auth_kind.bearer': 'Bearer token',
|
||||
'wizard.step2.auth_kind.basic': 'Basic auth',
|
||||
'wizard.step2.auth_kind.api_key_header': 'API key header',
|
||||
'wizard.step2.auth_kind.api_key_query': 'API key query',
|
||||
'wizard.step2.header_name': 'Header name',
|
||||
'wizard.step2.query_param': 'Query param',
|
||||
'wizard.step2.username_secret': 'Username secret',
|
||||
'wizard.step2.password_secret': 'Password secret',
|
||||
'wizard.step2.secret_value': 'Secret',
|
||||
'wizard.step2.quick_secret': 'Quick create secret',
|
||||
'wizard.step2.manage_secrets': 'Open secrets page',
|
||||
'wizard.step2.static_headers': 'Static headers (optional)',
|
||||
'wizard.step2.static_headers_hint': 'Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.',
|
||||
'wizard.step2.quick_secret_title': 'Quick create secret',
|
||||
'wizard.step2.quick_secret_name': 'Secret name',
|
||||
'wizard.step2.quick_secret_kind': 'Secret kind',
|
||||
'wizard.step2.quick_secret_value': 'Secret value',
|
||||
'wizard.step2.quick_secret_hint': 'The plaintext is encrypted immediately and will not be returned again.',
|
||||
'wizard.step2.quick_secret_submit': 'Create secret',
|
||||
'wizard.step2.save_upstream': 'Save upstream',
|
||||
'wizard.step2.endpoint_title': 'Endpoint',
|
||||
'wizard.step2.endpoint_subtitle': 'Path and HTTP method for this specific operation',
|
||||
@@ -476,6 +605,8 @@ var TRANSLATIONS = {
|
||||
'wizard.step3.rest.label': 'Request config',
|
||||
'wizard.step3.graphql.label': 'GQL operation',
|
||||
'wizard.step3.grpc.label': 'RPC method',
|
||||
'wizard.step3.websocket.label': 'Socket config',
|
||||
'wizard.step3.soap.label': 'WSDL binding',
|
||||
'wizard.step3.rest.title': 'HTTP method & format',
|
||||
'wizard.step3.rest.subtitle': 'Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.',
|
||||
'wizard.step3.rest.method_title': 'HTTP method',
|
||||
@@ -537,6 +668,44 @@ var TRANSLATIONS = {
|
||||
'wizard.step3.grpc.route_label': 'gRPC route',
|
||||
'wizard.step3.grpc.request_label': 'Request',
|
||||
'wizard.step3.grpc.response_label': 'Response',
|
||||
'wizard.step3.websocket.title': 'WebSocket subscription',
|
||||
'wizard.step3.websocket.subtitle': 'Define the subscription handshake, message templates and runtime tuning that Crank should use before collecting bounded WebSocket events.',
|
||||
'wizard.step3.websocket.connection_title': 'Connection',
|
||||
'wizard.step3.websocket.connection_subtitle': 'The upstream base URL from step 2 becomes the WebSocket endpoint unless you override it here.',
|
||||
'wizard.step3.websocket.path': 'Socket path',
|
||||
'wizard.step3.websocket.path_hint': 'Appended to the upstream URL from step 2. Leave empty when the upstream URL already points to the full WebSocket endpoint.',
|
||||
'wizard.step3.websocket.subprotocols': 'Subprotocols',
|
||||
'wizard.step3.websocket.subprotocols_hint': 'One subprotocol per line. Crank offers them during the handshake in the same order.',
|
||||
'wizard.step3.websocket.messages_title': 'Subscription messages',
|
||||
'wizard.step3.websocket.messages_subtitle': 'Templates are sent after the socket opens. They can reference MCP input through the mapping step.',
|
||||
'wizard.step3.websocket.subscribe_template': 'Subscribe template',
|
||||
'wizard.step3.websocket.unsubscribe_template': 'Unsubscribe template',
|
||||
'wizard.step3.websocket.runtime_title': 'Connection tuning',
|
||||
'wizard.step3.websocket.runtime_subtitle': 'Used by the runtime adapter for heartbeats and reconnects in bounded session-oriented flows.',
|
||||
'wizard.step3.websocket.heartbeat': 'Heartbeat interval (ms)',
|
||||
'wizard.step3.websocket.reconnect_attempts': 'Reconnect attempts',
|
||||
'wizard.step3.websocket.reconnect_backoff': 'Reconnect backoff (ms)',
|
||||
'wizard.step3.soap.title': 'WSDL and SOAP binding',
|
||||
'wizard.step3.soap.subtitle': 'Upload the WSDL/XSD bundle, inspect services and choose the exact service, port and operation that Crank should expose as an MCP tool.',
|
||||
'wizard.step3.soap.wsdl_title': 'Contract files',
|
||||
'wizard.step3.soap.wsdl_subtitle': 'WSDL is required. Supporting XSD files are optional and can be uploaded before discovery.',
|
||||
'wizard.step3.soap.choose_wsdl': 'Choose WSDL',
|
||||
'wizard.step3.soap.choose_xsd': 'Choose XSD',
|
||||
'wizard.step3.soap.no_wsdl': 'No WSDL selected',
|
||||
'wizard.step3.soap.no_xsd': 'No XSD selected',
|
||||
'wizard.step3.soap.upload_discover': 'Upload and inspect services',
|
||||
'wizard.step3.soap.binding_title': 'Selected binding',
|
||||
'wizard.step3.soap.binding_subtitle': 'The chosen service, port and operation become the target binding for this operation draft.',
|
||||
'wizard.step3.soap.service_name': 'Service',
|
||||
'wizard.step3.soap.port_name': 'Port',
|
||||
'wizard.step3.soap.operation_name': 'Operation',
|
||||
'wizard.step3.soap.version': 'SOAP version',
|
||||
'wizard.step3.soap.action': 'SOAPAction',
|
||||
'wizard.step3.soap.binding_style': 'Binding style',
|
||||
'wizard.step3.soap.binding.document_literal': 'Document literal',
|
||||
'wizard.step3.soap.binding.rpc_literal': 'RPC literal',
|
||||
'wizard.step3.soap.endpoint_override': 'Endpoint override',
|
||||
'wizard.step3.soap.endpoint_override_hint': 'Defaults to the upstream base URL from step 2. Override only when the binding address differs from the selected upstream.',
|
||||
'wizard.step4.title': 'Tool config',
|
||||
'wizard.step4.subtitle': 'Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.',
|
||||
'wizard.step4.identity_title': 'Tool identity',
|
||||
@@ -559,7 +728,7 @@ var TRANSLATIONS = {
|
||||
'wizard.step4.protocol_agnostic_title': 'Schemas are protocol-agnostic',
|
||||
'wizard.step4.protocol_agnostic_body': 'These schemas describe the MCP contract, not the upstream wire format. Field mapping in Step 5 translates between the two. You can upload a sample JSON response to auto-generate the output schema.',
|
||||
'wizard.step5.title': 'Mapping and execution',
|
||||
'wizard.step5.subtitle': 'Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.',
|
||||
'wizard.step5.subtitle': 'Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 2.',
|
||||
'wizard.step5.input_request': 'Input → Request',
|
||||
'wizard.step5.output_response': 'Response → Output',
|
||||
'wizard.step5.execution': 'Execution',
|
||||
@@ -608,6 +777,21 @@ var TRANSLATIONS = {
|
||||
'wizard.error.tool_name': 'Tool name is required',
|
||||
'wizard.error.parser_unavailable': 'YAML parser is not available',
|
||||
'wizard.error.select_upstream': 'Select or register an upstream first',
|
||||
'wizard.error.auth_profile_required': 'Select an auth profile or switch upstream auth to No auth.',
|
||||
'wizard.error.auth_profile_name': 'Auth profile name is required.',
|
||||
'wizard.error.secret_required': 'Select the secret required by this auth profile.',
|
||||
'wizard.error.basic_secrets_required': 'Select both username and password secrets.',
|
||||
'wizard.error.header_name_required': 'Header name is required for this auth profile.',
|
||||
'wizard.error.query_param_required': 'Query parameter name is required for this auth profile.',
|
||||
'wizard.error.secret_name_required': 'Secret name is required.',
|
||||
'wizard.error.secret_value_required': 'Secret value is required.',
|
||||
'wizard.error.save_upstream_first': 'Save the upstream after creating a new auth profile before continuing.',
|
||||
'wizard.toast.auth_profile_created_title': 'Auth profile created',
|
||||
'wizard.toast.auth_profile_created_body': 'New upstream auth profile is ready and selected.',
|
||||
'wizard.toast.auth_profile_error_title': 'Auth profile creation failed',
|
||||
'wizard.toast.quick_secret_created_title': 'Secret created',
|
||||
'wizard.toast.quick_secret_created_body': 'The new secret is now available in the auth selector.',
|
||||
'wizard.toast.quick_secret_error_title': 'Secret creation failed',
|
||||
'wizard.error.save': 'Failed to save operation',
|
||||
'wizard.error.save_title': 'Save failed',
|
||||
'wizard.save.created': 'Draft created',
|
||||
@@ -622,6 +806,21 @@ var TRANSLATIONS = {
|
||||
'wizard.busy.import_yaml': 'Importing YAML…',
|
||||
'wizard.busy.publish': 'Publishing operation…',
|
||||
'wizard.busy.descriptor': 'Uploading descriptor set…',
|
||||
'wizard.busy.wsdl': 'Inspecting WSDL…',
|
||||
'wizard.step3.soap.wsdl_selected': 'WSDL selected',
|
||||
'wizard.step3.soap.wsdl_selected_body': 'The WSDL file is staged and will be uploaded with the current draft.',
|
||||
'wizard.step3.soap.xsd_selected': 'XSD selected',
|
||||
'wizard.step3.soap.xsd_selected_body': 'The XSD file is staged and will be uploaded with the current draft.',
|
||||
'wizard.step3.soap.wsdl_uploaded': 'WSDL uploaded',
|
||||
'wizard.step3.soap.xsd_uploaded': 'XSD uploaded',
|
||||
'wizard.step3.soap.services_discovered': '{services} service(s), {ports} port(s) and {operations} operation(s) discovered from the uploaded contract.',
|
||||
'wizard.step3.soap.services_none': 'No SOAP services discovered from the uploaded WSDL yet.',
|
||||
'wizard.step3.soap.applied': 'SOAP binding applied',
|
||||
'wizard.step3.soap.applied_body': 'The selected service, port and operation were applied to the current draft.',
|
||||
'wizard.step3.soap.no_wsdl_error': 'WSDL is required',
|
||||
'wizard.step3.soap.no_wsdl_error_body': 'Choose a WSDL file or reuse an already uploaded draft contract before inspection.',
|
||||
'wizard.step3.soap.discovered': 'SOAP services discovered',
|
||||
'wizard.step3.soap.discovered_body': 'The uploaded WSDL was inspected through the backend and the available bindings are ready to apply.',
|
||||
'wizard.sample.input_saved': 'Input sample saved',
|
||||
'wizard.sample.input_saved_body': 'The input sample is now stored against the current draft version.',
|
||||
'wizard.sample.output_saved': 'Output sample saved',
|
||||
@@ -632,10 +831,18 @@ var TRANSLATIONS = {
|
||||
'wizard.test.failed': 'Test run returned errors',
|
||||
'wizard.test.completed_body': 'The current draft executed successfully against the upstream.',
|
||||
'wizard.test.failed_body': 'The draft executed, but the backend returned validation or runtime errors.',
|
||||
'wizard.test.window_completed': 'Window test completed',
|
||||
'wizard.test.window_completed_body': 'Window complete: {window_complete}. Truncated: {truncated}. Has more: {has_more}.',
|
||||
'wizard.test.session_started': 'Session test started',
|
||||
'wizard.test.session_started_body': 'Session {session_id} was created. Poll after {poll_after_ms} ms from the stream sessions view.',
|
||||
'wizard.test.async_job_started': 'Async job test started',
|
||||
'wizard.test.async_job_started_body': 'Async job {job_id} was created. Track status and result from the async jobs view.',
|
||||
'wizard.test.no_response': 'No test response yet',
|
||||
'wizard.test.no_response_body': 'Run a test first, then copy the response preview into the output sample editor.',
|
||||
'wizard.test.response_copied': 'Response copied',
|
||||
'wizard.test.response_copied_body': 'The latest test response was copied into the output sample editor.',
|
||||
'wizard.boolean.yes': 'yes',
|
||||
'wizard.boolean.no': 'no',
|
||||
'wizard.yaml.exported': 'YAML exported',
|
||||
'wizard.yaml.exported_body': 'The current draft YAML was downloaded from the backend.',
|
||||
'wizard.yaml.none': 'No YAML provided',
|
||||
@@ -781,6 +988,8 @@ var TRANSLATIONS = {
|
||||
'login.sso_short': 'Google SSO',
|
||||
'login.request_prefix': "Don't have an account?",
|
||||
'login.request_link': 'Request access',
|
||||
'login.planned_badge': 'Planned',
|
||||
'login.password_only': 'This build supports password sign-in only. Password reset, Google SSO and self-service access requests are not wired yet.',
|
||||
'login.error.required': 'Please enter your email and password.',
|
||||
'login.error.invalid': 'Invalid email or password. Please try again.',
|
||||
'login.error.generic': 'Unable to sign in right now. Please try again.',
|
||||
@@ -791,6 +1000,7 @@ var TRANSLATIONS = {
|
||||
'nav.operations': 'Операции',
|
||||
'nav.agents': 'Агенты',
|
||||
'nav.apikeys': 'API Ключи',
|
||||
'nav.secrets': 'Секреты',
|
||||
'nav.logs': 'Логи',
|
||||
'nav.usage': 'Использование',
|
||||
'nav.settings': 'Настройки',
|
||||
@@ -939,6 +1149,97 @@ var TRANSLATIONS = {
|
||||
'apikeys.action.delete': 'Удалить',
|
||||
'apikeys.creating': 'Создание…',
|
||||
|
||||
// Secrets page
|
||||
'secrets.title': 'Секреты',
|
||||
'secrets.subtitle': 'Зашифрованные upstream-учетные данные для bearer token, basic auth и API key, которые используют auth profiles.',
|
||||
'secrets.new': 'Создать секрет',
|
||||
'secrets.callout.title': 'Plaintext секрета доступен только на запись.',
|
||||
'secrets.callout.body': 'Crank шифрует secret values мастер-ключом воркспейса. После создания или ротации API возвращает только metadata, поэтому эта страница сфокусирована на lifecycle и usage references.',
|
||||
'secrets.active.title': 'Секреты воркспейса',
|
||||
'secrets.active.subtitle': '{active} активных · {total} всего',
|
||||
'secrets.search': 'Фильтр секретов…',
|
||||
'secrets.th.name': 'НАЗВАНИЕ',
|
||||
'secrets.th.kind': 'ТИП',
|
||||
'secrets.th.version': 'ВЕРСИЯ',
|
||||
'secrets.th.last_used': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ',
|
||||
'secrets.th.used_by': 'ИСПОЛЬЗУЕТСЯ В',
|
||||
'secrets.th.status': 'СТАТУС',
|
||||
'secrets.kind.token': 'Токен',
|
||||
'secrets.kind.username_password': 'Логин/пароль',
|
||||
'secrets.kind.header': 'Значение заголовка',
|
||||
'secrets.kind.generic': 'Общий JSON',
|
||||
'secrets.status.active': 'Активен',
|
||||
'secrets.status.disabled': 'Отключен',
|
||||
'secrets.last_used.never': 'Никогда',
|
||||
'secrets.used_by_count': '{count} профилей',
|
||||
'secrets.loading': 'Загрузка…',
|
||||
'secrets.empty.none': 'Секретов пока нет',
|
||||
'secrets.empty.search': 'Нет секретов по текущему поиску',
|
||||
'secrets.error.workspace': 'Воркспейс не выбран',
|
||||
'secrets.error.load': 'Не удалось загрузить секреты',
|
||||
'secrets.action.rotate': 'Ротировать',
|
||||
'secrets.action.delete': 'Удалить',
|
||||
'secrets.confirm.delete': 'Удалить секрет {name}? Это нельзя сделать, пока на секрет ссылается auth profile.',
|
||||
'secrets.toast.create_title': 'Секрет создан',
|
||||
'secrets.toast.create_message': 'Секрет сохранен в зашифрованном виде. Повторно посмотреть plaintext через API нельзя.',
|
||||
'secrets.toast.create_error_title': 'Не удалось создать секрет',
|
||||
'secrets.toast.create_error_message': 'Не удалось создать секрет',
|
||||
'secrets.toast.rotate_title': 'Секрет ротирован',
|
||||
'secrets.toast.rotate_message': 'Новая версия секрета теперь активна.',
|
||||
'secrets.toast.rotate_error_title': 'Не удалось ротировать секрет',
|
||||
'secrets.toast.rotate_error_message': 'Не удалось ротировать секрет',
|
||||
'secrets.toast.delete_title': 'Секрет удален',
|
||||
'secrets.toast.delete_message': 'Секрет {name} удален.',
|
||||
'secrets.toast.delete_error_title': 'Не удалось удалить секрет',
|
||||
'secrets.toast.delete_error_message': 'Не удалось удалить секрет',
|
||||
'secrets.modal.create_title': 'Создать секрет',
|
||||
'secrets.modal.rotate_title': 'Ротировать секрет',
|
||||
'secrets.modal.name': 'Имя секрета',
|
||||
'secrets.modal.name_hint': 'Используйте стабильную операторскую метку. Plaintext после сохранения не возвращается.',
|
||||
'secrets.modal.kind': 'Тип секрета',
|
||||
'secrets.modal.value': 'Значение секрета',
|
||||
'secrets.modal.header_value': 'Значение заголовка',
|
||||
'secrets.modal.username': 'Логин',
|
||||
'secrets.modal.password': 'Пароль',
|
||||
'secrets.modal.json_payload': 'JSON payload',
|
||||
'secrets.modal.json_hint': 'Подходит для общих secret payloads. Значение должно быть валидным JSON.',
|
||||
'secrets.modal.create_note': 'Создание сохраняет зашифрованный plaintext и возвращает только metadata.',
|
||||
'secrets.modal.rotate_note': 'Ротация создает новую зашифрованную версию и сохраняет тот же secret id.',
|
||||
'secrets.modal.cancel': 'Отмена',
|
||||
'secrets.modal.create_action': 'Создать секрет',
|
||||
'secrets.modal.rotate_action': 'Ротировать секрет',
|
||||
'secrets.modal.creating': 'Создание…',
|
||||
'secrets.modal.rotating': 'Ротация…',
|
||||
'secrets.hint.token': 'Лучший вариант для bearer tokens и простых API key values.',
|
||||
'secrets.hint.username_password': 'Хранит логин и пароль как один зашифрованный payload.',
|
||||
'secrets.hint.header': 'Хранит сырое значение заголовка. Имя заголовка задается в auth profile.',
|
||||
'secrets.hint.generic': 'Хранит произвольный JSON для advanced future integrations.',
|
||||
'secrets.validation.name_required': 'Нужно указать имя секрета.',
|
||||
'secrets.validation.value_required': 'Нужно указать значение секрета.',
|
||||
'secrets.validation.username_password_required': 'Нужно указать и логин, и пароль.',
|
||||
'secrets.validation.json_invalid': 'Generic secret payload должен быть валидным JSON.',
|
||||
'secrets.profiles.title': 'Ссылки auth profiles',
|
||||
'secrets.profiles.subtitle': 'Профили резолвят секреты в runtime перед upstream execution.',
|
||||
'secrets.profiles.subtitle_count': '{count} auth profiles',
|
||||
'secrets.profiles.error_title': 'Не удалось загрузить ссылки auth profiles',
|
||||
'secrets.profiles.loading_title': 'Загрузка ссылок auth profiles…',
|
||||
'secrets.profiles.loading_body': 'Получаем auth profiles для текущего воркспейса.',
|
||||
'secrets.profiles.empty_title': 'Auth profiles пока нет',
|
||||
'secrets.profiles.empty_body': 'В следующем срезе появится UI для привязки секретов к upstream authentication через auth selector.',
|
||||
'secrets.profiles.references': 'Связанные секреты',
|
||||
'secrets.profiles.reference_count': '{count} ссылок',
|
||||
'secrets.profiles.meta.created': 'Создан',
|
||||
'secrets.profiles.meta.updated': 'Обновлен',
|
||||
'secrets.profiles.summary_bearer': 'Bearer auth через {header} с использованием {secret}.',
|
||||
'secrets.profiles.summary_basic': 'Basic auth с использованием {username} и {password}.',
|
||||
'secrets.profiles.summary_api_key_header': 'API key header {header} с использованием {secret}.',
|
||||
'secrets.profiles.summary_api_key_query': 'API key query param {param} с использованием {secret}.',
|
||||
'secrets.profiles.summary_unknown': 'Неизвестная конфигурация auth profile.',
|
||||
'secrets.auth_kind.bearer': 'Bearer auth',
|
||||
'secrets.auth_kind.basic': 'Basic auth',
|
||||
'secrets.auth_kind.api_key_header': 'API key header',
|
||||
'secrets.auth_kind.api_key_query': 'API key query',
|
||||
|
||||
// Logs page
|
||||
'logs.title': 'Логи',
|
||||
'logs.subtitle': 'Живой журнал вызовов по всем операциям этого воркспейса.',
|
||||
@@ -1097,6 +1398,7 @@ var TRANSLATIONS = {
|
||||
'settings.notifications.title': 'Настройки уведомлений',
|
||||
'settings.notifications.not_ready_title': 'Уведомления пока не подключены.',
|
||||
'settings.notifications.not_ready_body': 'Маршрутизация алертов и настройки на пользователя запланированы, но эта сборка не сохраняет и не доставляет notification settings.',
|
||||
'settings.planned_badge': 'Запланировано',
|
||||
'settings.notifications.spike_title': 'Всплеск ошибок операции',
|
||||
'settings.notifications.spike_body': 'Алерт, когда доля ошибок превышает скользящий порог для опубликованного инструмента.',
|
||||
'settings.notifications.latency_title': 'Деградация задержки upstream',
|
||||
@@ -1192,6 +1494,8 @@ var TRANSLATIONS = {
|
||||
'workspace_setup.custom_protocol.rest': 'REST',
|
||||
'workspace_setup.custom_protocol.graphql': 'GraphQL',
|
||||
'workspace_setup.custom_protocol.grpc': 'gRPC',
|
||||
'workspace_setup.custom_protocol.websocket': 'WebSocket',
|
||||
'workspace_setup.custom_protocol.soap': 'SOAP',
|
||||
|
||||
// Wizard
|
||||
'wizard.back_catalog': 'Операции',
|
||||
@@ -1226,6 +1530,10 @@ var TRANSLATIONS = {
|
||||
'wizard.step1.graphql_tagline': 'Запросы и мутации к GraphQL-схеме. Поддерживаются query и mutation для одной фиксированной операции.',
|
||||
'wizard.step1.grpc_name': 'gRPC',
|
||||
'wizard.step1.grpc_tagline': 'Высокопроизводительный бинарный RPC поверх HTTP/2. Подходит для низколатентных внутренних сервисов.',
|
||||
'wizard.step1.websocket_name': 'WebSocket',
|
||||
'wizard.step1.websocket_tagline': 'Состояние соединения, event streams и push-oriented интеграции, нормализованные в bounded MCP streaming tools.',
|
||||
'wizard.step1.soap_name': 'SOAP',
|
||||
'wizard.step1.soap_tagline': 'Enterprise WSDL/XSD-контракты, нормализованные в MCP-инструменты со структурированным XML mapping.',
|
||||
'wizard.step1.query': 'Запрос',
|
||||
'wizard.step1.mutation': 'Мутация',
|
||||
'wizard.step1.unary': 'Unary',
|
||||
@@ -1247,9 +1555,38 @@ var TRANSLATIONS = {
|
||||
'wizard.step2.name': 'Имя',
|
||||
'wizard.step2.base_url': 'Base URL',
|
||||
'wizard.step2.unique_hint': 'Уникальный идентификатор этого upstream-а.',
|
||||
'wizard.step2.base_url_hint': 'Корневой URL без завершающего слеша. Поддерживает ${secrets.*}.',
|
||||
'wizard.step2.auth_headers': 'Auth headers',
|
||||
'wizard.step2.auth_headers_hint': 'Эти заголовки отправляются с каждым запросом в этот upstream. Используйте secrets для учетных данных.',
|
||||
'wizard.step2.base_url_hint': 'Корневой URL без завершающего слеша. Auth настраивается отдельно ниже.',
|
||||
'wizard.step2.auth_selector': 'Upstream auth',
|
||||
'wizard.step2.auth_selector_hint': 'Используйте secrets-backed auth profiles вместо встраивания credential headers в определение upstream-а.',
|
||||
'wizard.step2.auth_mode.none': 'Без авторизации',
|
||||
'wizard.step2.auth_mode.existing': 'Использовать существующий auth profile',
|
||||
'wizard.step2.auth_mode.create': 'Создать auth profile сейчас',
|
||||
'wizard.step2.auth_profile': 'Auth profile',
|
||||
'wizard.step2.auth_profile_hint': 'Выбранный профиль будет резолвиться в runtime и подключаться через `execution_config.auth_profile_ref`.',
|
||||
'wizard.step2.auth_profile_empty': 'Auth profiles пока нет. Создайте его ниже или на странице Secrets.',
|
||||
'wizard.step2.create_profile_title': 'Создать auth profile',
|
||||
'wizard.step2.create_profile_subtitle': 'Создайте переиспользуемый профиль на базе зашифрованных workspace secrets.',
|
||||
'wizard.step2.profile_name': 'Имя профиля',
|
||||
'wizard.step2.profile_kind': 'Тип auth',
|
||||
'wizard.step2.auth_kind.bearer': 'Bearer token',
|
||||
'wizard.step2.auth_kind.basic': 'Basic auth',
|
||||
'wizard.step2.auth_kind.api_key_header': 'API key header',
|
||||
'wizard.step2.auth_kind.api_key_query': 'API key query',
|
||||
'wizard.step2.header_name': 'Имя заголовка',
|
||||
'wizard.step2.query_param': 'Query param',
|
||||
'wizard.step2.username_secret': 'Секрет логина',
|
||||
'wizard.step2.password_secret': 'Секрет пароля',
|
||||
'wizard.step2.secret_value': 'Секрет',
|
||||
'wizard.step2.quick_secret': 'Быстро создать секрет',
|
||||
'wizard.step2.manage_secrets': 'Открыть страницу Secrets',
|
||||
'wizard.step2.static_headers': 'Статические заголовки (опционально)',
|
||||
'wizard.step2.static_headers_hint': 'Необязательные не-секретные заголовки для каждого запроса к этому upstream-у. Для credential flows используйте auth profiles.',
|
||||
'wizard.step2.quick_secret_title': 'Быстро создать секрет',
|
||||
'wizard.step2.quick_secret_name': 'Имя секрета',
|
||||
'wizard.step2.quick_secret_kind': 'Тип секрета',
|
||||
'wizard.step2.quick_secret_value': 'Значение секрета',
|
||||
'wizard.step2.quick_secret_hint': 'Plaintext сразу шифруется и больше не возвращается.',
|
||||
'wizard.step2.quick_secret_submit': 'Создать секрет',
|
||||
'wizard.step2.save_upstream': 'Сохранить upstream',
|
||||
'wizard.step2.endpoint_title': 'Endpoint',
|
||||
'wizard.step2.endpoint_subtitle': 'Путь и HTTP-метод для конкретной операции',
|
||||
@@ -1258,6 +1595,8 @@ var TRANSLATIONS = {
|
||||
'wizard.step3.rest.label': 'Конфиг запроса',
|
||||
'wizard.step3.graphql.label': 'GQL операция',
|
||||
'wizard.step3.grpc.label': 'RPC метод',
|
||||
'wizard.step3.websocket.label': 'Конфиг сокета',
|
||||
'wizard.step3.soap.label': 'WSDL binding',
|
||||
'wizard.step3.rest.title': 'HTTP метод и формат',
|
||||
'wizard.step3.rest.subtitle': 'Выберите HTTP-метод, который отправляет эта операция, и настройте content negotiation headers. Crank сериализует аргументы MCP-инструмента в нужный формат request body.',
|
||||
'wizard.step3.rest.method_title': 'HTTP метод',
|
||||
@@ -1319,6 +1658,44 @@ var TRANSLATIONS = {
|
||||
'wizard.step3.grpc.route_label': 'gRPC route',
|
||||
'wizard.step3.grpc.request_label': 'Запрос',
|
||||
'wizard.step3.grpc.response_label': 'Ответ',
|
||||
'wizard.step3.websocket.title': 'WebSocket subscription',
|
||||
'wizard.step3.websocket.subtitle': 'Определите handshake подписки, шаблоны сообщений и runtime-параметры, которые Crank должен использовать перед сбором bounded WebSocket events.',
|
||||
'wizard.step3.websocket.connection_title': 'Соединение',
|
||||
'wizard.step3.websocket.connection_subtitle': 'Base URL upstream-а из шага 2 станет WebSocket endpoint-ом, если вы явно не переопределите его здесь.',
|
||||
'wizard.step3.websocket.path': 'Путь сокета',
|
||||
'wizard.step3.websocket.path_hint': 'Добавляется к upstream URL из шага 2. Оставьте пустым, если upstream URL уже указывает на полный WebSocket endpoint.',
|
||||
'wizard.step3.websocket.subprotocols': 'Subprotocols',
|
||||
'wizard.step3.websocket.subprotocols_hint': 'По одному subprotocol на строку. Crank предложит их в handshake в том же порядке.',
|
||||
'wizard.step3.websocket.messages_title': 'Сообщения подписки',
|
||||
'wizard.step3.websocket.messages_subtitle': 'Шаблоны отправляются после открытия сокета. Они могут ссылаться на MCP input через mapping на следующем шаге.',
|
||||
'wizard.step3.websocket.subscribe_template': 'Шаблон subscribe',
|
||||
'wizard.step3.websocket.unsubscribe_template': 'Шаблон unsubscribe',
|
||||
'wizard.step3.websocket.runtime_title': 'Тюнинг соединения',
|
||||
'wizard.step3.websocket.runtime_subtitle': 'Используется runtime adapter-ом для heartbeat и reconnect в bounded session-oriented flows.',
|
||||
'wizard.step3.websocket.heartbeat': 'Интервал heartbeat (мс)',
|
||||
'wizard.step3.websocket.reconnect_attempts': 'Число reconnect attempts',
|
||||
'wizard.step3.websocket.reconnect_backoff': 'Backoff reconnect-а (мс)',
|
||||
'wizard.step3.soap.title': 'WSDL и SOAP binding',
|
||||
'wizard.step3.soap.subtitle': 'Загрузите WSDL/XSD bundle, получите список сервисов и выберите точный service, port и operation, который Crank должен опубликовать как MCP tool.',
|
||||
'wizard.step3.soap.wsdl_title': 'Файлы контракта',
|
||||
'wizard.step3.soap.wsdl_subtitle': 'WSDL обязателен. Supporting XSD files можно загрузить дополнительно до discovery.',
|
||||
'wizard.step3.soap.choose_wsdl': 'Выбрать WSDL',
|
||||
'wizard.step3.soap.choose_xsd': 'Выбрать XSD',
|
||||
'wizard.step3.soap.no_wsdl': 'WSDL не выбран',
|
||||
'wizard.step3.soap.no_xsd': 'XSD не выбран',
|
||||
'wizard.step3.soap.upload_discover': 'Загрузить и получить сервисы',
|
||||
'wizard.step3.soap.binding_title': 'Выбранный binding',
|
||||
'wizard.step3.soap.binding_subtitle': 'Выбранные service, port и operation станут target binding для текущего draft.',
|
||||
'wizard.step3.soap.service_name': 'Service',
|
||||
'wizard.step3.soap.port_name': 'Port',
|
||||
'wizard.step3.soap.operation_name': 'Operation',
|
||||
'wizard.step3.soap.version': 'SOAP версия',
|
||||
'wizard.step3.soap.action': 'SOAPAction',
|
||||
'wizard.step3.soap.binding_style': 'Binding style',
|
||||
'wizard.step3.soap.binding.document_literal': 'Document literal',
|
||||
'wizard.step3.soap.binding.rpc_literal': 'RPC literal',
|
||||
'wizard.step3.soap.endpoint_override': 'Endpoint override',
|
||||
'wizard.step3.soap.endpoint_override_hint': 'По умолчанию берется upstream base URL из шага 2. Переопределяйте только если binding address отличается от выбранного upstream.',
|
||||
'wizard.step4.title': 'Конфиг инструмента',
|
||||
'wizard.step4.subtitle': 'Задайте имя инструмента, описание для LLM и определите входную/выходную схему. Описание — главный сигнал, по которому модель решает, вызывать ли этот инструмент.',
|
||||
'wizard.step4.identity_title': 'Идентичность инструмента',
|
||||
@@ -1341,7 +1718,7 @@ var TRANSLATIONS = {
|
||||
'wizard.step4.protocol_agnostic_title': 'Схемы не зависят от протокола',
|
||||
'wizard.step4.protocol_agnostic_body': 'Эти схемы описывают MCP-контракт, а не wire-format upstream-а. Маппинг на шаге 5 переводит одно в другое. Можно загрузить sample JSON-ответа, чтобы автоматически сгенерировать output schema.',
|
||||
'wizard.step5.title': 'Mapping и исполнение',
|
||||
'wizard.step5.subtitle': 'Определите, как аргументы MCP-инструмента мапятся в поля upstream-запроса и как ответ upstream-а мапится обратно в MCP output. Затем задайте execution-параметры — auth настраивается на уровне upstream на шаге 3.',
|
||||
'wizard.step5.subtitle': 'Определите, как аргументы MCP-инструмента мапятся в поля upstream-запроса и как ответ upstream-а мапится обратно в MCP output. Затем задайте execution-параметры — auth настраивается на уровне upstream на шаге 2.',
|
||||
'wizard.step5.input_request': 'Input → Request',
|
||||
'wizard.step5.output_response': 'Response → Output',
|
||||
'wizard.step5.execution': 'Исполнение',
|
||||
@@ -1390,6 +1767,21 @@ var TRANSLATIONS = {
|
||||
'wizard.error.tool_name': 'Имя инструмента обязательно',
|
||||
'wizard.error.parser_unavailable': 'YAML parser недоступен',
|
||||
'wizard.error.select_upstream': 'Сначала выберите или зарегистрируйте upstream',
|
||||
'wizard.error.auth_profile_required': 'Выберите auth profile или переключите upstream auth в режим «Без авторизации».',
|
||||
'wizard.error.auth_profile_name': 'Нужно указать имя auth profile.',
|
||||
'wizard.error.secret_required': 'Выберите секрет, необходимый для этого auth profile.',
|
||||
'wizard.error.basic_secrets_required': 'Выберите секреты и для логина, и для пароля.',
|
||||
'wizard.error.header_name_required': 'Для этого auth profile нужно указать имя заголовка.',
|
||||
'wizard.error.query_param_required': 'Для этого auth profile нужно указать имя query-параметра.',
|
||||
'wizard.error.secret_name_required': 'Нужно указать имя секрета.',
|
||||
'wizard.error.secret_value_required': 'Нужно указать значение секрета.',
|
||||
'wizard.error.save_upstream_first': 'Сначала сохраните upstream после создания нового auth profile.',
|
||||
'wizard.toast.auth_profile_created_title': 'Auth profile создан',
|
||||
'wizard.toast.auth_profile_created_body': 'Новый upstream auth profile готов и уже выбран.',
|
||||
'wizard.toast.auth_profile_error_title': 'Не удалось создать auth profile',
|
||||
'wizard.toast.quick_secret_created_title': 'Секрет создан',
|
||||
'wizard.toast.quick_secret_created_body': 'Новый секрет уже доступен в auth selector.',
|
||||
'wizard.toast.quick_secret_error_title': 'Не удалось создать секрет',
|
||||
'wizard.error.save': 'Не удалось сохранить операцию',
|
||||
'wizard.error.save_title': 'Не удалось сохранить',
|
||||
'wizard.save.created': 'Черновик создан',
|
||||
@@ -1404,6 +1796,21 @@ var TRANSLATIONS = {
|
||||
'wizard.busy.import_yaml': 'Импорт YAML…',
|
||||
'wizard.busy.publish': 'Публикация операции…',
|
||||
'wizard.busy.descriptor': 'Загрузка descriptor set…',
|
||||
'wizard.busy.wsdl': 'Inspection WSDL…',
|
||||
'wizard.step3.soap.wsdl_selected': 'WSDL выбран',
|
||||
'wizard.step3.soap.wsdl_selected_body': 'WSDL staged и будет загружен вместе с текущим draft.',
|
||||
'wizard.step3.soap.xsd_selected': 'XSD выбран',
|
||||
'wizard.step3.soap.xsd_selected_body': 'XSD staged и будет загружен вместе с текущим draft.',
|
||||
'wizard.step3.soap.wsdl_uploaded': 'WSDL загружен',
|
||||
'wizard.step3.soap.xsd_uploaded': 'XSD загружен',
|
||||
'wizard.step3.soap.services_discovered': 'Из загруженного контракта найдено {services} service(s), {ports} port(s) и {operations} operation(s).',
|
||||
'wizard.step3.soap.services_none': 'Из загруженного WSDL пока не найдено ни одного SOAP service.',
|
||||
'wizard.step3.soap.applied': 'SOAP binding применен',
|
||||
'wizard.step3.soap.applied_body': 'Выбранные service, port и operation применены к текущему draft.',
|
||||
'wizard.step3.soap.no_wsdl_error': 'Нужен WSDL',
|
||||
'wizard.step3.soap.no_wsdl_error_body': 'Выберите WSDL или используйте уже загруженный контракт текущего draft перед inspection.',
|
||||
'wizard.step3.soap.discovered': 'SOAP services получены',
|
||||
'wizard.step3.soap.discovered_body': 'Загруженный WSDL был inspected через backend, и доступные bindings готовы к применению.',
|
||||
'wizard.sample.input_saved': 'Входной sample сохранен',
|
||||
'wizard.sample.input_saved_body': 'Входной sample теперь сохранен в текущей версии черновика.',
|
||||
'wizard.sample.output_saved': 'Выходной sample сохранен',
|
||||
@@ -1414,10 +1821,18 @@ var TRANSLATIONS = {
|
||||
'wizard.test.failed': 'Тестовый прогон вернул ошибки',
|
||||
'wizard.test.completed_body': 'Текущий черновик успешно выполнился против upstream.',
|
||||
'wizard.test.failed_body': 'Черновик выполнился, но backend вернул ошибки валидации или runtime.',
|
||||
'wizard.test.window_completed': 'Оконный тест завершен',
|
||||
'wizard.test.window_completed_body': 'Окно завершено: {window_complete}. Усечено: {truncated}. Есть продолжение: {has_more}.',
|
||||
'wizard.test.session_started': 'Session-тест запущен',
|
||||
'wizard.test.session_started_body': 'Создана session {session_id}. Следующий poll можно делать через {poll_after_ms} мс из представления stream sessions.',
|
||||
'wizard.test.async_job_started': 'Async job тест запущен',
|
||||
'wizard.test.async_job_started_body': 'Создан async job {job_id}. Отслеживайте статус и результат через представление async jobs.',
|
||||
'wizard.test.no_response': 'Тестового ответа пока нет',
|
||||
'wizard.test.no_response_body': 'Сначала выполните тест, затем скопируйте response preview в editor output sample.',
|
||||
'wizard.test.response_copied': 'Ответ скопирован',
|
||||
'wizard.test.response_copied_body': 'Последний тестовый ответ скопирован в editor output sample.',
|
||||
'wizard.boolean.yes': 'да',
|
||||
'wizard.boolean.no': 'нет',
|
||||
'wizard.yaml.exported': 'YAML экспортирован',
|
||||
'wizard.yaml.exported_body': 'YAML текущего черновика скачан из backend.',
|
||||
'wizard.yaml.none': 'YAML не предоставлен',
|
||||
@@ -1563,12 +1978,248 @@ var TRANSLATIONS = {
|
||||
'login.sso_short': 'Google SSO',
|
||||
'login.request_prefix': 'Нет аккаунта?',
|
||||
'login.request_link': 'Запросить доступ',
|
||||
'login.planned_badge': 'Запланировано',
|
||||
'login.password_only': 'В этой сборке доступен только вход по паролю. Сброс пароля, Google SSO и самостоятельный запрос доступа пока не подключены.',
|
||||
'login.error.required': 'Введите email и пароль.',
|
||||
'login.error.invalid': 'Неверный email или пароль. Попробуйте еще раз.',
|
||||
'login.error.generic': 'Сейчас не удается войти. Попробуйте еще раз.',
|
||||
}
|
||||
};
|
||||
|
||||
Object.assign(TRANSLATIONS.en, {
|
||||
'wizard.streaming.title': 'Streaming execution',
|
||||
'wizard.streaming.subtitle': 'Bounded stream, session and async job settings for MCP tool families.',
|
||||
'wizard.streaming.execution_mode': 'Execution mode',
|
||||
'wizard.streaming.transport_behavior': 'Transport behavior',
|
||||
'wizard.streaming.aggregation_mode': 'Aggregation mode',
|
||||
'wizard.streaming.window_duration': 'Window duration (ms)',
|
||||
'wizard.streaming.poll_interval': 'Poll interval (ms)',
|
||||
'wizard.streaming.upstream_timeout': 'Upstream timeout (ms)',
|
||||
'wizard.streaming.idle_timeout': 'Idle timeout (ms)',
|
||||
'wizard.streaming.session_lifetime': 'Session lifetime (ms)',
|
||||
'wizard.streaming.max_items': 'Max items',
|
||||
'wizard.streaming.max_bytes': 'Max bytes',
|
||||
'wizard.streaming.max_field_length': 'Max field length',
|
||||
'wizard.streaming.sampling_rate': 'Sampling rate',
|
||||
'wizard.streaming.items_path': 'Items path',
|
||||
'wizard.streaming.summary_path': 'Summary path',
|
||||
'wizard.streaming.done_path': 'Done path',
|
||||
'wizard.streaming.cursor_path': 'Cursor path',
|
||||
'wizard.streaming.status_path': 'Status path',
|
||||
'wizard.streaming.redacted_paths': 'Redacted paths',
|
||||
'wizard.streaming.truncate_item_fields': 'Truncate item fields',
|
||||
'wizard.streaming.drop_duplicates': 'Drop duplicates',
|
||||
'wizard.streaming.tool_family': 'Tool family',
|
||||
'wizard.streaming.start_tool_name': 'Start tool name',
|
||||
'wizard.streaming.poll_tool_name': 'Poll tool name',
|
||||
'wizard.streaming.stop_tool_name': 'Stop tool name',
|
||||
'wizard.streaming.status_tool_name': 'Status tool name',
|
||||
'wizard.streaming.result_tool_name': 'Result tool name',
|
||||
'wizard.streaming.cancel_tool_name': 'Cancel tool name',
|
||||
'wizard.streaming.mode.unary': 'Unary',
|
||||
'wizard.streaming.mode.window': 'Window',
|
||||
'wizard.streaming.mode.session': 'Session',
|
||||
'wizard.streaming.mode.async_job': 'Async job',
|
||||
'wizard.streaming.transport.request_response': 'Request / response',
|
||||
'wizard.streaming.transport.server_stream': 'Server stream',
|
||||
'wizard.streaming.transport.stateful_session': 'Stateful session',
|
||||
'wizard.streaming.transport.deferred_result': 'Deferred result',
|
||||
'wizard.streaming.aggregation.raw_items': 'Raw items',
|
||||
'wizard.streaming.aggregation.summary_only': 'Summary only',
|
||||
'wizard.streaming.aggregation.summary_plus_samples': 'Summary + samples',
|
||||
'wizard.streaming.aggregation.stats': 'Stats',
|
||||
'wizard.streaming.aggregation.latest_state': 'Latest state',
|
||||
'wizard.streaming.help.unary': 'Unary keeps the existing request-response model. Other modes create bounded stream-aware MCP tools.',
|
||||
'wizard.streaming.help.window': 'Window mode collects a bounded slice of data and returns a compact result.',
|
||||
'wizard.streaming.help.session': 'Session mode publishes start/poll/stop tool families backed by persisted stream sessions.',
|
||||
'wizard.streaming.help.async_job': 'Async job mode publishes start/status/result/cancel tools and stores runtime job state.',
|
||||
|
||||
'stream_sessions.title': 'Stream sessions',
|
||||
'stream_sessions.subtitle': 'Inspect stateful streaming sessions created by generated MCP tool families.',
|
||||
'stream_sessions.refresh': 'Refresh',
|
||||
'stream_sessions.summary': '{count} sessions',
|
||||
'stream_sessions.filter.all_statuses': 'All statuses',
|
||||
'stream_sessions.filter.all_modes': 'All modes',
|
||||
'stream_sessions.loading.title': 'Loading stream sessions…',
|
||||
'stream_sessions.loading.body': 'Fetching persisted session state for the current workspace.',
|
||||
'stream_sessions.error.title': 'Unable to load stream sessions',
|
||||
'stream_sessions.error.workspace': 'Workspace is not selected',
|
||||
'stream_sessions.error.load': 'Failed to load stream sessions',
|
||||
'stream_sessions.empty.title': 'No stream sessions found',
|
||||
'stream_sessions.empty.body': 'Session-mode MCP tools will create entries here after start/poll/stop flows.',
|
||||
'stream_sessions.operation': 'Operation: {operation}',
|
||||
'stream_sessions.meta.created': 'Created',
|
||||
'stream_sessions.meta.last_poll': 'Last poll',
|
||||
'stream_sessions.meta.expires': 'Expires',
|
||||
'stream_sessions.meta.agent': 'Agent',
|
||||
'stream_sessions.cursor': 'Cursor',
|
||||
'stream_sessions.stop': 'Stop session',
|
||||
'stream_sessions.show_details': 'Show details',
|
||||
'stream_sessions.hide_details': 'Hide details',
|
||||
'stream_sessions.toast.stop.title': 'Session stopped',
|
||||
'stream_sessions.toast.stop.body': 'The stream session was stopped.',
|
||||
'stream_sessions.toast.stop_error.title': 'Stop failed',
|
||||
'stream_sessions.toast.stop_error.body': 'Failed to stop stream session',
|
||||
'stream_sessions.status.created': 'Created',
|
||||
'stream_sessions.status.running': 'Running',
|
||||
'stream_sessions.status.failed': 'Failed',
|
||||
'stream_sessions.status.stopped': 'Stopped',
|
||||
'stream_sessions.status.expired': 'Expired',
|
||||
|
||||
'async_jobs.title': 'Async jobs',
|
||||
'async_jobs.subtitle': 'Inspect deferred jobs started by streaming MCP tool families.',
|
||||
'async_jobs.refresh': 'Refresh',
|
||||
'async_jobs.summary': '{count} jobs',
|
||||
'async_jobs.filter.all_statuses': 'All statuses',
|
||||
'async_jobs.loading.title': 'Loading async jobs…',
|
||||
'async_jobs.loading.body': 'Fetching persisted job state for the current workspace.',
|
||||
'async_jobs.error.title': 'Unable to load async jobs',
|
||||
'async_jobs.error.workspace': 'Workspace is not selected',
|
||||
'async_jobs.error.load': 'Failed to load async jobs',
|
||||
'async_jobs.empty.title': 'No async jobs found',
|
||||
'async_jobs.empty.body': 'Async job MCP tools will create entries here after start/status/result flows.',
|
||||
'async_jobs.operation': 'Operation: {operation}',
|
||||
'async_jobs.meta.created': 'Created',
|
||||
'async_jobs.meta.updated': 'Updated',
|
||||
'async_jobs.meta.finished': 'Finished',
|
||||
'async_jobs.meta.expires': 'Expires',
|
||||
'async_jobs.progress': 'Progress payload',
|
||||
'async_jobs.error_preview': 'Error preview',
|
||||
'async_jobs.result': 'Result payload',
|
||||
'async_jobs.result_loading': 'Loading result…',
|
||||
'async_jobs.result_error': 'Failed to load result',
|
||||
'async_jobs.cancel': 'Cancel job',
|
||||
'async_jobs.show_details': 'Show details',
|
||||
'async_jobs.hide_details': 'Hide details',
|
||||
'async_jobs.toast.cancel.title': 'Job cancelled',
|
||||
'async_jobs.toast.cancel.body': 'The async job was cancelled.',
|
||||
'async_jobs.toast.cancel_error.title': 'Cancel failed',
|
||||
'async_jobs.toast.cancel_error.body': 'Failed to cancel async job',
|
||||
'async_jobs.status.created': 'Created',
|
||||
'async_jobs.status.running': 'Running',
|
||||
'async_jobs.status.completed': 'Completed',
|
||||
'async_jobs.status.failed': 'Failed',
|
||||
'async_jobs.status.cancelled': 'Cancelled',
|
||||
'async_jobs.status.expired': 'Expired',
|
||||
});
|
||||
|
||||
Object.assign(TRANSLATIONS.ru, {
|
||||
'wizard.streaming.title': 'Потоковое выполнение',
|
||||
'wizard.streaming.subtitle': 'Ограниченные настройки стрима, сессий и async job для семейств MCP-инструментов.',
|
||||
'wizard.streaming.execution_mode': 'Режим выполнения',
|
||||
'wizard.streaming.transport_behavior': 'Поведение транспорта',
|
||||
'wizard.streaming.aggregation_mode': 'Режим агрегации',
|
||||
'wizard.streaming.window_duration': 'Длительность окна (мс)',
|
||||
'wizard.streaming.poll_interval': 'Интервал poll (мс)',
|
||||
'wizard.streaming.upstream_timeout': 'Таймаут upstream (мс)',
|
||||
'wizard.streaming.idle_timeout': 'Таймаут простоя (мс)',
|
||||
'wizard.streaming.session_lifetime': 'Время жизни сессии (мс)',
|
||||
'wizard.streaming.max_items': 'Максимум элементов',
|
||||
'wizard.streaming.max_bytes': 'Максимум байт',
|
||||
'wizard.streaming.max_field_length': 'Максимальная длина поля',
|
||||
'wizard.streaming.sampling_rate': 'Частота выборки',
|
||||
'wizard.streaming.items_path': 'Путь к items',
|
||||
'wizard.streaming.summary_path': 'Путь к summary',
|
||||
'wizard.streaming.done_path': 'Путь к done',
|
||||
'wizard.streaming.cursor_path': 'Путь к cursor',
|
||||
'wizard.streaming.status_path': 'Путь к status',
|
||||
'wizard.streaming.redacted_paths': 'Скрываемые пути',
|
||||
'wizard.streaming.truncate_item_fields': 'Обрезать поля элементов',
|
||||
'wizard.streaming.drop_duplicates': 'Удалять дубликаты',
|
||||
'wizard.streaming.tool_family': 'Семейство инструментов',
|
||||
'wizard.streaming.start_tool_name': 'Имя start-инструмента',
|
||||
'wizard.streaming.poll_tool_name': 'Имя poll-инструмента',
|
||||
'wizard.streaming.stop_tool_name': 'Имя stop-инструмента',
|
||||
'wizard.streaming.status_tool_name': 'Имя status-инструмента',
|
||||
'wizard.streaming.result_tool_name': 'Имя result-инструмента',
|
||||
'wizard.streaming.cancel_tool_name': 'Имя cancel-инструмента',
|
||||
'wizard.streaming.mode.unary': 'Unary',
|
||||
'wizard.streaming.mode.window': 'Window',
|
||||
'wizard.streaming.mode.session': 'Session',
|
||||
'wizard.streaming.mode.async_job': 'Async job',
|
||||
'wizard.streaming.transport.request_response': 'Запрос / ответ',
|
||||
'wizard.streaming.transport.server_stream': 'Серверный стрим',
|
||||
'wizard.streaming.transport.stateful_session': 'Состояние сессии',
|
||||
'wizard.streaming.transport.deferred_result': 'Отложенный результат',
|
||||
'wizard.streaming.aggregation.raw_items': 'Сырые элементы',
|
||||
'wizard.streaming.aggregation.summary_only': 'Только summary',
|
||||
'wizard.streaming.aggregation.summary_plus_samples': 'Summary + примеры',
|
||||
'wizard.streaming.aggregation.stats': 'Статистика',
|
||||
'wizard.streaming.aggregation.latest_state': 'Последнее состояние',
|
||||
'wizard.streaming.help.unary': 'Unary сохраняет текущую модель запрос-ответ. Остальные режимы создают ограниченные потоковые MCP-инструменты.',
|
||||
'wizard.streaming.help.window': 'Режим window собирает ограниченный срез данных и возвращает компактный результат.',
|
||||
'wizard.streaming.help.session': 'Режим session публикует семейство start/poll/stop и опирается на сохраненные stream sessions.',
|
||||
'wizard.streaming.help.async_job': 'Режим async job публикует start/status/result/cancel и хранит состояние фоновой задачи.',
|
||||
|
||||
'stream_sessions.title': 'Потоковые сессии',
|
||||
'stream_sessions.subtitle': 'Просмотр stateful streaming-сессий, созданных сгенерированными семействами MCP-инструментов.',
|
||||
'stream_sessions.refresh': 'Обновить',
|
||||
'stream_sessions.summary': '{count} сессий',
|
||||
'stream_sessions.filter.all_statuses': 'Все статусы',
|
||||
'stream_sessions.filter.all_modes': 'Все режимы',
|
||||
'stream_sessions.loading.title': 'Загрузка потоковых сессий…',
|
||||
'stream_sessions.loading.body': 'Получаем сохраненное состояние сессий для текущего workspace.',
|
||||
'stream_sessions.error.title': 'Не удалось загрузить потоковые сессии',
|
||||
'stream_sessions.error.workspace': 'Workspace не выбран',
|
||||
'stream_sessions.error.load': 'Не удалось загрузить потоковые сессии',
|
||||
'stream_sessions.empty.title': 'Потоковых сессий пока нет',
|
||||
'stream_sessions.empty.body': 'Сессии появятся здесь после вызовов start/poll/stop для session-mode инструментов.',
|
||||
'stream_sessions.operation': 'Операция: {operation}',
|
||||
'stream_sessions.meta.created': 'Создана',
|
||||
'stream_sessions.meta.last_poll': 'Последний poll',
|
||||
'stream_sessions.meta.expires': 'Истекает',
|
||||
'stream_sessions.meta.agent': 'Агент',
|
||||
'stream_sessions.cursor': 'Cursor',
|
||||
'stream_sessions.stop': 'Остановить сессию',
|
||||
'stream_sessions.show_details': 'Показать детали',
|
||||
'stream_sessions.hide_details': 'Скрыть детали',
|
||||
'stream_sessions.toast.stop.title': 'Сессия остановлена',
|
||||
'stream_sessions.toast.stop.body': 'Потоковая сессия остановлена.',
|
||||
'stream_sessions.toast.stop_error.title': 'Не удалось остановить',
|
||||
'stream_sessions.toast.stop_error.body': 'Не удалось остановить потоковую сессию',
|
||||
'stream_sessions.status.created': 'Создана',
|
||||
'stream_sessions.status.running': 'Выполняется',
|
||||
'stream_sessions.status.failed': 'Ошибка',
|
||||
'stream_sessions.status.stopped': 'Остановлена',
|
||||
'stream_sessions.status.expired': 'Истекла',
|
||||
|
||||
'async_jobs.title': 'Асинхронные задачи',
|
||||
'async_jobs.subtitle': 'Просмотр отложенных задач, запущенных потоковыми семействами MCP-инструментов.',
|
||||
'async_jobs.refresh': 'Обновить',
|
||||
'async_jobs.summary': '{count} задач',
|
||||
'async_jobs.filter.all_statuses': 'Все статусы',
|
||||
'async_jobs.loading.title': 'Загрузка async job…',
|
||||
'async_jobs.loading.body': 'Получаем сохраненное состояние задач для текущего workspace.',
|
||||
'async_jobs.error.title': 'Не удалось загрузить async job',
|
||||
'async_jobs.error.workspace': 'Workspace не выбран',
|
||||
'async_jobs.error.load': 'Не удалось загрузить async job',
|
||||
'async_jobs.empty.title': 'Асинхронных задач пока нет',
|
||||
'async_jobs.empty.body': 'Задачи появятся здесь после вызовов start/status/result для async job инструментов.',
|
||||
'async_jobs.operation': 'Операция: {operation}',
|
||||
'async_jobs.meta.created': 'Создана',
|
||||
'async_jobs.meta.updated': 'Обновлена',
|
||||
'async_jobs.meta.finished': 'Завершена',
|
||||
'async_jobs.meta.expires': 'Истекает',
|
||||
'async_jobs.progress': 'Payload прогресса',
|
||||
'async_jobs.error_preview': 'Предпросмотр ошибки',
|
||||
'async_jobs.result': 'Payload результата',
|
||||
'async_jobs.result_loading': 'Загрузка результата…',
|
||||
'async_jobs.result_error': 'Не удалось загрузить результат',
|
||||
'async_jobs.cancel': 'Отменить задачу',
|
||||
'async_jobs.show_details': 'Показать детали',
|
||||
'async_jobs.hide_details': 'Скрыть детали',
|
||||
'async_jobs.toast.cancel.title': 'Задача отменена',
|
||||
'async_jobs.toast.cancel.body': 'Асинхронная задача отменена.',
|
||||
'async_jobs.toast.cancel_error.title': 'Не удалось отменить',
|
||||
'async_jobs.toast.cancel_error.body': 'Не удалось отменить async job',
|
||||
'async_jobs.status.created': 'Создана',
|
||||
'async_jobs.status.running': 'Выполняется',
|
||||
'async_jobs.status.completed': 'Завершена',
|
||||
'async_jobs.status.failed': 'Ошибка',
|
||||
'async_jobs.status.cancelled': 'Отменена',
|
||||
'async_jobs.status.expired': 'Истекла',
|
||||
});
|
||||
|
||||
function t(key) {
|
||||
var lang = localStorage.getItem('crank_lang') || 'en';
|
||||
var tr = TRANSLATIONS[lang] || TRANSLATIONS.en;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var state = {
|
||||
workspaceId: null,
|
||||
secrets: [],
|
||||
profiles: [],
|
||||
search: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
modalMode: 'create',
|
||||
modalSecretId: null,
|
||||
};
|
||||
|
||||
var modal = document.getElementById('secret-modal');
|
||||
var tbody = document.getElementById('secrets-tbody');
|
||||
var profilesList = document.getElementById('auth-profiles-list');
|
||||
var summary = document.getElementById('secrets-summary');
|
||||
var profilesSummary = document.getElementById('secret-profiles-summary');
|
||||
var searchInput = document.getElementById('secret-search');
|
||||
var modalTitle = document.getElementById('secret-modal-title');
|
||||
var modalName = document.getElementById('secret-name');
|
||||
var modalKind = document.getElementById('secret-kind');
|
||||
var modalNote = document.getElementById('secret-modal-note');
|
||||
var modalSubmit = document.getElementById('secret-modal-submit-btn');
|
||||
var kindHint = document.getElementById('secret-kind-hint');
|
||||
var stringLabel = document.getElementById('secret-string-label');
|
||||
var stringField = document.getElementById('secret-string-value');
|
||||
var usernameField = document.getElementById('secret-username');
|
||||
var passwordField = document.getElementById('secret-password');
|
||||
var jsonField = document.getElementById('secret-json-value');
|
||||
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : tKey(key);
|
||||
}
|
||||
|
||||
function currentWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
function currentWorkspace() {
|
||||
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
}
|
||||
|
||||
function secretKindLabel(kind) {
|
||||
return tKey('secrets.kind.' + String(kind || '').toLowerCase());
|
||||
}
|
||||
|
||||
function authKindLabel(kind) {
|
||||
return tKey('secrets.auth_kind.' + String(kind || '').toLowerCase());
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
function secretIdsForProfile(profile) {
|
||||
var config = profile && profile.config ? profile.config : {};
|
||||
if (config.bearer) return [config.bearer.secret_id];
|
||||
if (config.basic) return [config.basic.username_secret_id, config.basic.password_secret_id];
|
||||
if (config.api_key_header) return [config.api_key_header.secret_id];
|
||||
if (config.api_key_query) return [config.api_key_query.secret_id];
|
||||
return [];
|
||||
}
|
||||
|
||||
function profileSummary(profile, secretsById) {
|
||||
var config = profile && profile.config ? profile.config : {};
|
||||
if (config.bearer) {
|
||||
return tfKey('secrets.profiles.summary_bearer', {
|
||||
header: config.bearer.header_name,
|
||||
secret: secretName(secretsById, config.bearer.secret_id),
|
||||
});
|
||||
}
|
||||
if (config.basic) {
|
||||
return tfKey('secrets.profiles.summary_basic', {
|
||||
username: secretName(secretsById, config.basic.username_secret_id),
|
||||
password: secretName(secretsById, config.basic.password_secret_id),
|
||||
});
|
||||
}
|
||||
if (config.api_key_header) {
|
||||
return tfKey('secrets.profiles.summary_api_key_header', {
|
||||
header: config.api_key_header.header_name,
|
||||
secret: secretName(secretsById, config.api_key_header.secret_id),
|
||||
});
|
||||
}
|
||||
if (config.api_key_query) {
|
||||
return tfKey('secrets.profiles.summary_api_key_query', {
|
||||
param: config.api_key_query.param_name,
|
||||
secret: secretName(secretsById, config.api_key_query.secret_id),
|
||||
});
|
||||
}
|
||||
return tKey('secrets.profiles.summary_unknown');
|
||||
}
|
||||
|
||||
function secretName(secretsById, secretId) {
|
||||
var secret = secretsById[secretId];
|
||||
return secret ? secret.name : secretId;
|
||||
}
|
||||
|
||||
function usageBySecret() {
|
||||
var usage = {};
|
||||
state.profiles.forEach(function (profile) {
|
||||
secretIdsForProfile(profile).forEach(function (secretId) {
|
||||
if (!secretId) return;
|
||||
if (!usage[secretId]) usage[secretId] = [];
|
||||
usage[secretId].push(profile);
|
||||
});
|
||||
});
|
||||
return usage;
|
||||
}
|
||||
|
||||
function resetModalFields() {
|
||||
modalName.value = '';
|
||||
modalKind.value = 'token';
|
||||
stringField.value = '';
|
||||
usernameField.value = '';
|
||||
passwordField.value = '';
|
||||
jsonField.value = '{\n "value": ""\n}';
|
||||
}
|
||||
|
||||
function openModal(mode, secret) {
|
||||
state.modalMode = mode;
|
||||
state.modalSecretId = secret ? secret.id : null;
|
||||
resetModalFields();
|
||||
|
||||
if (mode === 'rotate' && secret) {
|
||||
modalTitle.textContent = tKey('secrets.modal.rotate_title');
|
||||
modalName.value = secret.name;
|
||||
modalName.disabled = true;
|
||||
modalKind.value = secret.kind;
|
||||
modalKind.disabled = true;
|
||||
modalSubmit.textContent = tKey('secrets.modal.rotate_action');
|
||||
modalNote.textContent = tKey('secrets.modal.rotate_note');
|
||||
} else {
|
||||
modalTitle.textContent = tKey('secrets.modal.create_title');
|
||||
modalName.disabled = false;
|
||||
modalKind.disabled = false;
|
||||
modalSubmit.textContent = tKey('secrets.modal.create_action');
|
||||
modalNote.textContent = tKey('secrets.modal.create_note');
|
||||
}
|
||||
|
||||
updateKindFields();
|
||||
modal.classList.add('open');
|
||||
setTimeout(function () {
|
||||
if (mode === 'rotate') {
|
||||
stringField.focus();
|
||||
} else {
|
||||
modalName.focus();
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove('open');
|
||||
state.modalMode = 'create';
|
||||
state.modalSecretId = null;
|
||||
}
|
||||
|
||||
function updateKindFields() {
|
||||
var kind = modalKind.value;
|
||||
document.querySelectorAll('[data-kind-field="string"]').forEach(function (element) {
|
||||
element.style.display = kind === 'token' || kind === 'header' ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('[data-kind-field="basic"]').forEach(function (element) {
|
||||
element.style.display = kind === 'username_password' ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('[data-kind-field="json"]').forEach(function (element) {
|
||||
element.style.display = kind === 'generic' ? '' : 'none';
|
||||
});
|
||||
|
||||
if (kind === 'token') {
|
||||
stringLabel.textContent = tKey('secrets.modal.value');
|
||||
} else if (kind === 'header') {
|
||||
stringLabel.textContent = tKey('secrets.modal.header_value');
|
||||
} else {
|
||||
stringLabel.textContent = tKey('secrets.modal.value');
|
||||
}
|
||||
kindHint.textContent = tKey('secrets.hint.' + kind);
|
||||
}
|
||||
|
||||
function buildSecretValue() {
|
||||
var kind = modalKind.value;
|
||||
if (kind === 'token' || kind === 'header') {
|
||||
var value = stringField.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(tKey('secrets.validation.value_required'));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (kind === 'username_password') {
|
||||
var username = usernameField.value.trim();
|
||||
var password = passwordField.value;
|
||||
if (!username || !password) {
|
||||
throw new Error(tKey('secrets.validation.username_password_required'));
|
||||
}
|
||||
return {
|
||||
username: username,
|
||||
password: password,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonField.value);
|
||||
} catch (_error) {
|
||||
throw new Error(tKey('secrets.validation.json_invalid'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSecrets() {
|
||||
var usage = usageBySecret();
|
||||
var query = state.search.toLowerCase();
|
||||
var rows = state.secrets.filter(function (secret) {
|
||||
return !query || secret.name.toLowerCase().includes(query) || String(secret.kind || '').toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
var active = state.secrets.filter(function (secret) { return secret.status === 'active'; }).length;
|
||||
summary.textContent = tfKey('secrets.active.subtitle', {
|
||||
active: active,
|
||||
total: state.secrets.length,
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (state.loading && state.secrets.length === 0) {
|
||||
var loadingRow = document.createElement('tr');
|
||||
var loadingCell = document.createElement('td');
|
||||
loadingCell.colSpan = 7;
|
||||
loadingCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
loadingCell.textContent = tKey('secrets.loading');
|
||||
loadingRow.appendChild(loadingCell);
|
||||
tbody.appendChild(loadingRow);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
var errorRow = document.createElement('tr');
|
||||
var errorCell = document.createElement('td');
|
||||
errorCell.colSpan = 7;
|
||||
errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);';
|
||||
errorCell.textContent = state.error;
|
||||
errorRow.appendChild(errorCell);
|
||||
tbody.appendChild(errorRow);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
var emptyRow = document.createElement('tr');
|
||||
var emptyCell = document.createElement('td');
|
||||
emptyCell.colSpan = 7;
|
||||
emptyCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
emptyCell.textContent = state.secrets.length ? tKey('secrets.empty.search') : tKey('secrets.empty.none');
|
||||
emptyRow.appendChild(emptyCell);
|
||||
tbody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach(function (secret) {
|
||||
var tr = document.createElement('tr');
|
||||
var references = usage[secret.id] || [];
|
||||
tr.innerHTML = [
|
||||
'<td class="col-name">' + secret.name + '</td>',
|
||||
'<td>' + secretKindLabel(secret.kind) + '</td>',
|
||||
'<td class="col-mono">v' + secret.current_version + '</td>',
|
||||
'<td>' + (secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never')) + '</td>',
|
||||
'<td>' + tfKey('secrets.used_by_count', { count: references.length }) + '</td>',
|
||||
'<td><span class="badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active') + '">' + tKey('secrets.status.' + secret.status) + '</span></td>',
|
||||
'<td class="col-actions"></td>'
|
||||
].join('');
|
||||
|
||||
var actions = tr.querySelector('.col-actions');
|
||||
var rotateButton = document.createElement('button');
|
||||
rotateButton.className = 'icon-btn';
|
||||
rotateButton.textContent = tKey('secrets.action.rotate');
|
||||
rotateButton.addEventListener('click', function () {
|
||||
openModal('rotate', secret);
|
||||
});
|
||||
actions.appendChild(rotateButton);
|
||||
|
||||
var deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'icon-btn danger';
|
||||
deleteButton.textContent = tKey('secrets.action.delete');
|
||||
deleteButton.addEventListener('click', async function () {
|
||||
await deleteSecret(secret);
|
||||
});
|
||||
actions.appendChild(deleteButton);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
var usage = usageBySecret();
|
||||
var secretsById = {};
|
||||
state.secrets.forEach(function (secret) {
|
||||
secretsById[secret.id] = secret;
|
||||
});
|
||||
|
||||
profilesSummary.textContent = tfKey('secrets.profiles.subtitle_count', {
|
||||
count: state.profiles.length,
|
||||
});
|
||||
|
||||
if (state.error) {
|
||||
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.error_title') + '</div><div class="empty-state-text">' + state.error + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loading && state.profiles.length === 0) {
|
||||
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.loading_title') + '</div><div class="empty-state-text">' + tKey('secrets.profiles.loading_body') + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.profiles.length) {
|
||||
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.empty_title') + '</div><div class="empty-state-text">' + tKey('secrets.profiles.empty_body') + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
profilesList.innerHTML = state.profiles.map(function (profile) {
|
||||
var secretIds = secretIdsForProfile(profile);
|
||||
var references = secretIds.map(function (secretId) {
|
||||
var users = usage[secretId] || [];
|
||||
return '<span class="secret-ref-pill"><strong>' + secretName(secretsById, secretId) + '</strong><span>' + tfKey('secrets.profiles.reference_count', { count: users.length }) + '</span></span>';
|
||||
}).join('');
|
||||
return [
|
||||
'<div class="resource-card">',
|
||||
' <div class="resource-card-header">',
|
||||
' <div>',
|
||||
' <div class="resource-card-title">' + profile.name + '</div>',
|
||||
' <div class="resource-card-subtitle">' + profileSummary(profile, secretsById) + '</div>',
|
||||
' <div class="resource-pill-row">',
|
||||
' <span class="resource-status-pill active">' + authKindLabel(profile.kind) + '</span>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="resource-meta-grid">',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('secrets.profiles.meta.created') + '</div><div class="resource-meta-value">' + formatDate(profile.created_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('secrets.profiles.meta.updated') + '</div><div class="resource-meta-value">' + formatDate(profile.updated_at) + '</div></div>',
|
||||
' </div>',
|
||||
' <div class="resource-detail-block">',
|
||||
' <div class="resource-detail-title">' + tKey('secrets.profiles.references') + '</div>',
|
||||
' <div class="secret-ref-list">' + references + '</div>',
|
||||
' </div>',
|
||||
'</div>'
|
||||
].join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.workspaceId = currentWorkspaceId();
|
||||
state.loading = true;
|
||||
state.error = '';
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
|
||||
if (!state.workspaceId) {
|
||||
state.loading = false;
|
||||
state.error = tKey('secrets.error.workspace');
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
window.CrankApi.listSecrets(state.workspaceId),
|
||||
window.CrankApi.listAuthProfiles(state.workspaceId),
|
||||
]);
|
||||
state.secrets = (results[0] && results[0].items) || [];
|
||||
state.profiles = (results[1] && results[1].items) || [];
|
||||
} catch (error) {
|
||||
state.error = error.message || tKey('secrets.error.load');
|
||||
state.secrets = [];
|
||||
state.profiles = [];
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSecret(secret) {
|
||||
if (!confirm(tfKey('secrets.confirm.delete', { name: secret.name }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.CrankApi.deleteSecret(state.workspaceId, secret.id);
|
||||
await load();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tfKey('secrets.toast.delete_message', { name: secret.name }),
|
||||
tKey('secrets.toast.delete_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('secrets.toast.delete_error_message'),
|
||||
tKey('secrets.toast.delete_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitModal() {
|
||||
var kind = modalKind.value;
|
||||
var value = buildSecretValue();
|
||||
modalSubmit.disabled = true;
|
||||
modalSubmit.textContent = state.modalMode === 'rotate'
|
||||
? tKey('secrets.modal.rotating')
|
||||
: tKey('secrets.modal.creating');
|
||||
|
||||
try {
|
||||
if (state.modalMode === 'rotate') {
|
||||
await window.CrankApi.rotateSecret(state.workspaceId, state.modalSecretId, { value: value });
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('secrets.toast.rotate_message'),
|
||||
tKey('secrets.toast.rotate_title')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
var name = modalName.value.trim();
|
||||
if (!name) {
|
||||
throw new Error(tKey('secrets.validation.name_required'));
|
||||
}
|
||||
await window.CrankApi.createSecret(state.workspaceId, {
|
||||
name: name,
|
||||
kind: kind,
|
||||
value: value,
|
||||
});
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('secrets.toast.create_message'),
|
||||
tKey('secrets.toast.create_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_message' : 'secrets.toast.create_error_message'),
|
||||
tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_title' : 'secrets.toast.create_error_title')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
modalSubmit.disabled = false;
|
||||
modalSubmit.textContent = state.modalMode === 'rotate'
|
||||
? tKey('secrets.modal.rotate_action')
|
||||
: tKey('secrets.modal.create_action');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-create-secret').addEventListener('click', function () {
|
||||
openModal('create');
|
||||
});
|
||||
document.getElementById('secret-modal-close-btn').addEventListener('click', closeModal);
|
||||
document.getElementById('secret-modal-cancel-btn').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
modalKind.addEventListener('change', updateKindFields);
|
||||
modalSubmit.addEventListener('click', function () {
|
||||
void submitModal();
|
||||
});
|
||||
searchInput.addEventListener('input', function (event) {
|
||||
state.search = event.target.value || '';
|
||||
renderSecrets();
|
||||
});
|
||||
document.addEventListener('workspace:changed', function () {
|
||||
void load();
|
||||
});
|
||||
|
||||
updateKindFields();
|
||||
void load();
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
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 renderEmpty(title, body) {
|
||||
list.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + title + '</div><div class="empty-state-text">' + body + '</div></div>';
|
||||
}
|
||||
|
||||
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.map(function(item) {
|
||||
var open = state.openId === item.id;
|
||||
var statusLabel = tKey('stream_sessions.status.' + item.status);
|
||||
return [
|
||||
'<div class="resource-card" data-session-id="' + item.id + '">',
|
||||
' <div class="resource-card-header">',
|
||||
' <div>',
|
||||
' <div class="resource-card-title">' + item.id + '</div>',
|
||||
' <div class="resource-card-subtitle">' + tKey('stream_sessions.operation', { operation: item.operation_id }) + '</div>',
|
||||
' <div class="resource-pill-row">',
|
||||
' <span class="resource-status-pill ' + String(item.status || '').toLowerCase() + '">' + statusLabel + '</span>',
|
||||
' <span class="resource-status-pill">' + item.mode + '</span>',
|
||||
' <span class="resource-status-pill">' + item.protocol + '</span>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="resource-card-actions">',
|
||||
item.status === 'created' || item.status === 'running'
|
||||
? ' <button class="btn-secondary" data-action="stop" data-session-id="' + item.id + '">' + tKey('stream_sessions.stop') + '</button>'
|
||||
: '',
|
||||
' <button class="btn-secondary" data-action="toggle" data-session-id="' + item.id + '">' + (open ? tKey('stream_sessions.hide_details') : tKey('stream_sessions.show_details')) + '</button>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="resource-meta-grid">',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.created') + '</div><div class="resource-meta-value">' + formatDateTime(item.created_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.last_poll') + '</div><div class="resource-meta-value">' + formatDateTime(item.last_poll_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.expires') + '</div><div class="resource-meta-value">' + formatDateTime(item.expires_at) + '</div></div>',
|
||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.agent') + '</div><div class="resource-meta-value">' + (item.agent_id || '—') + '</div></div>',
|
||||
' </div>',
|
||||
open
|
||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('stream_sessions.cursor') + '</div><pre class="resource-detail-pre">' + formatJson(item.cursor) + '</pre></div>'
|
||||
: '',
|
||||
'</div>'
|
||||
].join('');
|
||||
}).join('');
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
(function() {
|
||||
function currentWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
async function startWindowTest(operationId, payload) {
|
||||
if (!window.CrankApi || !operationId) {
|
||||
throw new Error('Window test requires an operation id.');
|
||||
}
|
||||
return window.CrankApi.runOperationTest(currentWorkspaceId(), operationId, payload);
|
||||
}
|
||||
|
||||
async function startSessionTest(workspaceId, operationId, payload) {
|
||||
return window.CrankApi.runOperationTest(workspaceId || currentWorkspaceId(), operationId, payload);
|
||||
}
|
||||
|
||||
async function pollSessionTest(workspaceId, sessionId) {
|
||||
return window.CrankApi.getStreamSession(workspaceId || currentWorkspaceId(), sessionId);
|
||||
}
|
||||
|
||||
async function stopSessionTest(workspaceId, sessionId) {
|
||||
return window.CrankApi.stopStreamSession(workspaceId || currentWorkspaceId(), sessionId);
|
||||
}
|
||||
|
||||
async function startAsyncJobTest(workspaceId, operationId, payload) {
|
||||
return window.CrankApi.runOperationTest(workspaceId || currentWorkspaceId(), operationId, payload);
|
||||
}
|
||||
|
||||
async function refreshAsyncJobStatus(workspaceId, jobId) {
|
||||
return window.CrankApi.getAsyncJob(workspaceId || currentWorkspaceId(), jobId);
|
||||
}
|
||||
|
||||
async function loadAsyncJobResult(workspaceId, jobId) {
|
||||
return window.CrankApi.getAsyncJobResult(workspaceId || currentWorkspaceId(), jobId);
|
||||
}
|
||||
|
||||
window.CrankStreamTestRun = {
|
||||
startWindowTest: startWindowTest,
|
||||
startSessionTest: startSessionTest,
|
||||
pollSessionTest: pollSessionTest,
|
||||
stopSessionTest: stopSessionTest,
|
||||
startAsyncJobTest: startAsyncJobTest,
|
||||
refreshAsyncJobStatus: refreshAsyncJobStatus,
|
||||
loadAsyncJobResult: loadAsyncJobResult,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,127 @@
|
||||
(function() {
|
||||
function text(id) {
|
||||
var element = document.getElementById(id);
|
||||
return element ? String(element.value || '').trim() : '';
|
||||
}
|
||||
|
||||
function number(id) {
|
||||
var value = text(id);
|
||||
if (!value) return null;
|
||||
var parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function lines(id) {
|
||||
var value = text(id);
|
||||
return value
|
||||
? value.split('\n').map(function(item) { return item.trim(); }).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function checked(id) {
|
||||
var element = document.getElementById(id);
|
||||
return !!(element && element.checked);
|
||||
}
|
||||
|
||||
function selectedMode() {
|
||||
return text('streaming-mode') || 'unary';
|
||||
}
|
||||
|
||||
function serializeStreamingConfig() {
|
||||
if (selectedMode() === 'unary') {
|
||||
return null;
|
||||
}
|
||||
|
||||
var config = {
|
||||
mode: selectedMode(),
|
||||
transport_behavior: text('streaming-transport-behavior') || 'request_response',
|
||||
aggregation_mode: text('streaming-aggregation-mode') || 'summary_plus_samples',
|
||||
window_duration_ms: number('streaming-window-duration-ms'),
|
||||
poll_interval_ms: number('streaming-poll-interval-ms'),
|
||||
upstream_timeout_ms: number('streaming-upstream-timeout-ms'),
|
||||
idle_timeout_ms: number('streaming-idle-timeout-ms'),
|
||||
max_session_lifetime_ms: number('streaming-session-lifetime-ms'),
|
||||
max_items: number('streaming-max-items'),
|
||||
max_bytes: number('streaming-max-bytes'),
|
||||
max_field_length: number('streaming-max-field-length'),
|
||||
sampling_rate: number('streaming-sampling-rate'),
|
||||
items_path: text('streaming-items-path') || null,
|
||||
summary_path: text('streaming-summary-path') || null,
|
||||
done_path: text('streaming-done-path') || null,
|
||||
cursor_path: text('streaming-cursor-path') || null,
|
||||
status_path: text('streaming-status-path') || null,
|
||||
redacted_paths: lines('streaming-redacted-paths'),
|
||||
truncate_item_fields: checked('streaming-truncate-item-fields'),
|
||||
drop_duplicates: checked('streaming-drop-duplicates'),
|
||||
tool_family: {},
|
||||
};
|
||||
|
||||
if (config.mode === 'session') {
|
||||
config.tool_family.start_tool_name = text('streaming-start-tool-name') || null;
|
||||
config.tool_family.poll_tool_name = text('streaming-poll-tool-name') || null;
|
||||
config.tool_family.stop_tool_name = text('streaming-stop-tool-name') || null;
|
||||
} else if (config.mode === 'async_job') {
|
||||
config.tool_family.start_tool_name = text('streaming-start-tool-name') || null;
|
||||
config.tool_family.status_tool_name = text('streaming-status-tool-name') || null;
|
||||
config.tool_family.result_tool_name = text('streaming-result-tool-name') || null;
|
||||
config.tool_family.cancel_tool_name = text('streaming-cancel-tool-name') || null;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function validateStreamingConfig(capabilities) {
|
||||
var config = serializeStreamingConfig();
|
||||
if (!config) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
var errors = [];
|
||||
var capability = (capabilities || []).find(function(item) {
|
||||
return item.protocol === window.wizardProtocol;
|
||||
});
|
||||
|
||||
if (capability && Array.isArray(capability.supports_execution_modes)) {
|
||||
var supported = capability.supports_execution_modes.map(String);
|
||||
if (supported.indexOf(config.mode) === -1) {
|
||||
errors.push('Selected execution mode is not supported by the current protocol.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.max_items || config.max_items <= 0) {
|
||||
errors.push('Max items must be a positive integer.');
|
||||
}
|
||||
|
||||
if (!config.max_bytes || config.max_bytes <= 0) {
|
||||
errors.push('Max bytes must be a positive integer.');
|
||||
}
|
||||
|
||||
if (config.mode === 'window' && (!config.window_duration_ms || config.window_duration_ms <= 0)) {
|
||||
errors.push('Window duration must be set for window mode.');
|
||||
}
|
||||
|
||||
if (config.mode === 'session' && (!config.poll_interval_ms || config.poll_interval_ms <= 0)) {
|
||||
errors.push('Poll interval must be set for session mode.');
|
||||
}
|
||||
|
||||
if (config.mode === 'async_job' && (!config.max_session_lifetime_ms || config.max_session_lifetime_ms <= 0)) {
|
||||
errors.push('Session lifetime must be set for async job mode.');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors,
|
||||
config: config,
|
||||
};
|
||||
}
|
||||
|
||||
window.CrankStreamingForm = {
|
||||
serializeStreamingConfig: serializeStreamingConfig,
|
||||
deserializeStreamingConfig: function(streaming) {
|
||||
if (typeof window.applyStreamingConfig === 'function') {
|
||||
window.applyStreamingConfig(streaming);
|
||||
}
|
||||
},
|
||||
validateStreamingConfig: validateStreamingConfig,
|
||||
};
|
||||
}());
|
||||
@@ -58,6 +58,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||
return 'var(--accent)';
|
||||
}
|
||||
if (protocol === 'websocket' || protocol === 'Websocket') {
|
||||
return 'var(--teal)';
|
||||
}
|
||||
if (protocol === 'soap' || protocol === 'Soap') {
|
||||
return 'var(--blue)';
|
||||
}
|
||||
return 'var(--blue)';
|
||||
}
|
||||
|
||||
@@ -68,6 +74,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||
return 'gRPC';
|
||||
}
|
||||
if (protocol === 'websocket' || protocol === 'Websocket') {
|
||||
return 'WebSocket';
|
||||
}
|
||||
if (protocol === 'soap' || protocol === 'Soap') {
|
||||
return 'SOAP';
|
||||
}
|
||||
return 'REST';
|
||||
}
|
||||
|
||||
|
||||
+1111
-70
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,10 @@ server {
|
||||
try_files /html/api-keys.html =404;
|
||||
}
|
||||
|
||||
location = /secrets {
|
||||
try_files /html/secrets.html =404;
|
||||
}
|
||||
|
||||
location = /logs {
|
||||
try_files /html/logs.html =404;
|
||||
}
|
||||
@@ -34,6 +38,14 @@ 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;
|
||||
}
|
||||
@@ -67,6 +79,10 @@ server {
|
||||
return 302 /api-keys;
|
||||
}
|
||||
|
||||
location = /html/secrets.html {
|
||||
return 302 /secrets;
|
||||
}
|
||||
|
||||
location = /html/logs.html {
|
||||
return 302 /logs;
|
||||
}
|
||||
@@ -75,6 +91,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const { defineConfig, devices } = require('@playwright/test');
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3300';
|
||||
const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === '1';
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
@@ -13,18 +16,20 @@ module.exports = defineConfig({
|
||||
? [['github'], ['html', { open: 'never' }]]
|
||||
: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3300',
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'bash scripts/playwright-stack.sh',
|
||||
cwd: __dirname,
|
||||
url: (process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:3300') + '/login',
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
webServer: skipWebServer
|
||||
? undefined
|
||||
: {
|
||||
command: 'bash scripts/playwright-stack.sh',
|
||||
cwd: __dirname,
|
||||
url: `${baseURL}/login`,
|
||||
timeout: 600_000,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
|
||||
@@ -9,6 +9,8 @@ POSTGRES_PORT="${CRANK_E2E_POSTGRES_PORT:-55433}"
|
||||
ADMIN_PORT="${CRANK_E2E_ADMIN_PORT:-3301}"
|
||||
MCP_PORT="${CRANK_E2E_MCP_PORT:-3302}"
|
||||
UI_PORT="${CRANK_E2E_UI_PORT:-3300}"
|
||||
STREAM_FIXTURE_PORT="${CRANK_E2E_STREAM_FIXTURE_PORT:-3310}"
|
||||
GRPC_FIXTURE_BIND="${CRANK_E2E_GRPC_FIXTURE_BIND:-127.0.0.1:3311}"
|
||||
POSTGRES_DB="${CRANK_E2E_POSTGRES_DB:-crank}"
|
||||
POSTGRES_USER="${CRANK_E2E_POSTGRES_USER:-crank}"
|
||||
POSTGRES_PASSWORD="${CRANK_E2E_POSTGRES_PASSWORD:-crank}"
|
||||
@@ -38,16 +40,45 @@ 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
|
||||
|
||||
cleanup
|
||||
|
||||
wait_for_port() {
|
||||
local host="$1"
|
||||
local port="$2"
|
||||
until python - "$host" "$port" <<'PY'
|
||||
import socket, sys
|
||||
sock = socket.socket()
|
||||
sock.settimeout(0.5)
|
||||
try:
|
||||
sock.connect((sys.argv[1], int(sys.argv[2])))
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
sock.close()
|
||||
PY
|
||||
do
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
docker run -d --rm \
|
||||
--name "$POSTGRES_CONTAINER" \
|
||||
-e POSTGRES_DB="$POSTGRES_DB" \
|
||||
@@ -69,6 +100,7 @@ export CRANK_LOG_LEVEL="info"
|
||||
export CRANK_SECRET_PROVIDER="env"
|
||||
export CRANK_SESSION_SECRET="e2e-session-secret"
|
||||
export CRANK_PASSWORD_PEPPER="e2e-password-pepper"
|
||||
export CRANK_MASTER_KEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
export CRANK_SESSION_TTL_HOURS="24"
|
||||
export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL"
|
||||
export CRANK_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASSWORD"
|
||||
@@ -76,6 +108,8 @@ export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E"
|
||||
export CRANK_DEMO_SEED="true"
|
||||
export CRANK_PUBLIC_BASE_URL="http://127.0.0.1:$UI_PORT"
|
||||
export CRANK_MCP_PUBLIC_URL="http://127.0.0.1:$MCP_PORT"
|
||||
export CRANK_E2E_STREAM_FIXTURE_PORT="$STREAM_FIXTURE_PORT"
|
||||
export CRANK_E2E_GRPC_FIXTURE_BIND="$GRPC_FIXTURE_BIND"
|
||||
|
||||
mkdir -p "$CRANK_STORAGE_ROOT"
|
||||
|
||||
@@ -99,6 +133,24 @@ 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
|
||||
|
||||
@@ -44,12 +44,21 @@ function mapRoute(urlPath) {
|
||||
if (urlPath === '/api-keys') {
|
||||
return path.join(ROOT_DIR, 'html', 'api-keys.html');
|
||||
}
|
||||
if (urlPath === '/secrets') {
|
||||
return path.join(ROOT_DIR, 'html', 'secrets.html');
|
||||
}
|
||||
if (urlPath === '/logs') {
|
||||
return path.join(ROOT_DIR, 'html', 'logs.html');
|
||||
}
|
||||
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');
|
||||
}
|
||||
@@ -150,6 +159,10 @@ const server = http.createServer((request, response) => {
|
||||
redirect(response, '/api-keys');
|
||||
return;
|
||||
}
|
||||
if (urlPath === '/html/secrets.html') {
|
||||
redirect(response, '/secrets');
|
||||
return;
|
||||
}
|
||||
if (urlPath === '/html/logs.html') {
|
||||
redirect(response, '/logs');
|
||||
return;
|
||||
@@ -158,6 +171,14 @@ 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;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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}`);
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ADMIN_EMAIL = process.env.CRANK_E2E_ADMIN_EMAIL || 'owner@crank.local';
|
||||
const ADMIN_PASSWORD = process.env.CRANK_E2E_ADMIN_PASSWORD || 'change-me-admin-password';
|
||||
const MCP_PORT = Number(process.env.CRANK_E2E_MCP_PORT || 3302);
|
||||
const STREAM_FIXTURE_PORT = Number(process.env.CRANK_E2E_STREAM_FIXTURE_PORT || 3310);
|
||||
const GRPC_FIXTURE_BIND = process.env.CRANK_E2E_GRPC_FIXTURE_BIND || '127.0.0.1:3311';
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
let cachedEchoDescriptorSetB64 = null;
|
||||
|
||||
function localized(en, ru) {
|
||||
return new RegExp(`(?:${en}|${ru})`, 'i');
|
||||
@@ -17,9 +26,550 @@ async function login(page) {
|
||||
await expect(page.locator('.page-title, .page-heading').first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function browserJson(page, method, urlPath, body, okStatuses = [200]) {
|
||||
const response = await page.evaluate(async ({ method, urlPath, body }) => {
|
||||
const request = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {},
|
||||
};
|
||||
if (body !== undefined) {
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const result = await fetch(urlPath, request);
|
||||
const text = await result.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch (_error) {
|
||||
json = null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
text,
|
||||
json,
|
||||
};
|
||||
}, { method, urlPath, body });
|
||||
|
||||
if (!okStatuses.includes(response.status)) {
|
||||
throw new Error(`HTTP ${response.status} ${urlPath}: ${response.text}`);
|
||||
}
|
||||
|
||||
return response.json;
|
||||
}
|
||||
|
||||
async function getSession(page) {
|
||||
return browserJson(page, 'GET', '/api/auth/session');
|
||||
}
|
||||
|
||||
async function getCurrentWorkspace(page) {
|
||||
const session = await getSession(page);
|
||||
const workspaceId = session.current_workspace_id
|
||||
|| (session.memberships && session.memberships[0] && session.memberships[0].workspace.id);
|
||||
const membership = (session.memberships || []).find((item) => item.workspace.id === workspaceId)
|
||||
|| (session.memberships || [])[0];
|
||||
return membership ? membership.workspace : null;
|
||||
}
|
||||
|
||||
async function createOperation(page, workspaceId, payload) {
|
||||
return browserJson(page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/operations`, payload);
|
||||
}
|
||||
|
||||
async function publishOperation(page, workspaceId, operationId, version = 1) {
|
||||
return browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/operations/${encodeURIComponent(operationId)}/publish`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
async function createPlatformApiKey(page, workspaceId, name, scopes) {
|
||||
return browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/platform-api-keys`,
|
||||
{ name, scopes },
|
||||
);
|
||||
}
|
||||
|
||||
async function createAgent(page, workspaceId, payload) {
|
||||
return browserJson(page, 'POST', `/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents`, payload);
|
||||
}
|
||||
|
||||
async function saveAgentBindings(page, workspaceId, agentId, bindings) {
|
||||
return browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/bindings`,
|
||||
bindings,
|
||||
);
|
||||
}
|
||||
|
||||
async function publishAgent(page, workspaceId, agentId, version = 1) {
|
||||
return browserJson(
|
||||
page,
|
||||
'POST',
|
||||
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/publish`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
function schemaObject(fields) {
|
||||
return {
|
||||
type: 'object',
|
||||
required: true,
|
||||
fields: fields || {},
|
||||
};
|
||||
}
|
||||
|
||||
function schemaString(required = true) {
|
||||
return {
|
||||
type: 'string',
|
||||
required,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueName(prefix) {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function findEchoDescriptorSetB64() {
|
||||
if (cachedEchoDescriptorSetB64) {
|
||||
return cachedEchoDescriptorSetB64;
|
||||
}
|
||||
|
||||
const escapedRoot = REPO_ROOT.replace(/'/g, "'\\''");
|
||||
const descriptorPath = execFileSync(
|
||||
'bash',
|
||||
['-lc', `find '${escapedRoot}/target/debug/build' -path '*/out/echo_descriptor.bin' -print -quit`],
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
|
||||
if (!descriptorPath) {
|
||||
throw new Error('echo_descriptor.bin was not found under target/debug/build');
|
||||
}
|
||||
|
||||
cachedEchoDescriptorSetB64 = fs.readFileSync(descriptorPath).toString('base64');
|
||||
return cachedEchoDescriptorSetB64;
|
||||
}
|
||||
|
||||
function buildRestWindowOperationPayload(name) {
|
||||
return {
|
||||
name,
|
||||
display_name: 'Playwright Window Logs',
|
||||
category: 'streaming',
|
||||
protocol: 'rest',
|
||||
target: {
|
||||
kind: 'rest',
|
||||
base_url: `http://127.0.0.1:${STREAM_FIXTURE_PORT}`,
|
||||
method: 'GET',
|
||||
path_template: '/sse/logs',
|
||||
static_headers: {},
|
||||
},
|
||||
input_schema: schemaObject({
|
||||
window: schemaString(false),
|
||||
}),
|
||||
output_schema: schemaObject({}),
|
||||
input_mapping: {
|
||||
rules: [
|
||||
{
|
||||
source: '$.mcp.window',
|
||||
target: '$.request.query.window',
|
||||
required: false,
|
||||
default_value: 'recent',
|
||||
},
|
||||
],
|
||||
},
|
||||
output_mapping: { rules: [] },
|
||||
execution_config: {
|
||||
timeout_ms: 1000,
|
||||
headers: {},
|
||||
streaming: {
|
||||
mode: 'window',
|
||||
transport_behavior: 'server_stream',
|
||||
window_duration_ms: 1000,
|
||||
upstream_timeout_ms: 1000,
|
||||
max_items: 3,
|
||||
max_bytes: 16384,
|
||||
aggregation_mode: 'summary_plus_samples',
|
||||
items_path: '$.items',
|
||||
done_path: '$.done',
|
||||
redacted_paths: [],
|
||||
truncate_item_fields: false,
|
||||
drop_duplicates: false,
|
||||
tool_family: {},
|
||||
},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Playwright Window Logs',
|
||||
description: 'Collects a bounded SSE log window from the local fixture.',
|
||||
tags: ['playwright', 'streaming', 'window'],
|
||||
examples: [{ input: {} }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SOAP_TEST_WSDL = `<?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="http://127.0.0.1:${STREAM_FIXTURE_PORT}/soap/leads"/>
|
||||
</port>
|
||||
</service>
|
||||
</definitions>`;
|
||||
|
||||
function buildSoapOperationPayload(name) {
|
||||
return {
|
||||
name,
|
||||
display_name: 'Playwright SOAP Lead',
|
||||
category: 'sales',
|
||||
protocol: 'soap',
|
||||
target: {
|
||||
kind: 'soap',
|
||||
wsdl_ref: 'sample_wsdl_pending',
|
||||
service_name: 'Service',
|
||||
port_name: 'Port',
|
||||
operation_name: 'Operation',
|
||||
endpoint_override: `http://127.0.0.1:${STREAM_FIXTURE_PORT}/soap/leads`,
|
||||
soap_version: 'soap_11',
|
||||
soap_action: 'urn:createLead',
|
||||
binding_style: 'document_literal',
|
||||
headers: [],
|
||||
metadata: {
|
||||
input_part_names: ['CreateLeadRequest'],
|
||||
output_part_names: ['CreateLeadResponse'],
|
||||
namespaces: ['urn:crm'],
|
||||
},
|
||||
},
|
||||
input_schema: schemaObject({
|
||||
email: schemaString(true),
|
||||
}),
|
||||
output_schema: schemaObject({
|
||||
id: schemaString(true),
|
||||
}),
|
||||
input_mapping: {
|
||||
rules: [
|
||||
{
|
||||
source: '$.mcp.email',
|
||||
target: '$.request.body.email',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
output_mapping: {
|
||||
rules: [
|
||||
{
|
||||
source: '$.response.body.id',
|
||||
target: '$.output.id',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
execution_config: {
|
||||
timeout_ms: 1000,
|
||||
headers: {},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Playwright SOAP Lead',
|
||||
description: 'Creates a lead through the local SOAP fixture.',
|
||||
tags: ['playwright', 'soap'],
|
||||
examples: [{ input: { email: 'user@example.com' } }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGrpcSessionOperationPayload(name) {
|
||||
return {
|
||||
name,
|
||||
display_name: 'Playwright gRPC Session',
|
||||
category: 'streaming',
|
||||
protocol: 'grpc',
|
||||
target: {
|
||||
kind: 'grpc',
|
||||
server_addr: `http://${GRPC_FIXTURE_BIND}`,
|
||||
package: 'echo',
|
||||
service: 'EchoService',
|
||||
method: 'ServerEcho',
|
||||
descriptor_ref: 'desc_echo_playwright',
|
||||
descriptor_set_b64: findEchoDescriptorSetB64(),
|
||||
},
|
||||
input_schema: schemaObject({
|
||||
message: schemaString(true),
|
||||
}),
|
||||
output_schema: schemaObject({}),
|
||||
input_mapping: {
|
||||
rules: [
|
||||
{
|
||||
source: '$.mcp.message',
|
||||
target: '$.request.grpc.message',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
output_mapping: { rules: [] },
|
||||
execution_config: {
|
||||
timeout_ms: 1000,
|
||||
headers: {},
|
||||
streaming: {
|
||||
mode: 'session',
|
||||
transport_behavior: 'server_stream',
|
||||
window_duration_ms: 1000,
|
||||
poll_interval_ms: 250,
|
||||
upstream_timeout_ms: 1000,
|
||||
idle_timeout_ms: 5000,
|
||||
max_session_lifetime_ms: 60000,
|
||||
max_items: 1,
|
||||
max_bytes: 16384,
|
||||
aggregation_mode: 'summary_plus_samples',
|
||||
items_path: '$.items',
|
||||
done_path: '$.done',
|
||||
redacted_paths: [],
|
||||
truncate_item_fields: false,
|
||||
drop_duplicates: false,
|
||||
tool_family: {
|
||||
start_tool_name: `${name}_start`,
|
||||
poll_tool_name: `${name}_poll`,
|
||||
stop_tool_name: `${name}_stop`,
|
||||
},
|
||||
},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Playwright gRPC Session',
|
||||
description: 'Streams gRPC echo messages through a bounded MCP session tool family.',
|
||||
tags: ['playwright', 'streaming', 'session', 'grpc'],
|
||||
examples: [{ input: { message: 'hello' } }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRestAsyncJobOperationPayload(name) {
|
||||
return {
|
||||
name,
|
||||
display_name: 'Playwright Async Lead',
|
||||
category: 'streaming',
|
||||
protocol: 'rest',
|
||||
target: {
|
||||
kind: 'rest',
|
||||
base_url: `http://127.0.0.1:${STREAM_FIXTURE_PORT}`,
|
||||
method: 'POST',
|
||||
path_template: '/crm/leads',
|
||||
static_headers: {},
|
||||
},
|
||||
input_schema: schemaObject({
|
||||
email: schemaString(true),
|
||||
}),
|
||||
output_schema: schemaObject({
|
||||
id: schemaString(true),
|
||||
}),
|
||||
input_mapping: {
|
||||
rules: [
|
||||
{
|
||||
source: '$.mcp.email',
|
||||
target: '$.request.body.email',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
output_mapping: {
|
||||
rules: [
|
||||
{
|
||||
source: '$.response.body.id',
|
||||
target: '$.output.id',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
execution_config: {
|
||||
timeout_ms: 2000,
|
||||
headers: {},
|
||||
streaming: {
|
||||
mode: 'async_job',
|
||||
transport_behavior: 'deferred_result',
|
||||
poll_interval_ms: 250,
|
||||
upstream_timeout_ms: 2000,
|
||||
max_session_lifetime_ms: 300000,
|
||||
max_bytes: 16384,
|
||||
aggregation_mode: 'summary_only',
|
||||
redacted_paths: [],
|
||||
truncate_item_fields: false,
|
||||
drop_duplicates: false,
|
||||
tool_family: {
|
||||
start_tool_name: `${name}_start`,
|
||||
status_tool_name: `${name}_status`,
|
||||
result_tool_name: `${name}_result`,
|
||||
cancel_tool_name: `${name}_cancel`,
|
||||
},
|
||||
},
|
||||
},
|
||||
tool_description: {
|
||||
title: 'Playwright Async Lead',
|
||||
description: 'Creates a lead through the async job MCP tool family.',
|
||||
tags: ['playwright', 'streaming', 'async_job'],
|
||||
examples: [{ input: { email: 'user@example.com' } }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setupPublishedAgent(page, { operationPayload, agentSlug, toolName, toolTitle, toolDescription }) {
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
if (!workspace) {
|
||||
throw new Error('current workspace was not resolved');
|
||||
}
|
||||
|
||||
const createdOperation = await createOperation(page, workspace.id, operationPayload);
|
||||
await publishOperation(page, workspace.id, createdOperation.operation_id, createdOperation.version);
|
||||
|
||||
const createdAgent = await createAgent(page, workspace.id, {
|
||||
slug: agentSlug,
|
||||
display_name: toolTitle,
|
||||
description: toolDescription,
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
});
|
||||
|
||||
await saveAgentBindings(page, workspace.id, createdAgent.agent_id, [
|
||||
{
|
||||
operation_id: createdOperation.operation_id,
|
||||
operation_version: createdOperation.version,
|
||||
tool_name: toolName,
|
||||
tool_title: toolTitle,
|
||||
tool_description_override: toolDescription,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
await publishAgent(page, workspace.id, createdAgent.agent_id, createdAgent.version);
|
||||
|
||||
const key = await createPlatformApiKey(page, workspace.id, uniqueName('playwright_key'), ['read', 'write']);
|
||||
|
||||
return {
|
||||
workspace,
|
||||
operationId: createdOperation.operation_id,
|
||||
agentId: createdAgent.agent_id,
|
||||
apiKeySecret: key.secret,
|
||||
};
|
||||
}
|
||||
|
||||
function mcpUrl(workspaceSlug, agentSlug) {
|
||||
return `http://127.0.0.1:${MCP_PORT}/v1/${workspaceSlug}/${agentSlug}`;
|
||||
}
|
||||
|
||||
async function initializeMcpSession({ workspaceSlug, agentSlug, apiKey }) {
|
||||
const response = await fetch(mcpUrl(workspaceSlug, agentSlug), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/event-stream',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`initialize failed: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const sessionId = response.headers.get('MCP-Session-Id');
|
||||
if (!sessionId) {
|
||||
throw new Error('initialize response did not include MCP-Session-Id');
|
||||
}
|
||||
|
||||
const initialized = await fetch(mcpUrl(workspaceSlug, agentSlug), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/event-stream',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'MCP-Session-Id': sessionId,
|
||||
'MCP-Protocol-Version': '2025-11-25',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (initialized.status !== 202) {
|
||||
throw new Error(`initialized notification failed: ${initialized.status} ${await initialized.text()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
workspaceSlug,
|
||||
agentSlug,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
async function mcpToolCall(session, toolName, argumentsValue) {
|
||||
const response = await fetch(mcpUrl(session.workspaceSlug, session.agentSlug), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json, text/event-stream',
|
||||
Authorization: `Bearer ${session.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'MCP-Session-Id': session.sessionId,
|
||||
'MCP-Protocol-Version': '2025-11-25',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: Date.now(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: argumentsValue || {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.error) {
|
||||
throw new Error(`tools/call failed for ${toolName}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload.result.structuredContent;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ADMIN_EMAIL,
|
||||
ADMIN_PASSWORD,
|
||||
SOAP_TEST_WSDL,
|
||||
browserJson,
|
||||
buildGrpcSessionOperationPayload,
|
||||
buildRestAsyncJobOperationPayload,
|
||||
buildRestWindowOperationPayload,
|
||||
buildSoapOperationPayload,
|
||||
createAgent,
|
||||
createOperation,
|
||||
createPlatformApiKey,
|
||||
getCurrentWorkspace,
|
||||
getSession,
|
||||
initializeMcpSession,
|
||||
login,
|
||||
localized,
|
||||
mcpToolCall,
|
||||
publishAgent,
|
||||
publishOperation,
|
||||
saveAgentBindings,
|
||||
setupPublishedAgent,
|
||||
uniqueName,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { getSession, localized, login } = require('./helpers');
|
||||
|
||||
const CORE_PAGES = [
|
||||
{ path: '/', heading: localized('Operations', 'Операции') },
|
||||
{ path: '/agents', heading: localized('Agents', 'Агенты') },
|
||||
{ path: '/api-keys', heading: localized('API Keys', 'API ключи') },
|
||||
{ path: '/secrets', heading: localized('Secrets', 'Секреты') },
|
||||
{ path: '/logs', heading: localized('Logs', 'Логи') },
|
||||
{ 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', 'Создать') },
|
||||
];
|
||||
|
||||
test('authenticated staging smoke logs in and exposes session json', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
const session = await getSession(page);
|
||||
expect(session.user).toBeTruthy();
|
||||
expect(Array.isArray(session.memberships)).toBe(true);
|
||||
expect(session.memberships.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('authenticated staging smoke opens core pages without console errors', async ({ page }) => {
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', (error) => {
|
||||
pageErrors.push(error.message);
|
||||
});
|
||||
|
||||
await login(page);
|
||||
|
||||
for (const pageSpec of CORE_PAGES) {
|
||||
await page.goto(pageSpec.path);
|
||||
await expect(page.locator('#login-form')).toHaveCount(0);
|
||||
await expect(page.locator('body')).toContainText(pageSpec.heading);
|
||||
}
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const {
|
||||
buildGrpcSessionOperationPayload,
|
||||
buildRestAsyncJobOperationPayload,
|
||||
buildRestWindowOperationPayload,
|
||||
initializeMcpSession,
|
||||
login,
|
||||
mcpToolCall,
|
||||
setupPublishedAgent,
|
||||
uniqueName,
|
||||
} = require('./helpers');
|
||||
|
||||
test('rest window tool executes through MCP', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
const operationName = uniqueName('playwright_window_logs');
|
||||
const agentSlug = uniqueName('sales_window');
|
||||
const payload = buildRestWindowOperationPayload(operationName);
|
||||
|
||||
const bundle = await setupPublishedAgent(page, {
|
||||
operationPayload: payload,
|
||||
agentSlug,
|
||||
toolName: operationName,
|
||||
toolTitle: 'Playwright Window Logs',
|
||||
toolDescription: 'Collects a bounded SSE log window from the local fixture.',
|
||||
});
|
||||
|
||||
const mcp = await initializeMcpSession({
|
||||
workspaceSlug: bundle.workspace.slug,
|
||||
agentSlug,
|
||||
apiKey: bundle.apiKeySecret,
|
||||
});
|
||||
const result = await mcpToolCall(mcp, operationName, {});
|
||||
|
||||
expect(result.window_complete).toBe(true);
|
||||
expect(result.has_more).toBe(false);
|
||||
expect(Array.isArray(result.items)).toBe(true);
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items[0].message).toBe('billing started');
|
||||
expect(result.items[2].message).toBe('invoice timeout');
|
||||
});
|
||||
|
||||
test('grpc session tools create persisted stream sessions visible in UI', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
const operationName = uniqueName('playwright_echo_session');
|
||||
const agentSlug = uniqueName('sales_session');
|
||||
const payload = buildGrpcSessionOperationPayload(operationName);
|
||||
|
||||
const bundle = await setupPublishedAgent(page, {
|
||||
operationPayload: payload,
|
||||
agentSlug,
|
||||
toolName: operationName,
|
||||
toolTitle: 'Playwright gRPC Session',
|
||||
toolDescription: 'Streams gRPC echo messages through a bounded MCP session tool family.',
|
||||
});
|
||||
|
||||
const mcp = await initializeMcpSession({
|
||||
workspaceSlug: bundle.workspace.slug,
|
||||
agentSlug,
|
||||
apiKey: bundle.apiKeySecret,
|
||||
});
|
||||
|
||||
const started = await mcpToolCall(mcp, `${operationName}_start`, { message: 'hello' });
|
||||
const sessionId = started.session_id;
|
||||
expect(started.status).toBe('running');
|
||||
|
||||
const polled = await mcpToolCall(mcp, `${operationName}_poll`, { session_id: sessionId });
|
||||
expect(polled.session_id).toBe(sessionId);
|
||||
expect(Array.isArray(polled.items)).toBe(true);
|
||||
expect(polled.items[0].message).toContain('hello');
|
||||
|
||||
await page.goto('/stream-sessions');
|
||||
const sessionCard = page.locator(`.resource-card[data-session-id="${sessionId}"]`);
|
||||
await expect(sessionCard).toBeVisible();
|
||||
await sessionCard.locator('[data-action="toggle"]').click();
|
||||
await expect(sessionCard.locator('.resource-detail-pre')).toBeVisible();
|
||||
await sessionCard.locator('[data-action="stop"]').click();
|
||||
await expect(sessionCard.locator('[data-action="stop"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('async job tools complete and expose results in UI', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
const operationName = uniqueName('playwright_async_lead');
|
||||
const agentSlug = uniqueName('sales_async');
|
||||
const payload = buildRestAsyncJobOperationPayload(operationName);
|
||||
|
||||
const bundle = await setupPublishedAgent(page, {
|
||||
operationPayload: payload,
|
||||
agentSlug,
|
||||
toolName: operationName,
|
||||
toolTitle: 'Playwright Async Lead',
|
||||
toolDescription: 'Creates a lead through the async job MCP tool family.',
|
||||
});
|
||||
|
||||
const mcp = await initializeMcpSession({
|
||||
workspaceSlug: bundle.workspace.slug,
|
||||
agentSlug,
|
||||
apiKey: bundle.apiKeySecret,
|
||||
});
|
||||
|
||||
const started = await mcpToolCall(mcp, `${operationName}_start`, { email: 'user@example.com' });
|
||||
const jobId = started.job_id;
|
||||
expect(started.status).toBe('running');
|
||||
|
||||
let status = null;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
status = await mcpToolCall(mcp, `${operationName}_status`, { job_id: jobId });
|
||||
if (status.status === 'completed') {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
expect(status.status).toBe('completed');
|
||||
const result = await mcpToolCall(mcp, `${operationName}_result`, { job_id: jobId });
|
||||
expect(result.id).toBe('lead_123');
|
||||
|
||||
await page.goto('/async-jobs');
|
||||
const jobCard = page.locator(`.resource-card[data-job-id="${jobId}"]`);
|
||||
await expect(jobCard).toBeVisible();
|
||||
await jobCard.locator('[data-action="toggle"]').click();
|
||||
await expect(page.locator(`[data-result-for="${jobId}"]`)).toContainText('lead_123');
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { login, localized } = require('./helpers');
|
||||
const {
|
||||
SOAP_TEST_WSDL,
|
||||
buildSoapOperationPayload,
|
||||
createOperation,
|
||||
getCurrentWorkspace,
|
||||
login,
|
||||
localized,
|
||||
uniqueName,
|
||||
} = require('./helpers');
|
||||
|
||||
test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||
await login(page);
|
||||
@@ -11,3 +19,77 @@ test('wizard loads and protocol selection updates flow', async ({ page }) => {
|
||||
await expect(page.locator('[data-step-counter]')).toContainText(/2/);
|
||||
await expect(page.locator('#step-panel-2 .step-panel-title')).toContainText(localized('Select upstream', 'Выберите upstream'));
|
||||
});
|
||||
|
||||
test('soap wizard uploads wsdl, applies discovered binding and runs test', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
const operation = await createOperation(
|
||||
page,
|
||||
workspace.id,
|
||||
buildSoapOperationPayload(uniqueName('playwright_soap_wizard')),
|
||||
);
|
||||
|
||||
await page.goto(`/wizard/?mode=edit&operationId=${encodeURIComponent(operation.operation_id)}`);
|
||||
await page.locator('#btn-continue').click();
|
||||
await page.locator('#btn-continue').click();
|
||||
|
||||
await expect(page.locator('#step-panel-3-soap .step-panel-title')).toContainText(/WSDL/i);
|
||||
await page.evaluate((wsdl) => {
|
||||
const fileName = document.getElementById('wizard-soap-wsdl-name');
|
||||
if (fileName) fileName.textContent = 'lead.wsdl';
|
||||
}, SOAP_TEST_WSDL);
|
||||
await page.evaluate(async ({ workspaceId, operationId, wsdl }) => {
|
||||
const uploaded = await window.CrankApi.uploadWsdlFile(
|
||||
workspaceId,
|
||||
operationId,
|
||||
new TextEncoder().encode(wsdl),
|
||||
'lead.wsdl',
|
||||
);
|
||||
const services = await window.CrankApi.listSoapServices(
|
||||
workspaceId,
|
||||
operationId,
|
||||
null,
|
||||
);
|
||||
window.renderSoapServiceCatalog(services.services || []);
|
||||
return uploaded;
|
||||
}, {
|
||||
workspaceId: workspace.id,
|
||||
operationId: operation.operation_id,
|
||||
wsdl: SOAP_TEST_WSDL,
|
||||
});
|
||||
|
||||
await expect(page.locator('#wizard-soap-services-list button')).toContainText('CreateLead');
|
||||
await page.locator('#wizard-soap-services-list button').getByText('CreateLead').click();
|
||||
await expect(page.locator('#soap-service-name')).toHaveValue('LeadService');
|
||||
await expect(page.locator('#soap-port-name')).toHaveValue('LeadPort');
|
||||
await expect(page.locator('#soap-operation-name')).toHaveValue('CreateLead');
|
||||
|
||||
await page.locator('#btn-continue').click();
|
||||
await expect(page.locator('#step-panel-4 .step-panel-title')).toBeVisible();
|
||||
await page.locator('#tool-input-schema').fill(JSON.stringify({
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string' },
|
||||
},
|
||||
}, null, 2));
|
||||
await page.locator('#tool-output-schema').fill(JSON.stringify({
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
}, null, 2));
|
||||
await page.locator('#btn-continue').click();
|
||||
await expect(page.locator('[data-i18n="wizard.step5.test_title"]')).toContainText(localized('Test run', 'Тестовый запуск'));
|
||||
await page.locator('#tool-input-mapping').fill('email: "$.input.email"');
|
||||
await page.locator('#tool-output-mapping').fill('id: "$.response.body.id"');
|
||||
await page.locator('#tool-exec-config').fill('timeout_ms: 1000');
|
||||
|
||||
await page.locator('#wizard-test-input').fill(JSON.stringify({ email: 'user@example.com' }, null, 2));
|
||||
await page.locator('#wizard-run-test').click();
|
||||
|
||||
await expect(page.locator('#wizard-test-request-preview')).toHaveValue(/user@example.com/);
|
||||
await expect(page.locator('#wizard-test-response-preview')).toHaveValue(/lead_soap_123/);
|
||||
await expect(page.locator('#wizard-test-errors')).toHaveValue('[]');
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ test-support = []
|
||||
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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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(())
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package echo;
|
||||
|
||||
service EchoService {
|
||||
rpc UnaryEcho(EchoRequest) returns (EchoResponse);
|
||||
rpc ServerEcho(EchoRequest) returns (stream EchoResponse);
|
||||
}
|
||||
|
||||
message EchoRequest {
|
||||
|
||||
@@ -2,8 +2,11 @@ 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,
|
||||
@@ -11,7 +14,10 @@ use tonic::{
|
||||
transport::Endpoint,
|
||||
};
|
||||
|
||||
use crate::{GrpcAdapterError, GrpcRequest, GrpcResponse, codec::JsonCodec};
|
||||
use crate::{
|
||||
GrpcAdapterError, GrpcRequest, GrpcResponse, GrpcWindowRequest, GrpcWindowResponse,
|
||||
codec::JsonCodec,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct GrpcAdapter;
|
||||
@@ -34,26 +40,8 @@ impl GrpcAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
let endpoint = Endpoint::from_shared(target.server_addr.clone())?
|
||||
.timeout(Duration::from_millis(request.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(),
|
||||
})?;
|
||||
|
||||
let service_name = method.parent_service().full_name().to_owned();
|
||||
let method_name = method.name().to_owned();
|
||||
let path = tonic::codegen::http::uri::PathAndQuery::from_str(&format!(
|
||||
"/{service_name}/{method_name}"
|
||||
))
|
||||
.map_err(|_| GrpcAdapterError::InvalidMethodPath {
|
||||
service: service_name,
|
||||
method: method_name,
|
||||
})?;
|
||||
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());
|
||||
|
||||
@@ -69,6 +57,95 @@ impl GrpcAdapter {
|
||||
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> {
|
||||
@@ -141,7 +218,7 @@ mod tests {
|
||||
use crank_core::{DescriptorId, GrpcTarget};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{GrpcAdapter, GrpcRequest, test_support};
|
||||
use crate::{GrpcAdapter, GrpcAdapterError, GrpcRequest, GrpcWindowRequest, test_support};
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_unary_grpc_request() {
|
||||
@@ -165,4 +242,70 @@ mod tests {
|
||||
|
||||
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(),
|
||||
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(),
|
||||
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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ pub enum GrpcAdapterError {
|
||||
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 unary")]
|
||||
#[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,
|
||||
@@ -34,6 +36,8 @@ pub enum GrpcAdapterError {
|
||||
},
|
||||
#[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 },
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ mod model;
|
||||
|
||||
pub use client::GrpcAdapter;
|
||||
pub use error::GrpcAdapterError;
|
||||
pub use model::{GrpcRequest, GrpcResponse};
|
||||
pub use model::{GrpcRequest, GrpcResponse, GrpcWindowRequest, GrpcWindowResponse};
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
||||
@@ -18,3 +18,20 @@ pub struct GrpcResponse {
|
||||
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,4 +1,5 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use futures_util::stream;
|
||||
use tokio::net::TcpListener;
|
||||
use tonic::{Request, Response, Status, transport::Server};
|
||||
|
||||
@@ -20,6 +21,30 @@ impl echo::echo_service_server::EchoService for EchoServiceImpl {
|
||||
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 {
|
||||
|
||||
@@ -7,10 +7,12 @@ version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crank-core = { path = "../crank-core" }
|
||||
reqwest.workspace = true
|
||||
futures-util = "0.3"
|
||||
reqwest = { workspace = true, features = ["stream"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
axum.workspace = true
|
||||
|
||||
@@ -7,7 +7,7 @@ use reqwest::{
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{RestAdapterError, RestRequest, RestResponse};
|
||||
use crate::{RestAdapterError, RestRequest, RestResponse, RestWindowRequest, RestWindowResponse};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RestAdapter {
|
||||
@@ -62,6 +62,61 @@ impl RestAdapter {
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn execute_window(
|
||||
&self,
|
||||
target: &RestTarget,
|
||||
request: &RestWindowRequest,
|
||||
) -> Result<RestWindowResponse, RestAdapterError> {
|
||||
let url = build_url(target, &request.request)?;
|
||||
let mut headers = build_headers(target, &request.request)?;
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
|
||||
let mut builder = self
|
||||
.client
|
||||
.request(to_reqwest_method(target.method), url)
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_millis(request.request.timeout_ms));
|
||||
|
||||
if let Some(body) = &request.request.body {
|
||||
builder = builder.json(body);
|
||||
}
|
||||
|
||||
let response = builder.send().await?;
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let headers = normalize_headers(response.headers());
|
||||
let body = decode_body(response).await?;
|
||||
return Err(RestAdapterError::UnexpectedStatus {
|
||||
status: status.as_u16(),
|
||||
body: Value::Object(
|
||||
[
|
||||
(
|
||||
"headers".to_owned(),
|
||||
serde_json::to_value(headers).unwrap_or(Value::Null),
|
||||
),
|
||||
("body".to_owned(), body),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let (status_code, headers, body) =
|
||||
crate::sse::collect_sse_window(response, request.window_duration_ms, request.max_items)
|
||||
.await?;
|
||||
|
||||
Ok(RestWindowResponse {
|
||||
status_code,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
|
||||
@@ -172,13 +227,15 @@ mod tests {
|
||||
Json, Router,
|
||||
extract::{Path, Query},
|
||||
http::HeaderMap,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
routing::{get, post},
|
||||
};
|
||||
use crank_core::{HttpMethod, RestTarget};
|
||||
use futures_util::stream;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{RestAdapter, RestAdapterError, RestRequest};
|
||||
use crate::{RestAdapter, RestAdapterError, RestRequest, RestWindowRequest};
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_rest_request_and_normalizes_json_response() {
|
||||
@@ -242,10 +299,104 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collects_sse_events_with_window_bounds() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::new();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/events".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestWindowRequest {
|
||||
request: RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
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.status_code, 200);
|
||||
assert_eq!(
|
||||
response.body,
|
||||
json!({
|
||||
"items": [
|
||||
{ "message": "one" },
|
||||
{ "message": "two" }
|
||||
],
|
||||
"done": false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_timeout_window_when_no_events_arrive_before_deadline() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::new();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/events-idle".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestWindowRequest {
|
||||
request: RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
timeout_ms: 1_000,
|
||||
},
|
||||
window_duration_ms: 50,
|
||||
max_items: Some(10),
|
||||
};
|
||||
|
||||
let response = adapter.execute_window(&target, &request).await.unwrap();
|
||||
|
||||
assert_eq!(response.body, json!({ "items": [], "done": true }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_malformed_sse_payloads() {
|
||||
let base_url = spawn_test_server().await;
|
||||
let adapter = RestAdapter::new();
|
||||
let target = RestTarget {
|
||||
base_url,
|
||||
method: HttpMethod::Get,
|
||||
path_template: "/events-broken".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
};
|
||||
let request = RestWindowRequest {
|
||||
request: RestRequest {
|
||||
path_params: BTreeMap::new(),
|
||||
query_params: BTreeMap::new(),
|
||||
headers: BTreeMap::new(),
|
||||
body: None,
|
||||
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, RestAdapterError::InvalidSseEvent));
|
||||
}
|
||||
|
||||
async fn spawn_test_server() -> String {
|
||||
let app = Router::new()
|
||||
.route("/users/{user_id}", post(create_user))
|
||||
.route("/fail", get(fail));
|
||||
.route("/fail", get(fail))
|
||||
.route("/events", get(sse_events))
|
||||
.route("/events-idle", get(sse_idle))
|
||||
.route("/events-broken", get(sse_broken));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
@@ -286,4 +437,27 @@ mod tests {
|
||||
Json(json!({ "error": "upstream failed" })),
|
||||
)
|
||||
}
|
||||
|
||||
async fn sse_events()
|
||||
-> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
let events = vec![
|
||||
Ok(Event::default().data("{\"message\":\"one\"}")),
|
||||
Ok(Event::default().data("{\"message\":\"two\"}")),
|
||||
Ok(Event::default().data("{\"message\":\"three\"}")),
|
||||
];
|
||||
|
||||
Sse::new(stream::iter(events)).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
async fn sse_idle()
|
||||
-> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
Sse::new(stream::pending()).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
async fn sse_broken()
|
||||
-> Sse<impl futures_util::Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
let events = vec![Ok(Event::default().data("{broken-json}"))];
|
||||
|
||||
Sse::new(stream::iter(events)).keep_alive(KeepAlive::default())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ pub enum RestAdapterError {
|
||||
InvalidHeaderValue { header: String },
|
||||
#[error("request failed")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
#[error("sse collection window expired before stream completed")]
|
||||
WindowExpired,
|
||||
#[error("rest endpoint returned status {status}")]
|
||||
UnexpectedStatus { status: u16, body: Value },
|
||||
#[error("sse stream produced malformed event payload")]
|
||||
InvalidSseEvent,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
mod client;
|
||||
mod error;
|
||||
mod model;
|
||||
mod sse;
|
||||
|
||||
pub use client::RestAdapter;
|
||||
pub use error::RestAdapterError;
|
||||
pub use model::{RestRequest, RestResponse};
|
||||
pub use model::{RestRequest, RestResponse, RestWindowRequest, RestWindowResponse};
|
||||
|
||||
@@ -23,3 +23,20 @@ pub struct RestResponse {
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct RestWindowRequest {
|
||||
#[serde(flatten)]
|
||||
pub request: RestRequest,
|
||||
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 RestWindowResponse {
|
||||
pub status_code: u16,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::time::{Duration, Instant, timeout_at};
|
||||
|
||||
use crate::RestAdapterError;
|
||||
|
||||
pub async fn collect_sse_window(
|
||||
response: reqwest::Response,
|
||||
window_duration_ms: u64,
|
||||
max_items: Option<u32>,
|
||||
) -> Result<(u16, BTreeMap<String, String>, Value), RestAdapterError> {
|
||||
let status = response.status();
|
||||
let headers = normalize_headers(response.headers());
|
||||
let deadline = Instant::now() + Duration::from_millis(window_duration_ms);
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
let mut items = Vec::new();
|
||||
let mut done = true;
|
||||
|
||||
loop {
|
||||
if max_items.is_some_and(|limit| items.len() >= limit as usize) {
|
||||
done = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let next_chunk = match timeout_at(deadline, stream.next()).await {
|
||||
Ok(next_chunk) => next_chunk,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let Some(next_chunk) = next_chunk else {
|
||||
break;
|
||||
};
|
||||
|
||||
let chunk = next_chunk?;
|
||||
buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
while let Some(event_end) = find_event_boundary(&buffer) {
|
||||
let event = buffer[..event_end].to_owned();
|
||||
let boundary_len = boundary_length(&buffer[event_end..]);
|
||||
buffer = buffer[event_end + boundary_len..].to_owned();
|
||||
|
||||
if let Some(item) = parse_sse_event(&event)? {
|
||||
items.push(item);
|
||||
|
||||
if max_items.is_some_and(|limit| items.len() >= limit as usize) {
|
||||
done = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
status.as_u16(),
|
||||
headers,
|
||||
json!({
|
||||
"items": items,
|
||||
"done": done
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_sse_event(raw: &str) -> Result<Option<Value>, RestAdapterError> {
|
||||
let mut data_lines = Vec::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
if line.is_empty() || line.starts_with(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(value) = line.strip_prefix("data:") {
|
||||
data_lines.push(value.trim_start().to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if data_lines.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let payload = data_lines.join("\n");
|
||||
if payload.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
serde_json::from_str::<Value>(&payload)
|
||||
.map(Some)
|
||||
.or_else(|_| {
|
||||
if payload.starts_with('{') || payload.starts_with('[') {
|
||||
Err(RestAdapterError::InvalidSseEvent)
|
||||
} else {
|
||||
Ok(Some(Value::String(payload)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn find_event_boundary(buffer: &str) -> Option<usize> {
|
||||
buffer
|
||||
.find("\r\n\r\n")
|
||||
.or_else(|| buffer.find("\n\n"))
|
||||
.or_else(|| buffer.find("\r\r"))
|
||||
}
|
||||
|
||||
fn boundary_length(boundary: &str) -> usize {
|
||||
if boundary.starts_with("\r\n\r\n") {
|
||||
4
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
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 serde_json::json;
|
||||
|
||||
use super::parse_sse_event;
|
||||
use crate::RestAdapterError;
|
||||
|
||||
#[test]
|
||||
fn parses_json_event_payload() {
|
||||
let event = "event: message\ndata: {\"message\":\"ok\"}\n\n";
|
||||
|
||||
let parsed = parse_sse_event(event).unwrap();
|
||||
|
||||
assert_eq!(parsed, Some(json!({ "message": "ok" })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_comment_only_events() {
|
||||
let parsed = parse_sse_event(": keepalive\n\n").unwrap();
|
||||
|
||||
assert_eq!(parsed, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_json_like_payload() {
|
||||
let error = parse_sse_event("data: {broken-json}\n\n").unwrap_err();
|
||||
|
||||
assert!(matches!(error, RestAdapterError::InvalidSseEvent));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[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
|
||||
@@ -0,0 +1,326 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
@@ -0,0 +1,40 @@
|
||||
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>,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
use crank_core::SoapBindingStyle;
|
||||
use roxmltree::{Document, Node};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::SoapAdapterError;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SoapEnvelopeHeader {
|
||||
pub name: String,
|
||||
pub namespace_uri: Option<String>,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
pub fn build_envelope(
|
||||
operation_name: &str,
|
||||
namespace: Option<&str>,
|
||||
body: &Value,
|
||||
envelope_namespace: &str,
|
||||
binding_style: SoapBindingStyle,
|
||||
headers: &[SoapEnvelopeHeader],
|
||||
) -> String {
|
||||
let mut xml = String::new();
|
||||
xml.push_str(&format!(
|
||||
r#"<soap:Envelope xmlns:soap="{envelope_namespace}">"#
|
||||
));
|
||||
if !headers.is_empty() {
|
||||
xml.push_str("<soap:Header>");
|
||||
for header in headers {
|
||||
append_header(&mut xml, header);
|
||||
}
|
||||
xml.push_str("</soap:Header>");
|
||||
}
|
||||
xml.push_str("<soap:Body>");
|
||||
match namespace {
|
||||
Some(namespace) => xml.push_str(&format!(r#"<m:{operation_name}" xmlns:m="{namespace}">"#)),
|
||||
None => xml.push_str(&format!("<{operation_name}>")),
|
||||
}
|
||||
let child_prefix = match binding_style {
|
||||
SoapBindingStyle::DocumentLiteral => None,
|
||||
SoapBindingStyle::RpcLiteral => namespace.map(|_| "m"),
|
||||
};
|
||||
append_value_children(&mut xml, body, child_prefix);
|
||||
match namespace {
|
||||
Some(_) => xml.push_str(&format!("</m:{operation_name}>")),
|
||||
None => xml.push_str(&format!("</{operation_name}>")),
|
||||
}
|
||||
xml.push_str("</soap:Body></soap:Envelope>");
|
||||
xml
|
||||
}
|
||||
|
||||
pub fn decode_envelope(payload: &str) -> Result<Value, SoapAdapterError> {
|
||||
let document = Document::parse(payload)?;
|
||||
let body = document
|
||||
.descendants()
|
||||
.find(|node| node.is_element() && node.tag_name().name() == "Body")
|
||||
.ok_or(SoapAdapterError::MissingBody)?;
|
||||
|
||||
let body_child = body
|
||||
.children()
|
||||
.find(|node| node.is_element())
|
||||
.ok_or(SoapAdapterError::EmptyBody)?;
|
||||
|
||||
if body_child.tag_name().name() == "Fault" {
|
||||
return Err(parse_fault(body_child));
|
||||
}
|
||||
|
||||
Ok(node_to_json(body_child, true))
|
||||
}
|
||||
|
||||
fn parse_fault(fault: Node<'_, '_>) -> SoapAdapterError {
|
||||
let code = find_nested_text(fault, &["Code", "Value"])
|
||||
.or_else(|| find_nested_text(fault, &["faultcode"]));
|
||||
let message = find_nested_text(fault, &["Reason", "Text"])
|
||||
.or_else(|| find_nested_text(fault, &["faultstring"]))
|
||||
.unwrap_or_else(|| "SOAP fault".to_owned());
|
||||
let detail = fault
|
||||
.children()
|
||||
.find(|node| node.is_element() && node.tag_name().name() == "Detail")
|
||||
.map(|node| node_to_json(node, false));
|
||||
|
||||
SoapAdapterError::SoapFault {
|
||||
code,
|
||||
message,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn append_header(xml: &mut String, header: &SoapEnvelopeHeader) {
|
||||
match header.namespace_uri.as_deref() {
|
||||
Some(namespace_uri) => {
|
||||
xml.push_str(&format!(
|
||||
r#"<h:{} xmlns:h="{}">"#,
|
||||
header.name, namespace_uri
|
||||
));
|
||||
append_header_value(xml, &header.value);
|
||||
xml.push_str(&format!("</h:{}>", header.name));
|
||||
}
|
||||
None => {
|
||||
xml.push('<');
|
||||
xml.push_str(&header.name);
|
||||
xml.push('>');
|
||||
append_header_value(xml, &header.value);
|
||||
xml.push_str("</");
|
||||
xml.push_str(&header.name);
|
||||
xml.push('>');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_header_value(xml: &mut String, value: &Value) {
|
||||
match value {
|
||||
Value::Null => {}
|
||||
Value::Bool(value) => xml.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => xml.push_str(&value.to_string()),
|
||||
Value::String(value) => xml.push_str(&escape_xml(value)),
|
||||
Value::Array(_) | Value::Object(_) => append_value_children(xml, value, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_nested_text(node: Node<'_, '_>, path: &[&str]) -> Option<String> {
|
||||
let mut current = node;
|
||||
for segment in path {
|
||||
current = current
|
||||
.children()
|
||||
.find(|child| child.is_element() && child.tag_name().name() == *segment)?;
|
||||
}
|
||||
|
||||
current
|
||||
.text()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn append_value_children(xml: &mut String, value: &Value, namespace_prefix: Option<&str>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
append_named_value(xml, key, value, namespace_prefix);
|
||||
}
|
||||
}
|
||||
other => append_named_value(xml, "value", other, namespace_prefix),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_named_value(xml: &mut String, name: &str, value: &Value, namespace_prefix: Option<&str>) {
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
append_named_value(xml, name, item, namespace_prefix);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
push_start_tag(xml, name, namespace_prefix);
|
||||
for (child_name, child_value) in object {
|
||||
append_named_value(xml, child_name, child_value, namespace_prefix);
|
||||
}
|
||||
push_end_tag(xml, name, namespace_prefix);
|
||||
}
|
||||
Value::Null => {
|
||||
push_empty_tag(xml, name, namespace_prefix);
|
||||
}
|
||||
Value::Bool(value) => {
|
||||
push_start_tag(xml, name, namespace_prefix);
|
||||
xml.push_str(if *value { "true" } else { "false" });
|
||||
push_end_tag(xml, name, namespace_prefix);
|
||||
}
|
||||
Value::Number(value) => {
|
||||
push_start_tag(xml, name, namespace_prefix);
|
||||
xml.push_str(&value.to_string());
|
||||
push_end_tag(xml, name, namespace_prefix);
|
||||
}
|
||||
Value::String(value) => {
|
||||
push_start_tag(xml, name, namespace_prefix);
|
||||
xml.push_str(&escape_xml(value));
|
||||
push_end_tag(xml, name, namespace_prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_start_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) {
|
||||
xml.push('<');
|
||||
if let Some(prefix) = namespace_prefix {
|
||||
xml.push_str(prefix);
|
||||
xml.push(':');
|
||||
}
|
||||
xml.push_str(name);
|
||||
xml.push('>');
|
||||
}
|
||||
|
||||
fn push_end_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) {
|
||||
xml.push_str("</");
|
||||
if let Some(prefix) = namespace_prefix {
|
||||
xml.push_str(prefix);
|
||||
xml.push(':');
|
||||
}
|
||||
xml.push_str(name);
|
||||
xml.push('>');
|
||||
}
|
||||
|
||||
fn push_empty_tag(xml: &mut String, name: &str, namespace_prefix: Option<&str>) {
|
||||
xml.push('<');
|
||||
if let Some(prefix) = namespace_prefix {
|
||||
xml.push_str(prefix);
|
||||
xml.push(':');
|
||||
}
|
||||
xml.push_str(name);
|
||||
xml.push_str("/>");
|
||||
}
|
||||
|
||||
fn escape_xml(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn node_to_json(node: Node<'_, '_>, _unwrap_root: bool) -> Value {
|
||||
let children = node
|
||||
.children()
|
||||
.filter(|child| child.is_element())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if children.is_empty() {
|
||||
return node
|
||||
.text()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| Value::String(value.to_owned()))
|
||||
.unwrap_or(Value::Null);
|
||||
}
|
||||
|
||||
let mut object = Map::new();
|
||||
for child in children {
|
||||
let key = child.tag_name().name().to_owned();
|
||||
let value = node_to_json(child, false);
|
||||
match object.get_mut(&key) {
|
||||
Some(existing) => match existing {
|
||||
Value::Array(array) => array.push(value),
|
||||
other => {
|
||||
let previous = other.take();
|
||||
*other = Value::Array(vec![previous, value]);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
object.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{SoapEnvelopeHeader, build_envelope, decode_envelope};
|
||||
use crank_core::SoapBindingStyle;
|
||||
|
||||
#[test]
|
||||
fn builds_document_literal_envelope() {
|
||||
let xml = build_envelope(
|
||||
"CreateLead",
|
||||
Some("urn:crm"),
|
||||
&json!({"email":"user@example.com","source":"mcp"}),
|
||||
"http://schemas.xmlsoap.org/soap/envelope/",
|
||||
SoapBindingStyle::DocumentLiteral,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert!(xml.contains("<soap:Envelope"));
|
||||
assert!(xml.contains("<m:CreateLead"));
|
||||
assert!(xml.contains(r#"xmlns:m="urn:crm""#));
|
||||
assert!(xml.contains("<email>user@example.com</email>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_wrapped_response_body() {
|
||||
let value = decode_envelope(
|
||||
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<CreateLeadResponse>
|
||||
<id>lead_123</id>
|
||||
<status>created</status>
|
||||
</CreateLeadResponse>
|
||||
</soap:Body>
|
||||
</soap:Envelope>"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value["id"], "lead_123");
|
||||
assert_eq!(value["status"], "created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_rpc_literal_envelope_with_headers() {
|
||||
let xml = build_envelope(
|
||||
"CreateLead",
|
||||
Some("urn:crm"),
|
||||
&json!({"email":"user@example.com"}),
|
||||
"http://schemas.xmlsoap.org/soap/envelope/",
|
||||
SoapBindingStyle::RpcLiteral,
|
||||
&[SoapEnvelopeHeader {
|
||||
name: "CorrelationId".to_owned(),
|
||||
namespace_uri: Some("urn:headers".to_owned()),
|
||||
value: Value::String("corr-123".to_owned()),
|
||||
}],
|
||||
);
|
||||
|
||||
assert!(xml.contains("<soap:Header>"));
|
||||
assert!(
|
||||
xml.contains(r#"<h:CorrelationId xmlns:h="urn:headers">corr-123</h:CorrelationId>"#)
|
||||
);
|
||||
assert!(xml.contains("<m:email>user@example.com</m:email>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[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"] }
|
||||
@@ -0,0 +1,537 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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 },
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ReconnectPolicy {
|
||||
pub max_attempts: u32,
|
||||
pub backoff: Duration,
|
||||
}
|
||||
|
||||
impl Default for ReconnectPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_attempts: 0,
|
||||
backoff: Duration::from_millis(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeartbeatPolicy {
|
||||
pub interval: Duration,
|
||||
}
|
||||
@@ -1,45 +1,32 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
ids::{AuthProfileId, WorkspaceId},
|
||||
ids::{AuthProfileId, SecretId, WorkspaceId},
|
||||
protocol::AuthKind,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SecretRef(pub String);
|
||||
|
||||
impl SecretRef {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BearerAuthConfig {
|
||||
pub header_name: String,
|
||||
pub secret_ref: SecretRef,
|
||||
pub secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BasicAuthConfig {
|
||||
pub username_secret_ref: SecretRef,
|
||||
pub password_secret_ref: SecretRef,
|
||||
pub username_secret_id: SecretId,
|
||||
pub password_secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApiKeyHeaderAuthConfig {
|
||||
pub header_name: String,
|
||||
pub secret_ref: SecretRef,
|
||||
pub secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ApiKeyQueryAuthConfig {
|
||||
pub param_name: String,
|
||||
pub secret_ref: SecretRef,
|
||||
pub secret_id: SecretId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -51,6 +38,17 @@ pub enum AuthConfig {
|
||||
ApiKeyQuery(ApiKeyQueryAuthConfig),
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn secret_ids(&self) -> Vec<&SecretId> {
|
||||
match self {
|
||||
Self::Bearer(config) => vec![&config.secret_id],
|
||||
Self::Basic(config) => vec![&config.username_secret_id, &config.password_secret_id],
|
||||
Self::ApiKeyHeader(config) => vec![&config.secret_id],
|
||||
Self::ApiKeyQuery(config) => vec![&config.secret_id],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthProfile {
|
||||
pub id: AuthProfileId,
|
||||
|
||||
@@ -47,3 +47,6 @@ define_id!(AgentId);
|
||||
define_id!(InvitationId);
|
||||
define_id!(PlatformApiKeyId);
|
||||
define_id!(InvocationLogId);
|
||||
define_id!(SecretId);
|
||||
define_id!(StreamSessionId);
|
||||
define_id!(AsyncJobId);
|
||||
|
||||
@@ -5,6 +5,10 @@ pub mod ids;
|
||||
pub mod observability;
|
||||
pub mod operation;
|
||||
pub mod protocol;
|
||||
pub mod secret;
|
||||
pub mod soap;
|
||||
pub mod stream_session;
|
||||
pub mod streaming;
|
||||
pub mod workspace;
|
||||
|
||||
pub use access::{
|
||||
@@ -14,11 +18,12 @@ pub use access::{
|
||||
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
|
||||
pub use auth::{
|
||||
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
|
||||
BearerAuthConfig, SecretRef,
|
||||
BearerAuthConfig,
|
||||
};
|
||||
pub use ids::{
|
||||
AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
|
||||
PlatformApiKeyId, SampleId, ToolId, UserId, UserSessionId, WorkspaceId,
|
||||
AgentId, AsyncJobId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
|
||||
PlatformApiKeyId, SampleId, SecretId, StreamSessionId, ToolId, UserId, UserSessionId,
|
||||
WorkspaceId,
|
||||
};
|
||||
pub use observability::{
|
||||
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
|
||||
@@ -26,7 +31,17 @@ pub use observability::{
|
||||
pub use operation::{
|
||||
ConfigExport, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlTarget,
|
||||
GrpcProtocolOptions, GrpcTarget, Operation, OperationStatus, ProtocolOptions, RestTarget,
|
||||
RetryPolicy, Samples, Target, ToolDescription, ToolExample,
|
||||
RetryPolicy, Samples, SoapProtocolOptions, SoapTarget, Target, ToolDescription, ToolExample,
|
||||
WebsocketProtocolOptions, WebsocketTarget,
|
||||
};
|
||||
pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol};
|
||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
pub use soap::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||
};
|
||||
pub use stream_session::{AsyncJobHandle, JobStatus, StreamSession, StreamStatus};
|
||||
pub use streaming::{
|
||||
AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig,
|
||||
TransportBehavior,
|
||||
};
|
||||
pub use workspace::{Workspace, WorkspaceStatus};
|
||||
|
||||
@@ -6,6 +6,10 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
ids::{AuthProfileId, DescriptorId, OperationId, SampleId},
|
||||
protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
||||
soap::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||
},
|
||||
streaming::StreamingConfig,
|
||||
};
|
||||
|
||||
fn default_operation_category() -> String {
|
||||
@@ -49,12 +53,47 @@ pub struct GrpcTarget {
|
||||
pub descriptor_set_b64: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WebsocketTarget {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub subprotocols: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subscribe_message_template: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub unsubscribe_message_template: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub static_headers: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SoapTarget {
|
||||
pub wsdl_ref: SampleId,
|
||||
pub service_name: String,
|
||||
pub port_name: String,
|
||||
pub operation_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint_override: Option<String>,
|
||||
pub soap_version: SoapVersion,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub soap_action: Option<String>,
|
||||
pub binding_style: SoapBindingStyle,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub headers: Vec<SoapHeaderConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fault_contract: Option<SoapFaultContract>,
|
||||
#[serde(default)]
|
||||
pub metadata: SoapOperationMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Target {
|
||||
Rest(RestTarget),
|
||||
Graphql(GraphqlTarget),
|
||||
Grpc(GrpcTarget),
|
||||
Websocket(WebsocketTarget),
|
||||
Soap(SoapTarget),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -67,13 +106,37 @@ pub struct GrpcProtocolOptions {
|
||||
pub use_tls: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct WebsocketProtocolOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub heartbeat_interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reconnect_max_attempts: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reconnect_backoff_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SoapProtocolOptions {
|
||||
#[serde(default)]
|
||||
pub use_tls: bool,
|
||||
#[serde(default)]
|
||||
pub validate_certificate: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ws_security_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ProtocolOptions {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grpc: Option<GrpcProtocolOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub websocket: Option<WebsocketProtocolOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub soap: Option<SoapProtocolOptions>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExecutionConfig {
|
||||
pub timeout_ms: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -84,6 +147,8 @@ pub struct ExecutionConfig {
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub protocol_options: Option<ProtocolOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub streaming: Option<StreamingConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -143,7 +208,7 @@ pub struct ConfigExport {
|
||||
pub export_mode: ExportMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Operation<TSchema, TMapping> {
|
||||
pub id: OperationId,
|
||||
pub name: String,
|
||||
@@ -201,13 +266,18 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
auth::{AuthConfig, AuthProfile, BearerAuthConfig, SecretRef},
|
||||
ids::{AuthProfileId, OperationId},
|
||||
auth::{AuthConfig, AuthProfile, BearerAuthConfig},
|
||||
ids::{AuthProfileId, OperationId, SampleId},
|
||||
operation::{
|
||||
ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus,
|
||||
ProtocolOptions, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
||||
ProtocolOptions, RestTarget, Samples, SoapProtocolOptions, SoapTarget, Target,
|
||||
ToolDescription, ToolExample, WebsocketProtocolOptions, WebsocketTarget,
|
||||
},
|
||||
protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
||||
soap::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata,
|
||||
SoapVersion,
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -241,6 +311,60 @@ mod tests {
|
||||
assert_eq!(value["operation_type"], "mutation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_target_serializes_templates() {
|
||||
let target = Target::Websocket(WebsocketTarget {
|
||||
url: "wss://events.example.com/stream".to_owned(),
|
||||
subprotocols: vec!["graphql-transport-ws".to_owned()],
|
||||
subscribe_message_template: Some(json!({ "type": "subscribe" })),
|
||||
unsubscribe_message_template: Some(json!({ "type": "unsubscribe" })),
|
||||
static_headers: BTreeMap::from([("x-env".to_owned(), "test".to_owned())]),
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(target).unwrap();
|
||||
|
||||
assert_eq!(value["kind"], "websocket");
|
||||
assert_eq!(value["subprotocols"][0], "graphql-transport-ws");
|
||||
assert_eq!(value["subscribe_message_template"]["type"], "subscribe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soap_target_serializes_binding_metadata() {
|
||||
let target = Target::Soap(SoapTarget {
|
||||
wsdl_ref: SampleId::new("sample_wsdl_01"),
|
||||
service_name: "LeadService".to_owned(),
|
||||
port_name: "LeadPort".to_owned(),
|
||||
operation_name: "CreateLead".to_owned(),
|
||||
endpoint_override: Some("https://soap.example.com/lead".to_owned()),
|
||||
soap_version: SoapVersion::Soap12,
|
||||
soap_action: Some("urn:createLead".to_owned()),
|
||||
binding_style: SoapBindingStyle::DocumentLiteral,
|
||||
headers: vec![SoapHeaderConfig {
|
||||
name: "CorrelationId".to_owned(),
|
||||
namespace_uri: Some("urn:crm".to_owned()),
|
||||
required: true,
|
||||
value_path: Some("$.mcp.correlation_id".to_owned()),
|
||||
}],
|
||||
fault_contract: Some(SoapFaultContract {
|
||||
code_path: Some("$.Envelope.Body.Fault.Code.Value".to_owned()),
|
||||
message_path: Some("$.Envelope.Body.Fault.Reason.Text".to_owned()),
|
||||
detail_path: Some("$.Envelope.Body.Fault.Detail".to_owned()),
|
||||
}),
|
||||
metadata: SoapOperationMetadata {
|
||||
input_part_names: vec!["LeadRequest".to_owned()],
|
||||
output_part_names: vec!["LeadResponse".to_owned()],
|
||||
namespaces: vec!["urn:crm".to_owned()],
|
||||
},
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(target).unwrap();
|
||||
|
||||
assert_eq!(value["kind"], "soap");
|
||||
assert_eq!(value["soap_version"], "soap_12");
|
||||
assert_eq!(value["binding_style"], "document_literal");
|
||||
assert_eq!(value["metadata"]["input_part_names"][0], "LeadRequest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_exposes_local_domain_helpers() {
|
||||
let operation = Operation {
|
||||
@@ -266,7 +390,20 @@ mod tests {
|
||||
retry_policy: None,
|
||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: Some(ProtocolOptions::default()),
|
||||
protocol_options: Some(ProtocolOptions {
|
||||
grpc: None,
|
||||
websocket: Some(WebsocketProtocolOptions {
|
||||
heartbeat_interval_ms: Some(5_000),
|
||||
reconnect_max_attempts: Some(3),
|
||||
reconnect_backoff_ms: Some(250),
|
||||
}),
|
||||
soap: Some(SoapProtocolOptions {
|
||||
use_tls: true,
|
||||
validate_certificate: true,
|
||||
ws_security_profile: Some("username_token".to_owned()),
|
||||
}),
|
||||
}),
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create CRM lead".to_owned(),
|
||||
@@ -293,7 +430,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_profile_serializes_secret_refs_without_secret_values() {
|
||||
fn auth_profile_serializes_secret_ids_without_secret_values() {
|
||||
let profile = AuthProfile {
|
||||
id: AuthProfileId::new("auth_01"),
|
||||
workspace_id: crate::ids::WorkspaceId::new("ws_01"),
|
||||
@@ -301,7 +438,7 @@ mod tests {
|
||||
kind: AuthKind::Bearer,
|
||||
config: AuthConfig::Bearer(BearerAuthConfig {
|
||||
header_name: "Authorization".to_owned(),
|
||||
secret_ref: SecretRef::new("secret://auth/crm-prod-token"),
|
||||
secret_id: crate::ids::SecretId::new("secret_crm_prod_token"),
|
||||
}),
|
||||
created_at: "2026-03-25T08:00:00Z".to_owned(),
|
||||
updated_at: "2026-03-25T08:00:00Z".to_owned(),
|
||||
@@ -311,8 +448,8 @@ mod tests {
|
||||
|
||||
assert_eq!(value["kind"], "bearer");
|
||||
assert_eq!(
|
||||
value["config"]["bearer"]["secret_ref"],
|
||||
"secret://auth/crm-prod-token"
|
||||
value["config"]["bearer"]["secret_id"],
|
||||
"secret_crm_prod_token"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -342,6 +479,7 @@ mod tests {
|
||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||
headers: BTreeMap::new(),
|
||||
protocol_options: Some(ProtocolOptions::default()),
|
||||
streaming: None,
|
||||
},
|
||||
tool_description: ToolDescription {
|
||||
title: "Create CRM lead".to_owned(),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::streaming::{ExecutionMode, TransportBehavior};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Protocol {
|
||||
Rest,
|
||||
Graphql,
|
||||
Grpc,
|
||||
Websocket,
|
||||
Soap,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -40,3 +44,73 @@ pub enum ExportMode {
|
||||
Portable,
|
||||
Bundle,
|
||||
}
|
||||
|
||||
impl Protocol {
|
||||
pub fn supports_execution_mode(self, mode: ExecutionMode) -> bool {
|
||||
match self {
|
||||
Self::Rest => true,
|
||||
Self::Graphql => matches!(mode, ExecutionMode::Unary),
|
||||
Self::Grpc => true,
|
||||
Self::Websocket => !matches!(mode, ExecutionMode::Unary),
|
||||
Self::Soap => matches!(mode, ExecutionMode::Unary | ExecutionMode::AsyncJob),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_transport_behavior(self, behavior: TransportBehavior) -> bool {
|
||||
match self {
|
||||
Self::Rest => true,
|
||||
Self::Graphql => matches!(behavior, TransportBehavior::RequestResponse),
|
||||
Self::Grpc => !matches!(behavior, TransportBehavior::DeferredResult),
|
||||
Self::Websocket => !matches!(behavior, TransportBehavior::RequestResponse),
|
||||
Self::Soap => !matches!(behavior, TransportBehavior::StatefulSession),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Protocol;
|
||||
use crate::streaming::{ExecutionMode, TransportBehavior};
|
||||
|
||||
#[test]
|
||||
fn graphql_support_matrix_is_restricted() {
|
||||
assert!(Protocol::Graphql.supports_execution_mode(ExecutionMode::Unary));
|
||||
assert!(!Protocol::Graphql.supports_execution_mode(ExecutionMode::Window));
|
||||
assert!(!Protocol::Graphql.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(!Protocol::Graphql.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_supports_session_but_not_deferred_result() {
|
||||
assert!(Protocol::Grpc.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(!Protocol::Grpc.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_requires_streaming_modes() {
|
||||
assert!(!Protocol::Websocket.supports_execution_mode(ExecutionMode::Unary));
|
||||
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::Window));
|
||||
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::AsyncJob));
|
||||
assert!(
|
||||
!Protocol::Websocket.supports_transport_behavior(TransportBehavior::RequestResponse)
|
||||
);
|
||||
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||
assert!(
|
||||
Protocol::Websocket.supports_transport_behavior(TransportBehavior::StatefulSession)
|
||||
);
|
||||
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soap_is_request_response_first_with_async_job_support() {
|
||||
assert!(Protocol::Soap.supports_execution_mode(ExecutionMode::Unary));
|
||||
assert!(!Protocol::Soap.supports_execution_mode(ExecutionMode::Window));
|
||||
assert!(!Protocol::Soap.supports_execution_mode(ExecutionMode::Session));
|
||||
assert!(Protocol::Soap.supports_execution_mode(ExecutionMode::AsyncJob));
|
||||
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::RequestResponse));
|
||||
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||
assert!(!Protocol::Soap.supports_transport_behavior(TransportBehavior::StatefulSession));
|
||||
assert!(Protocol::Soap.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ids::{SecretId, UserId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretKind {
|
||||
Token,
|
||||
UsernamePassword,
|
||||
Header,
|
||||
Generic,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretStatus {
|
||||
Active,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Secret {
|
||||
pub id: SecretId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub name: String,
|
||||
pub kind: SecretKind,
|
||||
pub status: SecretStatus,
|
||||
pub current_version: u32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SecretVersion {
|
||||
pub secret_id: SecretId,
|
||||
pub version: u32,
|
||||
pub ciphertext: String,
|
||||
pub key_version: String,
|
||||
pub created_at: String,
|
||||
pub created_by: Option<UserId>,
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SoapVersion {
|
||||
#[serde(rename = "soap_11")]
|
||||
Soap11,
|
||||
#[serde(rename = "soap_12")]
|
||||
Soap12,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SoapBindingStyle {
|
||||
DocumentLiteral,
|
||||
RpcLiteral,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SoapHeaderConfig {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub namespace_uri: Option<String>,
|
||||
pub required: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub value_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SoapFaultContract {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct SoapOperationMetadata {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub input_part_names: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_part_names: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
SoapBindingStyle, SoapFaultContract, SoapHeaderConfig, SoapOperationMetadata, SoapVersion,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn soap_metadata_serializes_compactly() {
|
||||
let value = serde_json::to_value(SoapOperationMetadata {
|
||||
input_part_names: vec!["LeadRequest".to_owned()],
|
||||
output_part_names: vec!["LeadResponse".to_owned()],
|
||||
namespaces: vec!["urn:crm".to_owned()],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(value["input_part_names"][0], "LeadRequest");
|
||||
assert_eq!(value["output_part_names"][0], "LeadResponse");
|
||||
assert_eq!(value["namespaces"][0], "urn:crm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soap_supporting_types_roundtrip() {
|
||||
let value = json!({
|
||||
"version": "soap_12",
|
||||
"style": "document_literal",
|
||||
"header": {
|
||||
"name": "CorrelationId",
|
||||
"namespace_uri": "urn:crm",
|
||||
"required": true,
|
||||
"value_path": "$.mcp.correlation_id"
|
||||
},
|
||||
"fault": {
|
||||
"code_path": "$.Envelope.Body.Fault.Code.Value",
|
||||
"message_path": "$.Envelope.Body.Fault.Reason.Text",
|
||||
"detail_path": "$.Envelope.Body.Fault.Detail"
|
||||
}
|
||||
});
|
||||
|
||||
let version: SoapVersion = serde_json::from_value(value["version"].clone()).unwrap();
|
||||
let style: SoapBindingStyle = serde_json::from_value(value["style"].clone()).unwrap();
|
||||
let header: SoapHeaderConfig = serde_json::from_value(value["header"].clone()).unwrap();
|
||||
let fault: SoapFaultContract = serde_json::from_value(value["fault"].clone()).unwrap();
|
||||
|
||||
assert_eq!(version, SoapVersion::Soap12);
|
||||
assert_eq!(style, SoapBindingStyle::DocumentLiteral);
|
||||
assert_eq!(header.name, "CorrelationId");
|
||||
assert_eq!(
|
||||
fault.detail_path.as_deref(),
|
||||
Some("$.Envelope.Body.Fault.Detail")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
ids::{AgentId, AsyncJobId, OperationId, StreamSessionId, WorkspaceId},
|
||||
protocol::Protocol,
|
||||
streaming::ExecutionMode,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StreamStatus {
|
||||
Created,
|
||||
Running,
|
||||
Stopped,
|
||||
Failed,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl StreamStatus {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, Self::Stopped | Self::Failed | Self::Expired)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamSession {
|
||||
pub id: StreamSessionId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: OperationId,
|
||||
pub protocol: Protocol,
|
||||
pub mode: ExecutionMode,
|
||||
pub status: StreamStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<Value>,
|
||||
pub state: Value,
|
||||
pub expires_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_poll_at: Option<String>,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub closed_at: Option<String>,
|
||||
}
|
||||
|
||||
impl StreamSession {
|
||||
pub fn is_expired(&self, now: &str) -> bool {
|
||||
self.expires_at.as_str() <= now
|
||||
}
|
||||
|
||||
pub fn can_poll(&self, now: &str) -> bool {
|
||||
!self.status.is_terminal() && !self.is_expired(now)
|
||||
}
|
||||
|
||||
pub fn mark_polled(&mut self, now: impl Into<String>) {
|
||||
self.last_poll_at = Some(now.into());
|
||||
}
|
||||
|
||||
pub fn mark_closed(&mut self, now: impl Into<String>) {
|
||||
let now = now.into();
|
||||
self.status = StreamStatus::Stopped;
|
||||
self.last_poll_at = Some(now.clone());
|
||||
self.closed_at = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JobStatus {
|
||||
Created,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Completed | Self::Failed | Self::Cancelled | Self::Expired
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AsyncJobHandle {
|
||||
pub id: AsyncJobId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub operation_id: OperationId,
|
||||
pub status: JobStatus,
|
||||
pub progress: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
|
||||
impl AsyncJobHandle {
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.status.is_terminal()
|
||||
}
|
||||
|
||||
pub fn can_cancel(&self) -> bool {
|
||||
matches!(self.status, JobStatus::Created | JobStatus::Running)
|
||||
}
|
||||
|
||||
pub fn mark_finished(&mut self, now: impl Into<String>, result: Value) {
|
||||
let now = now.into();
|
||||
self.status = JobStatus::Completed;
|
||||
self.result = Some(result);
|
||||
self.error = None;
|
||||
self.updated_at = now.clone();
|
||||
self.finished_at = Some(now);
|
||||
}
|
||||
|
||||
pub fn mark_failed(&mut self, now: impl Into<String>, error: Value) {
|
||||
let now = now.into();
|
||||
self.status = JobStatus::Failed;
|
||||
self.error = Some(error);
|
||||
self.updated_at = now.clone();
|
||||
self.finished_at = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{AsyncJobHandle, JobStatus, StreamSession, StreamStatus};
|
||||
use crate::{
|
||||
ids::{AsyncJobId, OperationId, StreamSessionId, WorkspaceId},
|
||||
protocol::Protocol,
|
||||
streaming::ExecutionMode,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn stream_session_tracks_poll_and_close_transitions() {
|
||||
let mut session = StreamSession {
|
||||
id: StreamSessionId::new("stream_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
protocol: Protocol::Rest,
|
||||
mode: ExecutionMode::Session,
|
||||
status: StreamStatus::Running,
|
||||
cursor: None,
|
||||
state: json!({"cursor":"abc"}),
|
||||
expires_at: "2026-04-06T12:05:00Z".to_owned(),
|
||||
last_poll_at: None,
|
||||
created_at: "2026-04-06T12:00:00Z".to_owned(),
|
||||
closed_at: None,
|
||||
};
|
||||
|
||||
assert!(session.can_poll("2026-04-06T12:01:00Z"));
|
||||
|
||||
session.mark_polled("2026-04-06T12:01:00Z");
|
||||
session.mark_closed("2026-04-06T12:02:00Z");
|
||||
|
||||
assert_eq!(session.status, StreamStatus::Stopped);
|
||||
assert_eq!(session.closed_at.as_deref(), Some("2026-04-06T12:02:00Z"));
|
||||
assert!(!session.can_poll("2026-04-06T12:03:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_job_tracks_finish_and_failure() {
|
||||
let mut job = AsyncJobHandle {
|
||||
id: AsyncJobId::new("job_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
status: JobStatus::Running,
|
||||
progress: json!({"percent": 60}),
|
||||
result: None,
|
||||
error: None,
|
||||
expires_at: Some("2026-04-06T12:05:00Z".to_owned()),
|
||||
created_at: "2026-04-06T12:00:00Z".to_owned(),
|
||||
updated_at: "2026-04-06T12:00:00Z".to_owned(),
|
||||
finished_at: None,
|
||||
};
|
||||
|
||||
assert!(job.can_cancel());
|
||||
|
||||
job.mark_finished("2026-04-06T12:01:00Z", json!({"ok": true}));
|
||||
assert!(job.is_finished());
|
||||
assert_eq!(job.status, JobStatus::Completed);
|
||||
|
||||
let mut failed_job = job.clone();
|
||||
failed_job.status = JobStatus::Running;
|
||||
failed_job.result = None;
|
||||
failed_job.finished_at = None;
|
||||
|
||||
failed_job.mark_failed("2026-04-06T12:02:00Z", json!({"message": "boom"}));
|
||||
assert_eq!(failed_job.status, JobStatus::Failed);
|
||||
assert_eq!(
|
||||
failed_job.finished_at.as_deref(),
|
||||
Some("2026-04-06T12:02:00Z")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::protocol::Protocol;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionMode {
|
||||
Unary,
|
||||
Window,
|
||||
Session,
|
||||
AsyncJob,
|
||||
}
|
||||
|
||||
impl ExecutionMode {
|
||||
pub fn is_stateful(self) -> bool {
|
||||
matches!(self, Self::Session | Self::AsyncJob)
|
||||
}
|
||||
|
||||
pub fn requires_tool_family(self) -> bool {
|
||||
self.is_stateful()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AggregationMode {
|
||||
RawItems,
|
||||
SummaryOnly,
|
||||
SummaryPlusSamples,
|
||||
Stats,
|
||||
LatestState,
|
||||
}
|
||||
|
||||
impl AggregationMode {
|
||||
pub fn needs_items(self) -> bool {
|
||||
matches!(self, Self::RawItems | Self::SummaryPlusSamples)
|
||||
}
|
||||
|
||||
pub fn needs_summary(self) -> bool {
|
||||
!matches!(self, Self::RawItems)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransportBehavior {
|
||||
RequestResponse,
|
||||
ServerStream,
|
||||
StatefulSession,
|
||||
DeferredResult,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ToolFamilyConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result_tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cancel_tool_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolFamilyConfig {
|
||||
pub fn validate_for_mode(&self, mode: ExecutionMode) -> Result<(), StreamingConfigError> {
|
||||
match mode {
|
||||
ExecutionMode::Unary | ExecutionMode::Window => {
|
||||
if self.start_tool_name.is_some()
|
||||
|| self.poll_tool_name.is_some()
|
||||
|| self.stop_tool_name.is_some()
|
||||
|| self.status_tool_name.is_some()
|
||||
|| self.result_tool_name.is_some()
|
||||
|| self.cancel_tool_name.is_some()
|
||||
{
|
||||
return Err(StreamingConfigError::UnexpectedToolFamily(mode));
|
||||
}
|
||||
}
|
||||
ExecutionMode::Session => {
|
||||
if self.start_tool_name.is_none()
|
||||
|| self.poll_tool_name.is_none()
|
||||
|| self.stop_tool_name.is_none()
|
||||
{
|
||||
return Err(StreamingConfigError::MissingSessionToolNames);
|
||||
}
|
||||
}
|
||||
ExecutionMode::AsyncJob => {
|
||||
if self.start_tool_name.is_none()
|
||||
|| self.status_tool_name.is_none()
|
||||
|| self.result_tool_name.is_none()
|
||||
|| self.cancel_tool_name.is_none()
|
||||
{
|
||||
return Err(StreamingConfigError::MissingAsyncJobToolNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamingConfig {
|
||||
pub mode: ExecutionMode,
|
||||
pub transport_behavior: TransportBehavior,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub window_duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_interval_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub idle_timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_session_lifetime_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_items: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_bytes: Option<u32>,
|
||||
pub aggregation_mode: AggregationMode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub items_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub done_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub redacted_paths: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub truncate_item_fields: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_field_length: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub drop_duplicates: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sampling_rate: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub tool_family: ToolFamilyConfig,
|
||||
}
|
||||
|
||||
impl StreamingConfig {
|
||||
pub fn validate_common(&self) -> Result<(), StreamingConfigError> {
|
||||
self.tool_family.validate_for_mode(self.mode)?;
|
||||
|
||||
if self.max_items.is_some_and(|value| value == 0) {
|
||||
return Err(StreamingConfigError::InvalidMaxItems);
|
||||
}
|
||||
|
||||
if self.max_bytes.is_some_and(|value| value == 0) {
|
||||
return Err(StreamingConfigError::InvalidMaxBytes);
|
||||
}
|
||||
|
||||
if self.max_field_length.is_some_and(|value| value == 0) {
|
||||
return Err(StreamingConfigError::InvalidMaxFieldLength);
|
||||
}
|
||||
|
||||
if self
|
||||
.sampling_rate
|
||||
.is_some_and(|value| value <= 0.0 || value > 1.0)
|
||||
{
|
||||
return Err(StreamingConfigError::InvalidSamplingRate);
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
ExecutionMode::Unary => {
|
||||
if self.window_duration_ms.is_some()
|
||||
|| self.poll_interval_ms.is_some()
|
||||
|| self.idle_timeout_ms.is_some()
|
||||
|| self.max_session_lifetime_ms.is_some()
|
||||
{
|
||||
return Err(StreamingConfigError::UnexpectedStatefulLimits(
|
||||
ExecutionMode::Unary,
|
||||
));
|
||||
}
|
||||
}
|
||||
ExecutionMode::Window => {
|
||||
if self.window_duration_ms.is_none() {
|
||||
return Err(StreamingConfigError::MissingWindowDuration);
|
||||
}
|
||||
}
|
||||
ExecutionMode::Session => {
|
||||
if self.poll_interval_ms.is_none() || self.max_session_lifetime_ms.is_none() {
|
||||
return Err(StreamingConfigError::MissingSessionLimits);
|
||||
}
|
||||
}
|
||||
ExecutionMode::AsyncJob => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_for_protocol(&self, protocol: Protocol) -> Result<(), StreamingConfigError> {
|
||||
self.validate_common()?;
|
||||
|
||||
if !protocol.supports_execution_mode(self.mode) {
|
||||
return Err(StreamingConfigError::UnsupportedExecutionMode {
|
||||
protocol,
|
||||
mode: self.mode,
|
||||
});
|
||||
}
|
||||
|
||||
if !protocol.supports_transport_behavior(self.transport_behavior) {
|
||||
return Err(StreamingConfigError::UnsupportedTransportBehavior {
|
||||
protocol,
|
||||
behavior: self.transport_behavior,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq)]
|
||||
pub enum StreamingConfigError {
|
||||
#[error("window mode requires window_duration_ms")]
|
||||
MissingWindowDuration,
|
||||
#[error("session mode requires poll_interval_ms and max_session_lifetime_ms")]
|
||||
MissingSessionLimits,
|
||||
#[error("max_items must be greater than zero")]
|
||||
InvalidMaxItems,
|
||||
#[error("max_bytes must be greater than zero")]
|
||||
InvalidMaxBytes,
|
||||
#[error("max_field_length must be greater than zero")]
|
||||
InvalidMaxFieldLength,
|
||||
#[error("sampling_rate must be in range (0, 1]")]
|
||||
InvalidSamplingRate,
|
||||
#[error("{0:?} mode cannot use session/window-only limits")]
|
||||
UnexpectedStatefulLimits(ExecutionMode),
|
||||
#[error("{mode:?} is not supported for protocol {protocol:?}")]
|
||||
UnsupportedExecutionMode {
|
||||
protocol: Protocol,
|
||||
mode: ExecutionMode,
|
||||
},
|
||||
#[error("{behavior:?} is not supported for protocol {protocol:?}")]
|
||||
UnsupportedTransportBehavior {
|
||||
protocol: Protocol,
|
||||
behavior: TransportBehavior,
|
||||
},
|
||||
#[error("session mode requires start, poll and stop tool names")]
|
||||
MissingSessionToolNames,
|
||||
#[error("async_job mode requires start, status, result and cancel tool names")]
|
||||
MissingAsyncJobToolNames,
|
||||
#[error("{0:?} mode cannot define tool-family names")]
|
||||
UnexpectedToolFamily(ExecutionMode),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig,
|
||||
TransportBehavior,
|
||||
};
|
||||
use crate::protocol::Protocol;
|
||||
|
||||
fn session_config() -> StreamingConfig {
|
||||
StreamingConfig {
|
||||
mode: ExecutionMode::Session,
|
||||
transport_behavior: TransportBehavior::StatefulSession,
|
||||
window_duration_ms: None,
|
||||
poll_interval_ms: Some(1_000),
|
||||
upstream_timeout_ms: Some(5_000),
|
||||
idle_timeout_ms: Some(15_000),
|
||||
max_session_lifetime_ms: Some(60_000),
|
||||
max_items: Some(100),
|
||||
max_bytes: Some(65_536),
|
||||
aggregation_mode: AggregationMode::SummaryPlusSamples,
|
||||
summary_path: Some("$.summary".to_owned()),
|
||||
items_path: Some("$.items".to_owned()),
|
||||
cursor_path: Some("$.cursor".to_owned()),
|
||||
status_path: Some("$.status".to_owned()),
|
||||
done_path: Some("$.done".to_owned()),
|
||||
redacted_paths: vec!["$.items[*].token".to_owned()],
|
||||
truncate_item_fields: true,
|
||||
max_field_length: Some(256),
|
||||
drop_duplicates: true,
|
||||
sampling_rate: Some(0.5),
|
||||
tool_family: ToolFamilyConfig {
|
||||
start_tool_name: Some("logs_start".to_owned()),
|
||||
poll_tool_name: Some("logs_poll".to_owned()),
|
||||
stop_tool_name: Some("logs_stop".to_owned()),
|
||||
status_tool_name: None,
|
||||
result_tool_name: None,
|
||||
cancel_tool_name: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_config_roundtrips_through_json() {
|
||||
let config = session_config();
|
||||
|
||||
let value = serde_json::to_value(&config).unwrap();
|
||||
let decoded: StreamingConfig = serde_json::from_value(value.clone()).unwrap();
|
||||
|
||||
assert_eq!(decoded, config);
|
||||
assert_eq!(value["mode"], json!("session"));
|
||||
assert_eq!(value["tool_family"]["start_tool_name"], json!("logs_start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_mode_rejects_session_specific_fields() {
|
||||
let config = StreamingConfig {
|
||||
mode: ExecutionMode::Unary,
|
||||
transport_behavior: TransportBehavior::RequestResponse,
|
||||
window_duration_ms: None,
|
||||
poll_interval_ms: Some(1_000),
|
||||
upstream_timeout_ms: None,
|
||||
idle_timeout_ms: None,
|
||||
max_session_lifetime_ms: None,
|
||||
max_items: None,
|
||||
max_bytes: None,
|
||||
aggregation_mode: AggregationMode::SummaryOnly,
|
||||
summary_path: None,
|
||||
items_path: None,
|
||||
cursor_path: None,
|
||||
status_path: None,
|
||||
done_path: None,
|
||||
redacted_paths: Vec::new(),
|
||||
truncate_item_fields: false,
|
||||
max_field_length: None,
|
||||
drop_duplicates: false,
|
||||
sampling_rate: None,
|
||||
tool_family: ToolFamilyConfig::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.validate_common(),
|
||||
Err(StreamingConfigError::UnexpectedStatefulLimits(
|
||||
ExecutionMode::Unary
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_validation_rejects_graphql_session() {
|
||||
let config = session_config();
|
||||
|
||||
assert_eq!(
|
||||
config.validate_for_protocol(Protocol::Graphql),
|
||||
Err(StreamingConfigError::UnsupportedExecutionMode {
|
||||
protocol: Protocol::Graphql,
|
||||
mode: ExecutionMode::Session,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_validation_accepts_rest_session() {
|
||||
let config = session_config();
|
||||
|
||||
assert!(config.validate_for_protocol(Protocol::Rest).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,31 @@ pub enum RegistryError {
|
||||
InvitationNotFound { invitation_id: String },
|
||||
#[error("platform api key {key_id} was not found")]
|
||||
PlatformApiKeyNotFound { key_id: String },
|
||||
#[error("secret {secret_id} was not found")]
|
||||
SecretNotFound { secret_id: String },
|
||||
#[error("stream session {session_id} was not found")]
|
||||
StreamSessionNotFound { session_id: String },
|
||||
#[error("async job {job_id} was not found")]
|
||||
AsyncJobNotFound { job_id: String },
|
||||
#[error("invalid stream session transition for {session_id}: {from} -> {to}")]
|
||||
InvalidStreamSessionTransition {
|
||||
session_id: String,
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
#[error("invalid async job transition for {job_id}: {from} -> {to}")]
|
||||
InvalidAsyncJobTransition {
|
||||
job_id: String,
|
||||
from: String,
|
||||
to: String,
|
||||
},
|
||||
#[error("secret with name {name} already exists in workspace {workspace_id}")]
|
||||
SecretNameAlreadyExists { workspace_id: String, name: String },
|
||||
#[error("secret {secret_id} is referenced by auth profile {auth_profile_id}")]
|
||||
SecretReferencedByAuthProfile {
|
||||
secret_id: String,
|
||||
auth_profile_id: String,
|
||||
},
|
||||
#[error("invocation log {log_id} was not found")]
|
||||
InvocationLogNotFound { log_id: String },
|
||||
#[error("agent {agent_id} was not found")]
|
||||
|
||||
@@ -5,15 +5,18 @@ mod postgres;
|
||||
|
||||
pub use error::RegistryError;
|
||||
pub use model::{
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
|
||||
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
|
||||
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
|
||||
OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
|
||||
PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
||||
AgentSummary, AgentVersionRecord, AsyncJobFilter, AsyncJobRecord, AuthUserRecord,
|
||||
CreateAgentRequest, CreateAsyncJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateStreamSessionRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
||||
DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest,
|
||||
PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
StreamSessionFilter, StreamSessionRecord, UpdateAsyncJobStatusRequest,
|
||||
UpdateStreamSessionStateRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
|
||||
YamlImportJobId, YamlImportJobStatus,
|
||||
|
||||
@@ -328,6 +328,42 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secrets (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
name text not null,
|
||||
kind text not null,
|
||||
status text not null,
|
||||
current_version integer not null,
|
||||
last_used_at timestamptz null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create unique index if not exists secrets_workspace_name_idx on secrets(workspace_id, name)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists secret_versions (
|
||||
secret_id text not null references secrets(id) on delete cascade,
|
||||
version integer not null,
|
||||
ciphertext text not null,
|
||||
key_version text not null,
|
||||
created_at timestamptz not null,
|
||||
created_by text null references users(id) on delete set null,
|
||||
primary key (secret_id, version)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists yaml_import_jobs (
|
||||
id text primary key,
|
||||
@@ -477,5 +513,72 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists stream_sessions (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null references agents(id) on delete set null,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
protocol text not null,
|
||||
mode text not null,
|
||||
status text not null,
|
||||
cursor_json jsonb null,
|
||||
state_json jsonb not null,
|
||||
expires_at timestamptz not null,
|
||||
last_poll_at timestamptz null,
|
||||
created_at timestamptz not null,
|
||||
closed_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists stream_sessions_workspace_status_idx
|
||||
on stream_sessions(workspace_id, status, created_at desc)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists stream_sessions_expires_at_idx
|
||||
on stream_sessions(expires_at)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists async_jobs (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
agent_id text null references agents(id) on delete set null,
|
||||
operation_id text not null references operations(id) on delete cascade,
|
||||
status text not null,
|
||||
progress_json jsonb not null,
|
||||
result_json jsonb null,
|
||||
error_json jsonb null,
|
||||
expires_at timestamptz null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz not null,
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists async_jobs_workspace_status_idx
|
||||
on async_jobs(workspace_id, status, updated_at desc)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create index if not exists async_jobs_expires_at_idx
|
||||
on async_jobs(expires_at)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
|
||||
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
|
||||
Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, UsagePeriod,
|
||||
UsageRollup, User, UserSessionId, Workspace, WorkspaceId,
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AsyncJobHandle, AsyncJobId,
|
||||
AuthProfile, DescriptorId, ExecutionMode, ExportMode, InvitationToken, InvocationLevel,
|
||||
InvocationLog, InvocationSource, JobStatus, MembershipRole, Operation, OperationId,
|
||||
OperationStatus, PlatformApiKey, Protocol, SampleId, Secret, SecretId, SecretVersion,
|
||||
StreamSession, StreamSessionId, StreamStatus, UsagePeriod, UsageRollup, User, UserSessionId,
|
||||
Workspace, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_schema::Schema;
|
||||
@@ -42,6 +44,12 @@ define_registry_id!(YamlImportJobId);
|
||||
|
||||
pub type RegistryOperation = Operation<Schema, MappingSet>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Page<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceRecord {
|
||||
pub workspace: Workspace,
|
||||
@@ -85,6 +93,26 @@ pub struct PlatformApiKeyRecord {
|
||||
pub api_key: PlatformApiKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SecretRecord {
|
||||
pub secret: Secret,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SecretVersionRecord {
|
||||
pub secret_version: SecretVersion,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StreamSessionRecord {
|
||||
pub session: StreamSession,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AsyncJobRecord {
|
||||
pub job: AsyncJobHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InvocationLogRecord {
|
||||
pub log: InvocationLog,
|
||||
@@ -241,6 +269,8 @@ pub enum DescriptorKind {
|
||||
ProtoUpload,
|
||||
DescriptorSet,
|
||||
ReflectionSnapshot,
|
||||
WsdlUpload,
|
||||
XsdUpload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -403,6 +433,79 @@ pub struct CreatePlatformApiKeyRequest<'a> {
|
||||
pub secret_hash: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateSecretRequest<'a> {
|
||||
pub secret: &'a Secret,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RotateSecretRequest<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub secret_id: &'a SecretId,
|
||||
pub ciphertext: &'a str,
|
||||
pub key_version: &'a str,
|
||||
pub created_at: &'a str,
|
||||
pub updated_at: &'a str,
|
||||
pub created_by: Option<&'a crank_core::UserId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateStreamSessionRequest<'a> {
|
||||
pub session: &'a StreamSession,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UpdateStreamSessionStateRequest<'a> {
|
||||
pub session_id: &'a StreamSessionId,
|
||||
pub current_status: StreamStatus,
|
||||
pub next_status: StreamStatus,
|
||||
pub cursor: Option<&'a Value>,
|
||||
pub state: &'a Value,
|
||||
pub expires_at: Option<&'a str>,
|
||||
pub last_poll_at: Option<&'a str>,
|
||||
pub closed_at: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct StreamSessionFilter<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: Option<&'a AgentId>,
|
||||
pub operation_id: Option<&'a OperationId>,
|
||||
pub status: Option<StreamStatus>,
|
||||
pub mode: Option<ExecutionMode>,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateAsyncJobRequest<'a> {
|
||||
pub job: &'a AsyncJobHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UpdateAsyncJobStatusRequest<'a> {
|
||||
pub job_id: &'a AsyncJobId,
|
||||
pub current_status: JobStatus,
|
||||
pub next_status: JobStatus,
|
||||
pub progress: &'a Value,
|
||||
pub result: Option<&'a Value>,
|
||||
pub error: Option<&'a Value>,
|
||||
pub expires_at: Option<&'a str>,
|
||||
pub updated_at: &'a str,
|
||||
pub finished_at: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AsyncJobFilter<'a> {
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub agent_id: Option<&'a AgentId>,
|
||||
pub operation_id: Option<&'a OperationId>,
|
||||
pub status: Option<JobStatus>,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SaveSampleMetadataRequest<'a> {
|
||||
pub sample: &'a OperationSampleMetadata,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user