merge: streaming transport and protocol platform

This commit is contained in:
a.tolmachev
2026-04-11 09:41:28 +03:00
145 changed files with 22049 additions and 497 deletions
+1
View File
@@ -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
View File
@@ -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"
+5
View File
@@ -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"] }
+40 -3
View File
@@ -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"
```
+7 -12
View File
@@ -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`
+1
View File
@@ -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
View File
@@ -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,
+31
View File
@@ -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",
}
}
+3 -1
View File
@@ -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?;
+2
View File
@@ -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;
+56
View File
@@ -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>,
+98
View File
@@ -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 })))
}
+137
View File
@@ -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)))
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
+1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -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,
},
);
File diff suppressed because it is too large Load Diff
+70 -1
View File
@@ -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;
}
+171
View File
@@ -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;
}
+61
View File
@@ -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);
}
+37
View File
@@ -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;
+2
View File
@@ -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>
+2
View File
@@ -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>
+114
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -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>
+242
View File
@@ -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>
+22 -5
View File
@@ -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>
+119
View File
@@ -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>
+2
View File
@@ -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>
+39
View File
@@ -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>
+42 -3
View File
@@ -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 -->
+73 -6
View File
@@ -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>
+101
View File
@@ -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>
+94
View File
@@ -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&#10;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="{&#10; &quot;type&quot;: &quot;subscribe&quot;,&#10; &quot;topic&quot;: &quot;telemetry&quot;&#10;}"></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="{&#10; &quot;type&quot;: &quot;unsubscribe&quot;,&#10; &quot;topic&quot;: &quot;telemetry&quot;&#10;}"></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>
+159 -1
View File
@@ -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&#10;$.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>
+2
View File
@@ -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
View File
@@ -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>
+4
View File
@@ -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';
},
+62
View File
@@ -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)
+181
View File
@@ -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();
});
+2
View File
@@ -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';
},
+3
View File
@@ -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
View File
@@ -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;
+481
View File
@@ -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();
});
+166
View File
@@ -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();
});
+47
View File
@@ -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,
};
}());
+127
View File
@@ -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,
};
}());
+12
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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;
}
+13 -8
View File
@@ -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',
+52
View File
@@ -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
+21
View File
@@ -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;
+234
View File
@@ -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}`);
});
+550
View File
@@ -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([]);
});
+125
View File
@@ -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');
});
+83 -1
View File
@@ -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('[]');
});
+1
View File
@@ -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 {
+165 -22
View File
@@ -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 { .. }
));
}
}
+5 -1
View File
@@ -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 },
}
+1 -1
View File
@@ -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;
+17
View File
@@ -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 {
+3 -1
View File
@@ -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
+177 -3
View File
@@ -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())
}
}
+4
View File
@@ -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,
}
+2 -1
View File
@@ -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};
+17
View File
@@ -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,
}
+160
View File
@@ -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));
}
}
+19
View File
@@ -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
+326
View File
@@ -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");
}
}
+28
View File
@@ -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>,
},
}
+12
View File
@@ -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;
+40
View File
@@ -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>,
}
+165
View File
@@ -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")
);
}
}
+319
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn node_to_json(node: Node<'_, '_>, _unwrap_root: bool) -> Value {
let children = node
.children()
.filter(|child| child.is_element())
.collect::<Vec<_>>();
if children.is_empty() {
return node
.text()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| Value::String(value.to_owned()))
.unwrap_or(Value::Null);
}
let mut object = Map::new();
for child in children {
let key = child.tag_name().name().to_owned();
let value = node_to_json(child, false);
match object.get_mut(&key) {
Some(existing) => match existing {
Value::Array(array) => array.push(value),
other => {
let previous = other.take();
*other = Value::Array(vec![previous, value]);
}
},
None => {
object.insert(key, value);
}
}
}
Value::Object(object)
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::{SoapEnvelopeHeader, build_envelope, decode_envelope};
use crank_core::SoapBindingStyle;
#[test]
fn builds_document_literal_envelope() {
let xml = build_envelope(
"CreateLead",
Some("urn:crm"),
&json!({"email":"user@example.com","source":"mcp"}),
"http://schemas.xmlsoap.org/soap/envelope/",
SoapBindingStyle::DocumentLiteral,
&[],
);
assert!(xml.contains("<soap:Envelope"));
assert!(xml.contains("<m:CreateLead"));
assert!(xml.contains(r#"xmlns:m="urn:crm""#));
assert!(xml.contains("<email>user@example.com</email>"));
}
#[test]
fn decodes_wrapped_response_body() {
let value = decode_envelope(
r#"<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<CreateLeadResponse>
<id>lead_123</id>
<status>created</status>
</CreateLeadResponse>
</soap:Body>
</soap:Envelope>"#,
)
.unwrap();
assert_eq!(value["id"], "lead_123");
assert_eq!(value["status"], "created");
}
#[test]
fn builds_rpc_literal_envelope_with_headers() {
let xml = build_envelope(
"CreateLead",
Some("urn:crm"),
&json!({"email":"user@example.com"}),
"http://schemas.xmlsoap.org/soap/envelope/",
SoapBindingStyle::RpcLiteral,
&[SoapEnvelopeHeader {
name: "CorrelationId".to_owned(),
namespace_uri: Some("urn:headers".to_owned()),
value: Value::String("corr-123".to_owned()),
}],
);
assert!(xml.contains("<soap:Header>"));
assert!(
xml.contains(r#"<h:CorrelationId xmlns:h="urn:headers">corr-123</h:CorrelationId>"#)
);
assert!(xml.contains("<m:email>user@example.com</m:email>"));
}
}
+19
View File
@@ -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 },
}
+35
View File
@@ -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,
}
+17 -19
View File
@@ -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,
+3
View File
@@ -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);
+19 -4
View File
@@ -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};
+148 -10
View File
@@ -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(),
+74
View File
@@ -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));
}
}
+42
View File
@@ -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>,
}
+101
View File
@@ -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")
);
}
}
+210
View File
@@ -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")
);
}
}
+364
View File
@@ -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());
}
}
+25
View File
@@ -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")]
+12 -9
View File
@@ -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,
+103
View File
@@ -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(())
}
+107 -4
View File
@@ -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