From d7e5ae95d6eff3b6caefd266d0202aa832dfc7af Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 01:13:10 +0300 Subject: [PATCH 01/38] feat: add secret store foundation --- .env.example | 1 + Cargo.lock | 103 ++++++++++ Cargo.toml | 1 + TASKS.md | 11 +- apps/admin-api/Cargo.toml | 1 + apps/admin-api/src/app.rs | 102 +++++++++- apps/admin-api/src/error.rs | 6 + apps/admin-api/src/main.rs | 5 +- apps/admin-api/src/routes.rs | 1 + apps/admin-api/src/routes/secrets.rs | 98 +++++++++ apps/admin-api/src/secret_crypto.rs | 112 +++++++++++ apps/admin-api/src/service.rs | 157 ++++++++++++++- crates/crank-core/src/ids.rs | 1 + crates/crank-core/src/lib.rs | 4 +- crates/crank-core/src/secret.rs | 42 ++++ crates/crank-registry/src/error.rs | 4 + crates/crank-registry/src/lib.rs | 22 +-- crates/crank-registry/src/migrations.rs | 36 ++++ crates/crank-registry/src/model.rs | 33 +++- crates/crank-registry/src/postgres.rs | 251 ++++++++++++++++++++++-- docker-compose.yml | 1 + docs/admin-api.md | 4 +- docs/runtime-config.md | 6 +- 23 files changed, 954 insertions(+), 48 deletions(-) create mode 100644 apps/admin-api/src/routes/secrets.rs create mode 100644 apps/admin-api/src/secret_crypto.rs create mode 100644 crates/crank-core/src/secret.rs diff --git a/.env.example b/.env.example index c2719c1..e08f38f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 402bd99..3410d48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,7 @@ version = 4 name = "admin-api" version = "0.1.0" dependencies = [ + "aes-gcm", "argon2", "axum", "axum-extra", @@ -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" @@ -457,9 +503,19 @@ 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 = "deranged" version = "0.5.8" @@ -713,6 +769,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 +1104,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" @@ -1256,6 +1331,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 +1441,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" @@ -2626,6 +2719,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" diff --git a/Cargo.toml b/Cargo.toml index d3af950..beb18c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,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"] } diff --git a/TASKS.md b/TASKS.md index 2c4d8b6..f3c76f0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,18 +2,19 @@ ## Current -### `feat/secrets-auth-plan` +### `feat/secret-store-foundation` Status: completed 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 +- Workspace-scoped secret store is persisted in PostgreSQL +- Secret values are encrypted with `CRANK_MASTER_KEY` +- Admin API exposes create/list/get/rotate/delete secret endpoints +- Integration tests cover secret CRUD and rotation ## Next -- `feat/secret-store-foundation` +- `feat/auth-profile-secret-resolution` ## Backlog diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 9897ec2..1cab8b7 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +aes-gcm.workspace = true argon2.workspace = true axum.workspace = true axum-extra.workspace = true diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index d5081cf..3337f38 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -27,6 +27,7 @@ use crate::{ list_grpc_services, list_operations, publish_operation, run_test, update_operation, upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor, }, + secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret}, workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace}, }, state::AppState, @@ -99,6 +100,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}", @@ -187,7 +194,7 @@ mod tests { 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, + MembershipRole, Protocol, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; @@ -200,6 +207,7 @@ mod tests { use crate::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig, hash_password}, + secret_crypto::SecretCrypto, service::{AdminService, OperationPayload}, state::AppState, }; @@ -209,6 +217,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 +990,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(); @@ -1462,6 +1476,79 @@ mod tests { 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::().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 roundtrips_graphql_operation_through_yaml_upsert() { @@ -1644,7 +1731,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(), + ), }) } @@ -1823,6 +1915,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(), diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 9018099..3ffcde9 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -139,6 +139,9 @@ impl From 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::InvocationLogNotFound { log_id } => { Self::not_found(format!("invocation log {log_id} was not found")) } @@ -171,6 +174,9 @@ impl From 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::UserEmailAlreadyExists { email } => { Self::conflict(format!("user with email {email} already exists")) } diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 2eb1ab1..1f81762 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -2,6 +2,7 @@ mod app; mod auth; mod error; mod routes; +mod secret_crypto; mod service; mod state; mod storage; @@ -15,6 +16,7 @@ use tracing::info; use crate::{ app::build_app, auth::{AuthSettings, BootstrapAdminConfig}, + secret_crypto::SecretCrypto, service::AdminService, state::AppState, }; @@ -51,7 +53,8 @@ async fn main() -> Result<(), Box> { .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?; diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index 2f2828d..f5b1e3b 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod auth_profiles; pub mod observability; pub mod operations; +pub mod secrets; pub mod workspaces; use axum::Json; diff --git a/apps/admin-api/src/routes/secrets.rs b/apps/admin-api/src/routes/secrets.rs new file mode 100644 index 0000000..4bb1817 --- /dev/null +++ b/apps/admin-api/src/routes/secrets.rs @@ -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, + State(state): State, +) -> Result, 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, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, 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, + State(state): State, +) -> Result, ApiError> { + state + .service + .delete_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + ) + .await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/apps/admin-api/src/secret_crypto.rs b/apps/admin-api/src/secret_crypto.rs new file mode 100644 index 0000000..0316d68 --- /dev/null +++ b/apps/admin-api/src/secret_crypto.rs @@ -0,0 +1,112 @@ +use aes_gcm::{ + Aes256Gcm, KeyInit, Nonce, + aead::{Aead, OsRng, rand_core::RngCore}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::error::ApiError; + +#[derive(Clone)] +pub struct SecretCrypto { + cipher: Aes256Gcm, + key_version: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CipherEnvelope { + nonce_b64: String, + ciphertext_b64: String, +} + +impl SecretCrypto { + pub fn new(master_key: &str) -> Result { + let trimmed = master_key.trim(); + if trimmed.is_empty() { + return Err(ApiError::internal("CRANK_MASTER_KEY must not be empty")); + } + + let digest = Sha256::digest(trimmed.as_bytes()); + let cipher = Aes256Gcm::new_from_slice(digest.as_slice()).map_err(|error| { + ApiError::internal(format!("failed to initialize secret crypto: {error}")) + })?; + + Ok(Self { + cipher, + key_version: "v1".to_owned(), + }) + } + + pub fn key_version(&self) -> &str { + &self.key_version + } + + pub fn encrypt(&self, value: &Value) -> Result { + let plaintext = serde_json::to_vec(value).map_err(|error| { + ApiError::internal(format!("failed to serialize secret value: {error}")) + })?; + let mut nonce_bytes = [0_u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = self + .cipher + .encrypt(nonce, plaintext.as_ref()) + .map_err(|error| { + ApiError::internal(format!("failed to encrypt secret value: {error}")) + })?; + let envelope = CipherEnvelope { + nonce_b64: STANDARD.encode(nonce_bytes), + ciphertext_b64: STANDARD.encode(ciphertext), + }; + + serde_json::to_string(&envelope).map_err(|error| { + ApiError::internal(format!("failed to encode secret ciphertext: {error}")) + }) + } + + #[cfg(test)] + pub fn decrypt(&self, ciphertext: &str) -> Result { + let envelope: CipherEnvelope = serde_json::from_str(ciphertext).map_err(|error| { + ApiError::internal(format!("failed to decode secret envelope: {error}")) + })?; + let nonce_bytes = STANDARD.decode(envelope.nonce_b64).map_err(|error| { + ApiError::internal(format!("failed to decode secret nonce: {error}")) + })?; + let ciphertext_bytes = STANDARD.decode(envelope.ciphertext_b64).map_err(|error| { + ApiError::internal(format!("failed to decode secret payload: {error}")) + })?; + let plaintext = self + .cipher + .decrypt(Nonce::from_slice(&nonce_bytes), ciphertext_bytes.as_ref()) + .map_err(|error| { + ApiError::internal(format!("failed to decrypt secret value: {error}")) + })?; + + serde_json::from_slice(&plaintext).map_err(|error| { + ApiError::internal(format!("failed to deserialize secret value: {error}")) + }) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::SecretCrypto; + + #[test] + fn roundtrips_secret_payload() { + let crypto = SecretCrypto::new("test-master-key").unwrap(); + let plaintext = json!({ + "token": "top-secret", + "username": "demo" + }); + + let ciphertext = crypto.encrypt(&plaintext).unwrap(); + let decrypted = crypto.decrypt(&ciphertext).unwrap(); + + assert_eq!(decrypted, plaintext); + } +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c2a6d8e..607bbf9 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -12,21 +12,22 @@ use crank_core::{ InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, - Protocol, SampleId, Samples, Target, UsagePeriod, UserSessionId, Workspace, WorkspaceId, - WorkspaceStatus, + Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, Target, UsagePeriod, + UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_proto::{ProtoService, services_from_descriptor_set_bytes}; use crank_registry::{ AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, - CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, - OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry, - PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, - UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, + CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, + CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, + ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, + OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, + PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, RotateSecretRequest, + SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, + UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, + WorkspaceMembershipRecord, WorkspaceRecord, }; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; use crank_schema::Schema; @@ -43,6 +44,7 @@ use crate::{ hash_session_secret, verify_password, }, error::ApiError, + secret_crypto::SecretCrypto, storage::LocalArtifactStorage, }; @@ -52,6 +54,7 @@ pub struct AdminService { runtime: RuntimeExecutor, storage: LocalArtifactStorage, auth_settings: AuthSettings, + secret_crypto: SecretCrypto, } #[derive(Clone, Debug, Deserialize)] @@ -134,6 +137,18 @@ pub struct AuthProfilePayload { pub config: AuthConfig, } +#[derive(Clone, Debug, Deserialize)] +pub struct SecretPayload { + pub name: String, + pub kind: SecretKind, + pub value: Value, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RotateSecretPayload { + pub value: Value, +} + #[derive(Clone, Debug, Deserialize)] pub struct WorkspacePayload { pub slug: String, @@ -484,12 +499,14 @@ impl AdminService { registry: PostgresRegistry, storage_root: PathBuf, auth_settings: AuthSettings, + secret_crypto: SecretCrypto, ) -> Self { Self { registry, runtime: RuntimeExecutor::new(), storage: LocalArtifactStorage::new(storage_root), auth_settings, + secret_crypto, } } @@ -1704,6 +1721,114 @@ impl AdminService { Ok(self.registry.list_auth_profiles(workspace_id).await?) } + #[instrument(skip(self))] + pub async fn list_secrets(&self, workspace_id: &WorkspaceId) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + Ok(self + .registry + .list_secrets(workspace_id) + .await? + .into_iter() + .map(|record| record.secret) + .collect()) + } + + #[instrument(skip(self))] + pub async fn get_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + self.registry + .get_secret(workspace_id, secret_id) + .await? + .map(|record| record.secret) + .ok_or_else(|| { + ApiError::not_found(format!("secret {} was not found", secret_id.as_str())) + }) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))] + pub async fn create_secret( + &self, + workspace_id: &WorkspaceId, + created_by: Option<&UserId>, + payload: SecretPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + validate_secret_payload(&payload)?; + + let now = now_string()?; + let secret = Secret { + id: SecretId::new(new_prefixed_id("secret")), + workspace_id: workspace_id.clone(), + name: payload.name.trim().to_owned(), + kind: payload.kind, + status: SecretStatus::Active, + current_version: 1, + created_at: now.clone(), + updated_at: now, + last_used_at: None, + }; + let ciphertext = self.secret_crypto.encrypt(&payload.value)?; + + self.registry + .create_secret(CreateSecretRequest { + secret: &secret, + ciphertext: &ciphertext, + key_version: self.secret_crypto.key_version(), + created_by, + }) + .await?; + info!(secret_id = %secret.id.as_str(), "secret created"); + + Ok(secret) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] + pub async fn rotate_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + created_by: Option<&UserId>, + payload: RotateSecretPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + if payload.value.is_null() { + return Err(ApiError::validation("secret value must not be null")); + } + + let now = now_string()?; + let ciphertext = self.secret_crypto.encrypt(&payload.value)?; + self.registry + .rotate_secret(RotateSecretRequest { + workspace_id, + secret_id, + ciphertext: &ciphertext, + key_version: self.secret_crypto.key_version(), + created_at: &now, + updated_at: &now, + created_by, + }) + .await?; + info!(secret_id = %secret_id.as_str(), "secret rotated"); + + self.get_secret(workspace_id, secret_id).await + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] + pub async fn delete_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result<(), ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + self.registry.delete_secret(workspace_id, secret_id).await?; + info!(secret_id = %secret_id.as_str(), "secret deleted"); + Ok(()) + } + #[instrument(skip(self))] pub async fn list_agents( &self, @@ -3249,6 +3374,18 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), Err(ApiError::validation("auth kind and config must match")) } +fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> { + if payload.name.trim().is_empty() { + return Err(ApiError::validation("secret name must not be empty")); + } + + if payload.value.is_null() { + return Err(ApiError::validation("secret value must not be null")); + } + + Ok(()) +} + fn latest_sample_ref( samples: &[OperationSampleMetadata], sample_kind: SampleKind, diff --git a/crates/crank-core/src/ids.rs b/crates/crank-core/src/ids.rs index 604e255..be5a2b4 100644 --- a/crates/crank-core/src/ids.rs +++ b/crates/crank-core/src/ids.rs @@ -47,3 +47,4 @@ define_id!(AgentId); define_id!(InvitationId); define_id!(PlatformApiKeyId); define_id!(InvocationLogId); +define_id!(SecretId); diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 23cf570..688f7e3 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod ids; pub mod observability; pub mod operation; pub mod protocol; +pub mod secret; pub mod workspace; pub use access::{ @@ -18,7 +19,7 @@ pub use auth::{ }; pub use ids::{ AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, - PlatformApiKeyId, SampleId, ToolId, UserId, UserSessionId, WorkspaceId, + PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId, }; pub use observability::{ InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup, @@ -29,4 +30,5 @@ pub use operation::{ RetryPolicy, Samples, Target, ToolDescription, ToolExample, }; pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol}; +pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion}; pub use workspace::{Workspace, WorkspaceStatus}; diff --git a/crates/crank-core/src/secret.rs b/crates/crank-core/src/secret.rs new file mode 100644 index 0000000..375223f --- /dev/null +++ b/crates/crank-core/src/secret.rs @@ -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, +} + +#[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, +} diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index 8089561..2df780f 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -23,6 +23,10 @@ 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("secret with name {name} already exists in workspace {workspace_id}")] + SecretNameAlreadyExists { workspace_id: String, name: String }, #[error("invocation log {log_id} was not found")] InvocationLogNotFound { log_id: String }, #[error("agent {agent_id} was not found")] diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index fa9a8d3..773ecc3 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -6,16 +6,16 @@ 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, - UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, - WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, - YamlImportJobId, YamlImportJobStatus, + CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, + CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, + DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, + MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, + OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, + PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, + SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, SecretRecord, SecretVersionRecord, SessionRecord, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, + WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }; pub use postgres::PostgresRegistry; diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index 8560eb7..d26b8b2 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -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, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index 9b1b9c4..d578871 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -1,8 +1,8 @@ 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, + Operation, OperationId, OperationStatus, PlatformApiKey, Protocol, SampleId, Secret, SecretId, + SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId, Workspace, WorkspaceId, }; use crank_mapping::MappingSet; use crank_schema::Schema; @@ -85,6 +85,16 @@ 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 InvocationLogRecord { pub log: InvocationLog, @@ -403,6 +413,25 @@ 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, Eq)] pub struct SaveSampleMetadataRequest<'a> { pub sample: &'a OperationSampleMetadata, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 06d5eb7..ca6d103 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -1,8 +1,9 @@ use crank_core::{ AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, - OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target, - UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId, + OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, + SecretId, SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, + WorkspaceId, }; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -18,16 +19,17 @@ use crate::{ model::{ AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, - CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, - DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, - OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, - UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, - WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, - YamlImportJobStatus, + CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, + CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, InvocationLogRecord, + ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, + OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, + PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, + RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SecretRecord, + SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, + UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, + WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }, }; @@ -652,6 +654,212 @@ impl PostgresRegistry { row.as_ref().map(map_platform_api_key_record).transpose() } + pub async fn list_secrets( + &self, + workspace_id: &WorkspaceId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + id, + workspace_id, + name, + kind, + status, + current_version, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at + from secrets + where workspace_id = $1 + order by name asc", + ) + .bind(workspace_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_secret_record).collect() + } + + pub async fn get_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + workspace_id, + name, + kind, + status, + current_version, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at + from secrets + where workspace_id = $1 and id = $2", + ) + .bind(workspace_id.as_str()) + .bind(secret_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_secret_record).transpose() + } + + pub async fn create_secret( + &self, + request: CreateSecretRequest<'_>, + ) -> Result<(), RegistryError> { + let mut tx = self.pool.begin().await?; + let result = sqlx::query( + "insert into secrets ( + id, + workspace_id, + name, + kind, + status, + current_version, + last_used_at, + created_at, + updated_at + ) values ( + $1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9::timestamptz + )", + ) + .bind(request.secret.id.as_str()) + .bind(request.secret.workspace_id.as_str()) + .bind(&request.secret.name) + .bind(serialize_enum_text(&request.secret.kind, "kind")?) + .bind(serialize_enum_text(&request.secret.status, "status")?) + .bind(to_db_version(request.secret.current_version)) + .bind(request.secret.last_used_at.as_deref()) + .bind(&request.secret.created_at) + .bind(&request.secret.updated_at) + .execute(&mut *tx) + .await; + + match result { + Ok(_) => { + sqlx::query( + "insert into secret_versions ( + secret_id, + version, + ciphertext, + key_version, + created_at, + created_by + ) values ( + $1, $2, $3, $4, $5::timestamptz, $6 + )", + ) + .bind(request.secret.id.as_str()) + .bind(to_db_version(request.secret.current_version)) + .bind(request.ciphertext) + .bind(request.key_version) + .bind(&request.secret.created_at) + .bind(request.created_by.map(|value| value.as_str())) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + Err(sqlx::Error::Database(error)) + if error.constraint() == Some("secrets_workspace_name_idx") => + { + Err(RegistryError::SecretNameAlreadyExists { + workspace_id: request.secret.workspace_id.as_str().to_owned(), + name: request.secret.name.clone(), + }) + } + Err(error) => Err(RegistryError::Storage(error)), + } + } + + pub async fn rotate_secret( + &self, + request: RotateSecretRequest<'_>, + ) -> Result { + let existing = self + .get_secret(request.workspace_id, request.secret_id) + .await? + .ok_or_else(|| RegistryError::SecretNotFound { + secret_id: request.secret_id.as_str().to_owned(), + })?; + let next_version = existing.secret.current_version + 1; + + let mut tx = self.pool.begin().await?; + sqlx::query( + "insert into secret_versions ( + secret_id, + version, + ciphertext, + key_version, + created_at, + created_by + ) values ( + $1, $2, $3, $4, $5::timestamptz, $6 + )", + ) + .bind(request.secret_id.as_str()) + .bind(to_db_version(next_version)) + .bind(request.ciphertext) + .bind(request.key_version) + .bind(request.created_at) + .bind(request.created_by.map(|value| value.as_str())) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update secrets + set current_version = $3, + updated_at = $4::timestamptz + where workspace_id = $1 and id = $2", + ) + .bind(request.workspace_id.as_str()) + .bind(request.secret_id.as_str()) + .bind(to_db_version(next_version)) + .bind(request.updated_at) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(SecretVersionRecord { + secret_version: SecretVersion { + secret_id: request.secret_id.clone(), + version: next_version, + ciphertext: request.ciphertext.to_owned(), + key_version: request.key_version.to_owned(), + created_at: request.created_at.to_owned(), + created_by: request.created_by.cloned(), + }, + }) + } + + pub async fn delete_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "delete from secrets + where workspace_id = $1 and id = $2", + ) + .bind(workspace_id.as_str()) + .bind(secret_id.as_str()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(RegistryError::SecretNotFound { + secret_id: secret_id.as_str().to_owned(), + }); + } + + Ok(()) + } + pub async fn create_platform_api_key( &self, request: CreatePlatformApiKeyRequest<'_>, @@ -3088,6 +3296,25 @@ fn map_platform_api_key_record(row: &PgRow) -> Result Result { + Ok(SecretRecord { + secret: Secret { + id: SecretId::new(row.try_get::("id")?), + workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), + name: row.try_get("name")?, + kind: deserialize_enum_text(&row.try_get::("kind")?, "kind")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + current_version: from_db_version( + row.try_get::("current_version")?, + "current_version", + )?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + last_used_at: row.try_get("last_used_at")?, + }, + }) +} + fn map_invocation_log_record(row: &PgRow) -> Result { Ok(InvocationLogRecord { log: InvocationLog { diff --git a/docker-compose.yml b/docker-compose.yml index 72bb869..24760f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: CRANK_ADMIN_BIND: ${CRANK_ADMIN_BIND:-0.0.0.0:3001} CRANK_LOG_LEVEL: ${CRANK_LOG_LEVEL:-info} CRANK_SECRET_PROVIDER: ${CRANK_SECRET_PROVIDER:-env} + CRANK_MASTER_KEY: ${CRANK_MASTER_KEY} CRANK_PUBLIC_BASE_URL: ${CRANK_PUBLIC_BASE_URL:-http://localhost:3000} CRANK_SESSION_SECRET: ${CRANK_SESSION_SECRET} CRANK_PASSWORD_PEPPER: ${CRANK_PASSWORD_PEPPER} diff --git a/docs/admin-api.md b/docs/admin-api.md index 32b5ee4..63ea0fc 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -131,10 +131,10 @@ Контракт: -- `POST /secrets` принимает metadata и plaintext value, но plaintext возвращается только в create/rotate request path и не выдается повторно; +- `POST /secrets` принимает metadata и plaintext value, но create-response возвращает только metadata; - `GET /secrets` и `GET /secrets/{secret_id}` возвращают только metadata, `kind`, `status`, `current_version`, `created_at`, `updated_at`, `last_used_at` при наличии; - `POST /secrets/{secret_id}/rotate` создает новую secret version; -- `DELETE /secrets/{secret_id}` запрещен, если secret используется опубликованными auth profiles или operations; +- `DELETE /secrets/{secret_id}` в secret foundation удаляет secret без reference checks; валидация ссылок добавляется в `feat/auth-profile-secret-resolution`; - `AuthProfile.config` хранит ссылки на `secret_id`, а не placeholder-строки `${secrets.*}`. ### 5.6. Agents diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 3785ae6..447e028 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -65,13 +65,13 @@ var/crank/ - `CRANK_MCP_REFRESH_MS` - `CRANK_LOG_LEVEL` - `CRANK_SECRET_PROVIDER` +- `CRANK_MASTER_KEY` - `CRANK_PUBLIC_BASE_URL` - `CRANK_MCP_PUBLIC_URL` Опционально: - `CRANK_ADMIN_TOKEN` -- `CRANK_MASTER_KEY` - `CRANK_DEMO_SEED` Стартовое значение для refresh published tools: @@ -131,6 +131,10 @@ Demo/deployment: - `CRANK_BOOTSTRAP_ADMIN_PASSWORD` - `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME` +Для secret store foundation нужен: + +- `CRANK_MASTER_KEY` + Для опционального demo-seed: - `CRANK_DEMO_SEED=true` From 04ed704e948df4a1c2d266ad57e2ad3bdff6017e Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 01:45:48 +0300 Subject: [PATCH 02/38] docs: define streaming mcp architecture --- README.md | 4 +- TASKS.md | 22 +- docs/architecture.md | 20 +- docs/data-model.md | 57 +++- docs/database-schema.md | 42 +++ docs/implementation-plan.md | 14 + docs/mcp-interface.md | 39 +++ docs/module-decomposition.md | 7 + docs/protocols/graphql.md | 4 +- docs/protocols/grpc.md | 31 +- docs/protocols/rest.md | 8 + docs/streaming-mcp-plan.md | 615 +++++++++++++++++++++++++++++++++++ 12 files changed, 834 insertions(+), 29 deletions(-) create mode 100644 docs/streaming-mcp-plan.md diff --git a/README.md b/README.md index dc1132d..fadbc25 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Crank - платформа для публикации внешних API в в - `Agent` как curated MCP surface для LLM. - Поддержка REST для `GET`, `POST`, `PUT`, `PATCH` и `DELETE`. - Поддержка GraphQL для `query` и `mutation`. -- Поддержка только unary-методов gRPC. +- Поддержка unary и bounded server-streaming для gRPC. +- Поддержка controlled streaming modes поверх MCP `Streamable HTTP`. - Platform API keys и membership layer. - Observability: invocation logs, usage aggregates, latency/error metrics. - Импорт и экспорт operation-конфигураций в `YAML`. @@ -45,6 +46,7 @@ Crank - платформа для публикации внешних API в в - `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/rust-design.md` - правила распределения поведения в Rust. - `docs/development-rules.md` - правила разработки и workflow. - `docs/rust-code-rules.md` - Rust-specific coding rules. diff --git a/TASKS.md b/TASKS.md index f3c76f0..ad6532e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,23 +2,31 @@ ## Current -### `feat/secret-store-foundation` +### `feat/streaming-mcp-architecture` Status: completed DoD: -- Workspace-scoped secret store is persisted in PostgreSQL -- Secret values are encrypted with `CRANK_MASTER_KEY` -- Admin API exposes create/list/get/rotate/delete secret endpoints -- Integration tests cover secret CRUD and rotation +- Official MCP transport semantics are reflected in docs +- Streaming modes and protocol support matrix are documented +- Core docs and `TASKS.md` are synchronized around controlled streaming model ## Next -- `feat/auth-profile-secret-resolution` +- `feat/mcp-streamable-http-alignment` ## Backlog -- `feat/secret-store-foundation` +- `feat/streaming-mcp-architecture` +- `feat/mcp-streamable-http-alignment` +- `feat/streaming-core-model` +- `feat/stream-session-store` +- `feat/runtime-window-mode` +- `feat/rest-sse-adapter` +- `feat/grpc-server-streaming-adapter` +- `feat/session-and-job-tools` +- `feat/streaming-ui-config` +- `feat/streaming-e2e` - `feat/auth-profile-secret-resolution` - `feat/runtime-upstream-auth` - `feat/secrets-ui` diff --git a/docs/architecture.md b/docs/architecture.md index df215ac..d43a836 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,6 +130,7 @@ Crank - платформа для публикации внешних API в в - `Workspace` как tenant boundary. - Операции `REST`, `GraphQL`, `unary gRPC`. +- Controlled streaming operations поверх `Streamable HTTP`, REST SSE и gRPC server-streaming. - `Agent` и привязка операций к агенту. - Agent-scoped MCP endpoints. - Platform API keys. @@ -141,7 +142,8 @@ Crank - платформа для публикации внешних API в в ### Не входит -- gRPC streaming. +- GraphQL `subscription`. +- gRPC client-streaming и bidirectional streaming. - SOAP. - Оркестрация workflow. - Биллинг. @@ -201,10 +203,20 @@ GraphQL в MCP публикуется как фиксированная опер ### gRPC -- только unary RPC; +- unary RPC; +- bounded server-streaming через `window`, `session` и `async_job` execution modes; - `.proto` и `descriptor set`; - JSON-oriented schema model поверх protobuf; -- без streaming. +- без client-streaming и bidi. + +### Streaming + +Платформа поддерживает controlled streaming model: + +- downstream transport: `Streamable HTTP` с optional SSE; +- upstream streaming: REST SSE и gRPC server-streaming; +- execution modes: `unary`, `window`, `session`, `async_job`; +- никакого raw infinite stream passthrough в MCP client. ## 8. Работа с файлами и автогенерация черновика @@ -234,6 +246,8 @@ GraphQL в MCP публикуется как фиксированная опер - `Agent` - `AgentVersion` - `AgentOperationBinding` +- `StreamSession` +- `AsyncJobHandle` - `AuthProfile` - `PlatformApiKey` - `UserSession` diff --git a/docs/data-model.md b/docs/data-model.md index 670bdbf..d5dea27 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -17,7 +17,7 @@ Каждая `Operation` соответствует одному интеграционному контракту: - GraphQL -> один конкретный `query` или `mutation`; -- gRPC -> один unary method; +- gRPC -> один unary method или один server-streaming method в bounded execution mode; - REST -> один endpoint-сценарий. Однако MCP tool публикуется не напрямую из operation, а через `AgentOperationBinding` внутри конкретного `Agent`. @@ -54,6 +54,16 @@ Помимо канонической JSON-модели система поддерживает импорт и экспорт конфигураций в `YAML`. +### 2.7. Streaming operation обязана быть bounded + +Если операция использует upstream streaming, она должна работать в одном из execution modes: + +- `window` +- `session` +- `async_job` + +Бесконечный passthrough stream не является допустимой моделью `Operation`. + ## 3. Корневые сущности ### 3.1. `Workspace` @@ -96,6 +106,7 @@ - `samples` - `generated_draft` - `config_export` +- `streaming_config` - `created_at` - `updated_at` - `published_at` @@ -314,6 +325,50 @@ - `p95_ms` - `p99_ms` +### 3.16. `StreamSession` + +Поля: + +- `id` +- `workspace_id` +- `agent_id` +- `operation_id` +- `mode` +- `status` +- `cursor` +- `state` +- `expires_at` +- `last_poll_at` +- `created_at` +- `closed_at` + +Назначение: + +- хранение bounded session state для streaming tools; +- поддержка `start/poll/stop`; +- cleanup orphaned и expired sessions. + +### 3.17. `AsyncJobHandle` + +Поля: + +- `id` +- `workspace_id` +- `agent_id` +- `operation_id` +- `status` +- `progress` +- `result` +- `error` +- `expires_at` +- `created_at` +- `updated_at` +- `finished_at` + +Назначение: + +- поддержка `start/status/result/cancel` для long-running upstream actions. + ## 4. `Target` `Target` описывает конкретный внешний вызов. Это discriminated union по протоколу. diff --git a/docs/database-schema.md b/docs/database-schema.md index 9497258..88eccf3 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -49,6 +49,8 @@ - `agent_operation_bindings` - `published_agents` - `platform_api_keys` +- `stream_sessions` +- `async_jobs` - `invocation_logs` - `usage_rollups` - `yaml_import_jobs` @@ -180,6 +182,46 @@ - `unique (workspace_id, name)` +### `stream_sessions` + +- `id` +- `workspace_id` +- `agent_id` +- `operation_id` +- `mode` +- `status` +- `cursor_json` +- `state_json` +- `expires_at` +- `last_poll_at` +- `created_at` +- `closed_at` + +Индексы: + +- `(workspace_id, status, expires_at)` +- `(operation_id, status)` + +### `async_jobs` + +- `id` +- `workspace_id` +- `agent_id` +- `operation_id` +- `status` +- `progress_json` +- `result_json` +- `error_json` +- `expires_at` +- `created_at` +- `updated_at` +- `finished_at` + +Индексы: + +- `(workspace_id, status, expires_at)` +- `(operation_id, status)` + ## 7. Workspaces and access layer ### `workspaces` diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 4aef4ce..84c5400 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -134,3 +134,17 @@ DoD: - end-to-end demo воспроизводим; - deployment и healthchecks стабильно зелёные; - документация и продуктовый сценарий совпадают. + +## 12. Этап 11. MCP streaming proxy support + +Цель: + +- довести Crank до controlled streaming model поверх MCP `Streamable HTTP`. + +DoD: + +- `mcp-server` соответствует transport semantics `2025-06-18`; +- execution modes `window`, `session`, `async_job` формально описаны и реализованы; +- REST SSE и gRPC server-streaming поддерживаются в bounded форме; +- UI умеет конфигурировать streaming limits, aggregation и lifecycle; +- e2e сценарии покрывают window/session/job calls. diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 0467e26..f83e4d6 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -11,6 +11,8 @@ Решение: - основной transport: `Streamable HTTP`; +- `POST` может завершаться `application/json` или `text/event-stream`; +- `GET` SSE stream поддерживается как server-to-client канал; - отдельный `mcp-server` как сервис; - `stdio` не является обязательной частью текущего scope. @@ -40,6 +42,7 @@ - загрузить published agents и их bindings из registry; - преобразовать их в MCP tool definitions; +- вести `Mcp-Session-Id` и `MCP-Protocol-Version`; - принимать вызовы tools от MCP clients; - валидировать вход; - делегировать исполнение в runtime; @@ -116,6 +119,12 @@ - `tools/list` - `tools/call` +Дополнительно на transport уровне: + +- `POST /mcp/v1/{workspace_slug}/{agent_slug}` как основной MCP endpoint; +- `GET /mcp/v1/{workspace_slug}/{agent_slug}` для SSE stream; +- `DELETE /mcp/v1/{workspace_slug}/{agent_slug}` для explicit session termination, если сервер разрешает client-side session close. + ### Tool listing 1. клиент вызывает `tools/list`; @@ -133,6 +142,34 @@ 5. делегирует вызов в `crank-runtime`; 6. возвращает результат. +### Tool call и streaming + +Поддерживаются четыре execution modes: + +- `unary` +- `window` +- `session` +- `async_job` + +Правила публикации: + +- `unary` и `window` публикуются как один tool; +- `session` публикуется как `start/poll/stop` family; +- `async_job` публикуется как `start/status/result/cancel` family. + +Transport-level SSE не отменяет bounded tool semantics. Даже если `POST` отвечает через `text/event-stream`, итогом вызова должен оставаться управляемый JSON-RPC lifecycle. + +## 8.1. Multiple SSE connections + +Crank должен корректно работать, если MCP client держит несколько SSE streams одновременно. + +Правила: + +- одно server message отправляется только в один stream; +- disconnect не считается cancel; +- cancel выражается отдельным MCP notification или session/job control tool; +- resumability допустима как будущая возможность, но не обязательна в MVP. + ## 9. Обновление tools После публикации новой operation version или agent version: @@ -171,7 +208,9 @@ - `mcp-server` - отдельный сервис; - transport - `Streamable HTTP`; +- `POST` и `GET` transport semantics соответствуют MCP spec `2025-06-18`; - endpoint определяется парой `workspace + agent`; - одна published operation = один MCP tool внутри agent; +- streaming operations публикуются как bounded tools или tool families; - reload published tools без пересборки сервиса; - никакой draft-логики или admin CRUD в MCP слое. diff --git a/docs/module-decomposition.md b/docs/module-decomposition.md index 24bd200..41d107a 100644 --- a/docs/module-decomposition.md +++ b/docs/module-decomposition.md @@ -42,6 +42,7 @@ crank/ - secret management domain; - agent publishing domain; - observability domain. +- streaming execution domain. ## 4. Детальная декомпозиция по crate @@ -64,6 +65,7 @@ crank/ - `auth` - `secret` - `observability` +- `streaming` - `errors` ### 4.2. `crank-schema` @@ -98,6 +100,7 @@ crank/ - хранение workspace-scoped operations и version snapshots; - хранение workspace-scoped secrets и secret versions; - хранение agents и agent versions; +- хранение stream sessions и async jobs; - auth profiles; - platform API keys; - logs и usage aggregates; @@ -109,6 +112,7 @@ crank/ - исполнение published operation; - резолв `auth_profile_ref -> secret -> request auth`; +- orchestration window/session/job execution; - запись invocation events; - возврат нормализованного результата. @@ -133,6 +137,7 @@ crank/ - `platform_api_keys` - `logs` - `usage` +- `streaming` ### 4.9. `apps/mcp-server` @@ -141,6 +146,8 @@ crank/ - публикация published agent bindings как MCP tools; - transport handling; - JSON-RPC lifecycle; +- SSE lifecycle и `Mcp-Session-Id`; +- tool-family generation для `session` и `async_job`; - вызов runtime. Антипаттерн: diff --git a/docs/protocols/graphql.md b/docs/protocols/graphql.md index a6b11cc..b797cb1 100644 --- a/docs/protocols/graphql.md +++ b/docs/protocols/graphql.md @@ -30,6 +30,8 @@ GraphQL поддерживается как отдельный тип интег - обязательная зависимость от introspection - автоматическое построение любого запроса по полной GraphQL schema +`subscription` допускается только как future scope после появления отдельного websocket/subscription adapter и controlled streaming lifecycle. + ## 4. Ключевое архитектурное ограничение Платформа не должна публиковать в MCP общий GraphQL tool, который умеет получать любые поля и принимать любые параметры в зависимости от намерения LLM. @@ -92,7 +94,7 @@ GraphQL operation должна включать: - структура ответа зависит от `selection set`, значит она должна быть фиксирована заранее; - GraphQL endpoint обычно один, поэтому операция определяется не URL, а телом запроса; - variables должны быть строго ограничены, иначе один tool станет слишком широким и плохо управляемым; -- `subscription` по смыслу не подходит модели MCP tool, потому что это потоковая, а не request-response интеграция. +- `subscription` не входит в текущий scope, потому что требует отдельной lifecycle-модели, близкой к `session` mode, и отдельного transport adapter. - `JSONPath` используется для точечного извлечения вложенных данных из `data` и для управления структурой итогового ответа. ## 9. Почему GraphQL не считается "почти REST" diff --git a/docs/protocols/grpc.md b/docs/protocols/grpc.md index 6a1e873..ae8d36f 100644 --- a/docs/protocols/grpc.md +++ b/docs/protocols/grpc.md @@ -2,11 +2,12 @@ ## 1. Роль протокола в проекте -gRPC поддерживается как третий основной протокол платформы, но в самой узкой и управляемой форме. Цель состоит не в том, чтобы покрыть все возможности gRPC, а в том, чтобы представить unary RPC-методы как обычные MCP tools с формой входа и формой выхода. +gRPC поддерживается как третий основной протокол платформы в управляемой форме. Цель состоит не в том, чтобы покрыть все возможности gRPC, а в том, чтобы представить unary и bounded server-streaming методы как MCP tools с предсказуемым жизненным циклом. ## 2. Что поддерживается в MVP -- только unary RPC +- unary RPC +- bounded server-streaming через execution modes `window`, `session`, `async_job` - загрузка `.proto` - загрузка descriptor set - загрузка примеров JSON для MCP input/output при необходимости @@ -22,7 +23,6 @@ gRPC поддерживается как третий основной прот ## 3. Что не входит в MVP -- `server streaming` - `client streaming` - `bidirectional streaming` - обязательная поддержка server reflection @@ -31,17 +31,15 @@ gRPC поддерживается как третий основной прот ## 4. Ключевое архитектурное ограничение -В проекте поддерживаются только unary-методы, потому что MCP tool в этой архитектуре соответствует модели `один запрос -> один ответ`. +В проекте поддерживаются unary-методы и bounded server-streaming, потому что MCP tool в этой архитектуре должен оставаться управляемым. Это означает: - один request message; -- один response message; -- один завершенный вызов; -- отсутствие потоковых сообщений; -- отсутствие отдельного жизненного цикла stream-сессии. +- один bounded response или управляемая session/job-семантика; +- явно ограниченный lifecycle stream-сессии. -Streaming gRPC не нужен для выбранной модели взаимодействия с LLM и только усложнит runtime, UI и хранение состояния. +Streaming gRPC не публикуется как бесконечный raw stream. Он допускается только там, где runtime умеет bounded-ить, агрегировать и завершать результат. ## 5. Внутренняя модель gRPC operation @@ -64,7 +62,7 @@ gRPC operation должна включать: 1. Загружает `.proto` или descriptor set. 2. Система извлекает список services и methods. -3. Оператор выбирает конкретный unary-метод. +3. Оператор выбирает конкретный unary- или server-streaming метод. 4. UI показывает структуру request message и response message. 5. При необходимости загружает примеры JSON для MCP input/output. 6. Система строит черновую схему, стартовый mapping и runtime-ready snapshot descriptor set для выбранного метода. @@ -82,10 +80,11 @@ gRPC operation должна включать: 1. Валидировать MCP input по нормализованной схеме. 2. Применить input mapping. 3. Построить protobuf request message из JSON. -4. Выполнить unary RPC вызов. -5. Преобразовать protobuf response в нормализованный JSON. -6. Применить output mapping. -7. Вернуть итоговый результат. +4. Для unary выполнить unary RPC вызов. +5. Для server-streaming собрать bounded окно или session step. +6. Преобразовать protobuf response или stream items в нормализованный JSON. +7. Применить output mapping. +8. Вернуть итоговый результат. ## 8. Критические нюансы @@ -99,7 +98,7 @@ gRPC operation должна включать: - пользователь не должен видеть внутреннюю сложность protobuf-контракта больше, чем это нужно для настройки operation. - `JSONPath` используется как единый способ точечной адресации вложенных полей при настройке mapping поверх нормализованной JSON-модели. -## 9. Почему gRPC ограничивается unary +## 9. Почему gRPC ограничивается controlled streaming Причина не только в сложности реализации. Главное ограничение архитектурное: @@ -108,4 +107,4 @@ gRPC operation должна включать: - UI платформы построен вокруг формы входа и формы выхода; - streaming требует отдельной session-модели, buffering, cancellation и состояния. -Поэтому unary gRPC - это не "обрезанная" поддержка, а осознанно выбранная форма, которая действительно совместима с MCP-платформой. +Поэтому поддержка gRPC в Crank ограничивается unary и bounded server-streaming. Client-streaming и bidi остаются вне scope до появления полноценной interactive session model. diff --git a/docs/protocols/rest.md b/docs/protocols/rest.md index d42b01b..1fd05ae 100644 --- a/docs/protocols/rest.md +++ b/docs/protocols/rest.md @@ -21,6 +21,7 @@ REST - базовый и первый по очередности реализа - автогенерация чернового mapping - ручная донастройка через `JSONPath` - тестовый вызов перед публикацией +- optional REST SSE upstream в bounded `window` и `session` режимах ## 3. Что не входит в MVP @@ -31,6 +32,7 @@ REST - базовый и первый по очередности реализа - webhooks - long polling как специальный режим - `HEAD` и `OPTIONS` как отдельные пользовательские сценарии +- raw infinite SSE passthrough ## 4. Внутренняя модель REST operation @@ -49,6 +51,12 @@ REST operation в системе описывается следующими о На слое MCP REST operation всегда выглядит как вызов `запрос -> ответ` с фиксированной схемой входа и выхода. +Если upstream использует SSE, на слое MCP это все равно должно быть выражено как: + +- bounded window result; +- session-oriented `start/poll/stop`; +- async job semantics для длительных действий. + ## 5. Как оператор настраивает REST operation 1. Указывает `base_url`. diff --git a/docs/streaming-mcp-plan.md b/docs/streaming-mcp-plan.md new file mode 100644 index 0000000..e1fa4d4 --- /dev/null +++ b/docs/streaming-mcp-plan.md @@ -0,0 +1,615 @@ +# Streaming MCP Plan + +## 1. Назначение документа + +Этот документ фиксирует целевую архитектуру потоковой обработки в Crank. Он описывает: + +- какие transport- и upstream-протоколы поддерживаются; +- какие streaming-сценарии считаются допустимыми; +- какие ограничения обязательны для безопасности и управляемости; +- какие сущности, API, UI-поля и runtime-механизмы нужно добавить; +- в каком порядке это реализовывать. + +Документ синхронизирован с MCP transport specification `2025-06-18`, где `Streamable HTTP` определен как основной HTTP transport, а SSE допускается как часть `POST`-response и как отдельный `GET`-stream. + +## 2. Базовое архитектурное решение + +Crank поддерживает streaming не как бесконечный текстовый поток в чат, а как управляемую инструментальную модель поверх MCP tools. + +Принцип: + +- transport может быть long-lived; +- tool contract обязан оставаться ограниченным и управляемым; +- upstream stream всегда преобразуется в bounded result, session poll или async job; +- оператор в UI настраивает не "трубу", а режим сбора, агрегации и завершения. + +## 3. Поддерживаемые transport- и upstream-протоколы + +### 3.1. Downstream MCP transport + +Поддерживается: + +- `Streamable HTTP` как канонический network transport; +- HTTP `POST` с ответом `application/json` или `text/event-stream`; +- HTTP `GET` для server-to-client SSE stream; +- несколько SSE streams одновременно в рамках одного MCP session; +- `Mcp-Session-Id` и `MCP-Protocol-Version` headers; +- resumable SSE streams как опциональная возможность. + +Не является текущим приоритетом: + +- `stdio` как обязательная часть продукта; +- собственные нестандартные transport-режимы поверх MCP. + +### 3.2. Upstream protocols + +Поддерживаются: + +- REST unary; +- REST SSE в bounded режимах; +- GraphQL `query` и `mutation`; +- gRPC unary; +- gRPC server-streaming в bounded режимах. + +Отложено: + +- GraphQL `subscription`; +- gRPC client-streaming; +- gRPC bidirectional streaming; +- arbitrary websocket passthrough; +- raw infinite stream forwarding в MCP client. + +## 4. Поддерживаемые streaming modes + +Crank поддерживает четыре режима выполнения operation. + +### 4.1. `unary` + +Обычный request-response вызов. + +Подходит для: + +- REST; +- GraphQL `query` и `mutation`; +- gRPC unary. + +### 4.2. `window` + +Runtime открывает upstream stream или repeatedly polls upstream source, собирает данные в пределах окна и возвращает один bounded ответ. + +Параметры: + +- `window_duration_ms` +- `max_items` +- `max_bytes` +- `upstream_timeout_ms` +- `aggregation_mode` + +Подходит для: + +- логи за период; +- метрики за период; +- event window; +- SSE stream snapshot; +- gRPC server-stream window. + +### 4.3. `session` + +Runtime создает stream session, после чего данные читаются по шагам через session-oriented tool family. + +Обязательные операции: + +- `start` +- `poll` +- `stop` + +Подходит для: + +- follow logs; +- telemetry follow; +- alert/event feed; +- контроль длительных stream-подписок. + +### 4.4. `async_job` + +Runtime запускает long-running upstream operation и возвращает `job_id`. + +Обязательные операции: + +- `start` +- `status` +- `result` +- `cancel` + +Подходит для: + +- import/export; +- deploy/reindex; +- batch processing; +- инфраструктурные control-plane действия. + +## 5. Бизнес-кейсы + +### 5.1. Logs Window + +LLM запрашивает: + +- ошибки сервиса за последние `30s`; +- top errors за последние `100` записей; +- логи по конкретному `correlation_id`. + +Runtime: + +- собирает bounded окно; +- агрегирует counts, уровни, sample lines; +- возвращает summary плюс ограниченный список items. + +### 5.2. Metrics Window + +LLM запрашивает: + +- latency/error summary по сервису; +- CPU/memory snapshot; +- anomaly summary за окно. + +Runtime: + +- читает поток метрик; +- агрегирует min/max/avg/p95 или anomaly set; +- возвращает компактный JSON. + +### 5.3. Event Feed + +LLM запрашивает: + +- audit events за период; +- queue events по фильтру; +- security alerts за окно. + +Runtime: + +- фильтрует events; +- ограничивает количество; +- возвращает items plus cursor. + +### 5.4. Long-running Operation Status + +LLM запускает: + +- reindex; +- import job; +- rollout; +- repair task. + +Runtime: + +- создает job handle; +- возвращает `job_id`; +- позволяет дальше получать status/result/cancel. + +### 5.5. Control Plane Follow + +LLM инициирует: + +- reboot; +- rollout; +- node drain; +- workflow transition. + +Runtime: + +- стартует действие; +- пишет progress в session/job state; +- возвращает snapshots по `poll`. + +## 6. Функциональные требования + +### 6.1. Общие + +- operation должна явно указывать `execution_mode`; +- streaming operation обязана быть bounded; +- runtime обязан поддерживать timeout, max items и max bytes; +- tool output обязан иметь предсказуемую схему; +- stream/session/job state должен быть наблюдаемым и логируемым; +- cancel/stop должен быть явной операцией, а не побочным эффектом disconnect. + +### 6.2. Для `window` + +- задать размер окна; +- задать лимит items; +- задать лимит bytes; +- выбрать aggregation mode; +- вернуть `truncated` и `window_complete` flags; +- поддерживать optional cursor для следующего окна. + +### 6.3. Для `session` + +- создать `session_id`; +- поддерживать `poll`; +- поддерживать `stop`; +- хранить курсор и session status; +- иметь `idle_timeout`; +- иметь `max_session_lifetime`; +- удалять expired sessions. + +### 6.4. Для `async_job` + +- создать `job_id`; +- хранить progress, status и final result metadata; +- поддерживать `cancel`; +- поддерживать retrieval последнего готового результата. + +## 7. Нефункциональные требования + +### 7.1. Безопасность + +- никакого неограниченного passthrough потока; +- обязательные лимиты по времени, items и bytes; +- обязательный redact layer для secret-bearing полей; +- audit trail на `start`, `poll`, `stop`, `cancel`; +- session и job identifiers должны быть криптографически стойкими. + +### 7.2. Производительность + +- bounded memory per session; +- bounded upstream read buffer; +- ограничение числа параллельных sessions и jobs на workspace и agent; +- backpressure при медленных клиентах; +- возможность early cut-off после достижения лимита. + +### 7.3. Надежность + +- TTL и cleanup для sessions/jobs; +- устойчивость к disconnect downstream client; +- poll должен быть идемпотентным; +- long-running upstream action не должен считаться отмененным из-за SSE disconnect; +- resumability для downstream SSE допускается, но не является обязательной в MVP. + +### 7.4. UX + +- оператор должен видеть, что operation является `unary`, `window`, `session` или `async_job`; +- UI должен явно показывать все лимиты и режим агрегации; +- результат тестового вызова должен показывать `truncated`, `window_complete`, `has_more`, `status`. + +## 8. UI contract + +### 8.1. Новый блок `Execution mode` + +Поля: + +- `mode`: `unary | window | session | async_job` +- `transport_behavior`: `request_response | server_stream` + +### 8.2. Блок `Collection limits` + +Поля: + +- `window_duration_ms` +- `poll_interval_ms` +- `upstream_timeout_ms` +- `idle_timeout_ms` +- `max_session_lifetime_ms` +- `max_items` +- `max_bytes` + +### 8.3. Блок `Aggregation` + +Поля: + +- `aggregation_mode`: `raw_items | summary_only | summary_plus_samples | stats | latest_state` +- `summary_path` +- `items_path` +- `cursor_path` +- `status_path` +- `done_path` + +### 8.4. Блок `Safety` + +Поля: + +- `truncate_item_fields` +- `max_field_length` +- `redacted_paths` +- `drop_duplicates` +- `sampling_rate` + +### 8.5. Block `Tool family` + +Для `session`: + +- `start_tool_name` +- `poll_tool_name` +- `stop_tool_name` + +Для `async_job`: + +- `start_tool_name` +- `status_tool_name` +- `result_tool_name` +- `cancel_tool_name` + +## 9. Domain model changes + +### 9.1. `ExecutionMode` + +Новый enum: + +- `Unary` +- `Window` +- `Session` +- `AsyncJob` + +### 9.2. `StreamingConfig` + +Новая часть `execution_config`: + +- `mode` +- `window_duration_ms` +- `poll_interval_ms` +- `upstream_timeout_ms` +- `idle_timeout_ms` +- `max_session_lifetime_ms` +- `max_items` +- `max_bytes` +- `aggregation_mode` +- `items_path` +- `summary_path` +- `cursor_path` +- `status_path` +- `done_path` +- `redacted_paths` + +### 9.3. `StreamSession` + +Новая runtime/store сущность: + +- `id` +- `workspace_id` +- `agent_id` +- `operation_id` +- `protocol` +- `mode` +- `status` +- `cursor` +- `state_json` +- `expires_at` +- `last_poll_at` +- `created_at` +- `closed_at` + +### 9.4. `AsyncJobHandle` + +Новая runtime/store сущность: + +- `id` +- `workspace_id` +- `agent_id` +- `operation_id` +- `status` +- `progress_json` +- `result_json` +- `error_json` +- `expires_at` +- `created_at` +- `updated_at` +- `finished_at` + +## 10. MCP publishing model for streaming tools + +### 10.1. Unary and Window + +`unary` и `window` публикуются как один MCP tool: + +- один input contract; +- один bounded result; +- transport может использовать `application/json` или SSE response stream до финального JSON-RPC response. + +### 10.2. Session + +`session` публикуется как tool family: + +- `{tool}_start` +- `{tool}_poll` +- `{tool}_stop` + +Причина: + +- lifecycle становится явным; +- LLM получает контролируемую state machine; +- runtime не скрывает долговременное состояние за одним "магическим" вызовом. + +### 10.3. Async Job + +`async_job` публикуется как tool family: + +- `{tool}_start` +- `{tool}_status` +- `{tool}_result` +- `{tool}_cancel` + +## 11. Module decomposition and responsibilities + +### 11.1. `crank-core` + +Новые модули: + +- `streaming` +- `stream_session` + +Новые типы: + +- `ExecutionMode` +- `StreamingConfig` +- `AggregationMode` +- `StreamSession` +- `AsyncJobHandle` +- `StreamStatus` +- `JobStatus` + +### 11.2. `crank-registry` + +Новые обязанности: + +- хранение `stream_sessions`; +- хранение `async_jobs`; +- cleanup expired rows; +- optimistic updates on poll/stop/cancel. + +Ожидаемые функции: + +- `create_stream_session` +- `get_stream_session` +- `advance_stream_session` +- `close_stream_session` +- `create_async_job` +- `get_async_job` +- `update_async_job_status` +- `cancel_async_job` +- `delete_expired_stream_sessions` + +### 11.3. `crank-runtime` + +Новые orchestration функции: + +- `execute_unary_operation` +- `execute_window_operation` +- `start_stream_session` +- `poll_stream_session` +- `stop_stream_session` +- `start_async_job` +- `get_async_job_status` +- `get_async_job_result` +- `cancel_async_job` + +### 11.4. Protocol adapters + +REST: + +- unary HTTP; +- bounded SSE collection; +- bounded long-poll collection. + +GraphQL: + +- `query` и `mutation`; +- `subscription` не входит в MVP. + +gRPC: + +- unary; +- bounded server-stream collection; +- client/bidi не входят в MVP. + +### 11.5. `apps/mcp-server` + +Новые обязанности: + +- корректно вести `Streamable HTTP` lifecycle; +- принимать `POST` с `Accept: application/json, text/event-stream`; +- отдавать `application/json` или `text/event-stream`; +- поддерживать `GET` SSE stream для server-to-client messages и notifications; +- вести `Mcp-Session-Id`; +- публиковать tool families для `session` и `async_job`. + +### 11.6. `apps/admin-api` + +Новые обязанности: + +- CRUD и versioning для streaming config; +- тестовые window/session/job runs; +- UI-oriented validation ошибок для streaming fields. + +### 11.7. `apps/ui` + +- конфиг execution mode; +- конфиг limits/aggregation/safety; +- test-run screen для bounded window/session/job behavior; +- отдельные предупреждения про truncation и timeouts. + +## 12. Ограничения MVP + +В MVP входит: + +- `Streamable HTTP` и SSE на MCP transport; +- `window` mode; +- `async_job` mode; +- REST SSE; +- gRPC server streaming; +- tool family generation; +- bounded session/job state. + +В MVP не входит: + +- GraphQL subscriptions; +- gRPC client streaming; +- gRPC bidirectional streaming; +- raw infinite stream passthrough; +- guaranteed resumability across all stream types; +- generic websocket proxy mode. + +## 13. Порядок реализации + +### 13.1. `feat/streaming-mcp-architecture` + +- зафиксировать docs; +- обновить protocol support matrix; +- синхронизировать `TASKS.md`. + +### 13.2. `feat/mcp-streamable-http-alignment` + +- довести `mcp-server` до полного соответствия `Streamable HTTP`; +- session headers; +- GET SSE; +- protocol version header validation; +- explicit cancel behavior. + +### 13.3. `feat/streaming-core-model` + +- ввести `ExecutionMode`, `StreamingConfig`, `StreamSession`, `AsyncJobHandle`. + +### 13.4. `feat/stream-session-store` + +- таблицы `stream_sessions` и `async_jobs`; +- cleanup; +- optimistic state transitions. + +### 13.5. `feat/runtime-window-mode` + +- bounded collection для `window`; +- `truncated`, `window_complete`, `has_more`. + +### 13.6. `feat/rest-sse-adapter` + +- поддержка REST SSE upstream. + +### 13.7. `feat/grpc-server-streaming-adapter` + +- поддержка bounded gRPC server-streaming. + +### 13.8. `feat/session-and-job-tools` + +- генерация tool families; +- `start/poll/stop`; +- `start/status/result/cancel`. + +### 13.9. `feat/streaming-ui-config` + +- новый execution mode selector; +- limits/aggregation/safety blocks; +- test-run UX. + +### 13.10. `feat/streaming-e2e` + +- публичные smoke targets; +- e2e сценарии; +- manual regression plan. + +## 14. Практический итог + +Crank должен поддерживать streaming как полнофункциональный MCP proxy, но в управляемой форме: + +- transport-level SSE и `Streamable HTTP` поддерживаются; +- upstream streaming поддерживается там, где его можно bounded-ить; +- tool contract остается контролируемым; +- UI настраивает лимиты, aggregation и lifecycle; +- платформа не превращается в бесконечную data pipe. From 7f15b2db9e3094488e9a75a05c176605fc585784 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 01:57:26 +0300 Subject: [PATCH 03/38] docs: extend protocol platform architecture --- README.md | 10 ++- TASKS.md | 11 +-- docs/architecture.md | 29 ++++++-- docs/data-model.md | 38 ++++++++++ docs/implementation-plan.md | 31 +++++++- docs/module-decomposition.md | 6 +- docs/protocols/graphql.md | 4 +- docs/protocols/grpc.md | 4 +- docs/protocols/rest.md | 4 +- docs/protocols/soap.md | 126 +++++++++++++++++++++++++++++++++ docs/protocols/websocket.md | 133 +++++++++++++++++++++++++++++++++++ docs/streaming-mcp-plan.md | 113 +++++++++++++++++++++++++---- 12 files changed, 475 insertions(+), 34 deletions(-) create mode 100644 docs/protocols/soap.md create mode 100644 docs/protocols/websocket.md diff --git a/README.md b/README.md index fadbc25..585f993 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Crank - платформа для публикации внешних API в в - Разработать MCP server на Rust. - Поддержать динамическое добавление интеграций через UI или конфигурацию. -- Обеспечить единый сценарий работы оператора для REST, GraphQL и gRPC. +- Обеспечить единый сценарий работы оператора для REST, GraphQL, gRPC, WebSocket и SOAP. - Нормализовать внешние протоколы в единую внутреннюю модель операции. - Ограничивать набор tools на уровне конкретного агента, а не отдавать один глобальный каталог. - Поддержать workspace-изоляцию, platform access и observability. @@ -21,6 +21,8 @@ Crank - платформа для публикации внешних API в в - Поддержка REST для `GET`, `POST`, `PUT`, `PATCH` и `DELETE`. - Поддержка GraphQL для `query` и `mutation`. - Поддержка 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. @@ -54,6 +56,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. ## Ключевая идея продукта @@ -90,8 +94,10 @@ Crank - платформа для публикации внешних API в в - REST - GraphQL - gRPC +- WebSocket +- SOAP -`SOAP` сознательно не входит в текущий scope. +Все пять протокольных семейств входят в целевой product scope. Разница только в очередности реализации. ## Frontend e2e diff --git a/TASKS.md b/TASKS.md index ad6532e..4685bf3 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,14 +2,14 @@ ## Current -### `feat/streaming-mcp-architecture` +### `feat/full-protocol-platform-architecture` Status: completed DoD: -- Official MCP transport semantics are reflected in docs -- Streaming modes and protocol support matrix are documented -- Core docs and `TASKS.md` are synchronized around controlled streaming model +- Product scope covers REST, GraphQL, gRPC, WebSocket and SOAP +- Execution model is documented independently from protocol families +- Core docs and protocol docs are synchronized around full protocol platform scope ## Next @@ -25,6 +25,9 @@ DoD: - `feat/rest-sse-adapter` - `feat/grpc-server-streaming-adapter` - `feat/session-and-job-tools` +- `feat/websocket-upstream-adapter` +- `feat/soap-architecture-and-core-model` +- `feat/soap-adapter-foundation` - `feat/streaming-ui-config` - `feat/streaming-e2e` - `feat/auth-profile-secret-resolution` diff --git a/docs/architecture.md b/docs/architecture.md index d43a836..e24c41c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,13 +124,13 @@ Crank - платформа для публикации внешних API в в - изолировать данные команд; - строить logs и usage не глобально, а по tenant boundary. -## 5. Границы целевого MVP +## 5. Границы целевого продукта ### Входит - `Workspace` как tenant boundary. -- Операции `REST`, `GraphQL`, `unary gRPC`. -- Controlled streaming operations поверх `Streamable HTTP`, REST SSE и gRPC server-streaming. +- Операции `REST`, `GraphQL`, `gRPC`, `WebSocket`, `SOAP`. +- Controlled streaming operations поверх `Streamable HTTP`, REST SSE, gRPC server-streaming и WebSocket upstream. - `Agent` и привязка операций к агенту. - Agent-scoped MCP endpoints. - Platform API keys. @@ -140,11 +140,12 @@ Crank - платформа для публикации внешних API в в - Импорт и экспорт operation-конфигураций в `YAML`. - Hot reload опубликованных agents и operations. -### Не входит +### Отложено - GraphQL `subscription`. - gRPC client-streaming и bidirectional streaming. -- SOAP. +- raw infinite stream passthrough. +- полный стек WS-* расширений. - Оркестрация workflow. - Биллинг. - Full RBAC policy engine. @@ -209,12 +210,28 @@ GraphQL в MCP публикуется как фиксированная опер - JSON-oriented schema model поверх protobuf; - без client-streaming и bidi. +### WebSocket + +- upstream-only adapter; +- bounded `window`, `session` и `async_job`; +- subscribe/unsubscribe messages; +- heartbeat и reconnect policy; +- не используется как downstream MCP transport. + +### SOAP + +- WSDL-driven request-response integration; +- service/port/operation selection; +- SOAP envelope и fault normalization; +- request-response first; +- long-running workflows через `async_job`, если upstream это поддерживает. + ### Streaming Платформа поддерживает controlled streaming model: - downstream transport: `Streamable HTTP` с optional SSE; -- upstream streaming: REST SSE и gRPC server-streaming; +- upstream streaming: REST SSE, gRPC server-streaming и WebSocket; - execution modes: `unary`, `window`, `session`, `async_job`; - никакого raw infinite stream passthrough в MCP client. diff --git a/docs/data-model.md b/docs/data-model.md index d5dea27..7567f25 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -64,6 +64,23 @@ Бесконечный passthrough stream не является допустимой моделью `Operation`. +### 2.8. Execution model важнее transport-specific особенностей + +Каждая операция в системе определяется двумя измерениями: + +- `protocol` +- `execution_mode` + +Это позволяет описывать: + +- REST unary; +- REST SSE window; +- gRPC server-stream session; +- WebSocket event feed; +- SOAP request-response; + +в рамках одной общей модели runtime и MCP publishing. + ## 3. Корневые сущности ### 3.1. `Workspace` @@ -400,6 +417,27 @@ - `descriptor_ref` - `descriptor_set_b64` +### 4.4. `WebSocketTarget` + +- `kind` +- `url` +- `subprotocols` +- `subscribe_message_template` +- `unsubscribe_message_template` +- `static_headers` + +### 4.5. `SoapTarget` + +- `kind` +- `wsdl_ref` +- `service_name` +- `port_name` +- `operation_name` +- `endpoint_override` +- `soap_version` +- `soap_action` +- `header_config` + ## 5. `Schema` `Schema` - нормализованное описание входа или выхода. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 84c5400..9568897 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -2,7 +2,7 @@ ## 1. Назначение документа -Этот документ фиксирует порядок перехода от текущего состояния проекта к целевой Alpine UI модели. +Этот документ фиксирует порядок перехода от текущего состояния проекта к целевой product-ready integration platform модели. Принцип: @@ -10,7 +10,8 @@ - потом foundation под workspace/agent model; - потом возврат к end-to-end UI сценариям; - потом observability и access layer; -- потом polish и demo readiness. +- потом polish и demo readiness; +- потом расширение до полного protocol platform scope. ## 2. Этап 1. Перепроектирование `As Is -> To Be` @@ -148,3 +149,29 @@ DoD: - REST SSE и gRPC server-streaming поддерживаются в bounded форме; - UI умеет конфигурировать streaming limits, aggregation и lifecycle; - e2e сценарии покрывают window/session/job calls. + +## 13. Этап 12. WebSocket upstream support + +Цель: + +- добавить полноценный WebSocket upstream adapter в общую execution model. + +DoD: + +- есть WebSocket target model; +- runtime поддерживает bounded `window`, `session` и `async_job`; +- heartbeat, reconnect и subscription lifecycle конфигурируются явно; +- docs, UI и e2e синхронизированы. + +## 14. Этап 13. SOAP support + +Цель: + +- добавить SOAP как enterprise-oriented protocol family. + +DoD: + +- есть WSDL/XSD-driven target model; +- runtime умеет строить SOAP envelopes и нормализовать SOAP Faults; +- operator может выбрать service, port и operation; +- test-run, publish и observability работают так же, как для остальных протоколов. diff --git a/docs/module-decomposition.md b/docs/module-decomposition.md index 41d107a..2bf05cc 100644 --- a/docs/module-decomposition.md +++ b/docs/module-decomposition.md @@ -19,7 +19,7 @@ ## 3. Workspace-структура ```text -crank/ + crank/ apps/ admin-api/ mcp-server/ @@ -31,6 +31,8 @@ crank/ crank-adapter-rest/ crank-adapter-graphql/ crank-adapter-grpc/ + crank-adapter-websocket/ + crank-adapter-soap/ crank-mapping/ crank-schema/ crank-proto/ @@ -121,6 +123,8 @@ crank/ - `crank-adapter-rest` - `crank-adapter-graphql` - `crank-adapter-grpc` +- `crank-adapter-websocket` +- `crank-adapter-soap` Каждый adapter знает только свой протокол. diff --git a/docs/protocols/graphql.md b/docs/protocols/graphql.md index b797cb1..1fdca2d 100644 --- a/docs/protocols/graphql.md +++ b/docs/protocols/graphql.md @@ -4,7 +4,7 @@ GraphQL поддерживается как отдельный тип интеграции, но на слое MCP намеренно ограничивается. Цель платформы не в том, чтобы дать LLM универсальный доступ ко всему GraphQL endpoint, а в том, чтобы превратить конкретный GraphQL-запрос в узкий и предсказуемый MCP tool. -## 2. Что поддерживается в MVP +## 2. Что поддерживается в целевом продукте - `query` - `mutation` @@ -21,7 +21,7 @@ GraphQL поддерживается как отдельный тип интег - ручная донастройка через `JSONPath` - тестовый вызов перед публикацией -## 3. Что не входит в MVP +## 3. Что отложено - `subscription` - универсальный GraphQL explorer для LLM diff --git a/docs/protocols/grpc.md b/docs/protocols/grpc.md index ae8d36f..e54ff5b 100644 --- a/docs/protocols/grpc.md +++ b/docs/protocols/grpc.md @@ -4,7 +4,7 @@ gRPC поддерживается как третий основной протокол платформы в управляемой форме. Цель состоит не в том, чтобы покрыть все возможности gRPC, а в том, чтобы представить unary и bounded server-streaming методы как MCP tools с предсказуемым жизненным циклом. -## 2. Что поддерживается в MVP +## 2. Что поддерживается в целевом продукте - unary RPC - bounded server-streaming через execution modes `window`, `session`, `async_job` @@ -21,7 +21,7 @@ gRPC поддерживается как третий основной прот - auth/transport settings на уровне соединения - тестовый вызов перед публикацией -## 3. Что не входит в MVP +## 3. Что отложено - `client streaming` - `bidirectional streaming` diff --git a/docs/protocols/rest.md b/docs/protocols/rest.md index 1fd05ae..06585ce 100644 --- a/docs/protocols/rest.md +++ b/docs/protocols/rest.md @@ -4,7 +4,7 @@ REST - базовый и первый по очередности реализации протокол платформы. На нем должна быть обкатана общая модель `Operation`, схема входа и выхода, маппинг, тестовый запуск и публикация MCP tool. -## 2. Что поддерживается в MVP +## 2. Что поддерживается в целевом продукте - HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` - загрузка примера входного `JSON` @@ -23,7 +23,7 @@ REST - базовый и первый по очередности реализа - тестовый вызов перед публикацией - optional REST SSE upstream в bounded `window` и `session` режимах -## 3. Что не входит в MVP +## 3. Что отложено - multipart/form-data - file upload/download как отдельный сценарий diff --git a/docs/protocols/soap.md b/docs/protocols/soap.md new file mode 100644 index 0000000..c1d35eb --- /dev/null +++ b/docs/protocols/soap.md @@ -0,0 +1,126 @@ +# SOAP + +## 1. Роль протокола в проекте + +SOAP поддерживается как enterprise-oriented upstream protocol для интеграций с системами, где REST уже не является стандартом де-факто. + +Типичные домены: + +- ERP; +- banking; +- insurance; +- government and B2B gateways; +- legacy enterprise systems; +- ESB-oriented internal APIs. + +## 2. Что поддерживается в целевом продукте + +- SOAP 1.1 и SOAP 1.2; +- WSDL upload/import; +- выбор `service`, `port` и `operation`; +- document/literal first; +- XML schema extraction из WSDL/XSD; +- input/output schema generation; +- mapping `MCP JSON -> SOAP body`; +- mapping `SOAP response -> normalized JSON`; +- SOAP headers config; +- basic auth, bearer auth и auth profiles; +- test run; +- publish as MCP tool. + +## 3. Что не входит в текущий продуктовый scope + +- полный стек WS-* расширений; +- MTOM attachments; +- arbitrary XML transformation engine; +- full schema authoring inside UI; +- server-side SOAP hosting. + +## 4. Ключевое архитектурное ограничение + +SOAP не является просто HTTP POST с XML body. + +Для платформы SOAP operation определяется: + +- WSDL model; +- service/port/operation binding; +- envelope structure; +- namespaces; +- optional SOAPAction; +- fault model; +- XML schema contract. + +Поэтому SOAP должен иметь отдельный adapter и отдельную конфигурационную модель. + +## 5. Типовые use cases + +- enterprise CRM/ERP operations; +- payment and settlement integrations; +- policy and claims systems; +- regulated B2B data exchange; +- legacy internal service contracts. + +## 6. Внутренняя модель SOAP operation + +SOAP operation должна включать: + +- `wsdl_ref` +- `service_name` +- `port_name` +- `operation_name` +- `endpoint_override` +- `soap_version` +- `soap_action` +- `input_schema` +- `output_schema` +- `input_mapping` +- `output_mapping` +- `header_config` +- `execution_config` +- `tool_description` + +## 7. Как оператор настраивает SOAP operation + +1. Загружает WSDL. +2. Система извлекает services, ports, operations и XSD schemas. +3. Оператор выбирает service, port и operation. +4. UI показывает input/output schema в JSON-oriented виде. +5. Оператор настраивает mapping `MCP input -> SOAP body`. +6. При необходимости задает SOAP headers и auth profile. +7. Настраивает output mapping и fault handling. +8. Выполняет test run. +9. Публикует operation как MCP tool. + +## 8. Поведение runtime + +При выполнении SOAP operation runtime должен: + +1. Валидировать MCP input. +2. Построить XML envelope. +3. Подставить namespaces и headers. +4. Выполнить HTTP request. +5. Разобрать SOAP response и SOAP Fault. +6. Нормализовать XML result в JSON. +7. Применить output mapping. +8. Вернуть итоговый результат. + +## 9. Критические нюансы + +- WSDL import должен быть отделен от runtime call; +- XML namespaces должны быть first-class частью конфигурации; +- SOAP Fault нельзя сводить только к HTTP error; +- input/output schema должны отображаться в UI как нормализованные поля, а не как raw XML; +- auth и headers должны настраиваться отдельно от body mapping. + +## 10. Почему SOAP должен входить в product scope + +Если Crank позиционируется как enterprise integration platform, отсутствие SOAP оставляет большую часть legacy и regulated environments вне продукта. + +Поэтому SOAP должен быть частью общей protocol strategy наравне с: + +- REST +- GraphQL +- gRPC +- WebSocket + +Но при этом он должен оставаться request-response oriented adapter, а не ломать общую execution model. diff --git a/docs/protocols/websocket.md b/docs/protocols/websocket.md new file mode 100644 index 0000000..48b92ca --- /dev/null +++ b/docs/protocols/websocket.md @@ -0,0 +1,133 @@ +# WebSocket + +## 1. Роль протокола в проекте + +WebSocket поддерживается как полноценный upstream protocol для realtime, push-oriented и stateful integrations. Он не заменяет downstream MCP transport, а работает за Crank proxy. + +Иными словами: + +- downstream: MCP `Streamable HTTP`; +- upstream: WebSocket; +- Crank нормализует WebSocket lifecycle в bounded MCP tool semantics. + +## 2. Что поддерживается в целевом продукте + +- клиентское WebSocket подключение к внешнему upstream; +- custom headers и auth profiles; +- handshake config; +- optional subprotocol selection; +- subscription payload или start message; +- bounded `window` mode; +- `session` mode с `start/poll/stop`; +- `async_job` mode для control-plane и progress channels; +- JSON message parsing; +- text-frame based event collection; +- mapping `MCP input -> subscribe payload`; +- mapping `WebSocket message -> normalized JSON`; +- heartbeat/keepalive config; +- reconnect policy для controlled session modes. + +## 3. Что не входит в текущий продуктовый scope + +- raw binary frame passthrough в LLM; +- arbitrary bidirectional conversation tunnel; +- full message bus semantics; +- generic browser-like socket inspector; +- guaranteed resumability across arbitrary upstream websocket providers. + +## 4. Ключевое архитектурное ограничение + +WebSocket не публикуется как "живой канал в чат". Он должен быть выражен в одной из execution models: + +- `window` +- `session` +- `async_job` + +Недопустимо: + +- бесконечно проксировать frames напрямую в MCP client; +- скрывать жизненный цикл сокета за одним неопределенным tool call; +- смешивать downstream SSE transport и upstream WebSocket semantics в одну абстракцию. + +## 5. Типовые use cases + +### 5.1. Monitoring and telemetry + +- realtime метрики; +- device telemetry; +- market data; +- anomaly events. + +### 5.2. Event subscriptions + +- alert streams; +- queue events; +- workflow transitions; +- security events. + +### 5.3. Stateful control channels + +- rollout progress; +- remote task status; +- infrastructure control plane updates; +- long-running remote sessions. + +## 6. Внутренняя модель WebSocket operation + +WebSocket operation должна включать: + +- `url` +- `headers` +- `subprotocols` +- `connect_timeout_ms` +- `heartbeat_interval_ms` +- `subscribe_message_template` +- `unsubscribe_message_template` +- `input_mapping` +- `output_mapping` +- `execution_config` +- `tool_description` + +## 7. Как оператор настраивает WebSocket operation + +1. Указывает URL WebSocket upstream. +2. При необходимости выбирает auth profile и headers. +3. Указывает subprotocol или оставляет пустым. +4. Выбирает execution mode: `window`, `session` или `async_job`. +5. Настраивает subscribe payload. +6. Указывает правила извлечения items, status и cursor. +7. Настраивает aggregation limits. +8. Выполняет test run. +9. Публикует operation как MCP tool или tool family. + +## 8. Поведение runtime + +При выполнении WebSocket operation runtime должен: + +1. Валидировать MCP input. +2. Построить subscribe payload. +3. Открыть WebSocket connection. +4. Пройти handshake и auth. +5. Отправить subscribe message. +6. Собрать bounded window или session step. +7. Нормализовать messages в JSON. +8. Применить output mapping. +9. Закрыть соединение или сохранить session state. + +## 9. Критические нюансы + +- upstream WebSocket может быть stateful и требовать explicit unsubscribe; +- heartbeat и reconnect должны быть управляемыми через config, а не захардкоженными; +- session state должен хранить cursor, last_event и subscription metadata; +- disconnect downstream MCP client не означает cancel upstream session; +- runtime обязан уметь cleanly завершать orphaned connections. + +## 10. Почему WebSocket должен быть отдельным adapter + +WebSocket нельзя свести к "почти SSE" или "почти HTTP", потому что: + +- transport двунаправленный; +- lifecycle connection stateful; +- есть handshake, heartbeat и reconnect; +- подписка часто задается сообщением, а не URL; +- semantics событий и прогресса отличаются от request-response вызова. diff --git a/docs/streaming-mcp-plan.md b/docs/streaming-mcp-plan.md index e1fa4d4..70b4a2d 100644 --- a/docs/streaming-mcp-plan.md +++ b/docs/streaming-mcp-plan.md @@ -49,7 +49,8 @@ Crank поддерживает streaming не как бесконечный те - REST SSE в bounded режимах; - GraphQL `query` и `mutation`; - gRPC unary; -- gRPC server-streaming в bounded режимах. +- gRPC server-streaming в bounded режимах; +- WebSocket в bounded режимах. Отложено: @@ -59,6 +60,16 @@ Crank поддерживает streaming не как бесконечный те - arbitrary websocket passthrough; - raw infinite stream forwarding в MCP client. +### 3.3. Protocol capability matrix + +| Protocol | Unary | Window | Session | Async Job | Notes | +| --- | --- | --- | --- | --- | --- | +| REST | Yes | Yes | Limited | Yes | SSE and long-poll sources are supported in controlled form | +| GraphQL | Yes | No | No | Limited | `query` and `mutation` only; `subscription` is future scope | +| gRPC | Yes | Yes | Yes | Yes | server-streaming only; client/bidi deferred | +| WebSocket | No | Yes | Yes | Yes | upstream adapter only; not downstream MCP transport | +| SOAP | Yes | Limited | Limited | Yes | primarily request-response enterprise workflows | + ## 4. Поддерживаемые streaming modes Crank поддерживает четыре режима выполнения operation. @@ -71,7 +82,8 @@ Crank поддерживает четыре режима выполнения op - REST; - GraphQL `query` и `mutation`; -- gRPC unary. +- gRPC unary; +- SOAP. ### 4.2. `window` @@ -91,7 +103,8 @@ Runtime открывает upstream stream или repeatedly polls upstream sour - метрики за период; - event window; - SSE stream snapshot; -- gRPC server-stream window. +- gRPC server-stream window; +- WebSocket event window. ### 4.3. `session` @@ -108,7 +121,8 @@ Runtime создает stream session, после чего данные чита - follow logs; - telemetry follow; - alert/event feed; -- контроль длительных stream-подписок. +- контроль длительных stream-подписок; +- WebSocket subscriptions. ### 4.4. `async_job` @@ -126,7 +140,8 @@ Runtime запускает long-running upstream operation и возвращае - import/export; - deploy/reindex; - batch processing; -- инфраструктурные control-plane действия. +- инфраструктурные control-plane действия; +- SOAP workflows with deferred status polling. ## 5. Бизнес-кейсы @@ -202,6 +217,38 @@ Runtime: - пишет progress в session/job state; - возвращает snapshots по `poll`. +### 5.6. WebSocket Realtime Feeds + +LLM запрашивает: + +- realtime alert snapshot; +- device telemetry window; +- market data slice; +- status feed по subscription channel. + +Runtime: + +- открывает upstream WebSocket; +- подписывается на channel; +- собирает bounded окно или session step; +- возвращает summary и limited items. + +### 5.7. SOAP Enterprise Operations + +LLM запрашивает: + +- создание/поиск сущности в ERP; +- запуск enterprise workflow; +- получение статуса batch operation; +- B2B request через SOAP gateway. + +Runtime: + +- строит SOAP envelope из MCP input; +- вызывает enterprise endpoint; +- нормализует XML response или SOAP Fault; +- возвращает JSON-oriented output. + ## 6. Функциональные требования ### 6.1. Общие @@ -263,7 +310,7 @@ Runtime: - устойчивость к disconnect downstream client; - poll должен быть идемпотентным; - long-running upstream action не должен считаться отмененным из-за SSE disconnect; -- resumability для downstream SSE допускается, но не является обязательной в MVP. +- resumability для downstream SSE допускается как следующая волна, но не является обязательной в первой реализации. ### 7.4. UX @@ -492,13 +539,26 @@ REST: GraphQL: - `query` и `mutation`; -- `subscription` не входит в MVP. +- `subscription` отложен на отдельную protocol wave. gRPC: - unary; - bounded server-stream collection; -- client/bidi не входят в MVP. +- client/bidi отложены на отдельную protocol wave. + +WebSocket: + +- bounded event collection; +- subscribe/poll/stop orchestration; +- heartbeat and reconnect policy. + +SOAP: + +- WSDL-driven request/response adapter; +- XML normalization; +- SOAP Fault normalization; +- future WS-Security expansion. ### 11.5. `apps/mcp-server` @@ -526,9 +586,9 @@ gRPC: - test-run screen для bounded window/session/job behavior; - отдельные предупреждения про truncation и timeouts. -## 12. Ограничения MVP +## 12. Границы текущей продуктовой волны -В MVP входит: +В первой продуктовой волне входит: - `Streamable HTTP` и SSE на MCP transport; - `window` mode; @@ -538,7 +598,14 @@ gRPC: - tool family generation; - bounded session/job state. -В MVP не входит: +Во второй продуктовой волне: + +- WebSocket upstream adapter; +- SOAP adapter foundation; +- richer session tooling; +- expanded protocol smoke suite. + +Отложено: - GraphQL subscriptions; - gRPC client streaming; @@ -592,13 +659,33 @@ gRPC: - `start/poll/stop`; - `start/status/result/cancel`. -### 13.9. `feat/streaming-ui-config` +### 13.9. `feat/websocket-upstream-adapter` + +- bounded WebSocket collection; +- subscribe/unsubscribe templates; +- heartbeat/reconnect policy; +- session integration. + +### 13.10. `feat/soap-architecture-and-core-model` + +- WSDL/XSD-driven domain model; +- SOAP execution config; +- XML normalization strategy. + +### 13.11. `feat/soap-adapter-foundation` + +- runtime SOAP adapter; +- envelope builder; +- fault normalization; +- test-run support. + +### 13.12. `feat/streaming-ui-config` - новый execution mode selector; - limits/aggregation/safety blocks; - test-run UX. -### 13.10. `feat/streaming-e2e` +### 13.13. `feat/streaming-e2e` - публичные smoke targets; - e2e сценарии; From dbc57a0419a2a88dcd1e4a01af48875b1155abf9 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 02:35:00 +0300 Subject: [PATCH 04/38] docs: add detailed streaming specifications --- README.md | 4 + TASKS.md | 11 +- docs/admin-api.md | 8 + docs/implementation-plan.md | 12 + docs/protocol-capability-matrix.md | 123 ++++++ docs/streaming-admin-api.md | 607 +++++++++++++++++++++++++++++ docs/streaming-runtime-design.md | 580 +++++++++++++++++++++++++++ docs/streaming-ui-contract.md | 434 +++++++++++++++++++++ 8 files changed, 1775 insertions(+), 4 deletions(-) create mode 100644 docs/protocol-capability-matrix.md create mode 100644 docs/streaming-admin-api.md create mode 100644 docs/streaming-runtime-design.md create mode 100644 docs/streaming-ui-contract.md diff --git a/README.md b/README.md index 585f993..b2afd43 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ Crank - платформа для публикации внешних API в в - `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/rust-design.md` - правила распределения поведения в Rust. - `docs/development-rules.md` - правила разработки и workflow. - `docs/rust-code-rules.md` - Rust-specific coding rules. diff --git a/TASKS.md b/TASKS.md index 4685bf3..2123577 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,14 +2,14 @@ ## Current -### `feat/full-protocol-platform-architecture` +### `feat/streaming-detailed-specs` Status: completed DoD: -- Product scope covers REST, GraphQL, gRPC, WebSocket and SOAP -- Execution model is documented independently from protocol families -- Core docs and protocol docs are synchronized around full protocol platform scope +- Streaming API, runtime, UI and capability docs exist as separate detailed specs +- Function-level responsibilities are documented +- Field-level UI contract and validation rules are documented ## Next @@ -30,6 +30,9 @@ DoD: - `feat/soap-adapter-foundation` - `feat/streaming-ui-config` - `feat/streaming-e2e` +- `feat/websocket-upstream-adapter` +- `feat/soap-architecture-and-core-model` +- `feat/soap-adapter-foundation` - `feat/auth-profile-secret-resolution` - `feat/runtime-upstream-auth` - `feat/secrets-ui` diff --git a/docs/admin-api.md b/docs/admin-api.md index 63ea0fc..b994db8 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -4,6 +4,10 @@ Этот документ фиксирует целевые HTTP-контракты административного API, через которое UI управляет workspace, operations, agents, platform access и observability. +Для потоковой модели детальные HTTP DTO вынесены отдельно в: + +- `docs/streaming-admin-api.md` + ## 2. Общие правила API - все payload по умолчанию в `JSON`; @@ -205,6 +209,10 @@ - gRPC descriptor upload и discovery. - upstream auth selector; - quick-create secret / auth profile modal. +- execution mode selector; +- streaming config blocks; +- stream test-runs; +- tool family preview. Детальные DTO и response shapes для экранов `Operations` и `Wizard` зафиксированы отдельно в: diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 9568897..dcbc2e6 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -175,3 +175,15 @@ DoD: - runtime умеет строить SOAP envelopes и нормализовать SOAP Faults; - operator может выбрать service, port и operation; - test-run, publish и observability работают так же, как для остальных протоколов. + +## 15. Этап 14. Detailed streaming specs + +Цель: + +- довести streaming-docs до function-level и field-level design спецификации. + +DoD: + +- есть отдельные docs для streaming admin-api, runtime, UI и capability matrix; +- protocol docs не противоречат общей execution model; +- roadmap и `TASKS.md` синхронизированы с full-detail architecture. diff --git a/docs/protocol-capability-matrix.md b/docs/protocol-capability-matrix.md new file mode 100644 index 0000000..90d6568 --- /dev/null +++ b/docs/protocol-capability-matrix.md @@ -0,0 +1,123 @@ +# Protocol Capability Matrix + +## 1. Назначение документа + +Этот документ фиксирует capability matrix по всем protocol families и execution modes. + +Он нужен как source of truth для: + +- UI availability rules; +- admin-api validation; +- runtime dispatch; +- roadmap planning. + +## 2. Matrix by protocol and execution mode + +| Protocol | Unary | Window | Session | Async Job | Notes | +| --- | --- | --- | --- | --- | --- | +| REST | Yes | Yes | Yes | Yes | window/session используют SSE или bounded polling | +| GraphQL | Yes | No | No | Limited | async job только как явная status/result workflow model | +| gRPC | Yes | Yes | Yes | Yes | только unary и server-streaming | +| WebSocket | No | Yes | Yes | Yes | upstream-only adapter | +| SOAP | Yes | Limited | No | Yes | request-response first, async через separate workflow contract | + +## 3. Matrix by protocol and transport behavior + +| Protocol | Request/Response | Server Stream | Stateful Session | +| --- | --- | --- | --- | +| REST | Yes | Yes | Limited | +| GraphQL | Yes | No | No | +| gRPC | Yes | Yes | Yes | +| WebSocket | No | Yes | Yes | +| SOAP | Yes | Limited | No | + +## 4. Matrix by protocol and artifact requirements + +| Protocol | Sample JSON | Descriptor | WSDL/XSD | Query Template | Subscribe Template | +| --- | --- | --- | --- | --- | --- | +| REST | Optional | No | No | No | Optional | +| GraphQL | Optional | No | No | Yes | No | +| gRPC | Optional | Yes | No | No | No | +| WebSocket | Optional | No | No | No | Yes | +| SOAP | Optional | No | Yes | No | No | + +## 5. Matrix by protocol and auth support + +| Protocol | None | Bearer | Basic | API Key Header | API Key Query | WS-Security | +| --- | --- | --- | --- | --- | --- | --- | +| REST | Yes | Yes | Yes | Yes | Yes | No | +| GraphQL | Yes | Yes | Yes | Yes | Limited | No | +| gRPC | Yes | Yes | Yes | Limited | No | No | +| WebSocket | Yes | Yes | Yes | Yes | Limited | No | +| SOAP | Yes | Yes | Yes | Limited | Limited | Future | + +## 6. Matrix by protocol and UI blocks + +| UI Block | REST | GraphQL | gRPC | WebSocket | SOAP | +| --- | --- | --- | --- | --- | --- | +| Endpoint URL | Yes | Yes | No | Yes | Optional | +| Method selector | Yes | No | No | No | No | +| Query template | No | Yes | No | No | No | +| Descriptor upload | No | No | Yes | No | No | +| WSDL upload | No | No | No | No | Yes | +| Subscribe template | Optional | No | No | Yes | No | +| Subprotocol list | No | No | No | Yes | No | +| SOAP headers | No | No | No | No | Yes | +| Stream kind selector | Optional | No | Yes | Yes | Limited | + +## 7. Validation rules matrix + +## 7.1. Unsupported combinations + +- `GraphQL + Window` +- `GraphQL + Session` +- `WebSocket + Unary` +- `SOAP + Session` +- `gRPC + Session` для non-server-streaming method +- `REST + Server Stream` без stream-capable target model + +## 7.2. Conditionally supported combinations + +- `GraphQL + Async Job` + Требует отдельного status/result workflow contract. + +- `SOAP + Window` + Допускается только для polling-style workflow, а не для native stream. + +- `SOAP + Async Job` + Требует отдельной operation family для status/result. + +## 8. Product-wave grouping + +### Wave 1 + +- REST +- GraphQL query/mutation +- gRPC unary +- Streamable HTTP downstream +- Window/session/job core model + +### Wave 2 + +- REST SSE +- gRPC server-streaming +- session/job tool families +- streaming UI + +### Wave 3 + +- WebSocket upstream adapter +- richer streaming observability + +### Wave 4 + +- SOAP foundation +- WSDL/XSD tooling +- SOAP UI flow + +### Wave 5 + +- GraphQL subscriptions +- gRPC client-streaming +- gRPC bidi +- advanced enterprise security stacks diff --git a/docs/streaming-admin-api.md b/docs/streaming-admin-api.md new file mode 100644 index 0000000..45a9496 --- /dev/null +++ b/docs/streaming-admin-api.md @@ -0,0 +1,607 @@ +# Streaming Admin API + +## 1. Назначение документа + +Этот документ фиксирует точные HTTP-контракты для потоковой модели Crank. + +Он дополняет: + +- [admin-api.md](/home/a.tolmachev/code/rust/mcpaas/docs/admin-api.md) +- [streaming-mcp-plan.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-mcp-plan.md) +- [streaming-ui-contract.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-ui-contract.md) +- [streaming-runtime-design.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-runtime-design.md) + +Цель документа: + +- зафиксировать DTO; +- зафиксировать route groups; +- зафиксировать валидацию; +- зафиксировать expected error model; +- зафиксировать page-to-endpoint contract для streaming configuration и test-runs. + +## 2. Общие принципы + +- все ресурсы являются `workspace-scoped`; +- streaming configuration является частью `operation version`; +- `session` и `async_job` state не редактируются напрямую из UI; +- test-runs могут создавать временные sessions и jobs, но не публикуют их как runtime resources; +- transport errors и validation errors разделяются; +- лимиты и safety-параметры валидируются на сервере, а не только в UI. + +Базовый префикс: + +```text +/api/admin/workspaces/{workspace_id} +``` + +## 3. Основные ресурсы + +- `streaming-presets` +- `streaming-validation` +- `stream-test-runs` +- `stream-sessions` +- `async-jobs` +- `protocol-capabilities` + +## 4. Общие DTO + +### 4.1. `ExecutionMode` + +```json +{ + "mode": "unary" +} +``` + +Допустимые значения: + +- `unary` +- `window` +- `session` +- `async_job` + +### 4.2. `StreamingConfig` + +```json +{ + "mode": "window", + "transport_behavior": "server_stream", + "window_duration_ms": 5000, + "poll_interval_ms": 2000, + "upstream_timeout_ms": 10000, + "idle_timeout_ms": 30000, + "max_session_lifetime_ms": 300000, + "max_items": 200, + "max_bytes": 131072, + "aggregation_mode": "summary_plus_samples", + "summary_path": "$.summary", + "items_path": "$.items", + "cursor_path": "$.cursor", + "status_path": "$.status", + "done_path": "$.done", + "redacted_paths": [ + "$.items[*].token", + "$.summary.secret" + ], + "truncate_item_fields": true, + "max_field_length": 512, + "drop_duplicates": true, + "sampling_rate": 1.0, + "tool_family": { + "start_tool_name": "cluster_events_start", + "poll_tool_name": "cluster_events_poll", + "stop_tool_name": "cluster_events_stop", + "status_tool_name": "deploy_status", + "result_tool_name": "deploy_result", + "cancel_tool_name": "deploy_cancel" + } +} +``` + +### 4.3. `StreamingValidationError` + +```json +{ + "code": "streaming_validation_error", + "message": "Streaming configuration is invalid", + "details": [ + { + "field": "window_duration_ms", + "reason": "must_be_positive" + }, + { + "field": "max_items", + "reason": "must_not_exceed_workspace_limit" + } + ] +} +``` + +### 4.4. `ProtocolCapability` + +```json +{ + "protocol": "grpc", + "supports_execution_modes": [ + "unary", + "window", + "session", + "async_job" + ], + "supports_transport_behaviors": [ + "request_response", + "server_stream" + ], + "supports_auth_kinds": [ + "none", + "bearer", + "basic", + "api_key_header", + "api_key_query" + ], + "supports_upload_artifacts": [ + "proto", + "descriptor_set" + ], + "supports_cursor_path": true, + "supports_done_path": true, + "supports_aggregation_mode": [ + "raw_items", + "summary_only", + "summary_plus_samples", + "stats", + "latest_state" + ] +} +``` + +## 5. Capabilities endpoints + +### `GET /api/admin/workspaces/{workspace_id}/protocol-capabilities` + +Назначение: + +- отдать UI полную capability matrix; +- убрать protocol-specific hardcode из frontend. + +Ответ: + +```json +{ + "items": [ + { + "protocol": "rest", + "supports_execution_modes": ["unary", "window", "session", "async_job"], + "supports_transport_behaviors": ["request_response", "server_stream"], + "supports_auth_kinds": ["none", "bearer", "basic", "api_key_header", "api_key_query"], + "supports_upload_artifacts": [], + "supports_cursor_path": true, + "supports_done_path": true, + "supports_aggregation_mode": ["raw_items", "summary_only", "summary_plus_samples", "stats", "latest_state"] + } + ] +} +``` + +## 6. Streaming validation endpoints + +### `POST /api/admin/workspaces/{workspace_id}/streaming/validate` + +Назначение: + +- проверить streaming config до сохранения operation; +- вернуть protocol-aware ошибки. + +Тело: + +```json +{ + "protocol": "websocket", + "target": {}, + "execution_config": { + "streaming": {} + } +} +``` + +Успех: + +```json +{ + "valid": true, + "warnings": [ + { + "field": "max_bytes", + "code": "may_truncate_large_event_payloads", + "message": "Large event payloads may be truncated" + } + ] +} +``` + +Ошибка: + +```json +{ + "valid": false, + "errors": [ + { + "field": "transport_behavior", + "code": "unsupported_transport_behavior" + } + ] +} +``` + +## 7. Streaming presets endpoints + +### `GET /api/admin/workspaces/{workspace_id}/streaming-presets` + +Назначение: + +- отдать рекомендованные UI presets. + +Ответ: + +```json +{ + "items": [ + { + "preset_id": "logs_window_5s", + "display_name": "Logs Window 5s", + "protocols": ["rest", "grpc", "websocket"], + "streaming": { + "mode": "window", + "window_duration_ms": 5000, + "max_items": 100, + "max_bytes": 65536, + "aggregation_mode": "summary_plus_samples" + } + } + ] +} +``` + +## 8. Streaming operation test-runs + +### `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/stream-test-runs` + +Назначение: + +- выполнить `window`, `session` или `async_job` test-run для draft version; +- показать оператору runtime behavior до publish. + +Тело: + +```json +{ + "version": 4, + "input": { + "service": "billing", + "level": "error" + }, + "test_mode": "window", + "overrides": { + "streaming": { + "window_duration_ms": 3000, + "max_items": 50 + } + } +} +``` + +Успех для `window`: + +```json +{ + "run_id": "strun_01j0test", + "mode": "window", + "status": "completed", + "window_complete": true, + "truncated": false, + "has_more": false, + "summary": {}, + "items": [], + "cursor": null, + "duration_ms": 2871 +} +``` + +Успех для `session`: + +```json +{ + "run_id": "strun_01j0test", + "mode": "session", + "status": "running", + "session": { + "session_id": "sess_01j0stream", + "expires_at": "2026-04-06T12:05:00Z", + "poll_after_ms": 2000 + }, + "preview": { + "summary": {}, + "items": [] + } +} +``` + +Успех для `async_job`: + +```json +{ + "run_id": "strun_01j0test", + "mode": "async_job", + "status": "running", + "job": { + "job_id": "job_01j0deploy", + "status": "running", + "progress": { + "pct": 12 + } + } +} +``` + +### `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/stream-test-runs/{run_id}/poll` + +Назначение: + +- продолжить session-oriented test-run. + +Тело: + +```json +{ + "session_id": "sess_01j0stream" +} +``` + +Ответ: + +```json +{ + "run_id": "strun_01j0test", + "mode": "session", + "status": "running", + "window_complete": false, + "truncated": false, + "has_more": true, + "summary": {}, + "items": [], + "cursor": "next_cursor" +} +``` + +### `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/stream-test-runs/{run_id}/stop` + +Назначение: + +- остановить test session. + +### `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/stream-test-runs/{run_id}/result` + +Назначение: + +- получить финальный результат async-job test-run. + +## 9. Runtime session resources + +### `GET /api/admin/workspaces/{workspace_id}/stream-sessions` + +Назначение: + +- список активных и недавних session resources для observability/debug UI. + +Query params: + +- `operation_id` +- `agent_id` +- `status` +- `page` +- `page_size` + +Ответ: + +```json +{ + "items": [ + { + "id": "sess_01j0stream", + "operation_id": "op_01j0", + "agent_id": "agent_01j0", + "mode": "session", + "status": "running", + "expires_at": "2026-04-06T12:05:00Z", + "last_poll_at": "2026-04-06T12:00:10Z", + "created_at": "2026-04-06T12:00:00Z" + } + ], + "page": 1, + "page_size": 20, + "total": 1 +} +``` + +### `GET /api/admin/workspaces/{workspace_id}/stream-sessions/{session_id}` + +Назначение: + +- detail session metadata; +- без возврата полного raw state. + +### `POST /api/admin/workspaces/{workspace_id}/stream-sessions/{session_id}/stop` + +Назначение: + +- административно остановить runtime session. + +### `DELETE /api/admin/workspaces/{workspace_id}/stream-sessions/{session_id}` + +Назначение: + +- hard cleanup завершенной session; +- доступен только для `stopped`, `failed`, `expired`. + +## 10. Async job resources + +### `GET /api/admin/workspaces/{workspace_id}/async-jobs` + +Назначение: + +- список активных и недавних job resources. + +### `GET /api/admin/workspaces/{workspace_id}/async-jobs/{job_id}` + +Назначение: + +- status/progress/result metadata. + +### `POST /api/admin/workspaces/{workspace_id}/async-jobs/{job_id}/cancel` + +Назначение: + +- административная отмена long-running job. + +### `GET /api/admin/workspaces/{workspace_id}/async-jobs/{job_id}/result` + +Назначение: + +- получить финальный нормализованный результат. + +## 11. Integration into operation contracts + +`streaming` не является отдельным top-level resource для опубликованной операции. Он живет внутри: + +- `OperationVersionDocument.execution_config.streaming` + +и входит в: + +- `POST /operations` +- `PATCH /operations/{operation_id}` +- `POST /operations/{operation_id}/versions` +- `GET /operations/{operation_id}/versions/{version}` + +### 11.1. `OperationVersionDocument.execution_config.streaming` + +```json +{ + "execution_config": { + "timeout_ms": 10000, + "retries": 0, + "auth_profile_ref": "auth_profile_01j0", + "streaming": { + "mode": "window", + "transport_behavior": "server_stream", + "window_duration_ms": 5000, + "upstream_timeout_ms": 10000, + "max_items": 100, + "max_bytes": 65536, + "aggregation_mode": "summary_plus_samples", + "summary_path": "$.summary", + "items_path": "$.items", + "cursor_path": "$.cursor", + "status_path": "$.status", + "done_path": "$.done", + "redacted_paths": [], + "truncate_item_fields": true, + "max_field_length": 512, + "drop_duplicates": true, + "sampling_rate": 1.0, + "tool_family": {} + } + } +} +``` + +## 12. Validation rules + +### 12.1. Общие + +- `mode=unary` запрещает `tool_family`; +- `max_items` > 0; +- `max_bytes` > 0; +- `window_duration_ms` > 0 для `window`; +- `idle_timeout_ms` обязателен для `session`; +- `max_session_lifetime_ms` обязателен для `session`; +- `tool_family.start_tool_name/poll_tool_name/stop_tool_name` обязательны для `session`; +- `tool_family.start_tool_name/status_tool_name/result_tool_name/cancel_tool_name` обязательны для `async_job`. + +### 12.2. Protocol-aware + +REST: + +- `transport_behavior=server_stream` допустим только при streaming-capable target. + +GraphQL: + +- `window`, `session`, `async_job` пока запрещены; +- `subscription` невалиден как target type. + +gRPC: + +- `transport_behavior=server_stream` допустим только для server-streaming method. + +WebSocket: + +- `mode=unary` запрещен; +- требуется `subscribe_message_template` для `session` и `window`. + +SOAP: + +- `mode=session` запрещен в первой волне; +- `mode=window` допустим только при наличии polling-style status operation family; +- `mode=async_job` требует status/result contract. + +## 13. Error model + +Коды: + +- `streaming_validation_error` +- `unsupported_execution_mode` +- `unsupported_transport_behavior` +- `stream_window_timeout` +- `stream_window_truncated` +- `stream_session_expired` +- `stream_session_not_found` +- `async_job_not_found` +- `async_job_not_ready` +- `async_job_cancelled` +- `protocol_capability_mismatch` + +### 13.1. Ошибка несовместимого protocol mode + +```json +{ + "code": "protocol_capability_mismatch", + "message": "websocket target does not support unary execution mode", + "details": [ + { + "field": "execution_config.streaming.mode", + "reason": "unsupported_for_protocol" + } + ] +} +``` + +## 14. Route-to-service mapping + +Ожидаемые service handlers в `apps/admin-api`: + +- `list_protocol_capabilities` +- `list_streaming_presets` +- `validate_streaming_config` +- `start_stream_test_run` +- `poll_stream_test_run` +- `stop_stream_test_run` +- `get_stream_test_result` +- `list_stream_sessions` +- `get_stream_session` +- `stop_stream_session` +- `delete_stream_session` +- `list_async_jobs` +- `get_async_job` +- `cancel_async_job` +- `get_async_job_result` + +Это не route names, а service-level функции orchestration. diff --git a/docs/streaming-runtime-design.md b/docs/streaming-runtime-design.md new file mode 100644 index 0000000..57d0877 --- /dev/null +++ b/docs/streaming-runtime-design.md @@ -0,0 +1,580 @@ +# Streaming Runtime Design + +## 1. Назначение документа + +Этот документ фиксирует, как потоковая модель должна быть разложена по crates, traits, services и функциям. + +Цель: + +- избежать god-services; +- зафиксировать boundaries между `core`, `registry`, `runtime`, protocol adapters и `mcp-server`; +- расписать ожидаемые функции и их ответственность до уровня инженерной спецификации. + +## 2. Общая схема слоев + +### 2.1. `crank-core` + +Отвечает за: + +- типы `ExecutionMode`, `StreamingConfig`, `AggregationMode`; +- типы `StreamSession`, `AsyncJobHandle`, `StreamStatus`, `JobStatus`; +- validation helpers без инфраструктуры. + +### 2.2. `crank-registry` + +Отвечает за: + +- persistence session/job state; +- optimistic transitions; +- cleanup expired state. + +### 2.3. `crank-runtime` + +Отвечает за: + +- orchestration execution modes; +- dispatch в protocol adapter; +- bounded collection; +- aggregation; +- state transitions; +- invocation logging. + +### 2.4. Protocol adapters + +Отвечают за: + +- protocol-specific upstream behavior; +- connect/request/collect/close; +- преобразование upstream payload в нормализованный JSON. + +### 2.5. `apps/mcp-server` + +Отвечает за: + +- downstream MCP transport; +- tool-family generation; +- JSON-RPC lifecycle; +- transport/session routing. + +### 2.6. `apps/admin-api` + +Отвечает за: + +- config CRUD; +- test-runs; +- validation; +- UI-oriented metadata and previews. + +## 3. Core domain types + +## 3.1. `ExecutionMode` + +```rust +pub enum ExecutionMode { + Unary, + Window, + Session, + AsyncJob, +} +``` + +Методы: + +- `fn is_stateful(&self) -> bool` +- `fn requires_tool_family(&self) -> bool` + +## 3.2. `AggregationMode` + +```rust +pub enum AggregationMode { + RawItems, + SummaryOnly, + SummaryPlusSamples, + Stats, + LatestState, +} +``` + +Методы: + +- `fn needs_items(&self) -> bool` +- `fn needs_summary(&self) -> bool` + +## 3.3. `StreamingConfig` + +```rust +pub struct StreamingConfig { + pub mode: ExecutionMode, + pub transport_behavior: TransportBehavior, + pub window_duration_ms: Option, + pub poll_interval_ms: Option, + pub upstream_timeout_ms: Option, + pub idle_timeout_ms: Option, + pub max_session_lifetime_ms: Option, + pub max_items: Option, + pub max_bytes: Option, + pub aggregation_mode: AggregationMode, + pub summary_path: Option, + pub items_path: Option, + pub cursor_path: Option, + pub status_path: Option, + pub done_path: Option, + pub redacted_paths: Vec, + pub truncate_item_fields: bool, + pub max_field_length: Option, + pub drop_duplicates: bool, + pub sampling_rate: Option, + pub tool_family: ToolFamilyConfig, +} +``` + +Методы: + +- `fn validate_common(&self) -> Result<(), StreamingConfigError>` +- `fn validate_for_protocol(&self, protocol: Protocol) -> Result<(), StreamingConfigError>` +- `fn effective_max_items(&self, limits: &StreamingLimits) -> u32` +- `fn effective_max_bytes(&self, limits: &StreamingLimits) -> u32` +- `fn effective_timeout(&self, defaults: &StreamingDefaults) -> Duration` + +## 3.4. `StreamSession` + +```rust +pub struct StreamSession { + pub id: StreamSessionId, + pub workspace_id: WorkspaceId, + pub agent_id: Option, + pub operation_id: OperationId, + pub protocol: Protocol, + pub mode: ExecutionMode, + pub status: StreamStatus, + pub cursor: Option, + pub state: Value, + pub expires_at: DateTime, + pub last_poll_at: Option>, + pub created_at: DateTime, + pub closed_at: Option>, +} +``` + +Методы: + +- `fn is_expired(&self, now: DateTime) -> bool` +- `fn can_poll(&self, now: DateTime) -> bool` +- `fn mark_polled(&mut self, now: DateTime)` +- `fn mark_closed(&mut self, now: DateTime)` + +## 3.5. `AsyncJobHandle` + +```rust +pub struct AsyncJobHandle { + pub id: AsyncJobId, + pub workspace_id: WorkspaceId, + pub agent_id: Option, + pub operation_id: OperationId, + pub status: JobStatus, + pub progress: Value, + pub result: Option, + pub error: Option, + pub expires_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub finished_at: Option>, +} +``` + +Методы: + +- `fn is_finished(&self) -> bool` +- `fn can_cancel(&self) -> bool` +- `fn mark_finished(&mut self, now: DateTime, result: Value)` +- `fn mark_failed(&mut self, now: DateTime, error: Value)` + +## 4. Registry contracts + +## 4.1. `StreamSessionRepository` + +```rust +#[async_trait] +pub trait StreamSessionRepository { + async fn create_stream_session(&self, session: NewStreamSession) -> Result; + async fn get_stream_session(&self, id: &StreamSessionId) -> Result, RegistryError>; + async fn update_stream_session_state(&self, update: StreamSessionStateUpdate) -> Result; + async fn close_stream_session(&self, id: &StreamSessionId, now: DateTime) -> Result<(), RegistryError>; + async fn list_stream_sessions(&self, filter: StreamSessionFilter) -> Result, RegistryError>; + async fn delete_expired_stream_sessions(&self, now: DateTime) -> Result; +} +``` + +## 4.2. `AsyncJobRepository` + +```rust +#[async_trait] +pub trait AsyncJobRepository { + async fn create_async_job(&self, job: NewAsyncJob) -> Result; + async fn get_async_job(&self, id: &AsyncJobId) -> Result, RegistryError>; + async fn update_async_job_status(&self, update: AsyncJobStatusUpdate) -> Result; + async fn cancel_async_job(&self, id: &AsyncJobId, now: DateTime) -> Result<(), RegistryError>; + async fn list_async_jobs(&self, filter: AsyncJobFilter) -> Result, RegistryError>; + async fn delete_expired_async_jobs(&self, now: DateTime) -> Result; +} +``` + +## 4.3. Storage rules + +- update methods должны использовать optimistic concurrency, если state transitions конфликтуют; +- `poll` не должен silently reopen closed session; +- cleanup job должен быть отдельным service; +- session/job state не должен хранить необрезанные raw payloads без лимитов. + +## 5. Runtime contracts + +## 5.1. `ProtocolAdapter` + +Базовый trait не должен пытаться описать все streaming cases в одном методе. + +```rust +#[async_trait] +pub trait ProtocolAdapter { + async fn execute_unary(&self, ctx: UnaryExecutionContext) -> Result; + async fn execute_window(&self, ctx: WindowExecutionContext) -> Result; + async fn start_session(&self, ctx: SessionStartContext) -> Result; + async fn poll_session(&self, ctx: SessionPollContext) -> Result; + async fn stop_session(&self, ctx: SessionStopContext) -> Result<(), AdapterError>; + async fn start_async_job(&self, ctx: AsyncJobStartContext) -> Result; + async fn get_async_job_status(&self, ctx: AsyncJobStatusContext) -> Result; + async fn get_async_job_result(&self, ctx: AsyncJobResultContext) -> Result; + async fn cancel_async_job(&self, ctx: AsyncJobCancelContext) -> Result<(), AdapterError>; +} +``` + +Не каждый adapter обязан поддерживать все методы. Capability mismatch должен проверяться выше. + +## 5.2. `OperationExecutor` + +Главная orchestration точка в `crank-runtime`. + +Ожидаемые функции: + +- `execute_unary_operation` +- `execute_window_operation` +- `start_stream_session` +- `poll_stream_session` +- `stop_stream_session` +- `start_async_job` +- `get_async_job_status` +- `get_async_job_result` +- `cancel_async_job` + +### `execute_unary_operation` + +Отвечает за: + +- schema validation; +- auth resolution; +- adapter dispatch; +- output mapping; +- observability write. + +### `execute_window_operation` + +Отвечает за: + +- schema validation; +- auth resolution; +- adapter dispatch в `execute_window`; +- aggregation; +- truncation flags; +- observability write. + +### `start_stream_session` + +Отвечает за: + +- schema validation; +- auth resolution; +- adapter `start_session`; +- registry `create_stream_session`; +- initial preview result; +- observability write. + +### `poll_stream_session` + +Отвечает за: + +- load session from registry; +- expiration check; +- adapter `poll_session`; +- registry `update_stream_session_state`; +- output mapping; +- observability write. + +### `stop_stream_session` + +Отвечает за: + +- load session; +- adapter `stop_session`; +- registry close; +- observability write. + +### `start_async_job` + +Отвечает за: + +- schema validation; +- adapter `start_async_job`; +- registry `create_async_job`; +- observability write. + +### `get_async_job_status` + +Отвечает за: + +- registry load; +- adapter `get_async_job_status`, если upstream status is live; +- registry update; +- normalized status result. + +### `get_async_job_result` + +Отвечает за: + +- job readiness check; +- adapter `get_async_job_result`, если result lazy-loaded; +- output mapping; +- observability write. + +### `cancel_async_job` + +Отвечает за: + +- adapter cancel; +- registry cancel transition; +- observability write. + +## 5.3. Aggregation services + +Нужно отделить aggregation от adapters. + +Отдельные services: + +- `WindowAggregator` +- `SummaryBuilder` +- `CursorTracker` +- `PayloadLimiter` +- `RedactionService` + +### `WindowAggregator` + +Функции: + +- `collect_items` +- `apply_item_limit` +- `apply_byte_limit` +- `mark_truncated` + +### `SummaryBuilder` + +Функции: + +- `build_summary` +- `build_stats_summary` +- `build_latest_state_summary` +- `build_summary_plus_samples` + +### `PayloadLimiter` + +Функции: + +- `truncate_item_fields` +- `truncate_bytes` +- `enforce_max_items` + +### `RedactionService` + +Функции: + +- `redact_paths` +- `redact_object` +- `redact_item` + +## 6. Adapter responsibilities + +## 6.1. REST adapter + +Функции: + +- `execute_rest_unary` +- `execute_rest_window` +- `start_rest_session` +- `poll_rest_session` +- `stop_rest_session` +- `start_rest_async_job` +- `get_rest_async_job_status` +- `get_rest_async_job_result` +- `cancel_rest_async_job` + +Дополнительные helpers: + +- `open_sse_stream` +- `collect_sse_window` +- `parse_sse_event` +- `close_sse_stream` + +## 6.2. GraphQL adapter + +Функции: + +- `execute_graphql_unary` + +Отдельно отложено: + +- `execute_graphql_subscription_window` +- `start_graphql_subscription_session` + +## 6.3. gRPC adapter + +Функции: + +- `execute_grpc_unary` +- `execute_grpc_window` +- `start_grpc_session` +- `poll_grpc_session` +- `stop_grpc_session` +- `start_grpc_async_job` +- `get_grpc_async_job_status` +- `get_grpc_async_job_result` +- `cancel_grpc_async_job` + +Helpers: + +- `invoke_unary_method` +- `open_server_stream` +- `collect_server_stream_window` +- `decode_stream_item` + +## 6.4. WebSocket adapter + +Функции: + +- `execute_websocket_window` +- `start_websocket_session` +- `poll_websocket_session` +- `stop_websocket_session` +- `start_websocket_async_job` +- `get_websocket_async_job_status` +- `cancel_websocket_async_job` + +Helpers: + +- `connect_websocket` +- `send_subscribe_message` +- `send_unsubscribe_message` +- `read_next_frame` +- `decode_text_frame` +- `heartbeat_tick` +- `reconnect_if_needed` + +## 6.5. SOAP adapter + +Функции: + +- `execute_soap_unary` +- `start_soap_async_job` +- `get_soap_async_job_status` +- `get_soap_async_job_result` +- `cancel_soap_async_job` + +Helpers: + +- `render_soap_envelope` +- `render_soap_headers` +- `parse_soap_envelope` +- `parse_soap_fault` +- `normalize_xml_value` + +## 7. MCP server design + +## 7.1. Route handlers + +Ожидаемые handlers: + +- `handle_initialize` +- `handle_notifications_initialized` +- `handle_ping` +- `handle_tools_list` +- `handle_tools_call` +- `handle_get_sse_stream` +- `handle_delete_session` + +## 7.2. Tool-family generation + +Функции: + +- `build_unary_tool_definition` +- `build_window_tool_definition` +- `build_session_tool_family` +- `build_async_job_tool_family` + +Для `session`: + +- `build_session_start_tool` +- `build_session_poll_tool` +- `build_session_stop_tool` + +Для `async_job`: + +- `build_async_job_start_tool` +- `build_async_job_status_tool` +- `build_async_job_result_tool` +- `build_async_job_cancel_tool` + +## 7.3. Transport session management + +Функции: + +- `ensure_protocol_version` +- `resolve_transport_mode` +- `ensure_mcp_session` +- `attach_session_headers` +- `stream_sse_event` +- `close_transport_session` + +## 8. Admin API service design + +Ожидаемые service функции: + +- `validate_streaming_config` +- `list_protocol_capabilities` +- `list_streaming_presets` +- `start_stream_test_run` +- `poll_stream_test_run` +- `stop_stream_test_run` +- `get_stream_test_result` +- `list_stream_sessions` +- `get_stream_session` +- `stop_stream_session` +- `delete_stream_session` +- `list_async_jobs` +- `get_async_job` +- `cancel_async_job` +- `get_async_job_result` + +## 9. Cleanup jobs + +Отдельные jobs: + +- `expire_stream_sessions` +- `expire_async_jobs` +- `reap_orphaned_transport_sessions` +- `compact_stream_payloads`, если будет нужен storage optimization layer + +## 10. Design rules + +- adapters не агрегируют product-level summaries; +- runtime не знает деталей transport framing; +- `mcp-server` не знает про upstream protocols; +- `admin-api` не управляет live protocol connections напрямую; +- session/job lifecycle должен быть явным в names и contracts; +- cancellation не должна зависеть от TCP disconnect; +- bounded limits должны применяться раньше, чем payload попадет в final result. diff --git a/docs/streaming-ui-contract.md b/docs/streaming-ui-contract.md new file mode 100644 index 0000000..4c6e9aa --- /dev/null +++ b/docs/streaming-ui-contract.md @@ -0,0 +1,434 @@ +# Streaming UI Contract + +## 1. Назначение документа + +Этот документ фиксирует точный UI-контракт для настройки потоковых операций. + +Цель: + +- определить экраны и блоки wizard; +- перечислить все поля; +- перечислить валидации; +- определить protocol-specific visibility rules; +- зафиксировать UX states, warnings и system messages. + +## 2. Основные экраны + +Потоковая конфигурация живет в: + +- `Operations Wizard` +- `Operation Detail` +- `Test Run` +- `Agent Tool Preview` +- `Logs` / `Usage` observability surfaces + +Дополнительно админские страницы: + +- `Stream Sessions` +- `Async Jobs` + +## 3. Wizard information architecture + +## 3.1. Shared wizard structure + +Шаги: + +1. `Protocol` +2. `Upstream` +3. `Request / Subscription` +4. `Execution Mode` +5. `Input Schema` +6. `Output / Aggregation` +7. `Safety and Limits` +8. `Tool Family` +9. `Test Run` +10. `Publish` + +## 3.2. Step `Execution Mode` + +Поля: + +- `Mode` + - `Unary` + - `Window` + - `Session` + - `Async Job` +- `Transport behavior` + - `Request / response` + - `Server stream` + +Подсказки: + +- `Unary` для обычных request-response integrations. +- `Window` для bounded snapshots из stream или feed. +- `Session` для follow-style tools с `start/poll/stop`. +- `Async Job` для long-running operations с `status/result/cancel`. + +Валидации: + +- `Transport behavior=server_stream` не может использоваться с protocol, который его не поддерживает. +- `WebSocket` не может быть `Unary`. +- `SOAP` в первой волне не может быть `Session`. + +## 3.3. Step `Input Schema` + +Поля: + +- `Input schema source` + - manual + - sample-derived + - descriptor/wsdl-derived +- `Input fields` +- `Required fields` +- `Defaults` +- `Mapping preview` + +## 3.4. Step `Output / Aggregation` + +Поля: + +- `Aggregation mode` + - `Raw items` + - `Summary only` + - `Summary + samples` + - `Stats` + - `Latest state` +- `Items path` +- `Summary path` +- `Cursor path` +- `Status path` +- `Done path` +- `Output mapping` + +Валидации: + +- `Items path` обязателен для `Raw items` и `Summary + samples`. +- `Summary path` обязателен для `Summary only`, `Summary + samples`, `Stats`, `Latest state`. +- `Done path` обязателен для `Async Job`, если upstream status не выражается отдельным field set. + +## 3.5. Step `Safety and Limits` + +Поля: + +- `Window duration` +- `Poll interval` +- `Upstream timeout` +- `Idle timeout` +- `Session lifetime` +- `Max items` +- `Max bytes` +- `Max field length` +- `Drop duplicates` +- `Sampling rate` +- `Redacted paths` + +Валидации: + +- `Window duration` обязателен для `Window`. +- `Poll interval` обязателен для `Session`. +- `Idle timeout` обязателен для `Session`. +- `Session lifetime` обязателен для `Session`. +- `Max items` > 0. +- `Max bytes` > 0. +- `Sampling rate` > 0 and <= 1. + +## 3.6. Step `Tool Family` + +Показывается только для: + +- `Session` +- `Async Job` + +Для `Session`: + +- `Start tool name` +- `Poll tool name` +- `Stop tool name` + +Для `Async Job`: + +- `Start tool name` +- `Status tool name` +- `Result tool name` +- `Cancel tool name` + +Валидации: + +- имена обязательны; +- имена должны быть уникальны в пределах agent; +- имена не должны конфликтовать с already bound tools. + +## 4. Protocol-specific UI + +## 4.1. REST + +Поля: + +- `Base URL` +- `HTTP method` +- `Path template` +- `Headers` +- `Query mapping` +- `Body mapping` +- `SSE enabled` +- `SSE event filter` + +Visibility: + +- `SSE enabled` показывается только если method/endpoint допускают stream use case; +- `Window` и `Session` доступны, если оператор включает stream behavior. + +## 4.2. GraphQL + +Поля: + +- `Endpoint` +- `Operation type` +- `Operation name` +- `Query template` +- `Variables schema` +- `Response path` + +Visibility: + +- `Window`, `Session`, `Async Job` скрыты в текущей продуктовой волне; +- `Subscription` UI не показывается. + +## 4.3. gRPC + +Поля: + +- `Server address` +- `Package` +- `Service` +- `Method` +- `Descriptor source` +- `Stream kind` + - `Unary` + - `Server streaming` + +Visibility: + +- `Window`, `Session`, `Async Job` доступны только для `Server streaming`; +- `Client streaming` и `Bidirectional` не показываются вообще. + +## 4.4. WebSocket + +Поля: + +- `WebSocket URL` +- `Subprotocols` +- `Connect timeout` +- `Heartbeat interval` +- `Subscribe message template` +- `Unsubscribe message template` +- `Message envelope path` + +Visibility: + +- `Unary` не показывается; +- `Window`, `Session`, `Async Job` доступны всегда; +- `Subscribe message template` обязательно для `Window` и `Session`. + +## 4.5. SOAP + +Поля: + +- `WSDL source` +- `Service` +- `Port` +- `Operation` +- `SOAP version` +- `SOAPAction` +- `Endpoint override` +- `Header config` + +Visibility: + +- `Session` скрыт; +- `Window` скрыт по умолчанию и включается только для polling-style enterprise workflows; +- `Unary` и `Async Job` доступны. + +## 5. Test Run UX + +## 5.1. Window + +UI должен показывать: + +- `Status` +- `Duration` +- `Items count` +- `Bytes count` +- `Window complete` +- `Truncated` +- `Has more` +- `Summary` +- `Items preview` +- `Cursor` + +Кнопки: + +- `Run window test` +- `Repeat` +- `Save config` + +## 5.2. Session + +UI должен показывать: + +- `Session id` +- `Status` +- `Expires at` +- `Poll after` +- `Summary preview` +- `Items preview` + +Кнопки: + +- `Start session` +- `Poll next chunk` +- `Stop session` + +## 5.3. Async Job + +UI должен показывать: + +- `Job id` +- `Status` +- `Progress` +- `Started at` +- `Finished at` +- `Result preview` + +Кнопки: + +- `Start job` +- `Refresh status` +- `Get result` +- `Cancel job` + +## 6. Page-level states + +Каждый streaming-aware экран обязан поддерживать: + +- `idle` +- `validating` +- `saving` +- `testing` +- `running` +- `completed` +- `failed` +- `stopped` +- `expired` + +## 7. Warnings and messages + +## 7.1. Validation warnings + +- `This mode will truncate responses above the configured byte limit.` +- `Current protocol does not support the selected execution mode.` +- `Session lifetime is shorter than idle timeout.` +- `Aggregation mode summary_only hides raw items from the final tool output.` +- `WebSocket reconnect may duplicate events if upstream does not provide cursor semantics.` +- `SOAP async mode requires a separate status/result contract.` + +## 7.2. Confirmations + +- `Stop current session?` +- `Cancel running job?` +- `Switching execution mode will reset protocol-specific fields.` + +## 7.3. Errors + +- `Streaming configuration is invalid.` +- `Test session expired.` +- `Upstream did not return any messages within the configured window.` +- `The collected stream payload was truncated by size limits.` +- `This protocol does not support the selected execution mode.` + +## 8. Stream Sessions page + +Колонки: + +- `Session ID` +- `Operation` +- `Agent` +- `Mode` +- `Status` +- `Created` +- `Last poll` +- `Expires` + +Actions: + +- `Open` +- `Stop` +- `Delete` + +Detail view: + +- `Session metadata` +- `Cursor preview` +- `State summary` +- `Recent events summary` + +## 9. Async Jobs page + +Колонки: + +- `Job ID` +- `Operation` +- `Agent` +- `Status` +- `Progress` +- `Created` +- `Updated` +- `Finished` + +Actions: + +- `Open` +- `Cancel` +- `Get result` + +Detail view: + +- `Job metadata` +- `Progress payload` +- `Result preview` +- `Error preview` + +## 10. Exact frontend adapters + +Ожидаемые frontend modules: + +- `streaming-form.js` +- `streaming-validation.js` +- `stream-test-run.js` +- `stream-sessions.js` +- `async-jobs.js` + +Ожидаемые frontend functions: + +- `loadProtocolCapabilities()` +- `applyStreamingPreset()` +- `validateStreamingConfig()` +- `serializeStreamingConfig()` +- `deserializeStreamingConfig()` +- `startWindowTest()` +- `startSessionTest()` +- `pollSessionTest()` +- `stopSessionTest()` +- `startAsyncJobTest()` +- `refreshAsyncJobStatus()` +- `loadAsyncJobResult()` + +## 11. Shared UI rules + +- UI никогда не должен предлагать unsupported mode; +- UI должен строить availability по `protocol-capabilities`, а не по hardcoded if-else; +- UI должен явно объяснять, почему поле скрыто или disabled; +- UI должен всегда показывать итоговую tool topology: + - `1 tool` + - `3 tools start/poll/stop` + - `4 tools start/status/result/cancel` From 633af39c827629bc975a0199ad9fb3c3b847ab2c Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 09:40:59 +0300 Subject: [PATCH 05/38] docs: define streaming implementation slices --- README.md | 1 + TASKS.md | 8 +- docs/implementation-plan.md | 13 + docs/streaming-implementation-spec.md | 584 ++++++++++++++++++++++++++ 4 files changed, 602 insertions(+), 4 deletions(-) create mode 100644 docs/streaming-implementation-spec.md diff --git a/README.md b/README.md index b2afd43..dae9f85 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Crank - платформа для публикации внешних API в в - `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. diff --git a/TASKS.md b/TASKS.md index 2123577..22b8fbd 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,14 +2,14 @@ ## Current -### `feat/streaming-detailed-specs` +### `feat/streaming-implementation-spec` Status: completed DoD: -- Streaming API, runtime, UI and capability docs exist as separate detailed specs -- Function-level responsibilities are documented -- Field-level UI contract and validation rules are documented +- Streaming slices are defined file-by-file +- Acceptance criteria and tests are explicit for each slice +- State transitions and sequence outlines are documented ## Next diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index dcbc2e6..3837f97 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -187,3 +187,16 @@ DoD: - есть отдельные docs для streaming admin-api, runtime, UI и capability matrix; - protocol docs не противоречат общей execution model; - roadmap и `TASKS.md` синхронизированы с full-detail architecture. + +## 16. Этап 15. Streaming implementation slices + +Цель: + +- перевести streaming design в agent-friendly execution plan по файлам, тестам и DoD. + +DoD: + +- есть отдельный implementation spec по всем streaming slices; +- указаны file-level changes; +- указаны обязательные тесты и acceptance criteria; +- state transitions и sequence outlines зафиксированы явно. diff --git a/docs/streaming-implementation-spec.md b/docs/streaming-implementation-spec.md new file mode 100644 index 0000000..74b035b --- /dev/null +++ b/docs/streaming-implementation-spec.md @@ -0,0 +1,584 @@ +# Streaming Implementation Specification + +## 1. Назначение документа + +Этот документ переводит streaming architecture в execution-oriented plan, пригодный для агентной разработки. + +Он отвечает на вопросы: + +- в каком порядке реализовывать фичи; +- какие файлы менять в каждом срезе; +- какие новые модули создавать; +- какие тесты обязательны; +- какие риски и зависимости есть между срезами; +- какой Definition of Done нужен для каждого шага. + +Документ дополняет: + +- [streaming-mcp-plan.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-mcp-plan.md) +- [streaming-admin-api.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-admin-api.md) +- [streaming-runtime-design.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-runtime-design.md) +- [streaming-ui-contract.md](/home/a.tolmachev/code/rust/mcpaas/docs/streaming-ui-contract.md) +- [protocol-capability-matrix.md](/home/a.tolmachev/code/rust/mcpaas/docs/protocol-capability-matrix.md) + +## 2. Принципы агентной реализации + +- один vertical slice на ветку; +- docs first, code second; +- не смешивать transport alignment, core model, registry, runtime и UI в одном срезе; +- каждый срез должен быть протестирован отдельно; +- stateful streaming нельзя вводить через скрытые side effects; +- session/job lifecycle должен появляться в коде и API явно. + +## 3. Dependency graph + +Порядок реализации: + +1. `feat/mcp-streamable-http-alignment` +2. `feat/streaming-core-model` +3. `feat/stream-session-store` +4. `feat/runtime-window-mode` +5. `feat/rest-sse-adapter` +6. `feat/grpc-server-streaming-adapter` +7. `feat/session-and-job-tools` +8. `feat/streaming-ui-config` +9. `feat/streaming-e2e` +10. `feat/websocket-upstream-adapter` +11. `feat/soap-architecture-and-core-model` +12. `feat/soap-adapter-foundation` + +Почему именно так: + +- сначала transport correctness; +- потом core types; +- потом persistence; +- потом bounded runtime; +- потом protocol adapters; +- потом MCP tool publishing; +- потом UI; +- потом e2e; +- потом расширение protocol platform. + +## 4. Slice 1: `feat/mcp-streamable-http-alignment` + +### 4.1. Цель + +Довести `apps/mcp-server` до полного и явного соответствия MCP `Streamable HTTP`. + +### 4.2. Файлы + +Изменить: + +- [apps/mcp-server/src/app.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/app.rs) +- [apps/mcp-server/src/jsonrpc.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/jsonrpc.rs) +- [apps/mcp-server/src/session.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/session.rs) +- [apps/mcp-server/src/main.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/main.rs) + +Добавить при необходимости: + +- `apps/mcp-server/src/transport.rs` +- `apps/mcp-server/src/sse.rs` + +### 4.3. Что должно быть сделано + +- parse `Accept` корректно для `application/json` и `text/event-stream`; +- `GET` endpoint перестает быть `405` и становится SSE-capable transport stream; +- `POST` может отвечать JSON или SSE в зависимости от negotiated transport; +- `Mcp-Session-Id` создается, читается и валидируется явно; +- `MCP-Protocol-Version` валидируется и возвращается явно; +- `DELETE` корректно завершает transport session; +- disconnect не трактуется как cancel operation. + +### 4.4. Тесты + +Обязательно добавить: + +- initialize через JSON response; +- initialize через SSE response; +- `GET` SSE handshake; +- повторное использование `Mcp-Session-Id`; +- invalid protocol version; +- invalid accept header; +- session close through `DELETE`. + +### 4.5. DoD + +- `mcp-server` ведет себя согласно spec; +- transport tests зелёные; +- docs и code names совпадают. + +## 5. Slice 2: `feat/streaming-core-model` + +### 5.1. Цель + +Ввести доменные типы streaming execution. + +### 5.2. Файлы + +Изменить: + +- [crates/crank-core/src/lib.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/lib.rs) +- [crates/crank-core/src/operation.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/operation.rs) +- [crates/crank-core/src/ids.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/ids.rs) +- [crates/crank-core/src/protocol.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/protocol.rs) + +Добавить: + +- `crates/crank-core/src/streaming.rs` +- `crates/crank-core/src/stream_session.rs` + +### 5.3. Что должно быть сделано + +- добавить `ExecutionMode`; +- добавить `TransportBehavior`; +- добавить `AggregationMode`; +- добавить `StreamingConfig`; +- добавить `StreamSessionId`, `AsyncJobId`; +- добавить `StreamSession`, `AsyncJobHandle`, `StreamStatus`, `JobStatus`; +- вшить `streaming: Option` в `ExecutionConfig`. + +### 5.4. Тесты + +- serde roundtrip для `StreamingConfig`; +- validation rules для `ExecutionMode`; +- protocol-aware validation helpers. + +### 5.5. DoD + +- core types стабильны; +- экспортированы через `lib.rs`; +- naming не спорит с docs. + +## 6. Slice 3: `feat/stream-session-store` + +### 6.1. Цель + +Добавить persistent store для `stream_sessions` и `async_jobs`. + +### 6.2. Файлы + +Изменить: + +- [crates/crank-registry/src/lib.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/lib.rs) +- [crates/crank-registry/src/model.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/model.rs) +- [crates/crank-registry/src/migrations.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/migrations.rs) +- [crates/crank-registry/src/postgres.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/postgres.rs) + +### 6.3. Что должно быть сделано + +- таблицы `stream_sessions`, `async_jobs`; +- record types; +- create/get/update/close/list/delete-expired methods; +- filters and paging; +- optimistic transitions для state updates. + +### 6.4. Тесты + +- migration test; +- create/get/update/close session; +- create/get/update/cancel async job; +- cleanup expired rows; +- invalid transition rejection. + +### 6.5. DoD + +- storage API соответствует runtime design; +- transitions не допускают silent corruption. + +## 7. Slice 4: `feat/runtime-window-mode` + +### 7.1. Цель + +Ввести bounded `window` mode до session/job complexity. + +### 7.2. Файлы + +Изменить: + +- [crates/crank-runtime/src/lib.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/lib.rs) +- [crates/crank-runtime/src/model.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/model.rs) +- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs) +- [crates/crank-runtime/src/error.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/error.rs) + +Добавить: + +- `crates/crank-runtime/src/streaming.rs` +- `crates/crank-runtime/src/aggregation.rs` +- `crates/crank-runtime/src/redaction.rs` + +### 7.3. Что должно быть сделано + +- `execute_window_operation`; +- bounded item/byte limiting; +- `truncated`, `window_complete`, `has_more`; +- summary building; +- redaction and truncation; +- observability write. + +### 7.4. Тесты + +- raw items mode; +- summary only mode; +- summary plus samples; +- max item truncation; +- max byte truncation; +- redaction; +- timeout handling. + +### 7.5. DoD + +- unary execution не ломается; +- window mode работает для synthetic adapter-level fixtures. + +## 8. Slice 5: `feat/rest-sse-adapter` + +### 8.1. Цель + +Добавить bounded REST SSE support. + +### 8.2. Файлы + +Изменить: + +- текущий crate [crates/crank-adapter-rest](/home/a.tolmachev/code/rust/mcpaas/crates/crank-adapter-rest) +- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs) + +Ожидаемые файлы: + +- `src/lib.rs` +- `src/error.rs` +- `src/client.rs` +- `src/sse.rs` + +### 8.3. Что должно быть сделано + +- SSE connect; +- event parsing; +- bounded collect window; +- session start/poll/stop hooks, если adapter slice сразу включает stateful mode; +- proper close. + +### 8.4. Тесты + +- local SSE upstream fixture; +- collect N events; +- timeout without events; +- malformed event handling; +- reconnect not enabled by default. + +## 9. Slice 6: `feat/grpc-server-streaming-adapter` + +### 9.1. Цель + +Добавить bounded gRPC server-streaming. + +### 9.2. Файлы + +Изменить: + +- [crates/crank-adapter-grpc](/home/a.tolmachev/code/rust/mcpaas/crates/crank-adapter-grpc) +- [crates/crank-proto](/home/a.tolmachev/code/rust/mcpaas/crates/crank-proto) +- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs) + +### 9.3. Что должно быть сделано + +- identify unary vs server-streaming method from descriptors; +- invoke server stream; +- collect bounded window; +- decode stream items to normalized JSON; +- integrate with window mode. + +### 9.4. Тесты + +- local grpc fixture with server-streaming; +- descriptor-backed invocation; +- bounded collection; +- timeout; +- malformed item handling. + +## 10. Slice 7: `feat/session-and-job-tools` + +### 10.1. Цель + +Ввести `session` и `async_job` как first-class runtime and MCP constructs. + +### 10.2. Файлы + +Изменить: + +- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs) +- [apps/mcp-server/src/app.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/app.rs) +- [apps/mcp-server/src/catalog.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/catalog.rs) +- [apps/mcp-server/src/session.rs](/home/a.tolmachev/code/rust/mcpaas/apps/mcp-server/src/session.rs) +- [crates/crank-registry/src/postgres.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-registry/src/postgres.rs) + +### 10.3. Что должно быть сделано + +- runtime methods for start/poll/stop and start/status/result/cancel; +- MCP tool-family generation; +- binding-level tool-name derivation; +- JSON-RPC call routing for tool families; +- session/job observability. + +### 10.4. Тесты + +- tool listing shows generated family; +- `session start -> poll -> stop`; +- `async_job start -> status -> result -> cancel`; +- invalid session id; +- expired session; +- result before ready. + +## 11. Slice 8: `feat/streaming-ui-config` + +### 11.1. Цель + +Добавить UI для streaming configuration. + +### 11.2. Файлы + +Изменить: + +- `apps/ui/html/wizard/*.html` +- `apps/ui/js/wizard.js` +- `apps/ui/js/api.js` +- `apps/ui/js/i18n.js` +- CSS файлы wizard/settings/pages по необходимости + +Добавить: + +- `apps/ui/js/streaming-form.js` +- `apps/ui/js/stream-test-run.js` +- `apps/ui/html/stream-sessions.html` +- `apps/ui/html/async-jobs.html` + +### 11.3. Что должно быть сделано + +- protocol capabilities fetch; +- execution mode selector; +- protocol-specific field visibility; +- validation wiring; +- test-run UX for window/session/async job; +- sessions/jobs views; +- localization. + +### 11.4. Тесты + +- Playwright: + - configure REST window; + - configure gRPC streaming; + - validation error render; + - session test flow; + - async job test flow. + +## 12. Slice 9: `feat/streaming-e2e` + +### 12.1. Цель + +Зафиксировать end-to-end behavior на живом тестовом стеке. + +### 12.2. Что должно быть сделано + +- добавить local fixtures: + - REST SSE server + - gRPC server-streaming server + - WebSocket event server + - SOAP fixture, если adapter уже готов +- добавить smoke scenarios; +- включить их в CI. + +### 12.3. Тесты + +- tool call end-to-end through mcp-server; +- usage/logging on streaming calls; +- session cleanup; +- async job cleanup. + +## 13. Slice 10: `feat/websocket-upstream-adapter` + +### 13.1. Файлы + +Добавить: + +- `crates/crank-adapter-websocket/Cargo.toml` +- `crates/crank-adapter-websocket/src/lib.rs` +- `crates/crank-adapter-websocket/src/error.rs` +- `crates/crank-adapter-websocket/src/client.rs` +- `crates/crank-adapter-websocket/src/session.rs` + +Изменить: + +- [crates/crank-core/src/protocol.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/protocol.rs) +- [crates/crank-core/src/operation.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/operation.rs) +- [crates/crank-runtime/src/executor.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-runtime/src/executor.rs) + +### 13.2. DoD + +- bounded WebSocket window; +- session support; +- reconnect and heartbeat policy; +- capability matrix exposed in admin-api and UI. + +## 14. Slice 11: `feat/soap-architecture-and-core-model` + +### 14.1. Файлы + +Изменить: + +- [crates/crank-core/src/protocol.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/protocol.rs) +- [crates/crank-core/src/operation.rs](/home/a.tolmachev/code/rust/mcpaas/crates/crank-core/src/operation.rs) +- [crates/crank-schema](/home/a.tolmachev/code/rust/mcpaas/crates/crank-schema) + +Добавить: + +- `crates/crank-core/src/soap.rs` + +### 14.2. DoD + +- SOAP target model; +- WSDL/XSD-derived metadata model; +- XML normalization strategy documented in code-level types. + +## 15. Slice 12: `feat/soap-adapter-foundation` + +### 15.1. Файлы + +Добавить: + +- `crates/crank-adapter-soap/Cargo.toml` +- `crates/crank-adapter-soap/src/lib.rs` +- `crates/crank-adapter-soap/src/error.rs` +- `crates/crank-adapter-soap/src/wsdl.rs` +- `crates/crank-adapter-soap/src/xml.rs` +- `crates/crank-adapter-soap/src/client.rs` + +Изменить: + +- `apps/admin-api` +- `apps/ui` +- `crates/crank-runtime` + +### 15.2. DoD + +- WSDL import; +- service/port/operation selection; +- SOAP request-response execution; +- fault normalization; +- UI test-run support. + +## 16. State transition tables + +## 16.1. `StreamSession` + +Allowed: + +- `created -> running` +- `running -> running` +- `running -> stopped` +- `running -> expired` +- `running -> failed` +- `stopped -> deleted` +- `expired -> deleted` +- `failed -> deleted` + +Forbidden: + +- `stopped -> running` +- `expired -> running` +- `deleted -> *` + +## 16.2. `AsyncJob` + +Allowed: + +- `created -> running` +- `running -> running` +- `running -> completed` +- `running -> failed` +- `running -> cancelled` +- `completed -> expired` +- `failed -> expired` +- `cancelled -> expired` + +Forbidden: + +- `completed -> running` +- `cancelled -> running` + +## 17. Sequence outlines + +## 17.1. Window + +1. MCP client calls tool. +2. `mcp-server` resolves tool binding. +3. `runtime.execute_window_operation`. +4. adapter collects bounded upstream data. +5. runtime aggregates and truncates. +6. `mcp-server` returns final JSON-RPC result. + +## 17.2. Session + +1. MCP client calls `{tool}_start`. +2. runtime starts upstream session and persists `StreamSession`. +3. client calls `{tool}_poll`. +4. runtime loads session and collects next bounded chunk. +5. client calls `{tool}_stop`. +6. runtime closes upstream and marks session stopped. + +## 17.3. Async Job + +1. MCP client calls `{tool}_start`. +2. runtime starts long-running job and persists `AsyncJob`. +3. client calls `{tool}_status`. +4. runtime returns current progress. +5. client calls `{tool}_result`. +6. runtime returns final normalized result if ready. + +## 18. Acceptance checklist per slice + +Перед merge каждого slice агент обязан проверить: + +- docs updated if contract changed; +- `just fmt-check` +- `just check` +- `just clippy` +- `just test` +- relevant Playwright/e2e if UI touched; +- no hidden feature flags without docs; +- no unsupported combinations exposed in UI. + +## 19. Branch and commit policy + +Ожидаемые branch names: + +- `feat/mcp-streamable-http-alignment` +- `feat/streaming-core-model` +- `feat/stream-session-store` +- `feat/runtime-window-mode` +- `feat/rest-sse-adapter` +- `feat/grpc-server-streaming-adapter` +- `feat/session-and-job-tools` +- `feat/streaming-ui-config` +- `feat/streaming-e2e` +- `feat/websocket-upstream-adapter` +- `feat/soap-architecture-and-core-model` +- `feat/soap-adapter-foundation` + +Ожидаемые commit classes: + +- `docs: ...` +- `feat: ...` +- `test: ...` +- `refactor: ...` +- `fix: ...` + +## 20. Practical rule for agents + +Агент не должен брать следующий slice, пока не выполнены DoD и тесты предыдущего slice. + +Если slice требует новый contract: + +1. обновить docs; +2. обновить tests; +3. обновить code; +4. только потом переходить дальше. From d841cd0dda96e589a73d53960f73a77302ef29a2 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 09:49:22 +0300 Subject: [PATCH 06/38] feat: align mcp transport with streamable http --- Cargo.lock | 13 ++ TASKS.md | 12 +- apps/mcp-server/Cargo.toml | 1 + apps/mcp-server/src/app.rs | 254 ++++++++++++++++++++++++++++++++---- apps/mcp-server/src/main.rs | 207 +++++++++++++++++++++++++++++ 5 files changed, 459 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3410d48..4e60815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -692,6 +692,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" @@ -712,6 +723,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1250,6 +1262,7 @@ dependencies = [ "crank-registry", "crank-runtime", "crank-schema", + "futures-util", "reqwest", "serde", "serde_json", diff --git a/TASKS.md b/TASKS.md index 22b8fbd..9413669 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,18 +2,20 @@ ## Current -### `feat/streaming-implementation-spec` +### `feat/mcp-streamable-http-alignment` Status: completed DoD: -- Streaming slices are defined file-by-file -- Acceptance criteria and tests are explicit for each slice -- State transitions and sequence outlines are documented +- `mcp-server` transport semantics match MCP Streamable HTTP +- `GET` SSE stream is supported for initialized sessions +- `POST` can return JSON or SSE depending on negotiated response mode +- session and protocol headers are validated consistently +- transport tests cover JSON mode, SSE mode, GET stream and session deletion ## Next -- `feat/mcp-streamable-http-alignment` +- `feat/streaming-core-model` ## Backlog diff --git a/apps/mcp-server/Cargo.toml b/apps/mcp-server/Cargo.toml index 6f419a6..3dfe7f1 100644 --- a/apps/mcp-server/Cargo.toml +++ b/apps/mcp-server/Cargo.toml @@ -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 diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 8a980b3..b86e677 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -1,4 +1,5 @@ use std::{ + convert::Infallible, sync::Arc, time::{Duration, Instant}, }; @@ -10,7 +11,10 @@ use axum::{ HeaderMap, HeaderValue, StatusCode, header::{self, ACCEPT, AUTHORIZATION}, }, - response::{IntoResponse, Response}, + response::{ + IntoResponse, Response, + sse::{Event, KeepAlive, Sse}, + }, routing::get, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -20,6 +24,7 @@ use crank_core::{ }; use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool}; use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation}; +use futures_util::stream; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -38,6 +43,12 @@ use crate::{ const HEADER_MCP_SESSION_ID: &str = "MCP-Session-Id"; const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; +#[derive(Clone, Copy)] +enum ResponseMode { + Json, + Sse, +} + #[derive(Clone)] pub struct AppState { registry: PostgresRegistry, @@ -100,8 +111,53 @@ async fn health() -> Json { })) } -async fn mcp_get(Path(_path): Path) -> Response { - StatusCode::METHOD_NOT_ALLOWED.into_response() +async fn mcp_get( + Path(path): Path, + State(state): State>, + headers: HeaderMap, +) -> Response { + if let Err(status) = validate_origin(&state.allowed_origins, &headers) { + return status.into_response(); + } + + if let Err(status) = validate_get_accept_header(&headers) { + return status.into_response(); + } + + if let Err(status) = + require_platform_api_key(&state, &path, &headers, PlatformApiKeyScope::Read).await + { + return status.into_response(); + } + + let session_id = match session_id_from_headers(&headers) { + Ok(Some(session_id)) => session_id, + Ok(None) => return StatusCode::BAD_REQUEST.into_response(), + Err(status) => return status.into_response(), + }; + + let Some(session) = state.sessions.get(&session_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + + if session.workspace_slug != path.workspace_slug || session.agent_slug != path.agent_slug { + return StatusCode::NOT_FOUND.into_response(); + } + + if !session.initialized { + return StatusCode::BAD_REQUEST.into_response(); + } + + if let Err(status) = validate_session_protocol_version(&headers, &session.protocol_version) { + return status.into_response(); + } + + sse_response( + StatusCode::OK, + stream::pending::>(), + Some(&session_id), + Some(&session.protocol_version), + ) } async fn mcp_delete( @@ -145,9 +201,10 @@ async fn mcp_post( return status.into_response(); } - if let Err(status) = validate_accept_header(&headers) { - return status.into_response(); - } + let response_mode = match negotiate_post_response_mode(&headers) { + Ok(mode) => mode, + Err(status) => return status.into_response(), + }; if is_response(&message) || is_notification(&message) && method_name(&message).is_none() { return StatusCode::ACCEPTED.into_response(); @@ -158,6 +215,18 @@ async fn mcp_post( Err(status) => return status.into_response(), }; + if let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID) { + if let Ok(session_id) = session_id.to_str() { + if let Some(session) = state.sessions.get(session_id).await { + if let Err(status) = + validate_session_protocol_version(&headers, &session.protocol_version) + { + return status.into_response(); + } + } + } + } + let required_scope = match method_name(&message) { Some("tools/call") => PlatformApiKeyScope::Write, _ => PlatformApiKeyScope::Read, @@ -168,7 +237,7 @@ async fn mcp_post( match method_name(&message) { Some("initialize") if is_request(&message) => { - handle_initialize(state, &path, &message).await + handle_initialize(state, &path, &message, response_mode).await } Some("notifications/initialized") if is_notification(&message) => { handle_initialized_notification(state, &path, &headers).await @@ -180,14 +249,15 @@ async fn mcp_post( Err(response) => return response, }; - json_response( + transport_response( StatusCode::OK, jsonrpc_result( request_id(&message), json!({ "protocolVersion": session.protocol_version }), ), + response_mode, None, - Some(&protocol_version), + Some(&session.protocol_version), ) } Some("tools/list") if is_request(&message) => { @@ -205,9 +275,10 @@ async fn mcp_post( Ok(tools) => { let definitions = tools.iter().map(tool_definition).collect::>(); - json_response( + transport_response( StatusCode::OK, jsonrpc_result(request_id(&message), json!({ "tools": definitions })), + response_mode, None, Some(&session.protocol_version), ) @@ -224,9 +295,10 @@ async fn mcp_post( let tool_call_params: ToolCallParams = match serde_json::from_value(params(&message)) { Ok(value) => value, Err(error) => { - return json_response( + return transport_response( StatusCode::OK, jsonrpc_error(request_id(&message), -32602, error.to_string()), + response_mode, None, Some(&session.protocol_version), ); @@ -255,7 +327,7 @@ async fn mcp_post( let started_at = Instant::now(); match state.runtime.execute(&runtime_operation, &arguments).await { - Ok(output) => json_response( + Ok(output) => transport_response( StatusCode::OK, { let _ = persist_invocation( @@ -287,6 +359,7 @@ async fn mcp_post( }), ) }, + response_mode, None, Some(&session.protocol_version), ), @@ -306,7 +379,7 @@ async fn mcp_post( }, ) .await; - json_response( + transport_response( StatusCode::OK, jsonrpc_result( request_id(&message), @@ -326,19 +399,21 @@ async fn mcp_post( "isError": true }), ), + response_mode, None, Some(&session.protocol_version), ) } } } - Ok(None) => json_response( + Ok(None) => transport_response( StatusCode::OK, jsonrpc_error( request_id(&message), -32602, format!("tool {} was not found", tool_call_params.name), ), + response_mode, None, Some(&session.protocol_version), ), @@ -349,19 +424,21 @@ async fn mcp_post( let _ = method; StatusCode::ACCEPTED.into_response() } - Some(method) => json_response( + Some(method) => transport_response( StatusCode::OK, jsonrpc_error( request_id(&message), -32601, format!("method {method} is not supported"), ), + response_mode, None, Some(&protocol_version), ), - None => json_response( + None => transport_response( StatusCode::BAD_REQUEST, jsonrpc_error(Value::Null, -32600, "invalid JSON-RPC message"), + response_mode, None, Some(&protocol_version), ), @@ -372,13 +449,15 @@ async fn handle_initialize( state: Arc, path: &AgentRoutePath, message: &Value, + response_mode: ResponseMode, ) -> Response { let initialize_params: InitializeParams = match serde_json::from_value(params(message)) { Ok(value) => value, Err(error) => { - return json_response( + return transport_response( StatusCode::OK, jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, None, Some(DEFAULT_PROTOCOL_VERSION), ); @@ -386,7 +465,7 @@ async fn handle_initialize( }; let Some(protocol_version) = negotiated_protocol_version(&initialize_params.protocol_version) else { - return json_response( + return transport_response( StatusCode::OK, jsonrpc_error( request_id(message), @@ -396,6 +475,7 @@ async fn handle_initialize( initialize_params.protocol_version ), ), + response_mode, None, Some(DEFAULT_PROTOCOL_VERSION), ); @@ -405,7 +485,7 @@ async fn handle_initialize( .create(protocol_version, &path.workspace_slug, &path.agent_slug) .await; - json_response( + transport_response( StatusCode::OK, jsonrpc_result( request_id(message), @@ -422,6 +502,7 @@ async fn handle_initialize( } }), ), + response_mode, Some(&session_id), Some(protocol_version), ) @@ -567,16 +648,69 @@ fn validate_origin( Err(StatusCode::FORBIDDEN) } -fn validate_accept_header(headers: &HeaderMap) -> Result<(), StatusCode> { +fn negotiate_post_response_mode(headers: &HeaderMap) -> Result { let Some(accept) = headers.get(ACCEPT) else { return Err(StatusCode::BAD_REQUEST); }; let Ok(accept) = accept.to_str() else { return Err(StatusCode::BAD_REQUEST); }; - let normalized = accept.to_ascii_lowercase(); - if normalized.contains("application/json") && normalized.contains("text/event-stream") { + let mut saw_json = false; + let mut saw_sse = false; + let mut preferred = None; + + for part in accept.split(',') { + let media_type = part + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + + match media_type.as_str() { + "application/json" => { + saw_json = true; + if preferred.is_none() { + preferred = Some(ResponseMode::Json); + } + } + "text/event-stream" => { + saw_sse = true; + if preferred.is_none() { + preferred = Some(ResponseMode::Sse); + } + } + _ => {} + } + } + + if saw_json && saw_sse { + return preferred.ok_or(StatusCode::NOT_ACCEPTABLE); + } + + Err(StatusCode::NOT_ACCEPTABLE) +} + +fn validate_get_accept_header(headers: &HeaderMap) -> Result<(), StatusCode> { + let Some(accept) = headers.get(ACCEPT) else { + return Err(StatusCode::BAD_REQUEST); + }; + let Ok(accept) = accept.to_str() else { + return Err(StatusCode::BAD_REQUEST); + }; + + if accept + .split(',') + .map(|part| { + part.split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + }) + .any(|media_type| media_type == "text/event-stream") + { return Ok(()); } @@ -598,6 +732,28 @@ fn protocol_version_from_headers(headers: &HeaderMap) -> Result Result<(), StatusCode> { + let Some(version) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { + return Ok(()); + }; + let Ok(version) = version.to_str() else { + return Err(StatusCode::BAD_REQUEST); + }; + + if negotiated_protocol_version(version).is_none() { + return Err(StatusCode::BAD_REQUEST); + } + + if version != negotiated_session_version { + return Err(StatusCode::BAD_REQUEST); + } + + Ok(()) +} + fn session_id_from_headers(headers: &HeaderMap) -> Result, StatusCode> { let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID) else { return Ok(None); @@ -610,9 +766,10 @@ fn session_id_from_headers(headers: &HeaderMap) -> Result, Status } fn internal_jsonrpc_error(message: &Value, error: impl std::fmt::Display) -> Response { - json_response( + transport_response( StatusCode::INTERNAL_SERVER_ERROR, jsonrpc_error(request_id(message), -32603, error.to_string()), + ResponseMode::Json, None, Some(DEFAULT_PROTOCOL_VERSION), ) @@ -725,6 +882,57 @@ fn json_response( response } +fn transport_response( + status: StatusCode, + payload: Value, + response_mode: ResponseMode, + session_id: Option<&str>, + protocol_version: Option<&str>, +) -> Response { + if status == StatusCode::OK && matches!(response_mode, ResponseMode::Sse) { + let payload = payload.to_string(); + let stream = stream::once(async move { Ok(Event::default().data(payload)) }); + + return sse_response(status, stream, session_id, protocol_version); + } + + json_response(status, payload, session_id, protocol_version) +} + +fn sse_response( + status: StatusCode, + stream: S, + session_id: Option<&str>, + protocol_version: Option<&str>, +) -> Response +where + S: futures_util::stream::Stream> + Send + 'static, +{ + let mut response = ( + status, + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))), + ) + .into_response(); + + if let Some(session_id) = session_id { + response.headers_mut().insert( + HEADER_MCP_SESSION_ID, + HeaderValue::from_str(session_id) + .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + ); + } + + if let Some(protocol_version) = protocol_version { + response.headers_mut().insert( + HEADER_MCP_PROTOCOL_VERSION, + HeaderValue::from_str(protocol_version) + .unwrap_or_else(|_| HeaderValue::from_static(CURRENT_PROTOCOL_VERSION)), + ); + } + + response +} + fn tool_definition(tool: &PublishedAgentTool) -> Value { json!({ "name": tool.tool_name, diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 12617a3..7fc4fe9 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -370,6 +370,213 @@ mod tests { assert_eq!(tools_list["error"]["code"], -32002); } + #[tokio::test] + async fn initialize_can_return_sse_response_when_client_prefers_event_stream() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-sse-init", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-sse-init", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let response = client + .post(agent_mcp_url(&base_url, "sales-sse-init")) + .header(header::ACCEPT, "text/event-stream, application/json") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25" + } + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "text/event-stream" + ); + assert!(response.headers().get("MCP-Session-Id").is_some()); + + let body = response.text().await.unwrap(); + assert!(body.contains("\"jsonrpc\":\"2.0\"")); + assert!(body.contains("\"protocolVersion\":\"2025-11-25\"")); + } + + #[tokio::test] + async fn get_opens_sse_stream_for_initialized_session() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-get-sse", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-get-sse", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-get-sse"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let response = client + .get(&mcp_url) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-11-25") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "text/event-stream" + ); + assert_eq!( + response + .headers() + .get("MCP-Session-Id") + .unwrap() + .to_str() + .unwrap(), + initialized_session + ); + } + + #[tokio::test] + async fn get_requires_session_header() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-get-sse-missing", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-get-sse-missing", + &[PlatformApiKeyScope::Read], + ) + .await; + let base_url = spawn_mcp_server(build_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + + let response = client + .get(agent_mcp_url(&base_url, "sales-get-sse-missing")) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn delete_terminates_transport_session() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-delete-session", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-delete-session", + &[PlatformApiKeyScope::Read], + ) + .await; + let base_url = spawn_mcp_server(build_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-delete-session"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let delete_response = client + .delete(&mcp_url) + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-11-25") + .send() + .await + .unwrap(); + + assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT); + + let after_delete = client + .get(&mcp_url) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-11-25") + .send() + .await + .unwrap(); + + assert_eq!(after_delete.status(), reqwest::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn rejects_get_with_protocol_version_mismatch() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-get-bad-version", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-get-bad-version", + &[PlatformApiKeyScope::Read], + ) + .await; + let base_url = spawn_mcp_server(build_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-get-bad-version"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let response = client + .get(&mcp_url) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-06-18") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn refreshes_published_tools_without_restart() { let registry = test_registry().await; From 6a0381b8e58b5af766a0e757f59a322f52676492 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 09:57:28 +0300 Subject: [PATCH 07/38] feat: add streaming core domain model --- TASKS.md | 14 +- apps/admin-api/src/app.rs | 3 + apps/admin-api/src/service.rs | 4 + apps/mcp-server/src/main.rs | 3 + crates/crank-core/src/ids.rs | 2 + crates/crank-core/src/lib.rs | 12 +- crates/crank-core/src/operation.rs | 9 +- crates/crank-core/src/protocol.rs | 40 +++ crates/crank-core/src/stream_session.rs | 210 ++++++++++++++ crates/crank-core/src/streaming.rs | 364 ++++++++++++++++++++++++ crates/crank-registry/src/postgres.rs | 1 + crates/crank-runtime/src/executor.rs | 3 + 12 files changed, 654 insertions(+), 11 deletions(-) create mode 100644 crates/crank-core/src/stream_session.rs create mode 100644 crates/crank-core/src/streaming.rs diff --git a/TASKS.md b/TASKS.md index 9413669..603ebb3 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,20 @@ ## Current -### `feat/mcp-streamable-http-alignment` +### `feat/streaming-core-model` Status: completed DoD: -- `mcp-server` transport semantics match MCP Streamable HTTP -- `GET` SSE stream is supported for initialized sessions -- `POST` can return JSON or SSE depending on negotiated response mode -- session and protocol headers are validated consistently -- transport tests cover JSON mode, SSE mode, GET stream and session deletion +- core domain types exist for window/session/job execution +- `ExecutionConfig` can carry `streaming` +- protocol-aware validation follows documented capability matrix +- streaming types are exported from `crank-core` +- core tests cover serde and validation rules ## Next -- `feat/streaming-core-model` +- `feat/stream-session-store` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 3337f38..b975275 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -1961,6 +1961,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead".to_owned(), @@ -2016,6 +2017,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead GraphQL".to_owned(), @@ -2070,6 +2072,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Unary Echo gRPC".to_owned(), diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 607bbf9..3ac6230 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -3468,6 +3468,7 @@ fn demo_rest_operation_payload() -> OperationPayload { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Create CRM Lead".to_owned(), @@ -3521,6 +3522,7 @@ fn demo_graphql_operation_payload() -> OperationPayload { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Get Invoice Status".to_owned(), @@ -3574,6 +3576,7 @@ fn demo_grpc_operation_payload() -> OperationPayload { protocol_options: Some(crank_core::ProtocolOptions { grpc: Some(crank_core::GrpcProtocolOptions { use_tls: false }), }), + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Lookup Support Ticket".to_owned(), @@ -3631,6 +3634,7 @@ fn demo_archived_operation_payload() -> OperationPayload { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Archive Marketing Contact".to_owned(), diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 7fc4fe9..cd034f2 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -1128,6 +1128,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead".to_owned(), @@ -1192,6 +1193,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead GraphQL".to_owned(), @@ -1255,6 +1257,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Unary Echo gRPC".to_owned(), diff --git a/crates/crank-core/src/ids.rs b/crates/crank-core/src/ids.rs index be5a2b4..62bee94 100644 --- a/crates/crank-core/src/ids.rs +++ b/crates/crank-core/src/ids.rs @@ -48,3 +48,5 @@ define_id!(InvitationId); define_id!(PlatformApiKeyId); define_id!(InvocationLogId); define_id!(SecretId); +define_id!(StreamSessionId); +define_id!(AsyncJobId); diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index 688f7e3..1006a79 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -6,6 +6,8 @@ pub mod observability; pub mod operation; pub mod protocol; pub mod secret; +pub mod stream_session; +pub mod streaming; pub mod workspace; pub use access::{ @@ -18,8 +20,9 @@ pub use auth::{ BearerAuthConfig, SecretRef, }; pub use ids::{ - AgentId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, - PlatformApiKeyId, SampleId, SecretId, 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, @@ -31,4 +34,9 @@ pub use operation::{ }; pub use protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol}; pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion}; +pub use stream_session::{AsyncJobHandle, JobStatus, StreamSession, StreamStatus}; +pub use streaming::{ + AggregationMode, ExecutionMode, StreamingConfig, StreamingConfigError, ToolFamilyConfig, + TransportBehavior, +}; pub use workspace::{Workspace, WorkspaceStatus}; diff --git a/crates/crank-core/src/operation.rs b/crates/crank-core/src/operation.rs index 99c42a3..709e673 100644 --- a/crates/crank-core/src/operation.rs +++ b/crates/crank-core/src/operation.rs @@ -6,6 +6,7 @@ use serde_json::Value; use crate::{ ids::{AuthProfileId, DescriptorId, OperationId, SampleId}, protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol}, + streaming::StreamingConfig, }; fn default_operation_category() -> String { @@ -73,7 +74,7 @@ pub struct ProtocolOptions { pub grpc: Option, } -#[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 +85,8 @@ pub struct ExecutionConfig { pub headers: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] pub protocol_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub streaming: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -143,7 +146,7 @@ pub struct ConfigExport { pub export_mode: ExportMode, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Operation { pub id: OperationId, pub name: String, @@ -267,6 +270,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(), @@ -342,6 +346,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(), diff --git a/crates/crank-core/src/protocol.rs b/crates/crank-core/src/protocol.rs index 2258ce6..1f59eed 100644 --- a/crates/crank-core/src/protocol.rs +++ b/crates/crank-core/src/protocol.rs @@ -1,5 +1,7 @@ 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 { @@ -40,3 +42,41 @@ 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, + } + } + + 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), + } + } +} + +#[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)); + } +} diff --git a/crates/crank-core/src/stream_session.rs b/crates/crank-core/src/stream_session.rs new file mode 100644 index 0000000..b510936 --- /dev/null +++ b/crates/crank-core/src/stream_session.rs @@ -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, + pub operation_id: OperationId, + pub protocol: Protocol, + pub mode: ExecutionMode, + pub status: StreamStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + pub state: Value, + pub expires_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_poll_at: Option, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub closed_at: Option, +} + +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) { + self.last_poll_at = Some(now.into()); + } + + pub fn mark_closed(&mut self, now: impl Into) { + 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, + pub operation_id: OperationId, + pub status: JobStatus, + pub progress: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + pub created_at: String, + pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, +} + +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, 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, 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") + ); + } +} diff --git a/crates/crank-core/src/streaming.rs b/crates/crank-core/src/streaming.rs new file mode 100644 index 0000000..2ff562e --- /dev/null +++ b/crates/crank-core/src/streaming.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub poll_tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result_tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cancel_tool_name: Option, +} + +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, + #[serde(skip_serializing_if = "Option::is_none")] + pub poll_interval_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idle_timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_session_lifetime_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_bytes: Option, + pub aggregation_mode: AggregationMode, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub items_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub done_path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub redacted_paths: Vec, + #[serde(default)] + pub truncate_item_fields: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_field_length: Option, + #[serde(default)] + pub drop_duplicates: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub sampling_rate: Option, + #[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()); + } +} diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index ca6d103..1344383 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -4147,6 +4147,7 @@ mod tests { auth_profile_ref: Some("auth_crank".into()), headers: BTreeMap::from([("X-Request-Id".to_owned(), "static".to_owned())]), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create lead".to_owned(), diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 08ffbff..32e5d94 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -474,6 +474,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead".to_owned(), @@ -549,6 +550,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead GraphQL".to_owned(), @@ -623,6 +625,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Unary Echo gRPC".to_owned(), From 7e4f3d142e955c807e8f2f4aa2f7ef571b8eb4f3 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 10:23:01 +0300 Subject: [PATCH 08/38] feat: add streaming state storage --- TASKS.md | 13 +- apps/admin-api/src/error.rs | 8 + crates/crank-registry/src/error.rs | 16 + crates/crank-registry/src/lib.rs | 15 +- crates/crank-registry/src/migrations.rs | 67 ++ crates/crank-registry/src/model.rs | 80 ++- crates/crank-registry/src/postgres.rs | 903 +++++++++++++++++++++++- 7 files changed, 1070 insertions(+), 32 deletions(-) diff --git a/TASKS.md b/TASKS.md index 603ebb3..0d0620b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,19 @@ ## Current -### `feat/streaming-core-model` +### `feat/stream-session-store` Status: completed DoD: -- core domain types exist for window/session/job execution -- `ExecutionConfig` can carry `streaming` -- protocol-aware validation follows documented capability matrix -- streaming types are exported from `crank-core` -- core tests cover serde and validation rules +- persistent tables exist for `stream_sessions` and `async_jobs` +- registry supports create/get/update/close/list/cleanup for both stores +- invalid state transitions are rejected explicitly +- migration and storage tests cover lifecycle and cleanup ## Next -- `feat/stream-session-store` +- `feat/runtime-window-mode` ## Backlog diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 3ffcde9..f9b288e 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -142,6 +142,12 @@ impl From for ApiError { 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")) } @@ -177,6 +183,8 @@ impl From for ApiError { RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict( format!("secret with name {name} already exists in workspace {workspace_id}"), ), + RegistryError::InvalidStreamSessionTransition { .. } + | RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()), RegistryError::UserEmailAlreadyExists { email } => { Self::conflict(format!("user with email {email} already exists")) } diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index 2df780f..2db2998 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -25,6 +25,22 @@ pub enum RegistryError { 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("invocation log {log_id} was not found")] diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index 773ecc3..6e9d592 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -5,17 +5,20 @@ mod postgres; pub use error::RegistryError; pub use model::{ - AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, CreateInvitationRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, + 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, PlatformApiKeyRecord, PublishAgentRequest, + OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SecretRecord, SecretVersionRecord, SessionRecord, - UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, - UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, - WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + StreamSessionFilter, StreamSessionRecord, UpdateAsyncJobStatusRequest, + UpdateStreamSessionStateRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, + UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, + WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }; pub use postgres::PostgresRegistry; diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index d26b8b2..a590b93 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -513,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(()) } diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index d578871..a6146cf 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -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, Secret, SecretId, - SecretVersion, 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; +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Page { + pub items: Vec, + pub total: u64, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct WorkspaceRecord { pub workspace: Workspace, @@ -95,6 +103,16 @@ 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, @@ -432,6 +450,60 @@ pub struct RotateSecretRequest<'a> { 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, + pub mode: Option, + 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, + pub limit: u32, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SaveSampleMetadataRequest<'a> { pub sample: &'a OperationSampleMetadata, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 1344383..00c21e3 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -1,8 +1,9 @@ use crank_core::{ - AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType, - HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, - OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, - SecretId, SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, + AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AsyncJobHandle, AsyncJobId, + AuthProfile, GraphqlOperationType, HttpMethod, InvitationId, InvitationToken, InvocationLog, + InvocationLogId, JobStatus, MembershipRole, OperationId, OperationStatus, PlatformApiKey, + PlatformApiKeyId, PlatformApiKeyStatus, Secret, SecretId, SecretVersion, StreamSession, + StreamSessionId, StreamStatus, Target, UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId, }; use serde::{Serialize, de::DeserializeOwned}; @@ -17,16 +18,18 @@ use crate::{ error::RegistryError, migrations, model::{ - AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentRequest, - CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, - CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, - CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, InvocationLogRecord, - ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, - OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, + AgentSummary, AgentVersionRecord, AsyncJobFilter, AuthUserRecord, CreateAgentRequest, + CreateAsyncJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, + CreatePlatformApiKeyRequest, CreateSecretRequest, CreateStreamSessionRequest, + CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, + DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, + MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, + OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SecretRecord, - SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, + SecretVersionRecord, SessionRecord, StreamSessionFilter, UpdateAsyncJobStatusRequest, + UpdateStreamSessionStateRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, @@ -860,6 +863,481 @@ impl PostgresRegistry { Ok(()) } + pub async fn create_stream_session( + &self, + request: CreateStreamSessionRequest<'_>, + ) -> Result { + sqlx::query( + "insert into stream_sessions ( + id, + workspace_id, + agent_id, + operation_id, + protocol, + mode, + status, + cursor_json, + state_json, + expires_at, + last_poll_at, + created_at, + closed_at + ) values ( + $1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::timestamptz, + $11::timestamptz, $12::timestamptz, $13::timestamptz + )", + ) + .bind(request.session.id.as_str()) + .bind(request.session.workspace_id.as_str()) + .bind( + request + .session + .agent_id + .as_ref() + .map(|value| value.as_str()), + ) + .bind(request.session.operation_id.as_str()) + .bind(serialize_enum_text(&request.session.protocol, "protocol")?) + .bind(serialize_enum_text(&request.session.mode, "mode")?) + .bind(serialize_enum_text(&request.session.status, "status")?) + .bind(request.session.cursor.clone().map(Json)) + .bind(Json(request.session.state.clone())) + .bind(&request.session.expires_at) + .bind(request.session.last_poll_at.as_deref()) + .bind(&request.session.created_at) + .bind(request.session.closed_at.as_deref()) + .execute(&self.pool) + .await?; + + Ok(request.session.clone()) + } + + pub async fn get_stream_session( + &self, + id: &StreamSessionId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + protocol, + mode, + status, + cursor_json, + state_json, + to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as expires_at, + to_char(last_poll_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_poll_at, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(closed_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as closed_at + from stream_sessions + where id = $1", + ) + .bind(id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_stream_session).transpose() + } + + pub async fn update_stream_session_state( + &self, + request: UpdateStreamSessionStateRequest<'_>, + ) -> Result { + validate_stream_session_transition( + request.session_id, + request.current_status, + request.next_status, + )?; + + let row = sqlx::query( + "update stream_sessions + set status = $3, + cursor_json = $4::jsonb, + state_json = $5::jsonb, + expires_at = coalesce($6::timestamptz, expires_at), + last_poll_at = coalesce($7::timestamptz, last_poll_at), + closed_at = case + when $8::timestamptz is null then closed_at + else $8::timestamptz + end + where id = $1 + and status = $2 + returning + id, + workspace_id, + agent_id, + operation_id, + protocol, + mode, + status, + cursor_json, + state_json, + to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as expires_at, + to_char(last_poll_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_poll_at, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(closed_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as closed_at", + ) + .bind(request.session_id.as_str()) + .bind(serialize_enum_text(&request.current_status, "status")?) + .bind(serialize_enum_text(&request.next_status, "status")?) + .bind(request.cursor.cloned().map(Json)) + .bind(Json(request.state.clone())) + .bind(request.expires_at) + .bind(request.last_poll_at) + .bind(request.closed_at) + .fetch_optional(&self.pool) + .await?; + + match row.as_ref() { + Some(row) => map_stream_session(row), + None => { + if self.get_stream_session(request.session_id).await?.is_some() { + Err(RegistryError::InvalidStreamSessionTransition { + session_id: request.session_id.as_str().to_owned(), + from: serialize_enum_text(&request.current_status, "status")?, + to: serialize_enum_text(&request.next_status, "status")?, + }) + } else { + Err(RegistryError::StreamSessionNotFound { + session_id: request.session_id.as_str().to_owned(), + }) + } + } + } + } + + pub async fn close_stream_session( + &self, + id: &StreamSessionId, + now: &str, + ) -> Result<(), RegistryError> { + let result = sqlx::query( + "update stream_sessions + set status = 'stopped', + last_poll_at = $2::timestamptz, + closed_at = $2::timestamptz + where id = $1 + and status in ('created', 'running')", + ) + .bind(id.as_str()) + .bind(now) + .execute(&self.pool) + .await?; + + if result.rows_affected() > 0 { + return Ok(()); + } + + match self.get_stream_session(id).await? { + Some(existing) => Err(RegistryError::InvalidStreamSessionTransition { + session_id: id.as_str().to_owned(), + from: serialize_enum_text(&existing.status, "status")?, + to: "stopped".to_owned(), + }), + None => Err(RegistryError::StreamSessionNotFound { + session_id: id.as_str().to_owned(), + }), + } + } + + pub async fn list_stream_sessions( + &self, + filter: StreamSessionFilter<'_>, + ) -> Result, RegistryError> { + let status = filter + .status + .as_ref() + .map(|value| serialize_enum_text(value, "status")) + .transpose()?; + let mode = filter + .mode + .as_ref() + .map(|value| serialize_enum_text(value, "mode")) + .transpose()?; + let limit = i64::from(filter.limit); + + let rows = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + protocol, + mode, + status, + cursor_json, + state_json, + to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as expires_at, + to_char(last_poll_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_poll_at, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(closed_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as closed_at + from stream_sessions + where workspace_id = $1 + and ($2::text is null or agent_id = $2) + and ($3::text is null or operation_id = $3) + and ($4::text is null or status = $4) + and ($5::text is null or mode = $5) + order by created_at desc + limit $6", + ) + .bind(filter.workspace_id.as_str()) + .bind(filter.agent_id.map(|value| value.as_str())) + .bind(filter.operation_id.map(|value| value.as_str())) + .bind(status) + .bind(mode) + .bind(limit) + .fetch_all(&self.pool) + .await?; + + let items = rows + .iter() + .map(map_stream_session) + .collect::, _>>()?; + let total = items.len() as u64; + + Ok(Page { items, total }) + } + + pub async fn delete_expired_stream_sessions(&self, now: &str) -> Result { + let result = sqlx::query( + "delete from stream_sessions + where expires_at <= $1::timestamptz", + ) + .bind(now) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected()) + } + + pub async fn create_async_job( + &self, + request: CreateAsyncJobRequest<'_>, + ) -> Result { + sqlx::query( + "insert into async_jobs ( + id, + workspace_id, + agent_id, + operation_id, + status, + progress_json, + result_json, + error_json, + expires_at, + created_at, + updated_at, + finished_at + ) values ( + $1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8::jsonb, $9::timestamptz, + $10::timestamptz, $11::timestamptz, $12::timestamptz + )", + ) + .bind(request.job.id.as_str()) + .bind(request.job.workspace_id.as_str()) + .bind(request.job.agent_id.as_ref().map(|value| value.as_str())) + .bind(request.job.operation_id.as_str()) + .bind(serialize_enum_text(&request.job.status, "status")?) + .bind(Json(request.job.progress.clone())) + .bind(request.job.result.clone().map(Json)) + .bind(request.job.error.clone().map(Json)) + .bind(request.job.expires_at.as_deref()) + .bind(&request.job.created_at) + .bind(&request.job.updated_at) + .bind(request.job.finished_at.as_deref()) + .execute(&self.pool) + .await?; + + Ok(request.job.clone()) + } + + pub async fn get_async_job( + &self, + id: &AsyncJobId, + ) -> Result, RegistryError> { + let row = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + status, + progress_json, + result_json, + error_json, + to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as expires_at, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(finished_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as finished_at + from async_jobs + where id = $1", + ) + .bind(id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.as_ref().map(map_async_job).transpose() + } + + pub async fn update_async_job_status( + &self, + request: UpdateAsyncJobStatusRequest<'_>, + ) -> Result { + validate_async_job_transition(request.job_id, request.current_status, request.next_status)?; + + let row = sqlx::query( + "update async_jobs + set status = $3, + progress_json = $4::jsonb, + result_json = $5::jsonb, + error_json = $6::jsonb, + expires_at = coalesce($7::timestamptz, expires_at), + updated_at = $8::timestamptz, + finished_at = case + when $9::timestamptz is null then finished_at + else $9::timestamptz + end + where id = $1 + and status = $2 + returning + id, + workspace_id, + agent_id, + operation_id, + status, + progress_json, + result_json, + error_json, + to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as expires_at, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(finished_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as finished_at", + ) + .bind(request.job_id.as_str()) + .bind(serialize_enum_text(&request.current_status, "status")?) + .bind(serialize_enum_text(&request.next_status, "status")?) + .bind(Json(request.progress.clone())) + .bind(request.result.cloned().map(Json)) + .bind(request.error.cloned().map(Json)) + .bind(request.expires_at) + .bind(request.updated_at) + .bind(request.finished_at) + .fetch_optional(&self.pool) + .await?; + + match row.as_ref() { + Some(row) => map_async_job(row), + None => { + if self.get_async_job(request.job_id).await?.is_some() { + Err(RegistryError::InvalidAsyncJobTransition { + job_id: request.job_id.as_str().to_owned(), + from: serialize_enum_text(&request.current_status, "status")?, + to: serialize_enum_text(&request.next_status, "status")?, + }) + } else { + Err(RegistryError::AsyncJobNotFound { + job_id: request.job_id.as_str().to_owned(), + }) + } + } + } + } + + pub async fn cancel_async_job(&self, id: &AsyncJobId, now: &str) -> Result<(), RegistryError> { + let result = sqlx::query( + "update async_jobs + set status = 'cancelled', + updated_at = $2::timestamptz, + finished_at = $2::timestamptz + where id = $1 + and status in ('created', 'running')", + ) + .bind(id.as_str()) + .bind(now) + .execute(&self.pool) + .await?; + + if result.rows_affected() > 0 { + return Ok(()); + } + + match self.get_async_job(id).await? { + Some(existing) => Err(RegistryError::InvalidAsyncJobTransition { + job_id: id.as_str().to_owned(), + from: serialize_enum_text(&existing.status, "status")?, + to: "cancelled".to_owned(), + }), + None => Err(RegistryError::AsyncJobNotFound { + job_id: id.as_str().to_owned(), + }), + } + } + + pub async fn list_async_jobs( + &self, + filter: AsyncJobFilter<'_>, + ) -> Result, RegistryError> { + let status = filter + .status + .as_ref() + .map(|value| serialize_enum_text(value, "status")) + .transpose()?; + let limit = i64::from(filter.limit); + + let rows = sqlx::query( + "select + id, + workspace_id, + agent_id, + operation_id, + status, + progress_json, + result_json, + error_json, + to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as expires_at, + to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at, + to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at, + to_char(finished_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as finished_at + from async_jobs + where workspace_id = $1 + and ($2::text is null or agent_id = $2) + and ($3::text is null or operation_id = $3) + and ($4::text is null or status = $4) + order by updated_at desc + limit $5", + ) + .bind(filter.workspace_id.as_str()) + .bind(filter.agent_id.map(|value| value.as_str())) + .bind(filter.operation_id.map(|value| value.as_str())) + .bind(status) + .bind(limit) + .fetch_all(&self.pool) + .await?; + + let items = rows + .iter() + .map(map_async_job) + .collect::, _>>()?; + let total = items.len() as u64; + + Ok(Page { items, total }) + } + + pub async fn delete_expired_async_jobs(&self, now: &str) -> Result { + let result = sqlx::query( + "delete from async_jobs + where expires_at is not null + and expires_at <= $1::timestamptz", + ) + .bind(now) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected()) + } + pub async fn create_platform_api_key( &self, request: CreatePlatformApiKeyRequest<'_>, @@ -3315,6 +3793,51 @@ fn map_secret_record(row: &PgRow) -> Result { }) } +fn map_stream_session(row: &PgRow) -> Result { + Ok(StreamSession { + id: StreamSessionId::new(row.try_get::("id")?), + workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), + agent_id: row + .try_get::, _>("agent_id")? + .map(AgentId::new), + operation_id: OperationId::new(row.try_get::("operation_id")?), + protocol: deserialize_enum_text(&row.try_get::("protocol")?, "protocol")?, + mode: deserialize_enum_text(&row.try_get::("mode")?, "mode")?, + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + cursor: row + .try_get::>, _>("cursor_json")? + .map(|value| value.0), + state: row.try_get::, _>("state_json")?.0, + expires_at: row.try_get("expires_at")?, + last_poll_at: row.try_get("last_poll_at")?, + created_at: row.try_get("created_at")?, + closed_at: row.try_get("closed_at")?, + }) +} + +fn map_async_job(row: &PgRow) -> Result { + Ok(AsyncJobHandle { + id: AsyncJobId::new(row.try_get::("id")?), + workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), + agent_id: row + .try_get::, _>("agent_id")? + .map(AgentId::new), + operation_id: OperationId::new(row.try_get::("operation_id")?), + status: deserialize_enum_text(&row.try_get::("status")?, "status")?, + progress: row.try_get::, _>("progress_json")?.0, + result: row + .try_get::>, _>("result_json")? + .map(|value| value.0), + error: row + .try_get::>, _>("error_json")? + .map(|value| value.0), + expires_at: row.try_get("expires_at")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + finished_at: row.try_get("finished_at")?, + }) +} + fn map_invocation_log_record(row: &PgRow) -> Result { Ok(InvocationLogRecord { log: InvocationLog { @@ -3724,6 +4247,59 @@ fn from_db_version(value: i32, field: &'static str) -> Result Result<(), RegistryError> { + let allowed = matches!( + (from, to), + (StreamStatus::Created, StreamStatus::Running) + | (StreamStatus::Running, StreamStatus::Running) + | (StreamStatus::Running, StreamStatus::Stopped) + | (StreamStatus::Running, StreamStatus::Expired) + | (StreamStatus::Running, StreamStatus::Failed) + ); + + if allowed { + Ok(()) + } else { + Err(RegistryError::InvalidStreamSessionTransition { + session_id: session_id.as_str().to_owned(), + from: serialize_enum_text(&from, "status").unwrap_or_else(|_| "unknown".to_owned()), + to: serialize_enum_text(&to, "status").unwrap_or_else(|_| "unknown".to_owned()), + }) + } +} + +fn validate_async_job_transition( + job_id: &AsyncJobId, + from: JobStatus, + to: JobStatus, +) -> Result<(), RegistryError> { + let allowed = matches!( + (from, to), + (JobStatus::Created, JobStatus::Running) + | (JobStatus::Running, JobStatus::Running) + | (JobStatus::Running, JobStatus::Completed) + | (JobStatus::Running, JobStatus::Failed) + | (JobStatus::Running, JobStatus::Cancelled) + | (JobStatus::Completed, JobStatus::Expired) + | (JobStatus::Failed, JobStatus::Expired) + | (JobStatus::Cancelled, JobStatus::Expired) + ); + + if allowed { + Ok(()) + } else { + Err(RegistryError::InvalidAsyncJobTransition { + job_id: job_id.as_str().to_owned(), + from: serialize_enum_text(&from, "status").unwrap_or_else(|_| "unknown".to_owned()), + to: serialize_enum_text(&to, "status").unwrap_or_else(|_| "unknown".to_owned()), + }) + } +} + fn to_u64(value: i64, field: &'static str) -> Result { u64::try_from(value).map_err(|_| RegistryError::InvalidNumericValue { field, value }) } @@ -3746,10 +4322,11 @@ mod tests { }; use crank_core::{ - ApiKeyHeaderAuthConfig, AuthConfig, AuthKind, AuthProfile, ConfigExport, ExecutionConfig, - ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod, OperationId, OperationStatus, - Protocol, RestTarget, RetryPolicy, Samples, SecretRef, Target, ToolDescription, - ToolExample, WorkspaceId, + ApiKeyHeaderAuthConfig, AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, ConfigExport, + ExecutionConfig, ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod, JobStatus, + OperationId, OperationStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretRef, + StreamSession, StreamSessionId, StreamStatus, Target, ToolDescription, ToolExample, + WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_schema::{Schema, SchemaKind}; @@ -3759,9 +4336,11 @@ mod tests { use crate::{ PostgresRegistry, RegistryError, model::{ + AsyncJobFilter, CreateAsyncJobRequest, CreateStreamSessionRequest, CreateVersionRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, OperationSampleMetadata, PublishRequest, RegistryOperation, SampleKind, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + StreamSessionFilter, UpdateAsyncJobStatusRequest, UpdateStreamSessionStateRequest, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }, }; @@ -4003,6 +4582,265 @@ mod tests { database.cleanup().await; } + #[tokio::test] + async fn manages_stream_sessions_with_transitions_and_cleanup() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let operation = test_operation("op_stream_01", 1, OperationStatus::Draft); + let session = test_stream_session("stream_01", "op_stream_01", StreamStatus::Running); + + registry + .create_operation(&test_workspace_id(), &operation, None) + .await + .unwrap(); + + registry + .create_stream_session(CreateStreamSessionRequest { session: &session }) + .await + .unwrap(); + + let loaded = registry + .get_stream_session(&session.id) + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.status, StreamStatus::Running); + + let closable_session = + test_stream_session("stream_closable", "op_stream_01", StreamStatus::Running); + registry + .create_stream_session(CreateStreamSessionRequest { + session: &closable_session, + }) + .await + .unwrap(); + registry + .close_stream_session(&closable_session.id, "2026-04-06T12:00:30Z") + .await + .unwrap(); + + let closed = registry + .get_stream_session(&closable_session.id) + .await + .unwrap() + .unwrap(); + assert_eq!(closed.status, StreamStatus::Stopped); + + let updated = registry + .update_stream_session_state(UpdateStreamSessionStateRequest { + session_id: &session.id, + current_status: StreamStatus::Running, + next_status: StreamStatus::Failed, + cursor: Some(&json!({"cursor":"next"})), + state: &json!({"phase":"failed"}), + expires_at: Some("2026-04-06T12:10:00Z"), + last_poll_at: Some("2026-04-06T12:01:00Z"), + closed_at: Some("2026-04-06T12:01:00Z"), + }) + .await + .unwrap(); + assert_eq!(updated.status, StreamStatus::Failed); + assert_eq!(updated.closed_at.as_deref(), Some("2026-04-06T12:01:00Z")); + + let page = registry + .list_stream_sessions(StreamSessionFilter { + workspace_id: &test_workspace_id(), + agent_id: None, + operation_id: None, + status: Some(StreamStatus::Failed), + mode: Some(crank_core::ExecutionMode::Session), + limit: 10, + }) + .await + .unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].id, session.id); + + let expired_session = + test_stream_session("stream_expired", "op_stream_01", StreamStatus::Running); + registry + .create_stream_session(CreateStreamSessionRequest { + session: &StreamSession { + id: expired_session.id.clone(), + expires_at: "2026-04-06T11:00:00Z".to_owned(), + ..expired_session + }, + }) + .await + .unwrap(); + + let deleted = registry + .delete_expired_stream_sessions("2026-04-06T12:00:00Z") + .await + .unwrap(); + assert_eq!(deleted, 1); + + database.cleanup().await; + } + + #[tokio::test] + async fn rejects_invalid_stream_session_transition() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let operation = test_operation("op_stream_02", 1, OperationStatus::Draft); + let session = test_stream_session("stream_invalid", "op_stream_02", StreamStatus::Stopped); + + registry + .create_operation(&test_workspace_id(), &operation, None) + .await + .unwrap(); + registry + .create_stream_session(CreateStreamSessionRequest { session: &session }) + .await + .unwrap(); + + let error = registry + .update_stream_session_state(UpdateStreamSessionStateRequest { + session_id: &session.id, + current_status: StreamStatus::Stopped, + next_status: StreamStatus::Running, + cursor: None, + state: &json!({"phase":"resume"}), + expires_at: None, + last_poll_at: None, + closed_at: None, + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + RegistryError::InvalidStreamSessionTransition { .. } + )); + + database.cleanup().await; + } + + #[tokio::test] + async fn manages_async_jobs_with_transitions_and_cleanup() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let operation = test_operation("op_job_01", 1, OperationStatus::Draft); + let job = test_async_job("job_01", "op_job_01", JobStatus::Running); + + registry + .create_operation(&test_workspace_id(), &operation, None) + .await + .unwrap(); + registry + .create_async_job(CreateAsyncJobRequest { job: &job }) + .await + .unwrap(); + + let cancellable_job = test_async_job("job_cancel", "op_job_01", JobStatus::Running); + registry + .create_async_job(CreateAsyncJobRequest { + job: &cancellable_job, + }) + .await + .unwrap(); + registry + .cancel_async_job(&cancellable_job.id, "2026-04-06T12:00:30Z") + .await + .unwrap(); + + let cancelled = registry + .get_async_job(&cancellable_job.id) + .await + .unwrap() + .unwrap(); + assert_eq!(cancelled.status, JobStatus::Cancelled); + + let updated = registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job.id, + current_status: JobStatus::Running, + next_status: JobStatus::Completed, + progress: &json!({"percent":100}), + result: Some(&json!({"ok":true})), + error: None, + expires_at: Some("2026-04-06T12:15:00Z"), + updated_at: "2026-04-06T12:01:00Z", + finished_at: Some("2026-04-06T12:01:00Z"), + }) + .await + .unwrap(); + assert_eq!(updated.status, JobStatus::Completed); + assert_eq!(updated.result, Some(json!({"ok":true}))); + + let loaded = registry.get_async_job(&job.id).await.unwrap().unwrap(); + assert_eq!(loaded.status, JobStatus::Completed); + + let page = registry + .list_async_jobs(AsyncJobFilter { + workspace_id: &test_workspace_id(), + agent_id: None, + operation_id: None, + status: Some(JobStatus::Completed), + limit: 10, + }) + .await + .unwrap(); + assert_eq!(page.total, 1); + + let expired_job = AsyncJobHandle { + id: crank_core::AsyncJobId::new("job_expired"), + expires_at: Some("2026-04-06T11:00:00Z".to_owned()), + ..test_async_job("job_expired", "op_job_01", JobStatus::Cancelled) + }; + registry + .create_async_job(CreateAsyncJobRequest { job: &expired_job }) + .await + .unwrap(); + + let deleted = registry + .delete_expired_async_jobs("2026-04-06T12:00:00Z") + .await + .unwrap(); + assert_eq!(deleted, 1); + + database.cleanup().await; + } + + #[tokio::test] + async fn rejects_invalid_async_job_transition() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let operation = test_operation("op_job_02", 1, OperationStatus::Draft); + let job = test_async_job("job_invalid", "op_job_02", JobStatus::Completed); + + registry + .create_operation(&test_workspace_id(), &operation, None) + .await + .unwrap(); + registry + .create_async_job(CreateAsyncJobRequest { job: &job }) + .await + .unwrap(); + + let error = registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job.id, + current_status: JobStatus::Completed, + next_status: JobStatus::Running, + progress: &json!({"percent":50}), + result: None, + error: None, + expires_at: None, + updated_at: "2026-04-06T12:01:00Z", + finished_at: None, + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + RegistryError::InvalidAsyncJobTransition { .. } + )); + + database.cleanup().await; + } + struct TestDatabase { admin_pool: PgPool, database_url: String, @@ -4182,4 +5020,39 @@ mod tests { published_at: None, } } + + fn test_stream_session(id: &str, operation_id: &str, status: StreamStatus) -> StreamSession { + StreamSession { + id: StreamSessionId::new(id), + workspace_id: test_workspace_id(), + agent_id: None, + operation_id: OperationId::new(operation_id), + protocol: Protocol::Rest, + mode: crank_core::ExecutionMode::Session, + status, + cursor: Some(json!({"cursor":"initial"})), + state: json!({"phase":"running"}), + 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, + } + } + + fn test_async_job(id: &str, operation_id: &str, status: JobStatus) -> AsyncJobHandle { + AsyncJobHandle { + id: crank_core::AsyncJobId::new(id), + workspace_id: test_workspace_id(), + agent_id: None, + operation_id: OperationId::new(operation_id), + status, + progress: json!({"percent": 25}), + 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, + } + } } From 8204a59dac07646a7a0fa1377ab7e9cfa2a45bad Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 10:38:23 +0300 Subject: [PATCH 09/38] feat: add window execution to runtime --- TASKS.md | 13 +- apps/admin-api/src/error.rs | 3 + apps/admin-api/src/service.rs | 3 + apps/mcp-server/src/app.rs | 3 + crates/crank-runtime/src/aggregation.rs | 292 ++++++++++++++++++++++++ crates/crank-runtime/src/error.rs | 11 +- crates/crank-runtime/src/executor.rs | 269 ++++++++++++++++++++-- crates/crank-runtime/src/lib.rs | 4 + crates/crank-runtime/src/redaction.rs | 124 ++++++++++ crates/crank-runtime/src/streaming.rs | 13 ++ 10 files changed, 708 insertions(+), 27 deletions(-) create mode 100644 crates/crank-runtime/src/aggregation.rs create mode 100644 crates/crank-runtime/src/redaction.rs create mode 100644 crates/crank-runtime/src/streaming.rs diff --git a/TASKS.md b/TASKS.md index 0d0620b..d7e8887 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,20 @@ ## Current -### `feat/stream-session-store` +### `feat/runtime-window-mode` Status: completed DoD: -- persistent tables exist for `stream_sessions` and `async_jobs` -- registry supports create/get/update/close/list/cleanup for both stores -- invalid state transitions are rejected explicitly -- migration and storage tests cover lifecycle and cleanup +- runtime exposes bounded `window` execution +- item and byte limits are enforced +- `window_complete`, `truncated`, `has_more` are returned +- aggregation and redaction are covered by tests +- unary execution remains intact ## Next -- `feat/runtime-window-mode` +- `feat/rest-sse-adapter` ## Backlog diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index f9b288e..b0fe3f2 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -240,5 +240,8 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { RuntimeError::RestAdapter(_) => "runtime_rest_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", } } diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 3ac6230..ddb9c7e 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -3756,6 +3756,9 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GrpcAdapter(_) => "grpc_error", RuntimeError::RestAdapter(_) => "rest_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", + RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", + RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", + RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error", } } diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index b86e677..04233e1 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -846,7 +846,10 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GrpcAdapter(_) => "adapter_execution_error", RuntimeError::RestAdapter(_) => "adapter_execution_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", + RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", + RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", RuntimeError::InvalidPreparedRequest { .. } => "runtime_error", + RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error", } } diff --git a/crates/crank-runtime/src/aggregation.rs b/crates/crank-runtime/src/aggregation.rs new file mode 100644 index 0000000..79d9545 --- /dev/null +++ b/crates/crank-runtime/src/aggregation.rs @@ -0,0 +1,292 @@ +use serde_json::{Map, Value, json}; + +use crate::{RuntimeError, WindowExecutionResult}; + +pub fn collect_window_result( + response: &Value, + config: &crank_core::StreamingConfig, +) -> Result { + let mut items = extract_path_value(response, config.items_path.as_deref()) + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(); + let cursor = extract_path_value(response, config.cursor_path.as_deref()).cloned(); + let done = extract_path_value(response, config.done_path.as_deref()) + .and_then(Value::as_bool) + .unwrap_or(true); + + let mut truncated = false; + let mut has_more = !done; + + if let Some(max_items) = config.max_items.map(|value| value as usize) { + if items.len() > max_items { + items.truncate(max_items); + truncated = true; + has_more = true; + } + } + + let mut summary = build_summary(response, &items, config); + redact_and_truncate(&mut summary, &mut items, config); + + if let Some(max_bytes) = config.max_bytes.map(|value| value as usize) { + while payload_size_bytes(&summary, &items, cursor.as_ref()) > max_bytes && !items.is_empty() + { + items.pop(); + truncated = true; + has_more = true; + } + + if payload_size_bytes(&summary, &items, cursor.as_ref()) > max_bytes { + summary = json!({ + "notice": "window payload exceeded byte limit", + "items_returned": items.len() + }); + truncated = true; + has_more = true; + } + } + + if matches!( + config.aggregation_mode, + crank_core::AggregationMode::SummaryOnly + ) { + items.clear(); + } + + Ok(WindowExecutionResult { + summary, + items, + cursor, + window_complete: !has_more, + truncated, + has_more, + }) +} + +fn build_summary(response: &Value, items: &[Value], config: &crank_core::StreamingConfig) -> Value { + match config.aggregation_mode { + crank_core::AggregationMode::RawItems => Value::Null, + crank_core::AggregationMode::SummaryOnly + | crank_core::AggregationMode::SummaryPlusSamples => { + extract_path_value(response, config.summary_path.as_deref()) + .cloned() + .unwrap_or_else(|| stats_summary(items)) + } + crank_core::AggregationMode::Stats => stats_summary(items), + crank_core::AggregationMode::LatestState => items.last().cloned().unwrap_or(Value::Null), + } +} + +fn stats_summary(items: &[Value]) -> Value { + let mut summary = Map::new(); + summary.insert("items_total".to_owned(), Value::from(items.len() as u64)); + summary.insert("items_sampled".to_owned(), Value::from(items.len() as u64)); + Value::Object(summary) +} + +fn redact_and_truncate( + summary: &mut Value, + items: &mut [Value], + config: &crank_core::StreamingConfig, +) { + crate::redaction::redact_paths(summary, &config.redacted_paths); + + for item in items.iter_mut() { + crate::redaction::redact_paths(item, &config.redacted_paths); + } + + if config.truncate_item_fields { + if let Some(max_len) = config.max_field_length.map(|value| value as usize) { + for item in items.iter_mut() { + crate::redaction::truncate_item_fields(item, max_len); + } + } + } +} + +fn payload_size_bytes(summary: &Value, items: &[Value], cursor: Option<&Value>) -> usize { + serde_json::to_vec(&json!({ + "summary": summary, + "items": items, + "cursor": cursor + })) + .map(|value| value.len()) + .unwrap_or(usize::MAX) +} + +pub fn extract_path_value<'a>(value: &'a Value, path: Option<&str>) -> Option<&'a Value> { + let path = path?; + let path = path.strip_prefix("$.").or_else(|| path.strip_prefix('$'))?; + + if path.is_empty() { + return Some(value); + } + + let mut current = value; + for segment in path.split('.') { + current = current.get(segment)?; + } + + Some(current) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crank_core::{ + AggregationMode, ExecutionMode, StreamingConfig, ToolFamilyConfig, TransportBehavior, + }; + + use super::collect_window_result; + + fn window_config() -> StreamingConfig { + StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(3_000), + poll_interval_ms: None, + upstream_timeout_ms: Some(5_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items: Some(10), + max_bytes: Some(10_000), + aggregation_mode: AggregationMode::RawItems, + summary_path: Some("$.summary".to_owned()), + items_path: Some("$.items".to_owned()), + cursor_path: Some("$.cursor".to_owned()), + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig::default(), + } + } + + #[test] + fn collects_raw_items_mode() { + let config = window_config(); + let result = collect_window_result( + &json!({ + "items": [{ "message": "one" }, { "message": "two" }], + "summary": { "count": 2 }, + "done": true + }), + &config, + ) + .unwrap(); + + assert_eq!(result.summary, Value::Null); + assert_eq!(result.items.len(), 2); + assert!(result.window_complete); + assert!(!result.truncated); + assert!(!result.has_more); + } + + #[test] + fn builds_summary_only_mode() { + let mut config = window_config(); + config.aggregation_mode = AggregationMode::SummaryOnly; + + let result = collect_window_result( + &json!({ + "items": [{ "message": "one" }], + "summary": { "count": 1 }, + "done": true + }), + &config, + ) + .unwrap(); + + assert_eq!(result.summary, json!({ "count": 1 })); + assert!(result.items.is_empty()); + } + + #[test] + fn keeps_summary_plus_samples() { + let mut config = window_config(); + config.aggregation_mode = AggregationMode::SummaryPlusSamples; + + let result = collect_window_result( + &json!({ + "items": [{ "message": "one" }, { "message": "two" }], + "summary": { "count": 2 }, + "done": true + }), + &config, + ) + .unwrap(); + + assert_eq!(result.summary, json!({ "count": 2 })); + assert_eq!(result.items.len(), 2); + } + + #[test] + fn truncates_by_item_count() { + let mut config = window_config(); + config.max_items = Some(1); + + let result = collect_window_result( + &json!({ + "items": [{ "message": "one" }, { "message": "two" }], + "cursor": "next", + "done": false + }), + &config, + ) + .unwrap(); + + assert_eq!(result.items.len(), 1); + assert!(result.truncated); + assert!(result.has_more); + assert!(!result.window_complete); + } + + #[test] + fn truncates_by_byte_limit() { + let mut config = window_config(); + config.max_bytes = Some(120); + config.aggregation_mode = AggregationMode::SummaryPlusSamples; + + let result = collect_window_result( + &json!({ + "items": [ + { "message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { "message": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } + ], + "summary": { "count": 2 }, + "done": true + }), + &config, + ) + .unwrap(); + + assert!(result.truncated); + assert!(result.items.len() < 2); + } + + #[test] + fn redacts_and_truncates_values() { + let mut config = window_config(); + config.redacted_paths = vec!["$.secret".to_owned()]; + config.truncate_item_fields = true; + config.max_field_length = Some(4); + + let result = collect_window_result( + &json!({ + "items": [ + { "message": "abcdefghijklmnop", "secret": "token" } + ], + "done": true + }), + &config, + ) + .unwrap(); + + assert_eq!(result.items[0]["secret"], json!("[REDACTED]")); + assert_eq!(result.items[0]["message"], json!("abcd...")); + } +} diff --git a/crates/crank-runtime/src/error.rs b/crates/crank-runtime/src/error.rs index 85b1827..dfb00c4 100644 --- a/crates/crank-runtime/src/error.rs +++ b/crates/crank-runtime/src/error.rs @@ -1,7 +1,7 @@ use crank_adapter_graphql::GraphqlAdapterError; use crank_adapter_grpc::GrpcAdapterError; use crank_adapter_rest::RestAdapterError; -use crank_core::Protocol; +use crank_core::{ExecutionMode, Protocol}; use crank_mapping::MappingError; use crank_schema::SchemaError; use thiserror::Error; @@ -20,6 +20,15 @@ pub enum RuntimeError { RestAdapter(#[from] RestAdapterError), #[error("protocol {protocol:?} is not supported by runtime")] UnsupportedProtocol { protocol: Protocol }, + #[error("operation {operation_id} does not define streaming config")] + MissingStreamingConfig { operation_id: String }, + #[error("operation {operation_id} does not support requested execution mode {mode:?}")] + UnsupportedExecutionMode { + operation_id: String, + mode: ExecutionMode, + }, #[error("invalid prepared request: {details}")] InvalidPreparedRequest { details: String }, + #[error("invalid streaming payload: {details}")] + InvalidStreamingPayload { details: String }, } diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 32e5d94..16d3253 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -3,10 +3,12 @@ use std::collections::BTreeMap; use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; use crank_adapter_grpc::{GrpcAdapter, GrpcRequest}; use crank_adapter_rest::{RestAdapter, RestRequest}; -use crank_core::Target; +use crank_core::{ExecutionMode, Target}; use serde_json::{Map, Value, json}; -use crate::{AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation}; +use crate::{ + AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation, WindowExecutionResult, +}; #[derive(Clone, Debug)] pub struct RuntimeExecutor { @@ -39,6 +41,30 @@ impl RuntimeExecutor { self.execute_prepared(operation, prepared_request).await } + pub async fn execute_window( + &self, + operation: &RuntimeOperation, + input: &Value, + ) -> Result { + let Some(streaming) = operation.execution_config.streaming.as_ref() else { + return Err(RuntimeError::MissingStreamingConfig { + operation_id: operation.operation_id.as_str().to_owned(), + }); + }; + + if streaming.mode != ExecutionMode::Window { + return Err(RuntimeError::UnsupportedExecutionMode { + operation_id: operation.operation_id.as_str().to_owned(), + mode: streaming.mode, + }); + } + + let prepared_request = self.prepare_request(operation, input)?; + let adapter_response = self.execute_adapter(operation, prepared_request).await?; + + crate::aggregation::collect_window_result(&adapter_response.body, streaming) + } + pub fn prepare_request( &self, operation: &RuntimeOperation, @@ -55,7 +81,20 @@ impl RuntimeExecutor { operation: &RuntimeOperation, prepared_request: PreparedRequest, ) -> Result { - let adapter_response = match &operation.target { + let adapter_response = self.execute_adapter(operation, prepared_request).await?; + let finalized_output = finalize_output(operation, &adapter_response)?; + + operation.output_schema.validate_shape(&finalized_output)?; + + Ok(finalized_output) + } + + async fn execute_adapter( + &self, + operation: &RuntimeOperation, + prepared_request: PreparedRequest, + ) -> Result { + match &operation.target { Target::Grpc(target) => { let request = GrpcRequest { headers: merge_headers( @@ -71,12 +110,12 @@ impl RuntimeExecutor { }; let response = self.grpc_adapter.execute(target, &request).await?; - AdapterResponse { + Ok(AdapterResponse { status_code: response.status_code, headers: response.headers, body: response.body.clone(), data: response.body, - } + }) } Target::Graphql(target) => { let request = GraphqlRequest { @@ -90,12 +129,12 @@ impl RuntimeExecutor { }; let response = self.graphql_adapter.execute(target, &request).await?; - AdapterResponse { + Ok(AdapterResponse { status_code: response.status_code, headers: response.headers, body: response.body, data: response.data, - } + }) } Target::Rest(target) => { let request = RestRequest { @@ -111,20 +150,14 @@ impl RuntimeExecutor { }; let response = self.rest_adapter.execute(target, &request).await?; - AdapterResponse { + Ok(AdapterResponse { status_code: response.status_code, headers: response.headers, body: response.body, data: Value::Null, - } + }) } - }; - - let finalized_output = finalize_output(operation, &adapter_response)?; - - operation.output_schema.validate_shape(&finalized_output)?; - - Ok(finalized_output) + } } } @@ -234,9 +267,10 @@ mod tests { use axum::{Json, Router, routing::post}; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ - DescriptorId, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlOperationType, - GraphqlTarget, GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, Protocol, - RestTarget, Samples, Target, ToolDescription, ToolExample, + AggregationMode, DescriptorId, ExecutionConfig, ExecutionMode, GeneratedDraft, + GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, + Operation, OperationId, OperationStatus, Protocol, RestTarget, Samples, StreamingConfig, + Target, ToolDescription, ToolExample, ToolFamilyConfig, TransportBehavior, }; use crank_mapping::{MappingRule, MappingSet}; use crank_schema::{Schema, SchemaKind}; @@ -340,8 +374,81 @@ mod tests { assert!(matches!(error, RuntimeError::GraphqlAdapter(_))); } + #[tokio::test] + async fn executes_window_mode_with_raw_items() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_window_operation(&base_url, AggregationMode::RawItems, None, None); + + let result = executor + .execute_window(&operation, &json!({})) + .await + .unwrap(); + + assert_eq!(result.items.len(), 3); + assert_eq!(result.summary, Value::Null); + assert!(result.window_complete); + assert!(!result.truncated); + } + + #[tokio::test] + async fn executes_window_mode_with_summary_only() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = + test_window_operation(&base_url, AggregationMode::SummaryOnly, Some(10), None); + + let result = executor + .execute_window(&operation, &json!({})) + .await + .unwrap(); + + assert_eq!(result.summary, json!({ "service": "billing", "errors": 1 })); + assert!(result.items.is_empty()); + } + + #[tokio::test] + async fn executes_window_mode_with_truncation_and_redaction() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_window_operation( + &base_url, + AggregationMode::SummaryPlusSamples, + Some(2), + Some(120), + ); + + let result = executor + .execute_window(&operation, &json!({})) + .await + .unwrap(); + + assert!(result.truncated); + assert!(result.has_more); + assert_eq!(result.items.len(), 1); + assert_eq!(result.items[0]["secret"], json!("[REDACTED]")); + assert_eq!(result.items[0]["message"], json!("disk...")); + } + + #[tokio::test] + async fn propagates_timeout_in_window_mode() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_slow_window_operation(&base_url); + + let error = executor + .execute_window(&operation, &json!({})) + .await + .unwrap_err(); + + assert!(matches!(error, RuntimeError::RestAdapter(_))); + } + async fn spawn_runtime_server() -> String { - let app = Router::new().route("/leads", post(create_lead)); + let app = Router::new() + .route("/leads", post(create_lead)) + .route("/events", post(events_window)) + .route("/slow-events", post(slow_events_window)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -383,6 +490,27 @@ mod tests { ) } + async fn events_window() -> (axum::http::StatusCode, Json) { + ( + axum::http::StatusCode::OK, + Json(json!({ + "summary": { "service": "billing", "errors": 1 }, + "items": [ + { "message": "disk pressure detected", "secret": "token-a" }, + { "message": "error budget exhausted", "secret": "token-b" }, + { "message": "node restarted", "secret": "token-c" } + ], + "cursor": "cursor_02", + "done": true + })), + ) + } + + async fn slow_events_window() -> (axum::http::StatusCode, Json) { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + events_window().await + } + async fn graphql_handler(Json(payload): Json) -> Json { let email = payload .get("variables") @@ -653,6 +781,107 @@ mod tests { }) } + fn test_window_operation( + base_url: &str, + aggregation_mode: AggregationMode, + max_items: Option, + max_bytes: Option, + ) -> RuntimeOperation { + RuntimeOperation::from(Operation { + id: OperationId::new("op_window_runtime"), + name: "billing_log_window".to_owned(), + display_name: "Billing Log Window".to_owned(), + category: "ops".to_owned(), + protocol: Protocol::Rest, + status: OperationStatus::Published, + version: 1, + target: Target::Rest(RestTarget { + base_url: base_url.to_owned(), + method: HttpMethod::Post, + path_template: "/events".to_owned(), + static_headers: BTreeMap::new(), + }), + input_schema: Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + output_schema: object_schema("ignored", SchemaKind::String), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp".to_owned(), + target: "$.request.body".to_owned(), + required: false, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { rules: Vec::new() }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: Some(StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(3_000), + poll_interval_ms: None, + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items, + max_bytes, + aggregation_mode, + summary_path: Some("$.summary".to_owned()), + items_path: Some("$.items".to_owned()), + cursor_path: Some("$.cursor".to_owned()), + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: vec!["$.secret".to_owned()], + truncate_item_fields: true, + max_field_length: Some(4), + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig::default(), + }), + }, + tool_description: ToolDescription { + title: "Billing Log Window".to_owned(), + description: "Collects bounded billing events.".to_owned(), + tags: vec!["logs".to_owned()], + examples: Vec::new(), + }, + samples: None, + generated_draft: None, + config_export: None, + created_at: "2026-04-06T12:00:00Z".to_owned(), + updated_at: "2026-04-06T12:00:00Z".to_owned(), + published_at: Some("2026-04-06T12:00:00Z".to_owned()), + }) + } + + fn test_slow_window_operation(base_url: &str) -> RuntimeOperation { + let mut operation = + test_window_operation(base_url, AggregationMode::RawItems, Some(10), None); + + if let Target::Rest(target) = &mut operation.target { + target.path_template = "/slow-events".to_owned(); + } + + operation.execution_config.timeout_ms = 10; + operation + } + fn object_schema(field_name: &str, kind: SchemaKind) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/crates/crank-runtime/src/lib.rs b/crates/crank-runtime/src/lib.rs index f712205..40dd27c 100644 --- a/crates/crank-runtime/src/lib.rs +++ b/crates/crank-runtime/src/lib.rs @@ -1,7 +1,11 @@ +mod aggregation; mod error; mod executor; mod model; +mod redaction; +mod streaming; pub use error::RuntimeError; pub use executor::RuntimeExecutor; pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation}; +pub use streaming::WindowExecutionResult; diff --git a/crates/crank-runtime/src/redaction.rs b/crates/crank-runtime/src/redaction.rs new file mode 100644 index 0000000..ebecc50 --- /dev/null +++ b/crates/crank-runtime/src/redaction.rs @@ -0,0 +1,124 @@ +use serde_json::Value; + +pub fn redact_paths(value: &mut Value, paths: &[String]) { + for path in paths { + let segments = parse_path(path); + if segments.is_empty() { + continue; + } + + redact_path(value, &segments); + } +} + +pub fn truncate_item_fields(value: &mut Value, max_len: usize) { + match value { + Value::String(text) => { + if text != "[REDACTED]" && text.chars().count() > max_len { + let truncated = text.chars().take(max_len).collect::(); + *text = format!("{truncated}..."); + } + } + Value::Array(items) => { + for item in items { + truncate_item_fields(item, max_len); + } + } + Value::Object(map) => { + for value in map.values_mut() { + truncate_item_fields(value, max_len); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum PathSegment { + Field(String), + Wildcard, +} + +fn parse_path(path: &str) -> Vec { + let path = path.strip_prefix("$.").or_else(|| path.strip_prefix('$')); + let Some(path) = path else { + return Vec::new(); + }; + + path.split('.') + .flat_map(|segment| { + if let Some(field) = segment.strip_suffix("[*]") { + vec![PathSegment::Field(field.to_owned()), PathSegment::Wildcard] + } else if segment == "*" { + vec![PathSegment::Wildcard] + } else { + vec![PathSegment::Field(segment.to_owned())] + } + }) + .collect() +} + +fn redact_path(value: &mut Value, segments: &[PathSegment]) { + let Some((current, rest)) = segments.split_first() else { + *value = Value::String("[REDACTED]".to_owned()); + return; + }; + + match current { + PathSegment::Field(field) => { + if let Some(next) = value + .as_object_mut() + .and_then(|object| object.get_mut(field)) + { + redact_path(next, rest); + } + } + PathSegment::Wildcard => { + if let Some(items) = value.as_array_mut() { + for item in items { + redact_path(item, rest); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{redact_paths, truncate_item_fields}; + + #[test] + fn redacts_simple_and_wildcard_paths() { + let mut value = json!({ + "summary": { "token": "secret" }, + "items": [ + { "token": "a", "message": "keep" }, + { "token": "b", "message": "keep" } + ] + }); + + redact_paths( + &mut value, + &["$.summary.token".to_owned(), "$.items[*].token".to_owned()], + ); + + assert_eq!(value["summary"]["token"], json!("[REDACTED]")); + assert_eq!(value["items"][0]["token"], json!("[REDACTED]")); + assert_eq!(value["items"][1]["token"], json!("[REDACTED]")); + } + + #[test] + fn truncates_long_strings_recursively() { + let mut value = json!({ + "message": "abcdefghijklmnop", + "nested": [{ "message": "qrstuvwxyz" }] + }); + + truncate_item_fields(&mut value, 4); + + assert_eq!(value["message"], json!("abcd...")); + assert_eq!(value["nested"][0]["message"], json!("qrst...")); + } +} diff --git a/crates/crank-runtime/src/streaming.rs b/crates/crank-runtime/src/streaming.rs new file mode 100644 index 0000000..6e19ab3 --- /dev/null +++ b/crates/crank-runtime/src/streaming.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowExecutionResult { + pub summary: Value, + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + pub window_complete: bool, + pub truncated: bool, + pub has_more: bool, +} From bf56494336e0ee241105d0f4105640f401615a46 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 10:54:01 +0300 Subject: [PATCH 10/38] feat: add rest sse streaming adapter --- Cargo.lock | 18 +++ TASKS.md | 14 +- crates/crank-adapter-rest/Cargo.toml | 4 +- crates/crank-adapter-rest/src/client.rs | 180 ++++++++++++++++++++++- crates/crank-adapter-rest/src/error.rs | 4 + crates/crank-adapter-rest/src/lib.rs | 3 +- crates/crank-adapter-rest/src/model.rs | 17 +++ crates/crank-adapter-rest/src/sse.rs | 160 +++++++++++++++++++++ crates/crank-runtime/Cargo.toml | 1 + crates/crank-runtime/src/executor.rs | 184 ++++++++++++++++++++++-- 10 files changed, 560 insertions(+), 25 deletions(-) create mode 100644 crates/crank-adapter-rest/src/sse.rs diff --git a/Cargo.lock b/Cargo.lock index 4e60815..6ce1cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,7 @@ version = "0.1.0" dependencies = [ "axum", "crank-core", + "futures-util", "reqwest", "serde", "serde_json", @@ -449,6 +450,7 @@ dependencies = [ "crank-core", "crank-mapping", "crank-schema", + "futures-util", "serde", "serde_json", "thiserror", @@ -1868,6 +1870,7 @@ dependencies = [ "cookie", "cookie_store", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1887,12 +1890,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", ] @@ -2916,6 +2921,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" diff --git a/TASKS.md b/TASKS.md index d7e8887..dcc08c9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,20 @@ ## Current -### `feat/runtime-window-mode` +### `feat/rest-sse-adapter` Status: completed DoD: -- runtime exposes bounded `window` execution -- item and byte limits are enforced -- `window_complete`, `truncated`, `has_more` are returned -- aggregation and redaction are covered by tests -- unary execution remains intact +- REST adapter supports bounded SSE collection for `window` mode +- SSE events are parsed into normalized JSON items +- adapter respects item/window limits and upstream timeout +- runtime dispatches REST `window` mode through SSE adapter path +- local SSE upstream tests cover success, timeout and malformed events ## Next -- `feat/rest-sse-adapter` +- `feat/grpc-server-streaming-adapter` ## Backlog diff --git a/crates/crank-adapter-rest/Cargo.toml b/crates/crank-adapter-rest/Cargo.toml index 090efc7..9e78916 100644 --- a/crates/crank-adapter-rest/Cargo.toml +++ b/crates/crank-adapter-rest/Cargo.toml @@ -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 diff --git a/crates/crank-adapter-rest/src/client.rs b/crates/crank-adapter-rest/src/client.rs index 5903dec..d5e82e3 100644 --- a/crates/crank-adapter-rest/src/client.rs +++ b/crates/crank-adapter-rest/src/client.rs @@ -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 { + 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 { @@ -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>> { + 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>> { + Sse::new(stream::pending()).keep_alive(KeepAlive::default()) + } + + async fn sse_broken() + -> Sse>> { + let events = vec![Ok(Event::default().data("{broken-json}"))]; + + Sse::new(stream::iter(events)).keep_alive(KeepAlive::default()) + } } diff --git a/crates/crank-adapter-rest/src/error.rs b/crates/crank-adapter-rest/src/error.rs index 1f18032..64f732d 100644 --- a/crates/crank-adapter-rest/src/error.rs +++ b/crates/crank-adapter-rest/src/error.rs @@ -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, } diff --git a/crates/crank-adapter-rest/src/lib.rs b/crates/crank-adapter-rest/src/lib.rs index 8f25b10..94ee256 100644 --- a/crates/crank-adapter-rest/src/lib.rs +++ b/crates/crank-adapter-rest/src/lib.rs @@ -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}; diff --git a/crates/crank-adapter-rest/src/model.rs b/crates/crank-adapter-rest/src/model.rs index f04ecec..f2c7e36 100644 --- a/crates/crank-adapter-rest/src/model.rs +++ b/crates/crank-adapter-rest/src/model.rs @@ -23,3 +23,20 @@ pub struct RestResponse { pub headers: BTreeMap, 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, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RestWindowResponse { + pub status_code: u16, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub body: Value, +} diff --git a/crates/crank-adapter-rest/src/sse.rs b/crates/crank-adapter-rest/src/sse.rs new file mode 100644 index 0000000..c60ed73 --- /dev/null +++ b/crates/crank-adapter-rest/src/sse.rs @@ -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, +) -> Result<(u16, BTreeMap, 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, 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::(&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 { + 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 { + 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)); + } +} diff --git a/crates/crank-runtime/Cargo.toml b/crates/crank-runtime/Cargo.toml index a2ef521..c3b6334 100644 --- a/crates/crank-runtime/Cargo.toml +++ b/crates/crank-runtime/Cargo.toml @@ -19,4 +19,5 @@ thiserror.workspace = true [dev-dependencies] axum.workspace = true crank-adapter-grpc = { path = "../crank-adapter-grpc", features = ["test-support"] } +futures-util = "0.3" tokio.workspace = true diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 16d3253..1611c37 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; use crank_adapter_grpc::{GrpcAdapter, GrpcRequest}; -use crank_adapter_rest::{RestAdapter, RestRequest}; -use crank_core::{ExecutionMode, Target}; +use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest}; +use crank_core::{ExecutionMode, Target, TransportBehavior}; use serde_json::{Map, Value, json}; use crate::{ @@ -60,7 +60,16 @@ impl RuntimeExecutor { } let prepared_request = self.prepare_request(operation, input)?; - let adapter_response = self.execute_adapter(operation, prepared_request).await?; + let adapter_response = if matches!( + streaming.transport_behavior, + TransportBehavior::ServerStream + ) && matches!(operation.target, Target::Rest(_)) + { + self.execute_window_adapter(operation, prepared_request) + .await? + } else { + self.execute_adapter(operation, prepared_request).await? + }; crate::aggregation::collect_window_result(&adapter_response.body, streaming) } @@ -159,6 +168,48 @@ impl RuntimeExecutor { } } } + + async fn execute_window_adapter( + &self, + operation: &RuntimeOperation, + prepared_request: PreparedRequest, + ) -> Result { + match &operation.target { + Target::Rest(target) => { + let Some(streaming) = operation.execution_config.streaming.as_ref() else { + return Err(RuntimeError::MissingStreamingConfig { + operation_id: operation.operation_id.as_str().to_owned(), + }); + }; + let request = RestWindowRequest { + request: RestRequest { + path_params: prepared_request.path_params.clone(), + query_params: prepared_request.query_params.clone(), + headers: merge_headers( + &target.static_headers, + &operation.execution_config.headers, + &prepared_request.headers, + ), + body: prepared_request.body.clone(), + timeout_ms: streaming + .upstream_timeout_ms + .unwrap_or(operation.execution_config.timeout_ms), + }, + window_duration_ms: streaming.window_duration_ms.unwrap_or_default(), + max_items: streaming.max_items.map(|value| value.saturating_add(1)), + }; + let response = self.rest_adapter.execute_window(target, &request).await?; + + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: Value::Null, + }) + } + _ => self.execute_adapter(operation, prepared_request).await, + } + } } impl PreparedRequest { @@ -264,7 +315,11 @@ fn non_empty_payload(value: Option) -> Option { mod tests { use std::collections::BTreeMap; - use axum::{Json, Router, routing::post}; + use axum::{ + Json, Router, + response::sse::{Event, KeepAlive, Sse}, + routing::post, + }; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ AggregationMode, DescriptorId, ExecutionConfig, ExecutionMode, GeneratedDraft, @@ -274,6 +329,7 @@ mod tests { }; use crank_mapping::{MappingRule, MappingSet}; use crank_schema::{Schema, SchemaKind}; + use futures_util::stream; use serde_json::{Value, json}; use tokio::net::TcpListener; @@ -378,7 +434,8 @@ mod tests { async fn executes_window_mode_with_raw_items() { let base_url = spawn_runtime_server().await; let executor = RuntimeExecutor::new(); - let operation = test_window_operation(&base_url, AggregationMode::RawItems, None, None); + let operation = + test_window_snapshot_operation(&base_url, AggregationMode::RawItems, None, None); let result = executor .execute_window(&operation, &json!({})) @@ -396,7 +453,7 @@ mod tests { let base_url = spawn_runtime_server().await; let executor = RuntimeExecutor::new(); let operation = - test_window_operation(&base_url, AggregationMode::SummaryOnly, Some(10), None); + test_window_snapshot_operation(&base_url, AggregationMode::SummaryOnly, Some(10), None); let result = executor .execute_window(&operation, &json!({})) @@ -411,7 +468,7 @@ mod tests { async fn executes_window_mode_with_truncation_and_redaction() { let base_url = spawn_runtime_server().await; let executor = RuntimeExecutor::new(); - let operation = test_window_operation( + let operation = test_window_snapshot_operation( &base_url, AggregationMode::SummaryPlusSamples, Some(2), @@ -434,7 +491,7 @@ mod tests { async fn propagates_timeout_in_window_mode() { let base_url = spawn_runtime_server().await; let executor = RuntimeExecutor::new(); - let operation = test_slow_window_operation(&base_url); + let operation = test_slow_window_snapshot_operation(&base_url); let error = executor .execute_window(&operation, &json!({})) @@ -444,11 +501,48 @@ mod tests { assert!(matches!(error, RuntimeError::RestAdapter(_))); } + #[tokio::test] + async fn executes_window_mode_with_rest_sse_stream() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_window_sse_operation(&base_url, AggregationMode::RawItems, Some(2)); + + let result = executor + .execute_window(&operation, &json!({})) + .await + .unwrap(); + + assert_eq!(result.items.len(), 2); + assert_eq!(result.summary, Value::Null); + assert!(result.truncated); + assert!(result.has_more); + assert!(!result.window_complete); + } + + #[tokio::test] + async fn completes_empty_window_for_idle_rest_sse_stream() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_slow_window_sse_operation(&base_url); + + let result = executor + .execute_window(&operation, &json!({})) + .await + .unwrap(); + + assert!(result.items.is_empty()); + assert!(result.window_complete); + assert!(!result.truncated); + assert!(!result.has_more); + } + async fn spawn_runtime_server() -> String { let app = Router::new() .route("/leads", post(create_lead)) .route("/events", post(events_window)) - .route("/slow-events", post(slow_events_window)); + .route("/slow-events", post(slow_events_window)) + .route("/events-sse", post(events_stream)) + .route("/slow-events-sse", post(slow_events_stream)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -511,6 +605,29 @@ mod tests { events_window().await } + async fn events_stream() + -> Sse>> { + let events = vec![ + Ok(Event::default() + .data("{\"message\":\"disk pressure detected\",\"secret\":\"token-a\"}")), + Ok(Event::default() + .data("{\"message\":\"error budget exhausted\",\"secret\":\"token-b\"}")), + Ok(Event::default().data("{\"message\":\"node restarted\",\"secret\":\"token-c\"}")), + ]; + + Sse::new(stream::iter(events)).keep_alive(KeepAlive::default()) + } + + async fn slow_events_stream() + -> Sse>> { + let delayed = stream::once(async { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + Ok(Event::default().data("{\"message\":\"late event\"}")) + }); + + Sse::new(delayed).keep_alive(KeepAlive::default()) + } + async fn graphql_handler(Json(payload): Json) -> Json { let email = payload .get("variables") @@ -781,7 +898,7 @@ mod tests { }) } - fn test_window_operation( + fn test_window_snapshot_operation( base_url: &str, aggregation_mode: AggregationMode, max_items: Option, @@ -833,7 +950,7 @@ mod tests { protocol_options: None, streaming: Some(StreamingConfig { mode: ExecutionMode::Window, - transport_behavior: TransportBehavior::ServerStream, + transport_behavior: TransportBehavior::RequestResponse, window_duration_ms: Some(3_000), poll_interval_ms: None, upstream_timeout_ms: Some(1_000), @@ -870,9 +987,9 @@ mod tests { }) } - fn test_slow_window_operation(base_url: &str) -> RuntimeOperation { + fn test_slow_window_snapshot_operation(base_url: &str) -> RuntimeOperation { let mut operation = - test_window_operation(base_url, AggregationMode::RawItems, Some(10), None); + test_window_snapshot_operation(base_url, AggregationMode::RawItems, Some(10), None); if let Target::Rest(target) = &mut operation.target { target.path_template = "/slow-events".to_owned(); @@ -882,6 +999,47 @@ mod tests { operation } + fn test_window_sse_operation( + base_url: &str, + aggregation_mode: AggregationMode, + max_items: Option, + ) -> RuntimeOperation { + let mut operation = + test_window_snapshot_operation(base_url, aggregation_mode, max_items, None); + + if let Target::Rest(target) = &mut operation.target { + target.path_template = "/events-sse".to_owned(); + } + + operation.execution_config.streaming = Some(StreamingConfig { + transport_behavior: TransportBehavior::ServerStream, + summary_path: None, + cursor_path: None, + done_path: Some("$.done".to_owned()), + ..operation.execution_config.streaming.clone().unwrap() + }); + + operation + } + + fn test_slow_window_sse_operation(base_url: &str) -> RuntimeOperation { + let mut operation = + test_window_sse_operation(base_url, AggregationMode::RawItems, Some(10)); + + if let Target::Rest(target) = &mut operation.target { + target.path_template = "/slow-events-sse".to_owned(); + } + + operation + .execution_config + .streaming + .as_mut() + .expect("streaming config") + .window_duration_ms = Some(20); + operation.execution_config.timeout_ms = 1_000; + operation + } + fn object_schema(field_name: &str, kind: SchemaKind) -> Schema { Schema { kind: SchemaKind::Object, From bd2c6d4f4840ff4642563ee1ee5f9058a5dbee69 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 11:04:10 +0300 Subject: [PATCH 11/38] feat: add grpc server streaming adapter --- Cargo.lock | 1 + TASKS.md | 10 +- crates/crank-adapter-grpc/Cargo.toml | 1 + crates/crank-adapter-grpc/proto/echo.proto | 1 + crates/crank-adapter-grpc/src/client.rs | 187 +++++++++++++++--- crates/crank-adapter-grpc/src/error.rs | 6 +- crates/crank-adapter-grpc/src/lib.rs | 2 +- crates/crank-adapter-grpc/src/model.rs | 17 ++ crates/crank-adapter-grpc/src/test_support.rs | 25 +++ crates/crank-runtime/src/executor.rs | 139 ++++++++++++- 10 files changed, 358 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ce1cb8..d690f90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,6 +363,7 @@ dependencies = [ "base64", "crank-core", "crank-proto", + "futures-util", "prost", "prost-reflect", "prost-types", diff --git a/TASKS.md b/TASKS.md index dcc08c9..40447f8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,16 +2,16 @@ ## Current -### `feat/rest-sse-adapter` +### `feat/grpc-server-streaming-adapter` Status: completed DoD: -- REST adapter supports bounded SSE collection for `window` mode -- SSE events are parsed into normalized JSON items +- gRPC adapter supports bounded server-stream collection for `window` mode +- streamed protobuf messages are decoded into normalized JSON items - adapter respects item/window limits and upstream timeout -- runtime dispatches REST `window` mode through SSE adapter path -- local SSE upstream tests cover success, timeout and malformed events +- runtime dispatches gRPC `window` mode through streaming adapter path +- local gRPC upstream tests cover success, timeout and malformed method kind handling ## Next diff --git a/crates/crank-adapter-grpc/Cargo.toml b/crates/crank-adapter-grpc/Cargo.toml index aafb201..182b2e1 100644 --- a/crates/crank-adapter-grpc/Cargo.toml +++ b/crates/crank-adapter-grpc/Cargo.toml @@ -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 diff --git a/crates/crank-adapter-grpc/proto/echo.proto b/crates/crank-adapter-grpc/proto/echo.proto index c26a235..0ac81c0 100644 --- a/crates/crank-adapter-grpc/proto/echo.proto +++ b/crates/crank-adapter-grpc/proto/echo.proto @@ -4,6 +4,7 @@ package echo; service EchoService { rpc UnaryEcho(EchoRequest) returns (EchoResponse); + rpc ServerEcho(EchoRequest) returns (stream EchoResponse); } message EchoRequest { diff --git a/crates/crank-adapter-grpc/src/client.rs b/crates/crank-adapter-grpc/src/client.rs index b58e7db..024a399 100644 --- a/crates/crank-adapter-grpc/src/client.rs +++ b/crates/crank-adapter-grpc/src/client.rs @@ -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 { + 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, 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 { + 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 { @@ -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 { .. } + )); + } } diff --git a/crates/crank-adapter-grpc/src/error.rs b/crates/crank-adapter-grpc/src/error.rs index 0cf158a..4ada46d 100644 --- a/crates/crank-adapter-grpc/src/error.rs +++ b/crates/crank-adapter-grpc/src/error.rs @@ -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 }, } diff --git a/crates/crank-adapter-grpc/src/lib.rs b/crates/crank-adapter-grpc/src/lib.rs index fde7905..03812a9 100644 --- a/crates/crank-adapter-grpc/src/lib.rs +++ b/crates/crank-adapter-grpc/src/lib.rs @@ -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; diff --git a/crates/crank-adapter-grpc/src/model.rs b/crates/crank-adapter-grpc/src/model.rs index c04f468..a1a61e1 100644 --- a/crates/crank-adapter-grpc/src/model.rs +++ b/crates/crank-adapter-grpc/src/model.rs @@ -18,3 +18,20 @@ pub struct GrpcResponse { pub headers: BTreeMap, 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, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GrpcWindowResponse { + pub status_code: u16, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub body: Value, +} diff --git a/crates/crank-adapter-grpc/src/test_support.rs b/crates/crank-adapter-grpc/src/test_support.rs index 4ad7dfd..c0cae13 100644 --- a/crates/crank-adapter-grpc/src/test_support.rs +++ b/crates/crank-adapter-grpc/src/test_support.rs @@ -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> + Send>, + >; + + async fn server_echo( + &self, + request: Request, + ) -> Result, 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 { diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 1611c37..c4b2748 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; -use crank_adapter_grpc::{GrpcAdapter, GrpcRequest}; +use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest}; use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest}; use crank_core::{ExecutionMode, Target, TransportBehavior}; use serde_json::{Map, Value, json}; @@ -63,7 +63,7 @@ impl RuntimeExecutor { let adapter_response = if matches!( streaming.transport_behavior, TransportBehavior::ServerStream - ) && matches!(operation.target, Target::Rest(_)) + ) && matches!(operation.target, Target::Rest(_) | Target::Grpc(_)) { self.execute_window_adapter(operation, prepared_request) .await? @@ -207,6 +207,39 @@ impl RuntimeExecutor { data: Value::Null, }) } + Target::Grpc(target) => { + let Some(streaming) = operation.execution_config.streaming.as_ref() else { + return Err(RuntimeError::MissingStreamingConfig { + operation_id: operation.operation_id.as_str().to_owned(), + }); + }; + let request = GrpcWindowRequest { + request: GrpcRequest { + headers: merge_headers( + &BTreeMap::new(), + &operation.execution_config.headers, + &prepared_request.headers, + ), + body: prepared_request + .grpc + .clone() + .unwrap_or(Value::Object(Map::new())), + timeout_ms: streaming + .upstream_timeout_ms + .unwrap_or(operation.execution_config.timeout_ms), + }, + window_duration_ms: streaming.window_duration_ms.unwrap_or_default(), + max_items: streaming.max_items.map(|value| value.saturating_add(1)), + }; + let response = self.grpc_adapter.execute_window(target, &request).await?; + + Ok(AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: Value::Null, + }) + } _ => self.execute_adapter(operation, prepared_request).await, } } @@ -377,6 +410,26 @@ mod tests { assert_eq!(output, json!({ "message": "hello" })); } + #[tokio::test] + async fn executes_grpc_window_mode_with_server_stream() { + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let executor = RuntimeExecutor::new(); + let operation = + test_grpc_window_operation(&server_addr, AggregationMode::RawItems, Some(2)); + + let result = executor + .execute_window(&operation, &json!({ "message": "hello" })) + .await + .unwrap(); + + assert_eq!(result.summary, Value::Null); + assert_eq!(result.items.len(), 2); + assert_eq!(result.items[0], json!({ "message": "hello-one" })); + assert!(result.truncated); + assert!(result.has_more); + assert!(!result.window_complete); + } + #[tokio::test] async fn rejects_invalid_input_shape() { let base_url = spawn_runtime_server().await; @@ -898,6 +951,88 @@ mod tests { }) } + fn test_grpc_window_operation( + server_addr: &str, + aggregation_mode: AggregationMode, + max_items: Option, + ) -> RuntimeOperation { + RuntimeOperation::from(Operation { + id: OperationId::new("op_grpc_window_runtime"), + name: "echo_stream_grpc".to_owned(), + display_name: "Server Stream Echo gRPC".to_owned(), + category: "support".to_owned(), + protocol: Protocol::Grpc, + status: OperationStatus::Published, + version: 1, + 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"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }), + input_schema: object_schema("message", SchemaKind::String), + output_schema: object_schema("ignored", SchemaKind::String), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.message".to_owned(), + target: "$.request.grpc.message".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { rules: Vec::new() }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: Some(StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(3_000), + poll_interval_ms: None, + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items, + max_bytes: None, + aggregation_mode, + summary_path: None, + items_path: Some("$.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig::default(), + }), + }, + tool_description: ToolDescription { + title: "Server Stream Echo gRPC".to_owned(), + description: "Collects bounded gRPC stream messages.".to_owned(), + tags: vec!["grpc".to_owned(), "stream".to_owned()], + examples: vec![ToolExample { + input: json!({ "message": "hello" }), + }], + }, + samples: None, + generated_draft: None, + config_export: None, + created_at: "2026-04-06T12:00:00Z".to_owned(), + updated_at: "2026-04-06T12:00:00Z".to_owned(), + published_at: Some("2026-04-06T12:00:00Z".to_owned()), + }) + } + fn test_window_snapshot_operation( base_url: &str, aggregation_mode: AggregationMode, From fdd0a4512449e73469ad47b24f63b7d1f806d417 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 11:35:37 +0300 Subject: [PATCH 12/38] feat: add session and async job mcp tools --- TASKS.md | 14 +- apps/mcp-server/src/app.rs | 1245 +++++++++++++++++++++++--- apps/mcp-server/src/catalog.rs | 21 - apps/mcp-server/src/main.rs | 406 ++++++++- crates/crank-runtime/src/executor.rs | 34 + 5 files changed, 1578 insertions(+), 142 deletions(-) diff --git a/TASKS.md b/TASKS.md index 40447f8..5038a98 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,20 @@ ## Current -### `feat/grpc-server-streaming-adapter` +### `feat/session-and-job-tools` Status: completed DoD: -- gRPC adapter supports bounded server-stream collection for `window` mode -- streamed protobuf messages are decoded into normalized JSON items -- adapter respects item/window limits and upstream timeout -- runtime dispatches gRPC `window` mode through streaming adapter path -- local gRPC upstream tests cover success, timeout and malformed method kind handling +- session and async_job tool families are published in the MCP tool catalog +- generated `start/poll/stop` and `start/status/result/cancel` tool calls work through JSON-RPC +- stream sessions and async jobs are persisted with explicit status transitions +- session and async_job tool calls are logged through observability +- MCP integration tests cover `session start -> poll -> stop` and `async job start -> status -> result` ## Next -- `feat/grpc-server-streaming-adapter` +- `feat/streaming-ui-config` ## Backlog diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 04233e1..21b4541 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -19,10 +19,14 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ - InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, - PlatformApiKeyScope, + AsyncJobHandle, AsyncJobId, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, + InvocationStatus, JobStatus, PlatformApiKeyScope, StreamSession, StreamSessionId, StreamStatus, +}; +use crank_registry::{ + CreateAsyncJobRequest, CreateInvocationLogRequest, CreateStreamSessionRequest, + PostgresRegistry, PublishedAgentTool, UpdateAsyncJobStatusRequest, + UpdateStreamSessionStateRequest, }; -use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool}; use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation}; use futures_util::stream; use serde::{Deserialize, Serialize}; @@ -76,6 +80,34 @@ struct ToolCallParams { arguments: Value, } +#[derive(Clone, Copy)] +enum GeneratedToolKind { + Base, + SessionStart, + SessionPoll, + SessionStop, + AsyncJobStart, + AsyncJobStatus, + AsyncJobResult, + AsyncJobCancel, +} + +#[derive(Clone)] +struct ResolvedToolCall { + tool: PublishedAgentTool, + kind: GeneratedToolKind, +} + +#[derive(Debug, Deserialize)] +struct SessionControlArgs { + session_id: String, +} + +#[derive(Debug, Deserialize)] +struct AsyncJobControlArgs { + job_id: String, +} + #[derive(Clone, Debug, Deserialize)] struct AgentRoutePath { workspace_slug: String, @@ -273,7 +305,7 @@ async fn mcp_post( .await { Ok(tools) => { - let definitions = tools.iter().map(tool_definition).collect::>(); + let definitions = tools.iter().flat_map(tool_definitions).collect::>(); transport_response( StatusCode::OK, @@ -313,110 +345,33 @@ async fn mcp_post( match state .catalog - .get_tool( - &session.workspace_slug, - &session.agent_slug, - &tool_call_params.name, - ) + .list_tools(&session.workspace_slug, &session.agent_slug) .await { - Ok(Some(tool)) => { - let runtime_operation = runtime_operation(&tool); - let request_preview = - build_request_preview(&state.runtime, &runtime_operation, &arguments); - let started_at = Instant::now(); - - match state.runtime.execute(&runtime_operation, &arguments).await { - Ok(output) => transport_response( - StatusCode::OK, - { - let _ = persist_invocation( - &state, - &tool, - InvocationRecord { - status: InvocationStatus::Ok, - level: InvocationLevel::Info, - message: "agent tool call completed", - status_code: None, - error_kind: None, - duration: started_at.elapsed(), - request_preview, - response_preview: output.clone(), - }, - ) - .await; - jsonrpc_result( - request_id(&message), - json!({ - "content": [ - { - "type": "text", - "text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned()) - } - ], - "structuredContent": output, - "isError": false - }), - ) - }, + Ok(tools) => match resolve_generated_tool(&tools, &tool_call_params.name) { + Some(resolved) => { + handle_tool_call( + state.clone(), + &session, + &message, response_mode, - None, - Some(&session.protocol_version), - ), - Err(error) => { - let _ = persist_invocation( - &state, - &tool, - InvocationRecord { - status: InvocationStatus::Error, - level: InvocationLevel::Error, - message: &error.to_string(), - status_code: None, - error_kind: Some(runtime_error_code(&error)), - duration: started_at.elapsed(), - request_preview, - response_preview: Value::Null, - }, - ) - .await; - transport_response( - StatusCode::OK, - jsonrpc_result( - request_id(&message), - json!({ - "content": [ - { - "type": "text", - "text": error.to_string() - } - ], - "structuredContent": { - "error": { - "code": runtime_error_code(&error), - "message": error.to_string() - } - }, - "isError": true - }), - ), - response_mode, - None, - Some(&session.protocol_version), - ) - } + resolved, + arguments, + ) + .await } - } - Ok(None) => transport_response( - StatusCode::OK, - jsonrpc_error( - request_id(&message), - -32602, - format!("tool {} was not found", tool_call_params.name), + None => transport_response( + StatusCode::OK, + jsonrpc_error( + request_id(&message), + -32602, + format!("tool {} was not found", tool_call_params.name), + ), + response_mode, + None, + Some(&session.protocol_version), ), - response_mode, - None, - Some(&session.protocol_version), - ), + }, Err(error) => internal_jsonrpc_error(&message, error), } } @@ -445,6 +400,805 @@ async fn mcp_post( } } +async fn handle_tool_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + resolved: ResolvedToolCall, + arguments: Value, +) -> Response { + match resolved.kind { + GeneratedToolKind::Base => { + handle_base_tool_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::SessionStart => { + handle_session_start_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::SessionPoll => { + handle_session_poll_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::SessionStop => { + handle_session_stop_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobStart => { + handle_async_job_start_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobStatus => { + handle_async_job_status_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobResult => { + handle_async_job_result_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobCancel => { + handle_async_job_cancel_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct StoredSessionState { + input: Value, + summary: Value, + items: Vec, + next_index: usize, + batch_size: usize, +} + +async fn handle_base_tool_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let operation = runtime_operation(&tool); + let request_preview = build_request_preview(&state.runtime, &operation, &arguments); + let started_at = Instant::now(); + + match state.runtime.execute(&operation, &arguments).await { + Ok(output) => { + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: &tool.tool_name, + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "agent tool call completed", + status_code: None, + error_kind: None, + duration: started_at.elapsed(), + request_preview, + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) + } + Err(error) => { + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: &tool.tool_name, + status: InvocationStatus::Error, + level: InvocationLevel::Error, + message: &error.to_string(), + status_code: None, + error_kind: Some(runtime_error_code(&error)), + duration: started_at.elapsed(), + request_preview, + response_preview: Value::Null, + }, + ) + .await; + + tool_error_response( + message, + response_mode, + &session.protocol_version, + runtime_error_code(&error), + error.to_string(), + ) + } + } +} + +async fn handle_session_start_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let runtime_operation = runtime_operation(&tool); + let request_preview = build_request_preview(&state.runtime, &runtime_operation, &arguments); + let started_at = Instant::now(); + let Some(streaming) = runtime_operation.execution_config.streaming.as_ref() else { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_config_error", + "streaming config is required for session tools".to_owned(), + ); + }; + + match state + .runtime + .execute_session_seed(&runtime_operation, &arguments) + .await + { + Ok(seed) => { + let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize; + let preview_count = seed.items.len().min(batch_size); + let preview_items = seed.items[..preview_count].to_vec(); + let next_index = preview_count; + let session_id = + StreamSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple())); + let now = now_rfc3339(); + let expires_at = add_millis(&now, streaming.max_session_lifetime_ms.unwrap_or(60_000)); + let session_record = StreamSession { + id: session_id.clone(), + workspace_id: tool.workspace_id.clone(), + agent_id: Some(tool.agent_id.clone()), + operation_id: tool.operation.id.clone(), + protocol: tool.operation.protocol, + mode: crank_core::ExecutionMode::Session, + status: StreamStatus::Running, + cursor: (next_index < seed.items.len()).then(|| json!(next_index)), + state: json!(StoredSessionState { + input: arguments.clone(), + summary: seed.summary.clone(), + items: seed.items.clone(), + next_index, + batch_size, + }), + expires_at: expires_at.clone(), + last_poll_at: Some(now.clone()), + created_at: now.clone(), + closed_at: None, + }; + + if let Err(error) = state + .registry + .create_stream_session(CreateStreamSessionRequest { + session: &session_record, + }) + .await + { + return internal_jsonrpc_error(message, error); + } + + let output = json!({ + "session_id": session_id.as_str(), + "status": "running", + "expires_at": expires_at, + "poll_after_ms": streaming.poll_interval_ms.unwrap_or(1000), + "preview": { + "summary": seed.summary, + "items": preview_items, + } + }); + + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .start_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "stream session started", + status_code: None, + error_kind: None, + duration: started_at.elapsed(), + request_preview, + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) + } + Err(error) => { + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .start_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Error, + level: InvocationLevel::Error, + message: &error.to_string(), + status_code: None, + error_kind: Some(runtime_error_code(&error)), + duration: started_at.elapsed(), + request_preview, + response_preview: Value::Null, + }, + ) + .await; + + tool_error_response( + message, + response_mode, + &session.protocol_version, + runtime_error_code(&error), + error.to_string(), + ) + } + } +} + +async fn handle_session_poll_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: SessionControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_config_error", + "streaming config is required for session tools".to_owned(), + ); + }; + + let now = now_rfc3339(); + let loaded = match state + .registry + .get_stream_session(&StreamSessionId::new(control.session_id.clone())) + .await + { + Ok(Some(session_record)) => session_record, + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_not_found", + format!("stream session {} was not found", control.session_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + if loaded.is_expired(&now) { + let _ = state + .registry + .update_stream_session_state(UpdateStreamSessionStateRequest { + session_id: &loaded.id, + current_status: loaded.status, + next_status: StreamStatus::Expired, + cursor: loaded.cursor.as_ref(), + state: &loaded.state, + expires_at: None, + last_poll_at: Some(&now), + closed_at: Some(&now), + }) + .await; + + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_expired", + format!("stream session {} has expired", control.session_id), + ); + } + + if !loaded.can_poll(&now) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_not_found", + format!("stream session {} is not running", control.session_id), + ); + } + + let mut state_payload: StoredSessionState = match serde_json::from_value(loaded.state.clone()) { + Ok(value) => value, + Err(error) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_payload_error", + error.to_string(), + ); + } + }; + + let next_end = + (state_payload.next_index + state_payload.batch_size).min(state_payload.items.len()); + let items = state_payload.items[state_payload.next_index..next_end].to_vec(); + state_payload.next_index = next_end; + let has_more = state_payload.next_index < state_payload.items.len(); + let next_status = if has_more { + StreamStatus::Running + } else { + StreamStatus::Stopped + }; + let cursor = has_more.then(|| json!(state_payload.next_index)); + + let updated = match state + .registry + .update_stream_session_state(UpdateStreamSessionStateRequest { + session_id: &loaded.id, + current_status: loaded.status, + next_status, + cursor: cursor.as_ref(), + state: &json!(state_payload), + expires_at: Some(&add_millis( + &now, + streaming.max_session_lifetime_ms.unwrap_or(60_000), + )), + last_poll_at: Some(&now), + closed_at: matches!(next_status, StreamStatus::Stopped).then_some(now.as_str()), + }) + .await + { + Ok(session_record) => session_record, + Err(error) => return internal_jsonrpc_error(message, error), + }; + + let output = json!({ + "session_id": updated.id.as_str(), + "status": serialize_stream_status(updated.status), + "expires_at": updated.expires_at, + "summary": serde_json::from_value::(updated.state.clone()).map(|value| value.summary).unwrap_or(Value::Null), + "items": items, + "cursor": updated.cursor, + "has_more": updated.status == StreamStatus::Running, + }); + + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .poll_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "stream session polled", + status_code: None, + error_kind: None, + duration: Duration::ZERO, + request_preview: json!({ "session_id": control.session_id }), + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) +} + +async fn handle_session_stop_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + _tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: SessionControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + match state + .registry + .close_stream_session( + &StreamSessionId::new(control.session_id.clone()), + &now_rfc3339(), + ) + .await + { + Ok(()) => success_tool_response( + message, + response_mode, + &session.protocol_version, + json!({ + "session_id": control.session_id, + "status": "stopped" + }), + ), + Err(error) => internal_jsonrpc_error(message, error), + } +} + +async fn handle_async_job_start_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let operation = runtime_operation(&tool); + let request_preview = build_request_preview(&state.runtime, &operation, &arguments); + let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_config_error", + "streaming config is required for async job tools".to_owned(), + ); + }; + + let now = now_rfc3339(); + let job = AsyncJobHandle { + id: AsyncJobId::new(format!("job_{}", uuid::Uuid::now_v7().simple())), + workspace_id: tool.workspace_id.clone(), + agent_id: Some(tool.agent_id.clone()), + operation_id: tool.operation.id.clone(), + status: JobStatus::Running, + progress: json!({ "pct": 0 }), + result: None, + error: None, + expires_at: Some(add_millis(&now, 300_000)), + created_at: now.clone(), + updated_at: now.clone(), + finished_at: None, + }; + + if let Err(error) = state + .registry + .create_async_job(CreateAsyncJobRequest { job: &job }) + .await + { + return internal_jsonrpc_error(message, error); + } + + let registry = state.registry.clone(); + let tool_for_task = tool.clone(); + let arguments_for_task = arguments.clone(); + let job_id = job.id.clone(); + tokio::spawn(async move { + let runtime = RuntimeExecutor::new(); + let task_operation = runtime_operation(&tool_for_task); + let result = runtime.execute(&task_operation, &arguments_for_task).await; + let finished_at = now_rfc3339(); + + let update_result = match result { + Ok(output) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Completed, + progress: &json!({ "pct": 100 }), + result: Some(&output), + error: None, + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + Err(error) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Failed, + progress: &json!({ "pct": 100 }), + result: None, + error: Some(&json!({ + "code": runtime_error_code(&error), + "message": error.to_string() + })), + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + }; + + let _ = update_result; + }); + + let output = json!({ + "job_id": job.id.as_str(), + "status": "running", + "progress": job.progress, + }); + + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .start_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "async job started", + status_code: None, + error_kind: None, + duration: Duration::ZERO, + request_preview, + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) +} + +async fn handle_async_job_status_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + _tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: AsyncJobControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + let job = match state + .registry + .get_async_job(&AsyncJobId::new(control.job_id.clone())) + .await + { + Ok(Some(job)) => job, + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + success_tool_response( + message, + response_mode, + &session.protocol_version, + json!({ + "job_id": job.id.as_str(), + "status": serialize_job_status(job.status), + "progress": job.progress, + "updated_at": job.updated_at, + "finished_at": job.finished_at, + }), + ) +} + +async fn handle_async_job_result_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + _tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: AsyncJobControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + let job = match state + .registry + .get_async_job(&AsyncJobId::new(control.job_id.clone())) + .await + { + Ok(Some(job)) => job, + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + match job.status { + JobStatus::Completed => success_tool_response( + message, + response_mode, + &session.protocol_version, + job.result.unwrap_or(Value::Null), + ), + JobStatus::Failed => tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_failed", + job.error + .and_then(|value| value.get("message").cloned()) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "async job failed".to_owned()), + ), + JobStatus::Cancelled => tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_cancelled", + "async job was cancelled".to_owned(), + ), + _ => tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_ready", + "async job result is not ready".to_owned(), + ), + } +} + +async fn handle_async_job_cancel_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + _tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: AsyncJobControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + match state + .registry + .cancel_async_job(&AsyncJobId::new(control.job_id.clone()), &now_rfc3339()) + .await + { + Ok(()) => success_tool_response( + message, + response_mode, + &session.protocol_version, + json!({ + "job_id": control.job_id, + "status": "cancelled" + }), + ), + Err(error) => internal_jsonrpc_error(message, error), + } +} + async fn handle_initialize( state: Arc, path: &AgentRoutePath, @@ -794,6 +1548,7 @@ fn build_request_preview( } struct InvocationRecord<'a> { + tool_name: &'a str, status: InvocationStatus, level: InvocationLevel, message: &'a str, @@ -821,7 +1576,7 @@ async fn persist_invocation( source: InvocationSource::AgentToolCall, level: record.level, status: record.status, - tool_name: tool.tool_name.clone(), + tool_name: record.tool_name.to_owned(), message: record.message.to_owned(), request_id: None, status_code: record.status_code, @@ -838,6 +1593,104 @@ async fn persist_invocation( .await } +fn success_tool_response( + message: &Value, + response_mode: ResponseMode, + protocol_version: &str, + output: Value, +) -> Response { + transport_response( + StatusCode::OK, + jsonrpc_result( + request_id(message), + json!({ + "content": [ + { + "type": "text", + "text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned()) + } + ], + "structuredContent": output, + "isError": false + }), + ), + response_mode, + None, + Some(protocol_version), + ) +} + +fn tool_error_response( + message: &Value, + response_mode: ResponseMode, + protocol_version: &str, + code: &str, + error_message: String, +) -> Response { + transport_response( + StatusCode::OK, + jsonrpc_result( + request_id(message), + json!({ + "content": [ + { + "type": "text", + "text": error_message + } + ], + "structuredContent": { + "error": { + "code": code, + "message": error_message + } + }, + "isError": true + }), + ), + response_mode, + None, + Some(protocol_version), + ) +} + +fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) +} + +fn add_millis(timestamp: &str, millis: u64) -> String { + let Ok(parsed) = OffsetDateTime::parse(timestamp, &Rfc3339) else { + return timestamp.to_owned(); + }; + let delta = time::Duration::milliseconds(i64::try_from(millis).unwrap_or(i64::MAX)); + + (parsed + delta) + .format(&Rfc3339) + .unwrap_or_else(|_| timestamp.to_owned()) +} + +fn serialize_stream_status(status: StreamStatus) -> &'static str { + match status { + StreamStatus::Created => "created", + StreamStatus::Running => "running", + StreamStatus::Stopped => "stopped", + StreamStatus::Failed => "failed", + StreamStatus::Expired => "expired", + } +} + +fn serialize_job_status(status: JobStatus) -> &'static str { + match status { + JobStatus::Created => "created", + JobStatus::Running => "running", + JobStatus::Completed => "completed", + JobStatus::Failed => "failed", + JobStatus::Cancelled => "cancelled", + JobStatus::Expired => "expired", + } +} + fn runtime_error_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "schema_validation_error", @@ -936,15 +1789,189 @@ where response } -fn tool_definition(tool: &PublishedAgentTool) -> Value { +fn tool_definitions(tool: &PublishedAgentTool) -> Vec { + let mut definitions = Vec::new(); + let operation = &tool.operation; + + match operation + .execution_config + .streaming + .as_ref() + .map(|value| value.mode) + { + Some(crank_core::ExecutionMode::Session) => { + let streaming = operation + .execution_config + .streaming + .as_ref() + .expect("streaming"); + if let (Some(start), Some(poll), Some(stop)) = ( + streaming.tool_family.start_tool_name.as_ref(), + streaming.tool_family.poll_tool_name.as_ref(), + streaming.tool_family.stop_tool_name.as_ref(), + ) { + definitions.push(tool_definition( + start, + &format!("{} Start", tool.tool_title), + &format!("Start session for {}", tool.tool_description), + schema_to_json_schema(&operation.input_schema), + )); + definitions.push(tool_definition( + poll, + &format!("{} Poll", tool.tool_title), + &format!("Poll session for {}", tool.tool_description), + id_input_schema("session_id"), + )); + definitions.push(tool_definition( + stop, + &format!("{} Stop", tool.tool_title), + &format!("Stop session for {}", tool.tool_description), + id_input_schema("session_id"), + )); + } + } + Some(crank_core::ExecutionMode::AsyncJob) => { + let streaming = operation + .execution_config + .streaming + .as_ref() + .expect("streaming"); + if let (Some(start), Some(status), Some(result), Some(cancel)) = ( + streaming.tool_family.start_tool_name.as_ref(), + streaming.tool_family.status_tool_name.as_ref(), + streaming.tool_family.result_tool_name.as_ref(), + streaming.tool_family.cancel_tool_name.as_ref(), + ) { + definitions.push(tool_definition( + start, + &format!("{} Start", tool.tool_title), + &format!("Start async job for {}", tool.tool_description), + schema_to_json_schema(&operation.input_schema), + )); + definitions.push(tool_definition( + status, + &format!("{} Status", tool.tool_title), + &format!("Get job status for {}", tool.tool_description), + id_input_schema("job_id"), + )); + definitions.push(tool_definition( + result, + &format!("{} Result", tool.tool_title), + &format!("Get async job result for {}", tool.tool_description), + id_input_schema("job_id"), + )); + definitions.push(tool_definition( + cancel, + &format!("{} Cancel", tool.tool_title), + &format!("Cancel async job for {}", tool.tool_description), + id_input_schema("job_id"), + )); + } + } + _ => { + definitions.push(tool_definition( + &tool.tool_name, + &tool.tool_title, + &tool.tool_description, + schema_to_json_schema(&operation.input_schema), + )); + } + } + + definitions +} + +fn tool_definition(name: &str, title: &str, description: &str, input_schema: Value) -> Value { json!({ - "name": tool.tool_name, - "title": tool.tool_title, - "description": tool.tool_description, - "inputSchema": schema_to_json_schema(&tool.operation.input_schema) + "name": name, + "title": title, + "description": description, + "inputSchema": input_schema }) } +fn id_input_schema(field_name: &str) -> Value { + json!({ + "type": "object", + "properties": { + field_name: { + "type": "string" + } + }, + "required": [field_name] + }) +} + +fn resolve_generated_tool( + tools: &[PublishedAgentTool], + tool_name: &str, +) -> Option { + for tool in tools { + if tool.tool_name == tool_name { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::Base, + }); + } + + let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else { + continue; + }; + + match streaming.mode { + crank_core::ExecutionMode::Session => { + if streaming.tool_family.start_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::SessionStart, + }); + } + if streaming.tool_family.poll_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::SessionPoll, + }); + } + if streaming.tool_family.stop_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::SessionStop, + }); + } + } + crank_core::ExecutionMode::AsyncJob => { + if streaming.tool_family.start_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobStart, + }); + } + if streaming.tool_family.status_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobStatus, + }); + } + if streaming.tool_family.result_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobResult, + }); + } + if streaming.tool_family.cancel_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobCancel, + }); + } + } + _ => {} + } + } + + None +} + fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation { let mut operation = RuntimeOperation::from(tool.operation.clone()); operation.tool_name = tool.tool_name.clone(); diff --git a/apps/mcp-server/src/catalog.rs b/apps/mcp-server/src/catalog.rs index 3a2df58..02521b9 100644 --- a/apps/mcp-server/src/catalog.rs +++ b/apps/mcp-server/src/catalog.rs @@ -25,7 +25,6 @@ struct CatalogKey { struct CachedCatalog { loaded_at: Option, tools: Vec, - tools_by_name: HashMap, } 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, 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::>(); 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, }, ); diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index cd034f2..8c2c0b3 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -52,10 +52,11 @@ mod tests { use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ - Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, DescriptorId, - ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, Operation, - OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, + DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget, + GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey, + PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, + StreamingConfig, Target, ToolDescription, ToolFamilyConfig, TransportBehavior, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ @@ -67,6 +68,7 @@ mod tests { use sha2::{Digest, Sha256}; use sqlx::{Executor, postgres::PgPoolOptions}; use tokio::net::TcpListener; + use tokio::time::sleep; use crate::app::build_app; @@ -821,6 +823,297 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); } + #[tokio::test] + async fn exposes_and_runs_session_tool_family_via_mcp() { + let registry = test_registry().await; + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let operation = test_grpc_session_operation(&server_addr, "echo_stream_session"); + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-session").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-session", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-session"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let tools = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }), + ) + .await; + let tool_names = tools["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_owned()) + .collect::>(); + assert!(!tool_names.contains(&operation.name)); + assert!(tool_names.contains(&"echo_stream_session_start".to_owned())); + assert!(tool_names.contains(&"echo_stream_session_poll".to_owned())); + assert!(tool_names.contains(&"echo_stream_session_stop".to_owned())); + + let start_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "echo_stream_session_start", + "arguments": { + "message": "hello" + } + } + }), + ) + .await; + let session_id = start_response["result"]["structuredContent"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + start_response["result"]["structuredContent"]["status"], + json!("running") + ); + + let poll_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "echo_stream_session_poll", + "arguments": { + "session_id": session_id + } + } + }), + ) + .await; + assert_eq!( + poll_response["result"]["structuredContent"]["session_id"], + json!(session_id) + ); + assert!( + poll_response["result"]["structuredContent"]["items"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + + let stop_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "echo_stream_session_stop", + "arguments": { + "session_id": session_id + } + } + }), + ) + .await; + assert_eq!( + stop_response["result"]["structuredContent"], + json!({ + "session_id": session_id, + "status": "stopped" + }) + ); + } + + #[tokio::test] + async fn exposes_and_runs_async_job_tool_family_via_mcp() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_async_job_operation(&upstream_base_url, "crm_async_create_lead"); + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-async").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-async", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-async"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let tools = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }), + ) + .await; + let tool_names = tools["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_owned()) + .collect::>(); + assert!(!tool_names.contains(&operation.name)); + assert!(tool_names.contains(&"crm_async_create_lead_start".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_status".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_result".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_cancel".to_owned())); + + let start_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_start", + "arguments": { + "email": "user@example.com" + } + } + }), + ) + .await; + let job_id = start_response["result"]["structuredContent"]["job_id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + start_response["result"]["structuredContent"]["status"], + json!("running") + ); + + let mut status_response = Value::Null; + for _ in 0..20 { + status_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_status", + "arguments": { + "job_id": job_id + } + } + }), + ) + .await; + + if status_response["result"]["structuredContent"]["status"] == json!("completed") { + break; + } + + sleep(Duration::from_millis(25)).await; + } + + assert_eq!( + status_response["result"]["structuredContent"]["status"], + json!("completed") + ); + + let result_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_result", + "arguments": { + "job_id": job_id + } + } + }), + ) + .await; + assert_eq!( + result_response["result"]["structuredContent"], + json!({ "id": "lead_123" }) + ); + } + async fn initialize_session(client: &reqwest::Client, mcp_url: &str, api_key: &str) -> String { let initialize_response = client .post(mcp_url) @@ -926,7 +1219,9 @@ mod tests { } async fn spawn_upstream_server() -> String { - let app = Router::new().route("/crm/leads", post(create_lead)); + let app = Router::new() + .route("/crm/leads", post(create_lead)) + .route("/crm/slow-leads", post(create_slow_lead)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -1039,6 +1334,15 @@ mod tests { })) } + async fn create_slow_lead(Json(payload): Json) -> Json { + sleep(Duration::from_millis(250)).await; + + Json(json!({ + "id": "lead_123", + "email": payload["email"] + })) + } + async fn graphql_handler(Json(payload): Json) -> Json { let email = payload .get("variables") @@ -1274,6 +1578,98 @@ mod tests { } } + fn test_grpc_session_operation(server_addr: &str, name: &str) -> Operation { + let mut operation = test_grpc_operation(server_addr, name); + operation.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"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }); + operation.display_name = "Server Echo Session".to_owned(); + operation.tool_description = ToolDescription { + title: "Server Echo Session".to_owned(), + description: "Streams echo messages through a bounded session".to_owned(), + tags: vec!["grpc".to_owned(), "streaming".to_owned()], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::Session, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(1_000), + poll_interval_ms: Some(250), + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: Some(5_000), + max_session_lifetime_ms: Some(60_000), + max_items: Some(1), + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: Some("$.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig { + start_tool_name: Some(format!("{name}_start")), + poll_tool_name: Some(format!("{name}_poll")), + stop_tool_name: Some(format!("{name}_stop")), + status_tool_name: None, + result_tool_name: None, + cancel_tool_name: None, + }, + }); + operation + } + + fn test_rest_async_job_operation(base_url: &str, name: &str) -> Operation { + let mut operation = test_operation(base_url, name); + operation.display_name = "Create Lead Async".to_owned(); + operation.tool_description = ToolDescription { + title: "Create Lead Async".to_owned(), + description: "Creates a CRM lead through an async job tool family".to_owned(), + tags: vec!["rest".to_owned(), "async_job".to_owned()], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::AsyncJob, + transport_behavior: TransportBehavior::DeferredResult, + window_duration_ms: None, + poll_interval_ms: Some(250), + upstream_timeout_ms: Some(2_000), + idle_timeout_ms: None, + max_session_lifetime_ms: Some(300_000), + max_items: None, + max_bytes: Some(16 * 1024), + 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 { + start_tool_name: Some(format!("{name}_start")), + poll_tool_name: None, + stop_tool_name: None, + status_tool_name: Some(format!("{name}_status")), + result_tool_name: Some(format!("{name}_result")), + cancel_tool_name: Some(format!("{name}_cancel")), + }, + }); + operation + } + fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index c4b2748..d73ba6f 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -74,6 +74,40 @@ impl RuntimeExecutor { crate::aggregation::collect_window_result(&adapter_response.body, streaming) } + pub async fn execute_session_seed( + &self, + operation: &RuntimeOperation, + input: &Value, + ) -> Result { + let Some(streaming) = operation.execution_config.streaming.as_ref() else { + return Err(RuntimeError::MissingStreamingConfig { + operation_id: operation.operation_id.as_str().to_owned(), + }); + }; + + if streaming.mode != ExecutionMode::Session { + return Err(RuntimeError::UnsupportedExecutionMode { + operation_id: operation.operation_id.as_str().to_owned(), + mode: streaming.mode, + }); + } + + let batch_size = streaming.max_items.unwrap_or(10).max(1); + let seed_limit = batch_size.saturating_mul(4); + let mut seeded_operation = operation.clone(); + if let Some(config) = seeded_operation.execution_config.streaming.as_mut() { + config.mode = ExecutionMode::Window; + config.window_duration_ms = config + .window_duration_ms + .or(config.poll_interval_ms) + .or(config.upstream_timeout_ms) + .or(Some(operation.execution_config.timeout_ms)); + config.max_items = Some(seed_limit); + } + + self.execute_window(&seeded_operation, input).await + } + pub fn prepare_request( &self, operation: &RuntimeOperation, From b40daf4f54f869ccd3bf6fb6e286f2f5312673c4 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 12:04:49 +0300 Subject: [PATCH 13/38] feat: add streaming ui configuration --- TASKS.md | 21 +- apps/admin-api/src/app.rs | 17 +- apps/admin-api/src/routes.rs | 1 + apps/admin-api/src/routes/streaming.rs | 137 +++++++++ apps/admin-api/src/service.rs | 395 ++++++++++++++++++++++++- apps/ui/css/pages.css | 171 +++++++++++ apps/ui/css/wizard.css | 32 ++ apps/ui/html/async-jobs.html | 112 +++++++ apps/ui/html/stream-sessions.html | 117 ++++++++ apps/ui/html/wizard/index.html | 2 + apps/ui/html/wizard/step5.html | 158 ++++++++++ apps/ui/js/api.js | 24 ++ apps/ui/js/async-jobs.js | 181 +++++++++++ apps/ui/js/config.js | 2 + apps/ui/js/i18n.js | 234 +++++++++++++++ apps/ui/js/stream-sessions.js | 166 +++++++++++ apps/ui/js/stream-test-run.js | 47 +++ apps/ui/js/streaming-form.js | 127 ++++++++ apps/ui/js/wizard.js | 314 ++++++++++++++++++++ apps/ui/nginx.conf | 16 + 20 files changed, 2254 insertions(+), 20 deletions(-) create mode 100644 apps/admin-api/src/routes/streaming.rs create mode 100644 apps/ui/html/async-jobs.html create mode 100644 apps/ui/html/stream-sessions.html create mode 100644 apps/ui/js/async-jobs.js create mode 100644 apps/ui/js/stream-sessions.js create mode 100644 apps/ui/js/stream-test-run.js create mode 100644 apps/ui/js/streaming-form.js diff --git a/TASKS.md b/TASKS.md index 5038a98..f19acb4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,25 @@ ## Current -### `feat/session-and-job-tools` +### `feat/streaming-e2e` -Status: completed +Status: pending DoD: -- session and async_job tool families are published in the MCP tool catalog -- generated `start/poll/stop` and `start/status/result/cancel` tool calls work through JSON-RPC -- stream sessions and async jobs are persisted with explicit status transitions -- session and async_job tool calls are logged through observability -- MCP integration tests cover `session start -> poll -> stop` and `async job start -> status -> result` +- local fixture stack includes: + - REST SSE server + - gRPC server-streaming server + - WebSocket event server +- Playwright covers: + - REST window flow + - gRPC streaming flow + - session tool flow + - async job flow +- CI runs streaming e2e against the local fixture stack ## Next -- `feat/streaming-ui-config` +- `feat/websocket-upstream-adapter` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index b975275..2a7ea31 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -28,6 +28,10 @@ use crate::{ upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_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}, }, state::AppState, @@ -133,7 +137,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)) diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index f5b1e3b..f53ac88 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -5,6 +5,7 @@ pub mod auth_profiles; pub mod observability; pub mod operations; pub mod secrets; +pub mod streaming; pub mod workspaces; use axum::Json; diff --git a/apps/admin-api/src/routes/streaming.rs b/apps/admin-api/src/routes/streaming.rs new file mode 100644 index 0000000..fae361f --- /dev/null +++ b/apps/admin-api/src/routes/streaming.rs @@ -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, + State(state): State, +) -> Result, ApiError> { + Ok(Json(json!({ + "items": state.service.list_protocol_capabilities().await + }))) +} + +pub async fn list_stream_sessions( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + Query(query): Query, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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))) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index ddb9c7e..afaeab3 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -7,26 +7,27 @@ use base64::{ }; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ - Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind, - AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus, - InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, - InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId, - OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, - Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, Target, UsagePeriod, - UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus, + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, + AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExecutionMode, + ExportMode, GeneratedDraft, GeneratedDraftStatus, InvitationId, InvitationStatus, + InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, + InvocationStatus, JobStatus, MembershipRole, OperationId, OperationStatus, PlatformApiKey, + PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, SampleId, Samples, + Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target, + TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_proto::{ProtoService, services_from_descriptor_set_bytes}; use crank_registry::{ - AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, + AgentSummary, AgentVersionRecord, AsyncJobFilter, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata, - OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, + OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, - UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, + SaveSampleMetadataRequest, StreamSessionFilter, UpdateWorkspaceRequest, UsageAgentBreakdown, + UsageBucket, UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, }; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; @@ -303,6 +304,78 @@ pub struct UsageOverviewResponse { pub agents: Vec, } +#[derive(Clone, Debug, Serialize)] +pub struct ProtocolCapabilityView { + pub protocol: Protocol, + pub supports_execution_modes: Vec, + pub supports_transport_behaviors: Vec, + pub supports_auth_kinds: Vec, + pub supports_upload_artifacts: Vec, + pub supports_cursor_path: bool, + pub supports_done_path: bool, + pub supports_aggregation_mode: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct StreamSessionsQuery { + pub operation_id: Option, + pub agent_id: Option, + pub status: Option, + pub mode: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionSummaryView { + pub id: String, + pub operation_id: String, + pub agent_id: Option, + pub protocol: Protocol, + pub mode: ExecutionMode, + pub status: StreamStatus, + pub expires_at: String, + pub last_poll_at: Option, + pub created_at: String, + pub closed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionDetailView { + #[serde(flatten)] + pub summary: StreamSessionSummaryView, + pub cursor: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AsyncJobsQuery { + pub operation_id: Option, + pub agent_id: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobSummaryView { + pub id: String, + pub operation_id: String, + pub agent_id: Option, + pub status: JobStatus, + pub progress: Value, + pub created_at: String, + pub updated_at: String, + pub finished_at: Option, + pub expires_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobDetailView { + #[serde(flatten)] + pub summary: AsyncJobSummaryView, + pub error: Option, +} + #[derive(Clone, Debug, Deserialize)] pub struct GenerateDraftPayload { #[serde(default)] @@ -1273,6 +1346,185 @@ impl AdminService { }) } + pub async fn list_protocol_capabilities(&self) -> Vec { + [Protocol::Rest, Protocol::Graphql, Protocol::Grpc] + .into_iter() + .map(protocol_capability_view) + .collect() + } + + #[instrument(skip(self))] + pub async fn list_stream_sessions( + &self, + workspace_id: &WorkspaceId, + query: StreamSessionsQuery, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + let operation_id = query.operation_id.as_deref().map(OperationId::new); + let agent_id = query.agent_id.as_deref().map(AgentId::new); + let limit = query.page_size.unwrap_or(20).max(1); + let page = self + .registry + .list_stream_sessions(StreamSessionFilter { + workspace_id, + agent_id: agent_id.as_ref(), + operation_id: operation_id.as_ref(), + status: query.status, + mode: query.mode, + limit, + }) + .await?; + + Ok(Page { + items: page + .items + .into_iter() + .map(stream_session_summary_view) + .collect(), + total: page.total, + }) + } + + #[instrument(skip(self))] + pub async fn get_stream_session( + &self, + workspace_id: &WorkspaceId, + session_id: &crank_core::StreamSessionId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let session = self + .registry + .get_stream_session(session_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "stream session {} was not found", + session_id.as_str() + )) + })?; + ensure_stream_session_workspace(&session, workspace_id)?; + + Ok(stream_session_detail_view(session)) + } + + #[instrument(skip(self))] + pub async fn stop_stream_session( + &self, + workspace_id: &WorkspaceId, + session_id: &crank_core::StreamSessionId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let session = self + .registry + .get_stream_session(session_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "stream session {} was not found", + session_id.as_str() + )) + })?; + ensure_stream_session_workspace(&session, workspace_id)?; + self.registry + .close_stream_session(session_id, &now_string()?) + .await?; + let updated = self + .registry + .get_stream_session(session_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "stream session {} was not found", + session_id.as_str() + )) + })?; + + Ok(stream_session_detail_view(updated)) + } + + #[instrument(skip(self))] + pub async fn list_async_jobs( + &self, + workspace_id: &WorkspaceId, + query: AsyncJobsQuery, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + let operation_id = query.operation_id.as_deref().map(OperationId::new); + let agent_id = query.agent_id.as_deref().map(AgentId::new); + let limit = query.page_size.unwrap_or(20).max(1); + let page = self + .registry + .list_async_jobs(AsyncJobFilter { + workspace_id, + agent_id: agent_id.as_ref(), + operation_id: operation_id.as_ref(), + status: query.status, + limit, + }) + .await?; + + Ok(Page { + items: page.items.into_iter().map(async_job_summary_view).collect(), + total: page.total, + }) + } + + #[instrument(skip(self))] + pub async fn get_async_job( + &self, + workspace_id: &WorkspaceId, + job_id: &crank_core::AsyncJobId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + ensure_async_job_workspace(&job, workspace_id)?; + + Ok(async_job_detail_view(job)) + } + + #[instrument(skip(self))] + pub async fn cancel_async_job( + &self, + workspace_id: &WorkspaceId, + job_id: &crank_core::AsyncJobId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + ensure_async_job_workspace(&job, workspace_id)?; + self.registry + .cancel_async_job(job_id, &now_string()?) + .await?; + let updated = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + + Ok(async_job_detail_view(updated)) + } + + #[instrument(skip(self))] + pub async fn get_async_job_result( + &self, + workspace_id: &WorkspaceId, + job_id: &crank_core::AsyncJobId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + ensure_async_job_workspace(&job, workspace_id)?; + + match job.status { + JobStatus::Completed => Ok(job.result.unwrap_or(Value::Null)), + JobStatus::Failed => Err(ApiError::conflict("async job failed")), + JobStatus::Cancelled => Err(ApiError::conflict("async job was cancelled")), + _ => Err(ApiError::conflict("async job result is not ready")), + } + } + pub async fn list_operations( &self, workspace_id: &WorkspaceId, @@ -3762,6 +4014,127 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { } } +fn protocol_capability_view(protocol: Protocol) -> ProtocolCapabilityView { + let supports_execution_modes = [ + ExecutionMode::Unary, + ExecutionMode::Window, + ExecutionMode::Session, + ExecutionMode::AsyncJob, + ] + .into_iter() + .filter(|mode| protocol.supports_execution_mode(*mode)) + .collect(); + let supports_transport_behaviors = [ + TransportBehavior::RequestResponse, + TransportBehavior::ServerStream, + TransportBehavior::StatefulSession, + TransportBehavior::DeferredResult, + ] + .into_iter() + .filter(|behavior| protocol.supports_transport_behavior(*behavior)) + .collect(); + let supports_upload_artifacts = match protocol { + Protocol::Rest | Protocol::Graphql => Vec::new(), + Protocol::Grpc => vec!["proto".to_owned(), "descriptor_set".to_owned()], + }; + + ProtocolCapabilityView { + protocol, + supports_execution_modes, + supports_transport_behaviors, + supports_auth_kinds: vec![ + "none".to_owned(), + "bearer".to_owned(), + "basic".to_owned(), + "api_key_header".to_owned(), + "api_key_query".to_owned(), + ], + supports_upload_artifacts, + supports_cursor_path: !matches!(protocol, Protocol::Graphql), + supports_done_path: !matches!(protocol, Protocol::Graphql), + supports_aggregation_mode: vec![ + AggregationMode::RawItems, + AggregationMode::SummaryOnly, + AggregationMode::SummaryPlusSamples, + AggregationMode::Stats, + AggregationMode::LatestState, + ], + } +} + +fn stream_session_summary_view(session: StreamSession) -> StreamSessionSummaryView { + StreamSessionSummaryView { + id: session.id.as_str().to_owned(), + operation_id: session.operation_id.as_str().to_owned(), + agent_id: session.agent_id.map(|value| value.as_str().to_owned()), + protocol: session.protocol, + mode: session.mode, + status: session.status, + expires_at: session.expires_at, + last_poll_at: session.last_poll_at, + created_at: session.created_at, + closed_at: session.closed_at, + } +} + +fn stream_session_detail_view(session: StreamSession) -> StreamSessionDetailView { + StreamSessionDetailView { + cursor: session.cursor.clone(), + summary: stream_session_summary_view(session), + } +} + +fn async_job_summary_view(job: AsyncJobHandle) -> AsyncJobSummaryView { + AsyncJobSummaryView { + id: job.id.as_str().to_owned(), + operation_id: job.operation_id.as_str().to_owned(), + agent_id: job.agent_id.map(|value| value.as_str().to_owned()), + status: job.status, + progress: job.progress, + created_at: job.created_at, + updated_at: job.updated_at, + finished_at: job.finished_at, + expires_at: job.expires_at, + } +} + +fn async_job_detail_view(job: AsyncJobHandle) -> AsyncJobDetailView { + AsyncJobDetailView { + error: job.error.clone(), + summary: async_job_summary_view(job), + } +} + +fn ensure_stream_session_workspace( + session: &StreamSession, + workspace_id: &WorkspaceId, +) -> Result<(), ApiError> { + if &session.workspace_id == workspace_id { + return Ok(()); + } + + Err(ApiError::not_found(format!( + "stream session {} was not found in workspace {}", + session.id.as_str(), + workspace_id.as_str() + ))) +} + +fn ensure_async_job_workspace( + job: &AsyncJobHandle, + workspace_id: &WorkspaceId, +) -> Result<(), ApiError> { + if &job.workspace_id == workspace_id { + return Ok(()); + } + + Err(ApiError::not_found(format!( + "async job {} was not found in workspace {}", + job.id.as_str(), + workspace_id.as_str() + ))) +} + fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> { let now = OffsetDateTime::now_utc(); let (start, bucket) = match period { diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css index c7316a7..22338dc 100644 --- a/apps/ui/css/pages.css +++ b/apps/ui/css/pages.css @@ -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; +} diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index f99a4b3..ec10830 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -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 ══════════════════════════════════════════════════ */ diff --git a/apps/ui/html/async-jobs.html b/apps/ui/html/async-jobs.html new file mode 100644 index 0000000..401ab4a --- /dev/null +++ b/apps/ui/html/async-jobs.html @@ -0,0 +1,112 @@ + + + + + + + Crank — Async Jobs + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + + + diff --git a/apps/ui/html/stream-sessions.html b/apps/ui/html/stream-sessions.html new file mode 100644 index 0000000..223fb44 --- /dev/null +++ b/apps/ui/html/stream-sessions.html @@ -0,0 +1,117 @@ + + + + + + + Crank — Stream Sessions + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ + + + + diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 9f973b4..38e864c 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -235,6 +235,8 @@ + + diff --git a/apps/ui/html/wizard/step5.html b/apps/ui/html/wizard/step5.html index ffb37a7..96168f9 100644 --- a/apps/ui/html/wizard/step5.html +++ b/apps/ui/html/wizard/step5.html @@ -87,6 +87,164 @@ tls: +
+
+
+ + + + +
+
+
Streaming execution
+
Bounded stream, session and async job settings for MCP tool families.
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Unary keeps the existing request-response model. Other modes create bounded stream-aware MCP tools.
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+
Live validation and publishing
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 3ddd516..c5907f9 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -332,6 +332,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) diff --git a/apps/ui/js/async-jobs.js b/apps/ui/js/async-jobs.js new file mode 100644 index 0000000..aa17190 --- /dev/null +++ b/apps/ui/js/async-jobs.js @@ -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 = '
' + title + '
' + body + '
'; + } + + 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 [ + '
', + '
', + '
', + '
' + item.id + '
', + '
' + tKey('async_jobs.operation', { operation: item.operation_id }) + '
', + '
', + ' ' + statusLabel + '', + '
', + '
', + '
', + item.status === 'created' || item.status === 'running' + ? ' ' + : '', + ' ', + '
', + '
', + '
', + '
' + tKey('async_jobs.meta.created') + '
' + formatDateTime(item.created_at) + '
', + '
' + tKey('async_jobs.meta.updated') + '
' + formatDateTime(item.updated_at) + '
', + '
' + tKey('async_jobs.meta.finished') + '
' + formatDateTime(item.finished_at) + '
', + '
' + tKey('async_jobs.meta.expires') + '
' + formatDateTime(item.expires_at) + '
', + '
', + open + ? '
' + tKey('async_jobs.progress') + '
' + formatJson(item.progress) + '
' + : '', + open + ? '
' + tKey('async_jobs.error_preview') + '
' + formatJson(item.error) + '
' + : '', + open && (item.status === 'completed' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'expired') + ? '
' + tKey('async_jobs.result') + '
' + tKey('async_jobs.result_loading') + '
' + : '', + '
' + ].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(); +}); diff --git a/apps/ui/js/config.js b/apps/ui/js/config.js index 699747c..1467fff 100644 --- a/apps/ui/js/config.js +++ b/apps/ui/js/config.js @@ -8,6 +8,8 @@ apiKeys: '/api-keys', logs: '/logs', usage: '/usage', + streamSessions: '/stream-sessions', + asyncJobs: '/async-jobs', settings: '/settings', workspaceSetup: '/workspace-setup', wizard: '/wizard/' diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 7d9f7e3..6f8cf8b 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -1569,6 +1569,240 @@ var TRANSLATIONS = { } }; +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; diff --git a/apps/ui/js/stream-sessions.js b/apps/ui/js/stream-sessions.js new file mode 100644 index 0000000..57e7b0d --- /dev/null +++ b/apps/ui/js/stream-sessions.js @@ -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 = '
' + title + '
' + body + '
'; + } + + 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 [ + '
', + '
', + '
', + '
' + item.id + '
', + '
' + tKey('stream_sessions.operation', { operation: item.operation_id }) + '
', + '
', + ' ' + statusLabel + '', + ' ' + item.mode + '', + ' ' + item.protocol + '', + '
', + '
', + '
', + item.status === 'created' || item.status === 'running' + ? ' ' + : '', + ' ', + '
', + '
', + '
', + '
' + tKey('stream_sessions.meta.created') + '
' + formatDateTime(item.created_at) + '
', + '
' + tKey('stream_sessions.meta.last_poll') + '
' + formatDateTime(item.last_poll_at) + '
', + '
' + tKey('stream_sessions.meta.expires') + '
' + formatDateTime(item.expires_at) + '
', + '
' + tKey('stream_sessions.meta.agent') + '
' + (item.agent_id || '—') + '
', + '
', + open + ? '
' + tKey('stream_sessions.cursor') + '
' + formatJson(item.cursor) + '
' + : '', + '
' + ].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(); +}); diff --git a/apps/ui/js/stream-test-run.js b/apps/ui/js/stream-test-run.js new file mode 100644 index 0000000..9a8e7cd --- /dev/null +++ b/apps/ui/js/stream-test-run.js @@ -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, + }; +}()); diff --git a/apps/ui/js/streaming-form.js b/apps/ui/js/streaming-form.js new file mode 100644 index 0000000..17da8c8 --- /dev/null +++ b/apps/ui/js/streaming-form.js @@ -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, + }; +}()); diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index eb95b9b..74a80e1 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -10,6 +10,7 @@ var wizardProtoUpload = null; var wizardDescriptorSetUpload = null; var wizardTestResponsePreview = null; var grpcDescriptorServices = []; +var wizardProtocolCapabilities = null; function tKey(key) { return typeof t === 'function' ? t(key) : key; } @@ -17,6 +18,48 @@ function tfKey(key, vars) { return typeof tf === 'function' ? tf(key, vars) : key; } +function defaultProtocolCapabilities() { + return { + rest: { + supports_execution_modes: ['unary', 'window', 'session', 'async_job'], + supports_transport_behaviors: ['request_response', 'server_stream', 'deferred_result'], + }, + graphql: { + supports_execution_modes: ['unary'], + supports_transport_behaviors: ['request_response'], + }, + grpc: { + supports_execution_modes: ['unary', 'window', 'session', 'async_job'], + supports_transport_behaviors: ['request_response', 'server_stream'], + }, + }; +} + +function currentProtocolCapabilities() { + var capabilities = wizardProtocolCapabilities || defaultProtocolCapabilities(); + return capabilities[wizardProtocol] || capabilities.rest; +} + +async function loadProtocolCapabilities() { + if (!wizardWorkspaceId || !window.CrankApi || typeof window.CrankApi.getProtocolCapabilities !== 'function') { + wizardProtocolCapabilities = defaultProtocolCapabilities(); + return wizardProtocolCapabilities; + } + + try { + var response = await window.CrankApi.getProtocolCapabilities(wizardWorkspaceId); + var mapped = {}; + (response.items || []).forEach(function(item) { + mapped[item.protocol] = item; + }); + wizardProtocolCapabilities = mapped; + } catch (_error) { + wizardProtocolCapabilities = defaultProtocolCapabilities(); + } + + return wizardProtocolCapabilities; +} + /* ── Dynamic step loading ── */ function _stepFile(n) { return (n === 3) ? 'step3-' + wizardProtocol + '.html' : 'step' + n + '.html'; @@ -222,10 +265,12 @@ document.addEventListener('DOMContentLoaded', async function() { await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; wizardWorkspaceId = workspace ? workspace.id : null; + await loadProtocolCapabilities(); await loadWizardPanels([1, 2, 3, 4, 5]); bindProtocolCards(); bindWizardLiveActions(); + bindStreamingConfigControls(); var params = new URLSearchParams(window.location.search); if (params.get('mode') === 'edit' && params.get('operationId')) { @@ -1092,6 +1137,12 @@ function buildMappingSet(rawValue, mode) { } function parseExecutionConfig(text) { + if (window.CrankStreamingForm && typeof window.CrankStreamingForm.validateStreamingConfig === 'function') { + var validation = window.CrankStreamingForm.validateStreamingConfig([currentProtocolCapabilities()]); + if (!validation.valid) { + throw new Error(validation.errors[0]); + } + } var value = parseStructuredText(text); var retry = value.retry || value.retry_policy || null; var headers = value.headers && typeof value.headers === 'object' ? value.headers : {}; @@ -1113,6 +1164,8 @@ function parseExecutionConfig(text) { config.protocol_options = { grpc: { use_tls: useTls } }; } + config.streaming = collectStreamingConfig(); + return config; } @@ -1236,6 +1289,264 @@ function parseHeaderMap(text) { return value && typeof value === 'object' ? value : {}; } +function numericValueOrNull(id) { + var value = textValue(id); + if (!value) return null; + var parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function stringValueOrNull(id) { + var value = textValue(id); + return value ? value : null; +} + +function textareaLines(id) { + var value = textValue(id); + if (!value) return []; + return value + .split(/\r?\n/) + .map(function(line) { return line.trim(); }) + .filter(Boolean); +} + +function selectedStreamingMode() { + var element = document.getElementById('streaming-mode'); + return element ? element.value : 'unary'; +} + +function transportOptionsForMode(mode) { + var capabilities = currentProtocolCapabilities(); + var behaviors = (capabilities.supports_transport_behaviors || []).slice(); + if (mode === 'unary') return behaviors.filter(function(value) { return value === 'request_response'; }); + if (mode === 'window') return behaviors.filter(function(value) { + return value === 'request_response' || value === 'server_stream'; + }); + if (mode === 'session') return behaviors.filter(function(value) { + return value === 'server_stream' || value === 'stateful_session'; + }); + if (mode === 'async_job') return behaviors.filter(function(value) { + return value === 'deferred_result'; + }); + return behaviors; +} + +function modeOptionsForProtocol() { + var capabilities = currentProtocolCapabilities(); + return (capabilities.supports_execution_modes || ['unary']).filter(function(mode) { + return transportOptionsForMode(mode).length > 0 || mode === 'unary'; + }); +} + +function updateStreamingTransportOptions(preferredValue) { + var element = document.getElementById('streaming-transport-behavior'); + if (!element) return; + var options = transportOptionsForMode(selectedStreamingMode()); + element.innerHTML = ''; + options.forEach(function(value) { + var option = document.createElement('option'); + option.value = value; + option.textContent = tKey('wizard.streaming.transport.' + value); + element.appendChild(option); + }); + if (preferredValue && options.indexOf(preferredValue) >= 0) { + element.value = preferredValue; + } else if (options.length) { + element.value = options[0]; + } +} + +function updateStreamingModeOptions(preferredValue) { + var element = document.getElementById('streaming-mode'); + if (!element) return; + var options = modeOptionsForProtocol(); + element.innerHTML = ''; + options.forEach(function(value) { + var option = document.createElement('option'); + option.value = value; + option.textContent = tKey('wizard.streaming.mode.' + value); + element.appendChild(option); + }); + if (preferredValue && options.indexOf(preferredValue) >= 0) { + element.value = preferredValue; + } else { + element.value = options.indexOf('unary') >= 0 ? 'unary' : (options[0] || 'unary'); + } + updateStreamingTransportOptions(); +} + +function ensureStreamingToolFamilyDefaults(mode) { + var base = textValue('tool-name') || 'stream_tool'; + if (mode === 'session') { + if (!textValue('streaming-start-tool-name')) setValue('streaming-start-tool-name', base + '_start'); + if (!textValue('streaming-poll-tool-name')) setValue('streaming-poll-tool-name', base + '_poll'); + if (!textValue('streaming-stop-tool-name')) setValue('streaming-stop-tool-name', base + '_stop'); + } + if (mode === 'async_job') { + if (!textValue('streaming-start-tool-name')) setValue('streaming-start-tool-name', base + '_start'); + if (!textValue('streaming-status-tool-name')) setValue('streaming-status-tool-name', base + '_status'); + if (!textValue('streaming-result-tool-name')) setValue('streaming-result-tool-name', base + '_result'); + if (!textValue('streaming-cancel-tool-name')) setValue('streaming-cancel-tool-name', base + '_cancel'); + } +} + +function updateStreamingConfigVisibility() { + var mode = selectedStreamingMode(); + var card = document.getElementById('wizard-streaming-config-card'); + var help = document.getElementById('streaming-mode-help'); + var toolFamily = document.getElementById('streaming-tool-family-block'); + var asyncRow = document.getElementById('streaming-async-tool-family-row'); + if (!card) return; + + var isUnary = mode === 'unary'; + var isWindow = mode === 'window'; + var isSession = mode === 'session'; + var isAsyncJob = mode === 'async_job'; + + [ + 'streaming-window-duration-ms', + 'streaming-poll-interval-ms', + 'streaming-upstream-timeout-ms', + 'streaming-idle-timeout-ms', + 'streaming-session-lifetime-ms', + 'streaming-max-items', + 'streaming-max-bytes', + 'streaming-max-field-length', + 'streaming-sampling-rate', + 'streaming-items-path', + 'streaming-summary-path', + 'streaming-done-path', + 'streaming-cursor-path', + 'streaming-status-path', + 'streaming-redacted-paths', + 'streaming-truncate-item-fields', + 'streaming-drop-duplicates' + ].forEach(function(id) { + var node = document.getElementById(id); + if (!node) return; + var group = node.closest('.form-group') || node.closest('.checkbox-pill'); + if (group) { + group.style.display = isUnary ? 'none' : ''; + } + }); + + if (help) { + help.textContent = isUnary + ? tKey('wizard.streaming.help.unary') + : (isSession + ? tKey('wizard.streaming.help.session') + : (isAsyncJob + ? tKey('wizard.streaming.help.async_job') + : tKey('wizard.streaming.help.window'))); + } + + if (toolFamily) toolFamily.style.display = (isSession || isAsyncJob) ? '' : 'none'; + if (asyncRow) asyncRow.style.display = isAsyncJob ? '' : 'none'; + + ensureStreamingToolFamilyDefaults(mode); +} + +function bindStreamingConfigControls() { + updateStreamingModeOptions(); + updateStreamingConfigVisibility(); + + var mode = document.getElementById('streaming-mode'); + if (mode) { + mode.addEventListener('change', function() { + updateStreamingTransportOptions(); + updateStreamingConfigVisibility(); + }); + } +} + +function collectStreamingConfig() { + var mode = selectedStreamingMode(); + if (mode === 'unary') return null; + + var config = { + mode: mode, + transport_behavior: stringValueOrNull('streaming-transport-behavior') || 'request_response', + window_duration_ms: numericValueOrNull('streaming-window-duration-ms'), + poll_interval_ms: numericValueOrNull('streaming-poll-interval-ms'), + upstream_timeout_ms: numericValueOrNull('streaming-upstream-timeout-ms'), + idle_timeout_ms: numericValueOrNull('streaming-idle-timeout-ms'), + max_session_lifetime_ms: numericValueOrNull('streaming-session-lifetime-ms'), + max_items: numericValueOrNull('streaming-max-items'), + max_bytes: numericValueOrNull('streaming-max-bytes'), + aggregation_mode: stringValueOrNull('streaming-aggregation-mode') || 'summary_plus_samples', + summary_path: stringValueOrNull('streaming-summary-path'), + items_path: stringValueOrNull('streaming-items-path'), + cursor_path: stringValueOrNull('streaming-cursor-path'), + status_path: stringValueOrNull('streaming-status-path'), + done_path: stringValueOrNull('streaming-done-path'), + redacted_paths: textareaLines('streaming-redacted-paths'), + truncate_item_fields: !!document.getElementById('streaming-truncate-item-fields').checked, + max_field_length: numericValueOrNull('streaming-max-field-length'), + drop_duplicates: !!document.getElementById('streaming-drop-duplicates').checked, + sampling_rate: (function() { + var value = textValue('streaming-sampling-rate'); + if (!value) return null; + var parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + }()), + tool_family: {}, + }; + + if (mode === 'session') { + config.tool_family.start_tool_name = stringValueOrNull('streaming-start-tool-name'); + config.tool_family.poll_tool_name = stringValueOrNull('streaming-poll-tool-name'); + config.tool_family.stop_tool_name = stringValueOrNull('streaming-stop-tool-name'); + } else if (mode === 'async_job') { + config.tool_family.start_tool_name = stringValueOrNull('streaming-start-tool-name'); + config.tool_family.status_tool_name = stringValueOrNull('streaming-status-tool-name'); + config.tool_family.result_tool_name = stringValueOrNull('streaming-result-tool-name'); + config.tool_family.cancel_tool_name = stringValueOrNull('streaming-cancel-tool-name'); + } + + Object.keys(config).forEach(function(key) { + if (config[key] === null) delete config[key]; + }); + if (!config.redacted_paths.length) delete config.redacted_paths; + + return config; +} + +function applyStreamingConfig(streaming) { + updateStreamingModeOptions(streaming && streaming.mode ? streaming.mode : 'unary'); + if (!streaming) { + updateStreamingConfigVisibility(); + return; + } + + setValue('streaming-window-duration-ms', streaming.window_duration_ms || ''); + setValue('streaming-poll-interval-ms', streaming.poll_interval_ms || ''); + setValue('streaming-upstream-timeout-ms', streaming.upstream_timeout_ms || ''); + setValue('streaming-idle-timeout-ms', streaming.idle_timeout_ms || ''); + setValue('streaming-session-lifetime-ms', streaming.max_session_lifetime_ms || ''); + setValue('streaming-max-items', streaming.max_items || ''); + setValue('streaming-max-bytes', streaming.max_bytes || ''); + setValue('streaming-max-field-length', streaming.max_field_length || ''); + setValue('streaming-sampling-rate', streaming.sampling_rate || ''); + setValue('streaming-items-path', streaming.items_path || ''); + setValue('streaming-summary-path', streaming.summary_path || ''); + setValue('streaming-done-path', streaming.done_path || ''); + setValue('streaming-cursor-path', streaming.cursor_path || ''); + setValue('streaming-status-path', streaming.status_path || ''); + setValue('streaming-redacted-paths', (streaming.redacted_paths || []).join('\n')); + document.getElementById('streaming-truncate-item-fields').checked = !!streaming.truncate_item_fields; + document.getElementById('streaming-drop-duplicates').checked = !!streaming.drop_duplicates; + updateStreamingTransportOptions(streaming.transport_behavior); + setValue('streaming-start-tool-name', streaming.tool_family && streaming.tool_family.start_tool_name ? streaming.tool_family.start_tool_name : ''); + setValue('streaming-poll-tool-name', streaming.tool_family && streaming.tool_family.poll_tool_name ? streaming.tool_family.poll_tool_name : ''); + setValue('streaming-stop-tool-name', streaming.tool_family && streaming.tool_family.stop_tool_name ? streaming.tool_family.stop_tool_name : ''); + setValue('streaming-status-tool-name', streaming.tool_family && streaming.tool_family.status_tool_name ? streaming.tool_family.status_tool_name : ''); + setValue('streaming-result-tool-name', streaming.tool_family && streaming.tool_family.result_tool_name ? streaming.tool_family.result_tool_name : ''); + setValue('streaming-cancel-tool-name', streaming.tool_family && streaming.tool_family.cancel_tool_name ? streaming.tool_family.cancel_tool_name : ''); + var aggregation = document.getElementById('streaming-aggregation-mode'); + if (aggregation && streaming.aggregation_mode) aggregation.value = streaming.aggregation_mode; + updateStreamingConfigVisibility(); +} + function buildToolDescription() { return { title: textValue('tool-title') || textValue('tool-display-name') || textValue('tool-name'), @@ -1439,6 +1750,7 @@ function prefillWizardFromEdit(detail, versionDocument) { setValue('tool-input-mapping', mappingSetToEditorValue(versionDocument.input_mapping, 'input', detail.protocol)); setValue('tool-output-mapping', mappingSetToEditorValue(versionDocument.output_mapping, 'output', detail.protocol)); setValue('tool-exec-config', executionConfigToEditorValue(versionDocument.execution_config)); + applyStreamingConfig(versionDocument.execution_config ? versionDocument.execution_config.streaming : null); var toolNameInput = document.getElementById('tool-name'); if (toolNameInput) toolNameInput.disabled = true; @@ -1525,6 +1837,8 @@ async function runWizardLiveAction(button, busyLabel, handler) { function updateWizardProtocolVisibility() { var grpcTools = document.getElementById('wizard-grpc-live-tools'); if (grpcTools) grpcTools.style.display = wizardProtocol === 'grpc' ? '' : 'none'; + updateStreamingModeOptions(); + updateStreamingConfigVisibility(); } function showWizardLiveStatus(title, text, isError) { diff --git a/apps/ui/nginx.conf b/apps/ui/nginx.conf index a14db78..744d223 100644 --- a/apps/ui/nginx.conf +++ b/apps/ui/nginx.conf @@ -34,6 +34,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; } @@ -75,6 +83,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; } From 4953272bcfbb8055531905811b6c65def6f7583e Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 12:12:21 +0300 Subject: [PATCH 14/38] feat: scaffold streaming e2e fixtures --- TASKS.md | 2 +- apps/ui/scripts/playwright-stack.sh | 51 ++++++ apps/ui/scripts/playwright-ui-server.js | 14 ++ apps/ui/scripts/stream-fixture-server.js | 145 ++++++++++++++++++ .../examples/stream_fixture.rs | 22 +++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 apps/ui/scripts/stream-fixture-server.js create mode 100644 crates/crank-adapter-grpc/examples/stream_fixture.rs diff --git a/TASKS.md b/TASKS.md index f19acb4..64c6dfb 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,7 +4,7 @@ ### `feat/streaming-e2e` -Status: pending +Status: in progress DoD: - local fixture stack includes: diff --git a/apps/ui/scripts/playwright-stack.sh b/apps/ui/scripts/playwright-stack.sh index f5ff65d..13589a4 100644 --- a/apps/ui/scripts/playwright-stack.sh +++ b/apps/ui/scripts/playwright-stack.sh @@ -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" \ @@ -76,6 +107,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 +132,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 diff --git a/apps/ui/scripts/playwright-ui-server.js b/apps/ui/scripts/playwright-ui-server.js index 2e1f8ef..9f03f4b 100644 --- a/apps/ui/scripts/playwright-ui-server.js +++ b/apps/ui/scripts/playwright-ui-server.js @@ -50,6 +50,12 @@ function mapRoute(urlPath) { if (urlPath === '/usage') { return path.join(ROOT_DIR, 'html', 'usage.html'); } + if (urlPath === '/stream-sessions') { + return path.join(ROOT_DIR, 'html', 'stream-sessions.html'); + } + if (urlPath === '/async-jobs') { + return path.join(ROOT_DIR, 'html', 'async-jobs.html'); + } if (urlPath === '/settings') { return path.join(ROOT_DIR, 'html', 'settings.html'); } @@ -158,6 +164,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; diff --git a/apps/ui/scripts/stream-fixture-server.js b/apps/ui/scripts/stream-fixture-server.js new file mode 100644 index 0000000..7580144 --- /dev/null +++ b/apps/ui/scripts/stream-fixture-server.js @@ -0,0 +1,145 @@ +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)); +} + +const server = http.createServer((request, response) => { + if (request.url === '/health') { + writeJson(response, 200, { ok: true }); + return; + } + + if (request.url === '/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) { + response.write('event: done\n'); + response.write('data: {"done":true}\n\n'); + 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 (request.url === '/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; + } + + 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}`); +}); diff --git a/crates/crank-adapter-grpc/examples/stream_fixture.rs b/crates/crank-adapter-grpc/examples/stream_fixture.rs new file mode 100644 index 0000000..e67bf67 --- /dev/null +++ b/crates/crank-adapter-grpc/examples/stream_fixture.rs @@ -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> { + 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(()) +} From b5f80c5d2f058a58f103a3c1c5199f231c9fe215 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 12:44:47 +0300 Subject: [PATCH 15/38] feat: add streaming end-to-end coverage --- TASKS.md | 2 +- apps/mcp-server/src/app.rs | 29 +- apps/mcp-server/src/main.rs | 188 ++++++++- apps/ui/scripts/playwright-stack.sh | 1 + apps/ui/scripts/stream-fixture-server.js | 46 ++- apps/ui/tests/e2e/helpers.js | 470 +++++++++++++++++++++++ apps/ui/tests/e2e/streaming.spec.js | 125 ++++++ 7 files changed, 853 insertions(+), 8 deletions(-) create mode 100644 apps/ui/tests/e2e/streaming.spec.js diff --git a/TASKS.md b/TASKS.md index 64c6dfb..35b5bb0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,7 +4,7 @@ ### `feat/streaming-e2e` -Status: in progress +Status: completed DoD: - local fixture stack includes: diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 21b4541..b48c1cd 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -520,8 +520,35 @@ async fn handle_base_tool_call( let operation = runtime_operation(&tool); let request_preview = build_request_preview(&state.runtime, &operation, &arguments); let started_at = Instant::now(); + let is_window_mode = matches!( + operation + .execution_config + .streaming + .as_ref() + .map(|streaming| streaming.mode), + Some(crank_core::ExecutionMode::Window) + ); - match state.runtime.execute(&operation, &arguments).await { + let result = if is_window_mode { + state + .runtime + .execute_window(&operation, &arguments) + .await + .map(|output| { + json!({ + "summary": output.summary, + "items": output.items, + "cursor": output.cursor, + "window_complete": output.window_complete, + "truncated": output.truncated, + "has_more": output.has_more, + }) + }) + } else { + state.runtime.execute(&operation, &arguments).await + }; + + match result { Ok(output) => { let _ = persist_invocation( &state, diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 8c2c0b3..39b6c6b 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -48,7 +48,12 @@ mod tests { time::{Duration, SystemTime, UNIX_EPOCH}, }; - use axum::{Json, Router, http::header, routing::post}; + use axum::{ + Json, Router, + http::header, + response::sse::{Event, KeepAlive, Sse}, + routing::{get, post}, + }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ @@ -64,6 +69,7 @@ mod tests { PublishAgentRequest, PublishRequest, }; use crank_schema::{Schema, SchemaKind}; + use futures_util::stream; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use sqlx::{Executor, postgres::PgPoolOptions}; @@ -1114,6 +1120,72 @@ mod tests { ); } + #[tokio::test] + async fn executes_window_operation_via_mcp() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_window_operation(&upstream_base_url, "crm_window_logs"); + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-window").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-window", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-window"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let call_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "crm_window_logs", + "arguments": {} + } + }), + ) + .await; + + assert_eq!(call_result["result"]["isError"], false); + assert_eq!( + call_result["result"]["structuredContent"]["items"][0]["message"], + json!("billing started") + ); + assert_eq!( + call_result["result"]["structuredContent"]["window_complete"], + json!(true) + ); + } + async fn initialize_session(client: &reqwest::Client, mcp_url: &str, api_key: &str) -> String { let initialize_response = client .post(mcp_url) @@ -1220,6 +1292,7 @@ mod tests { async fn spawn_upstream_server() -> String { let app = Router::new() + .route("/sse/logs", get(stream_logs)) .route("/crm/leads", post(create_lead)) .route("/crm/slow-leads", post(create_slow_lead)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1343,6 +1416,19 @@ mod tests { })) } + async fn stream_logs() + -> Sse>> { + let events = vec![ + json!({ "level": "info", "message": "billing started" }), + json!({ "level": "warn", "message": "cache warmup slow" }), + json!({ "level": "error", "message": "invoice timeout" }), + ]; + let stream = stream::iter(events.into_iter().map(|payload| { + Ok::<_, std::convert::Infallible>(Event::default().data(payload.to_string())) + })); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) + } + async fn graphql_handler(Json(payload): Json) -> Json { let email = payload .get("variables") @@ -1670,6 +1756,65 @@ mod tests { operation } + fn test_rest_window_operation(base_url: &str, name: &str) -> Operation { + let mut operation = test_operation(base_url, name); + operation.display_name = "Window Logs".to_owned(); + operation.target = Target::Rest(RestTarget { + base_url: base_url.to_owned(), + method: HttpMethod::Get, + path_template: "/sse/logs".to_owned(), + static_headers: BTreeMap::new(), + }); + operation.input_schema = optional_object_schema("window"); + operation.output_schema = empty_object_schema(); + operation.input_mapping = MappingSet { + rules: vec![MappingRule { + source: "$.mcp.window".to_owned(), + target: "$.request.query.window".to_owned(), + required: false, + default_value: Some(json!("recent")), + transform: None, + condition: None, + notes: None, + }], + }; + operation.output_mapping = MappingSet { rules: Vec::new() }; + operation.tool_description = ToolDescription { + title: "Window Logs".to_owned(), + description: "Collects a bounded SSE log window".to_owned(), + tags: vec![ + "rest".to_owned(), + "streaming".to_owned(), + "window".to_owned(), + ], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(1_000), + poll_interval_ms: None, + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items: Some(3), + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: Some("$.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig::default(), + }); + operation + } + fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, @@ -1696,4 +1841,45 @@ mod tests { variants: Vec::new(), } } + + fn empty_object_schema() -> Schema { + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + } + + fn optional_object_schema(field_name: &str) -> Schema { + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + field_name.to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + } } diff --git a/apps/ui/scripts/playwright-stack.sh b/apps/ui/scripts/playwright-stack.sh index 13589a4..30e2a98 100644 --- a/apps/ui/scripts/playwright-stack.sh +++ b/apps/ui/scripts/playwright-stack.sh @@ -100,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" diff --git a/apps/ui/scripts/stream-fixture-server.js b/apps/ui/scripts/stream-fixture-server.js index 7580144..f08d0fd 100644 --- a/apps/ui/scripts/stream-fixture-server.js +++ b/apps/ui/scripts/stream-fixture-server.js @@ -11,13 +11,37 @@ function writeJson(response, status, payload) { 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) => { - if (request.url === '/health') { + const url = new URL(request.url, `http://127.0.0.1:${PORT}`); + + if (url.pathname === '/health') { writeJson(response, 200, { ok: true }); return; } - if (request.url === '/sse/logs') { + if (url.pathname === '/sse/logs') { response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', @@ -33,8 +57,6 @@ const server = http.createServer((request, response) => { let index = 0; const timer = setInterval(() => { if (index >= events.length) { - response.write('event: done\n'); - response.write('data: {"done":true}\n\n'); clearInterval(timer); response.end(); return; @@ -52,7 +74,7 @@ const server = http.createServer((request, response) => { return; } - if (request.url === '/snapshot/metrics') { + if (url.pathname === '/snapshot/metrics') { writeJson(response, 200, { summary: { service: 'billing', @@ -67,6 +89,20 @@ const server = http.createServer((request, response) => { 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; + } + writeJson(response, 404, { error: 'not_found' }); }); diff --git a/apps/ui/tests/e2e/helpers.js b/apps/ui/tests/e2e/helpers.js index d7b074f..bc0cf7a 100644 --- a/apps/ui/tests/e2e/helpers.js +++ b/apps/ui/tests/e2e/helpers.js @@ -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,470 @@ 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: {} }], + }, + }; +} + +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, + browserJson, + buildGrpcSessionOperationPayload, + buildRestAsyncJobOperationPayload, + buildRestWindowOperationPayload, + createAgent, + createOperation, + createPlatformApiKey, + getCurrentWorkspace, + getSession, + initializeMcpSession, login, localized, + mcpToolCall, + publishAgent, + publishOperation, + saveAgentBindings, + setupPublishedAgent, + uniqueName, }; diff --git a/apps/ui/tests/e2e/streaming.spec.js b/apps/ui/tests/e2e/streaming.spec.js new file mode 100644 index 0000000..3a1109f --- /dev/null +++ b/apps/ui/tests/e2e/streaming.spec.js @@ -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'); +}); From 45ea011b7f6fbe3943fabd441d7b2ceaed8c1e9b Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 13:23:45 +0300 Subject: [PATCH 16/38] feat: add websocket upstream adapter --- Cargo.lock | 74 +++ Cargo.toml | 2 + TASKS.md | 18 +- apps/admin-api/src/error.rs | 1 + apps/admin-api/src/service.rs | 18 +- apps/mcp-server/src/app.rs | 1 + apps/ui/html/workspace-setup.html | 1 + apps/ui/index.html | 4 +- apps/ui/js/agents.js | 2 + apps/ui/js/catalog.js | 1 + apps/ui/js/i18n.js | 6 + apps/ui/js/usage.js | 6 + apps/ui/js/wizard.js | 6 +- crates/crank-adapter-websocket/Cargo.toml | 19 + crates/crank-adapter-websocket/src/client.rs | 442 ++++++++++++++++++ crates/crank-adapter-websocket/src/error.rs | 30 ++ crates/crank-adapter-websocket/src/lib.rs | 35 ++ crates/crank-adapter-websocket/src/session.rs | 21 + crates/crank-core/src/lib.rs | 3 +- crates/crank-core/src/operation.rs | 55 ++- crates/crank-core/src/protocol.rs | 19 + crates/crank-registry/src/postgres.rs | 1 + crates/crank-runtime/Cargo.toml | 2 + crates/crank-runtime/src/error.rs | 3 + crates/crank-runtime/src/executor.rs | 216 ++++++++- docs/data-model.md | 14 + docs/protocols/websocket.md | 8 +- 27 files changed, 978 insertions(+), 30 deletions(-) create mode 100644 crates/crank-adapter-websocket/Cargo.toml create mode 100644 crates/crank-adapter-websocket/src/client.rs create mode 100644 crates/crank-adapter-websocket/src/error.rs create mode 100644 crates/crank-adapter-websocket/src/lib.rs create mode 100644 crates/crank-adapter-websocket/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index d690f90..882a9a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,20 @@ dependencies = [ "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" @@ -448,6 +462,7 @@ dependencies = [ "crank-adapter-graphql", "crank-adapter-grpc", "crank-adapter-rest", + "crank-adapter-websocket", "crank-core", "crank-mapping", "crank-schema", @@ -456,6 +471,7 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tokio-tungstenite", ] [[package]] @@ -519,6 +535,12 @@ 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" @@ -2125,6 +2147,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" @@ -2495,6 +2528,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" @@ -2693,6 +2742,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" @@ -2772,6 +2840,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" diff --git a/Cargo.toml b/Cargo.toml index beb18c3..d5a4d81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/crank-adapter-rest", "crates/crank-adapter-graphql", "crates/crank-adapter-grpc", + "crates/crank-adapter-websocket", ] resolver = "3" @@ -46,4 +47,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"] } diff --git a/TASKS.md b/TASKS.md index 35b5bb0..7d0827b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,25 +2,19 @@ ## Current -### `feat/streaming-e2e` +### `feat/websocket-upstream-adapter` Status: completed DoD: -- local fixture stack includes: - - REST SSE server - - gRPC server-streaming server - - WebSocket event server -- Playwright covers: - - REST window flow - - gRPC streaming flow - - session tool flow - - async job flow -- CI runs streaming e2e against the local fixture stack +- bounded WebSocket window execution is supported +- WebSocket protocol is exposed through capability APIs +- session mode can seed from WebSocket window collection +- runtime and adapter tests cover WebSocket collection and reconnects ## Next -- `feat/websocket-upstream-adapter` +- `feat/soap-architecture-and-core-model` ## Backlog diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index b0fe3f2..28b241b 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -238,6 +238,7 @@ 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::WebsocketAdapter(_) => "runtime_websocket_error", RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error", diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index afaeab3..dab277a 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1347,10 +1347,15 @@ impl AdminService { } pub async fn list_protocol_capabilities(&self) -> Vec { - [Protocol::Rest, Protocol::Graphql, Protocol::Grpc] - .into_iter() - .map(protocol_capability_view) - .collect() + [ + Protocol::Rest, + Protocol::Graphql, + Protocol::Grpc, + Protocol::Websocket, + ] + .into_iter() + .map(protocol_capability_view) + .collect() } #[instrument(skip(self))] @@ -3601,6 +3606,7 @@ fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), A (Protocol::Rest, Target::Rest(_)) | (Protocol::Graphql, Target::Graphql(_)) | (Protocol::Grpc, Target::Grpc(_)) + | (Protocol::Websocket, Target::Websocket(_)) ); if is_match { @@ -3827,6 +3833,7 @@ fn demo_grpc_operation_payload() -> OperationPayload { headers: BTreeMap::new(), protocol_options: Some(crank_core::ProtocolOptions { grpc: Some(crank_core::GrpcProtocolOptions { use_tls: false }), + websocket: None, }), streaming: None, }, @@ -4007,6 +4014,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "graphql_error", RuntimeError::GrpcAdapter(_) => "grpc_error", RuntimeError::RestAdapter(_) => "rest_error", + RuntimeError::WebsocketAdapter(_) => "websocket_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", @@ -4034,7 +4042,7 @@ fn protocol_capability_view(protocol: Protocol) -> ProtocolCapabilityView { .filter(|behavior| protocol.supports_transport_behavior(*behavior)) .collect(); let supports_upload_artifacts = match protocol { - Protocol::Rest | Protocol::Graphql => Vec::new(), + Protocol::Rest | Protocol::Graphql | Protocol::Websocket => Vec::new(), Protocol::Grpc => vec!["proto".to_owned(), "descriptor_set".to_owned()], }; diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index b48c1cd..8488684 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -1725,6 +1725,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "adapter_execution_error", RuntimeError::GrpcAdapter(_) => "adapter_execution_error", RuntimeError::RestAdapter(_) => "adapter_execution_error", + RuntimeError::WebsocketAdapter(_) => "adapter_execution_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 97b3eb1..2782632 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -95,6 +95,7 @@ +
Pre-selected in the operation wizard.
diff --git a/apps/ui/index.html b/apps/ui/index.html index fa03f88..8a04077 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -181,13 +181,13 @@