diff --git a/CLA.md b/CLA.md index a76a715..4627344 100644 --- a/CLA.md +++ b/CLA.md @@ -36,7 +36,7 @@ - распространять вклад; - публиковать вклад; - сублицензировать вклад; -- включать вклад в открытые, коммерческие и закрытые версии Crank. +- включать вклад в проект Crank и производные работы. ## 4. Патенты @@ -50,7 +50,7 @@ Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only. -Владелец проекта может использовать принятые вклады также в других редакциях Crank, включая коммерческие, облачные и закрытые редакции. +Владелец проекта может использовать принятые вклады в проекте Crank и производных работах. ## 7. Как подтвердить согласие diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d1eb29..e36f100 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Crank Community принимает исправления ошибок, улуч Crank Community распространяется по лицензии GNU Affero General Public License v3.0 only. -Перед отправкой pull request нужно согласиться с [Contributor License Agreement](./CLA.md). Это нужно, чтобы проект мог использовать принятые изменения не только в Community-версии, но и в коммерческих редакциях Crank. +Перед отправкой pull request нужно согласиться с [Contributor License Agreement](./CLA.md). Это нужно, чтобы права на принятые изменения были оформлены явно и проект мог развиваться без юридических неопределенностей. ## Перед pull request @@ -35,7 +35,7 @@ npx playwright test - Для изменений SQL-запросов обновляйте `.sqlx`, если это требуется SQLx. - Для пользовательских изменений обновляйте документацию или примеры. -## Границы Community-версии +## Границы проекта В этом репозитории поддерживаются: @@ -46,4 +46,4 @@ npx playwright test - PostgreSQL как основное хранилище; - необязательный Valkey или Redis для служебного кэша. -Функции коммерческих редакций не должны попадать в этот репозиторий. +Функции за пределами перечисленного набора не должны попадать в этот репозиторий. diff --git a/NOTICE b/NOTICE index de665f7..e455845 100644 --- a/NOTICE +++ b/NOTICE @@ -4,4 +4,4 @@ Copyright 2026 bsodfather This product includes software developed for the Crank project. Crank Community is licensed under the GNU Affero General Public License -version 3 only. Commercial licensing for other editions is handled separately. +version 3 only. diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 0b06a4a..220d74d 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -18,7 +18,6 @@ use crate::{ auth::{change_password, get_profile, get_session, login, logout, update_profile}, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, capabilities::get_capabilities, - machine_auth::{issue_agent_token, issue_one_time_agent_token}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, operations::{ archive_operation, create_operation, create_version, delete_operation, @@ -27,7 +26,6 @@ use crate::{ upload_output_json, }, secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret}, - streaming::list_protocol_capabilities, upstreams::{create_upstream, list_upstreams, update_upstream}, workspaces::{get_workspace, list_workspaces, update_workspace}, }, @@ -117,8 +115,7 @@ 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("/protocol-capabilities", get(list_protocol_capabilities)); + .route("/usage/agents/{agent_id}", get(get_agent_usage)); let workspace_root_router = Router::new() .route("/capabilities", get(get_capabilities)) @@ -159,12 +156,6 @@ pub fn build_app(state: AppState) -> Router { .route("/login", post(login)) .merge(protected_auth_router), ) - .nest( - "/mcp-auth/v1", - Router::new() - .route("/token", post(issue_agent_token)) - .route("/token/one-time", post(issue_one_time_agent_token)), - ) .nest("/api/admin", admin_router) .layer(middleware::from_fn_with_state( state.clone(), @@ -187,14 +178,6 @@ mod tests { use async_trait::async_trait; use axum::{Json, Router, routing::post}; - #[cfg(any())] - use crank_adapter_grpc::test_support as grpc_test_support; - #[cfg(any())] - use crank_core::{ - AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionMode, GrpcTarget, JobStatus, - OperationId, SoapBindingStyle, SoapOperationMetadata, SoapTarget, SoapVersion, - StreamSession, StreamStatus, TransportBehavior, - }; use crank_core::{ ExecutionConfig, HttpMethod, MembershipRole, OperationSecurityLevel, Protocol, ResponseCachePolicy, RestTarget, SecretKind, Target, ToolDescription, WorkspaceId, @@ -202,15 +185,11 @@ mod tests { use crank_core::{IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome}; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; - #[cfg(any())] - use crank_registry::{CreateAsyncJobRequest, CreateStreamSessionRequest}; use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; use sqlx::{Connection, Executor, PgConnection}; - #[cfg(any())] - use time::OffsetDateTime; use tokio::net::TcpListener; use crate::{ @@ -1125,254 +1104,6 @@ mod tests { assert_eq!(response["limits"]["max_agents_per_workspace"], Value::Null); } - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn rejects_short_lived_machine_token_issue_in_community() { - let registry = test_registry().await; - let storage_root = test_storage_root("community_short_lived_token_contract"); - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let root_url = base_url - .as_ref() - .split("/api/admin/workspaces/") - .next() - .unwrap(); - - let response = reqwest::Client::new() - .post(format!("{root_url}/mcp-auth/v1/token")) - .json(&json!({ - "grant_type": "agent_key", - "agent_key": "crk_agent_demo_secret", - "scope": ["tools:call"] - })) - .send() - .await - .unwrap(); - - assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); - let payload = response.json::().await.unwrap(); - assert_eq!(payload["error"]["code"], "forbidden"); - assert_eq!( - payload["error"]["message"], - "short-lived machine access is not available in Community" - ); - assert_eq!(payload["error"]["context"]["edition"], "community"); - assert_eq!( - payload["error"]["context"]["machine_access_mode"], - "short_lived_token" - ); - assert_eq!(payload["error"]["context"]["grant_type"], "agent_key"); - assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); - } - - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn rejects_one_time_machine_token_issue_in_community() { - let registry = test_registry().await; - let storage_root = test_storage_root("community_one_time_token_contract"); - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let root_url = base_url - .as_ref() - .split("/api/admin/workspaces/") - .next() - .unwrap(); - - let response = reqwest::Client::new() - .post(format!("{root_url}/mcp-auth/v1/token/one-time")) - .json(&json!({ - "agent_key": "crk_agent_demo_secret", - "operation_id": "op_sensitive_01", - "scope": ["tools:call"] - })) - .send() - .await - .unwrap(); - - assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); - let payload = response.json::().await.unwrap(); - assert_eq!(payload["error"]["code"], "forbidden"); - assert_eq!( - payload["error"]["message"], - "one-time machine access is not available in Community" - ); - assert_eq!(payload["error"]["context"]["edition"], "community"); - assert_eq!( - payload["error"]["context"]["machine_access_mode"], - "one_time_token" - ); - assert_eq!( - payload["error"]["context"]["operation_id"], - "op_sensitive_01" - ); - assert_eq!(payload["error"]["context"]["upgrade_required"], json!(true)); - } - - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn returns_community_protocol_capabilities_only_for_supported_protocols() { - let registry = test_registry().await; - let storage_root = test_storage_root("community_protocol_capabilities"); - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let response = assert_success_json( - client - .get(format!("{base_url}/protocol-capabilities")) - .send() - .await - .unwrap(), - ) - .await; - - assert_eq!(response["items"].as_array().unwrap().len(), 1); - assert_eq!( - response["items"] - .as_array() - .unwrap() - .iter() - .map(|item| item["protocol"].as_str().unwrap()) - .collect::>(), - vec!["rest"] - ); - for item in response["items"].as_array().unwrap() { - assert_eq!(item["supports_execution_modes"], json!(["unary"])); - assert_eq!( - item["supports_transport_behaviors"], - json!(["request_response"]) - ); - } - } - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn rejects_premium_security_level_for_community_operation_create() { - let registry = test_registry().await; - let storage_root = test_storage_root("community_security_reject_create"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let mut payload = test_operation_payload(&upstream_base_url, "crm_elevated"); - payload.security_level = OperationSecurityLevel::Elevated; - - let response = client - .post(format!("{base_url}/operations")) - .json(&payload) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); - assert_eq!(body["error"]["code"], "validation_error"); - assert_eq!( - body["error"]["message"], - "security level elevated is not supported in Community" - ); - assert_eq!( - body["error"]["context"], - json!({ - "security_level": "elevated", - "edition": "community", - }) - ); - } - - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn rejects_premium_security_level_for_community_operation_update() { - let registry = test_registry().await; - let storage_root = test_storage_root("community_security_reject_update"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_operation_payload( - &upstream_base_url, - "crm_update_elevated", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let response = client - .patch(format!("{base_url}/operations/{operation_id}")) - .json(&json!({ - "display_name": "Create Lead", - "category": "sales", - "security_level": "elevated", - "target": { - "kind": "rest", - "base_url": upstream_base_url, - "method": "POST", - "path_template": "/crm/leads", - "static_headers": {} - }, - "input_schema": object_schema("email"), - "output_schema": object_schema("id"), - "input_mapping": { - "rules": [{ - "source": "$.mcp.email", - "target": "$.request.body.email", - "required": true, - "default_value": null, - "transform": null, - "condition": null, - "notes": null - }] - }, - "output_mapping": { - "rules": [{ - "source": "$.response.body.id", - "target": "$.output.id", - "required": true, - "default_value": null, - "transform": null, - "condition": null, - "notes": null - }] - }, - "execution_config": { - "timeout_ms": 1000, - "retry_policy": null, - "auth_profile_ref": null, - "headers": {}, - "protocol_options": null, - "streaming": null - }, - "tool_description": { - "title": "Create Lead", - "description": "Creates a CRM lead", - "tags": ["crm"], - "examples": [] - } - })) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); - assert_eq!(body["error"]["code"], "validation_error"); - assert_eq!( - body["error"]["message"], - "security level elevated is not supported in Community" - ); - assert_eq!( - body["error"]["context"], - json!({ - "security_level": "elevated", - "edition": "community", - }) - ); - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn rejects_response_cache_for_non_get_rest_operation_create() { @@ -1891,742 +1622,6 @@ mod tests { assert_eq!(logs["items"][0]["log"]["request_id"], request_id); } - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn creates_publishes_and_tests_graphql_operation() { - let registry = test_registry().await; - let storage_root = test_storage_root("graphql"); - let upstream_base_url = spawn_graphql_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_graphql_operation_payload( - &upstream_base_url, - "crm_create_lead_graphql", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let published = client - .post(format!("{base_url}/operations/{operation_id}/publish")) - .json(&json!({ "version": 1 })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 1, - "input": { "email": "user@example.com" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(published["published_version"], 1); - assert_eq!(test_run["ok"], true); - assert_eq!( - test_run["request_preview"]["variables"]["email"], - "user@example.com" - ); - assert_eq!(test_run["response_preview"]["id"], "lead_123"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn uploads_descriptor_set_and_lists_grpc_services() { - let registry = test_registry().await; - let storage_root = test_storage_root("grpc_descriptor"); - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_grpc_operation_payload( - &server_addr, - "echo_descriptor", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let uploaded = client - .post(format!( - "{base_url}/operations/{operation_id}/descriptors/descriptor-set" - )) - .header("x-file-name", "echo_descriptor.bin") - .body(grpc_test_support::echo::FILE_DESCRIPTOR_SET.to_vec()) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let services = client - .get(format!( - "{base_url}/operations/{operation_id}/grpc/services" - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert!(uploaded["descriptor_id"].as_str().is_some()); - assert_eq!(services["services"][0]["package"], "echo"); - assert_eq!(services["services"][0]["service"], "EchoService"); - assert_eq!(services["services"][0]["methods"][0]["name"], "UnaryEcho"); - assert_eq!(services["services"][0]["methods"][0]["kind"], "unary"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn creates_publishes_and_tests_grpc_operation() { - let registry = test_registry().await; - let storage_root = test_storage_root("grpc_runtime"); - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_grpc_operation_payload(&server_addr, "echo_runtime")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let published = client - .post(format!("{base_url}/operations/{operation_id}/publish")) - .json(&json!({ "version": 1 })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 1, - "input": { "message": "hello" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(published["published_version"], 1); - assert_eq!(test_run["ok"], true); - assert_eq!(test_run["request_preview"]["grpc"]["message"], "hello"); - assert_eq!(test_run["response_preview"]["message"], "hello"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn returns_window_metadata_for_streaming_test_runs() { - let registry = test_registry().await; - let storage_root = test_storage_root("grpc_window_test_run"); - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_grpc_window_operation_payload( - &server_addr, - "echo_window_runtime", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 1, - "input": { "message": "hello" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(test_run["ok"], true); - assert_eq!(test_run["mode"], "window"); - assert!(test_run["window"].is_object()); - assert!(test_run["window"]["window_complete"].is_boolean()); - assert!(test_run["window"]["truncated"].is_boolean()); - assert!(test_run["window"]["has_more"].is_boolean()); - assert!(test_run["response_preview"]["items"].is_array()); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn starts_stream_session_from_streaming_test_runs() { - let registry = test_registry().await; - let storage_root = test_storage_root("grpc_session_test_run"); - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_grpc_session_operation_payload( - &server_addr, - "echo_session_runtime", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 1, - "input": { "message": "hello" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let session_id = test_run["stream_session"]["session_id"] - .as_str() - .unwrap() - .to_owned(); - - let session = client - .get(format!("{base_url}/stream-sessions/{session_id}")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(test_run["ok"], true); - assert_eq!(test_run["mode"], "session"); - assert_eq!(test_run["stream_session"]["status"], "running"); - assert!(test_run["response_preview"]["preview"]["items"].is_array()); - assert_eq!(session["id"], session_id); - assert_eq!(session["status"], "running"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn starts_async_job_from_streaming_test_runs() { - let registry = test_registry().await; - let storage_root = test_storage_root("async_job_test_run"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_rest_async_job_operation_payload( - &upstream_base_url, - "crm_async_job_runtime", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 1, - "input": { "email": "user@example.com" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let job_id = test_run["async_job"]["job_id"].as_str().unwrap().to_owned(); - - let mut job = Value::Null; - for _ in 0..20 { - job = client - .get(format!("{base_url}/async-jobs/{job_id}")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - if job["status"] == "completed" { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - let result = client - .get(format!("{base_url}/async-jobs/{job_id}/result")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(test_run["ok"], true); - assert_eq!(test_run["mode"], "async_job"); - assert_eq!(test_run["async_job"]["status"], "running"); - assert_eq!(job["status"], "completed"); - assert_eq!(result["id"], "lead_123"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn returns_structured_context_for_pending_async_job_result() { - let registry = test_registry().await; - let storage_root = test_storage_root("async_job_pending_result"); - let upstream_base_url = spawn_upstream_server().await; - let job_id = AsyncJobId::new("job_pending_result".to_owned()); - let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; - let client = authorized_client(&base_url).await; - let created = client - .post(format!("{base_url}/operations")) - .json(&test_rest_async_job_operation_payload( - &upstream_base_url, - "crm_async_job_pending_result", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); - let now = OffsetDateTime::now_utc(); - registry - .create_async_job(CreateAsyncJobRequest { - job: &AsyncJobHandle { - id: job_id.clone(), - workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), - agent_id: None, - operation_id, - status: JobStatus::Running, - progress: json!({ "pct": 25 }), - result: None, - error: None, - expires_at: Some(now + time::Duration::minutes(5)), - last_poll_at: None, - created_at: now, - updated_at: now, - finished_at: None, - }, - }) - .await - .unwrap(); - - let response = client - .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::CONFLICT); - assert_eq!(body["error"]["code"], "conflict"); - assert_eq!(body["error"]["message"], "async job result is not ready"); - assert_eq!( - body["error"]["context"], - json!({ - "job_id": job_id.as_str(), - "status": "running" - }) - ); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn rate_limits_rapid_stream_session_reads() { - let registry = test_registry().await; - let storage_root = test_storage_root("stream_session_rate_limit"); - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_grpc_session_operation_payload( - &server_addr, - "echo_session_rate_limit", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); - let now = OffsetDateTime::now_utc(); - let session_id = crank_core::StreamSessionId::new("sess_rate_limited".to_owned()); - registry - .create_stream_session(CreateStreamSessionRequest { - session: &StreamSession { - id: session_id.clone(), - workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), - agent_id: None, - operation_id, - protocol: Protocol::Grpc, - mode: ExecutionMode::Session, - status: StreamStatus::Running, - cursor: None, - state: json!({ "items": [] }), - expires_at: now + time::Duration::minutes(5), - last_poll_at: Some(now), - created_at: now, - closed_at: None, - }, - }) - .await - .unwrap(); - - let response = client - .get(format!( - "{base_url}/stream-sessions/{}", - session_id.as_str() - )) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); - assert_eq!(body["error"]["code"], "rate_limited"); - assert_eq!( - body["error"]["message"], - "stream session poll rate limit exceeded" - ); - assert_eq!(body["error"]["context"]["session_id"], session_id.as_str()); - assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn rate_limits_rapid_async_job_result_polls() { - let registry = test_registry().await; - let storage_root = test_storage_root("async_job_rate_limit"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_rest_async_job_operation_payload( - &upstream_base_url, - "crm_async_job_rate_limit", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); - let now = OffsetDateTime::now_utc(); - let job_id = AsyncJobId::new("job_rate_limited".to_owned()); - registry - .create_async_job(CreateAsyncJobRequest { - job: &AsyncJobHandle { - id: job_id.clone(), - workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), - agent_id: None, - operation_id, - status: JobStatus::Running, - progress: json!({ "pct": 25 }), - result: None, - error: None, - expires_at: Some(now + time::Duration::minutes(5)), - last_poll_at: Some(now), - created_at: now, - updated_at: now, - finished_at: None, - }, - }) - .await - .unwrap(); - - let response = client - .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS); - assert_eq!(body["error"]["code"], "rate_limited"); - assert_eq!( - body["error"]["message"], - "async job poll rate limit exceeded" - ); - assert_eq!(body["error"]["context"]["job_id"], job_id.as_str()); - assert!(body["error"]["context"]["poll_after_ms"].as_u64().unwrap() > 0); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn allows_completed_async_job_result_fetch_without_poll_delay() { - let registry = test_registry().await; - let storage_root = test_storage_root("async_job_completed_result"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_rest_async_job_operation_payload( - &upstream_base_url, - "crm_async_job_completed_result", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned()); - let now = OffsetDateTime::now_utc(); - let job_id = AsyncJobId::new("job_completed_result".to_owned()); - registry - .create_async_job(CreateAsyncJobRequest { - job: &AsyncJobHandle { - id: job_id.clone(), - workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID), - agent_id: None, - operation_id, - status: JobStatus::Completed, - progress: json!({ "pct": 100 }), - result: Some(json!({ "id": "lead_123" })), - error: None, - expires_at: Some(now + time::Duration::minutes(5)), - last_poll_at: Some(now), - created_at: now, - updated_at: now, - finished_at: Some(now), - }, - }) - .await - .unwrap(); - - let response = client - .get(format!("{base_url}/async-jobs/{}/result", job_id.as_str())) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::OK); - assert_eq!(body["id"], "lead_123"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn returns_structured_context_for_missing_stream_session() { - let registry = test_registry().await; - let storage_root = test_storage_root("missing_stream_session"); - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let response = client - .get(format!("{base_url}/stream-sessions/sess_missing")) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::NOT_FOUND); - assert_eq!(body["error"]["code"], "not_found"); - assert_eq!( - body["error"]["message"], - "stream session sess_missing was not found" - ); - assert_eq!( - body["error"]["context"], - json!({ - "session_id": "sess_missing" - }) - ); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn uploads_wsdl_and_tests_soap_operation() { - let registry = test_registry().await; - let storage_root = test_storage_root("soap_runtime"); - let endpoint = spawn_soap_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_soap_operation_payload( - &endpoint, - "crm_create_lead_soap", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let uploaded = client - .post(format!( - "{base_url}/operations/{operation_id}/descriptors/wsdl" - )) - .header("x-file-name", "lead.wsdl") - .body(SOAP_TEST_WSDL) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - let services = client - .get(format!( - "{base_url}/operations/{operation_id}/soap/services" - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 1, - "input": { "email": "user@example.com" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(uploaded["version"], 1); - assert_eq!(services["services"][0]["service_name"], "LeadService"); - assert_eq!(services["services"][0]["ports"][0]["port_name"], "LeadPort"); - assert_eq!( - services["services"][0]["ports"][0]["operations"][0]["operation_name"], - "CreateLead" - ); - assert_eq!(test_run["ok"], true); - assert_eq!(test_run["response_preview"]["id"], "lead_123"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn returns_structured_context_when_wsdl_is_missing() { - let registry = test_registry().await; - let storage_root = test_storage_root("soap_missing_wsdl"); - let endpoint = spawn_soap_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_soap_operation_payload( - &endpoint, - "crm_create_lead_soap_missing_wsdl", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let response = client - .get(format!( - "{base_url}/operations/{operation_id}/soap/services" - )) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::NOT_FOUND); - assert_eq!(body["error"]["code"], "not_found"); - assert_eq!(body["error"]["message"], "wsdl was not uploaded"); - assert_eq!( - body["error"]["context"], - json!({ - "operation_id": operation_id, - "version": 1, - "descriptor_kind": "wsdl_upload" - }) - ); - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn returns_structured_context_for_missing_operation_usage() { @@ -2754,63 +1749,6 @@ mod tests { assert_eq!(imported["import_mode"], "upsert"); } - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn rejects_premium_security_level_for_community_yaml_upsert() { - let registry = test_registry().await; - let storage_root = test_storage_root("community_yaml_security_reject"); - let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_operation_payload( - &upstream_base_url, - "crm_yaml_standard", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let mut yaml = client - .get(format!("{base_url}/operations/{operation_id}/export")) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - yaml = yaml.replace("security_level: standard", "security_level: elevated"); - - let response = client - .post(format!("{base_url}/operations/import?mode=upsert")) - .body(yaml) - .send() - .await - .unwrap(); - let status = response.status(); - let body = response.json::().await.unwrap(); - - assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); - assert_eq!(body["error"]["code"], "validation_error"); - assert_eq!( - body["error"]["message"], - "security level elevated is not supported in Community" - ); - assert_eq!( - body["error"]["context"], - json!({ - "security_level": "elevated", - "edition": "community", - }) - ); - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_workspace_secrets_without_exposing_plaintext() { @@ -3025,123 +1963,6 @@ mod tests { ); } - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn roundtrips_graphql_operation_through_yaml_upsert() { - let registry = test_registry().await; - let storage_root = test_storage_root("yaml_graphql"); - let upstream_base_url = spawn_graphql_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_graphql_operation_payload( - &upstream_base_url, - "crm_yaml_graphql", - )) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let yaml = client - .get(format!("{base_url}/operations/{operation_id}/export")) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - let imported = client - .post(format!("{base_url}/operations/import?mode=upsert")) - .body(yaml) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 2, - "input": { "email": "user@example.com" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(imported["operation_id"], operation_id); - assert_eq!(imported["version"], 2); - assert_eq!(test_run["ok"], true); - assert_eq!(test_run["response_preview"]["id"], "lead_123"); - } - - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - #[serial] - async fn roundtrips_grpc_operation_through_yaml_upsert() { - let registry = test_registry().await; - let storage_root = test_storage_root("yaml_grpc"); - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; - let client = authorized_client(&base_url).await; - - let created = client - .post(format!("{base_url}/operations")) - .json(&test_grpc_operation_payload(&server_addr, "echo_yaml_grpc")) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let operation_id = created["operation_id"].as_str().unwrap().to_owned(); - - let yaml = client - .get(format!("{base_url}/operations/{operation_id}/export")) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - let imported = client - .post(format!("{base_url}/operations/import?mode=upsert")) - .body(yaml) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - let test_run = client - .post(format!("{base_url}/operations/{operation_id}/test-runs")) - .json(&json!({ - "version": 2, - "input": { "message": "hello" } - })) - .send() - .await - .unwrap() - .json::() - .await - .unwrap(); - - assert_eq!(imported["operation_id"], operation_id); - assert_eq!(imported["version"], 2); - assert_eq!(test_run["ok"], true); - assert_eq!(test_run["response_preview"]["message"], "hello"); - } - #[tokio::test(flavor = "multi_thread")] #[serial] async fn uploads_samples_and_generates_draft() { @@ -3233,19 +2054,6 @@ mod tests { format!("http://{}", address) } - #[cfg(any())] - async fn spawn_soap_server() -> String { - let app = Router::new().route("/", post(soap_handler)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - async fn spawn_admin_api(app: Router) -> TestServer { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -3416,24 +2224,6 @@ mod tests { })) } - #[cfg(any())] - async fn soap_handler(body: String) -> (axum::http::StatusCode, String) { - assert!(body.contains("user@example.com")); - - ( - axum::http::StatusCode::OK, - r#" - - - lead_123 - created - - - "# - .to_owned(), - ) - } - async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); @@ -3555,245 +2345,6 @@ mod tests { } } - #[cfg(any())] - fn test_grpc_operation_payload(server_addr: &str, name: &str) -> OperationPayload { - OperationPayload { - name: name.to_owned(), - display_name: "Unary Echo gRPC".to_owned(), - category: "support".to_owned(), - protocol: Protocol::Grpc, - security_level: OperationSecurityLevel::Standard, - target: Target::Grpc(GrpcTarget { - server_addr: server_addr.to_owned(), - package: "echo".to_owned(), - service: "EchoService".to_owned(), - method: "UnaryEcho".to_owned(), - read_only: false, - descriptor_ref: DescriptorId::new("desc_echo"), - descriptor_set_b64: grpc_test_support::descriptor_set_b64(), - }), - input_schema: object_schema("message"), - output_schema: object_schema("message"), - 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![MappingRule { - source: "$.response.data.message".to_owned(), - target: "$.output.message".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - }, - tool_description: ToolDescription { - title: "Unary Echo gRPC".to_owned(), - description: "Echoes a unary gRPC payload".to_owned(), - tags: vec!["grpc".to_owned()], - examples: Vec::new(), - }, - wizard_state: None, - } - } - - #[cfg(any())] - fn test_grpc_window_operation_payload(server_addr: &str, name: &str) -> OperationPayload { - let mut payload = test_grpc_operation_payload(server_addr, name); - payload.display_name = "Server Echo Window".to_owned(); - payload.target = Target::Grpc(GrpcTarget { - server_addr: server_addr.to_owned(), - package: "echo".to_owned(), - service: "EchoService".to_owned(), - method: "ServerEcho".to_owned(), - read_only: false, - descriptor_ref: DescriptorId::new("desc_echo_stream"), - descriptor_set_b64: grpc_test_support::descriptor_set_b64(), - }); - payload.execution_config.streaming = Some(crank_core::StreamingConfig { - mode: ExecutionMode::Window, - transport_behavior: TransportBehavior::ServerStream, - window_duration_ms: Some(100), - poll_interval_ms: None, - upstream_timeout_ms: Some(1_000), - idle_timeout_ms: None, - max_session_lifetime_ms: None, - max_items: Some(2), - max_bytes: None, - aggregation_mode: crank_core::AggregationMode::RawItems, - summary_path: None, - items_path: Some("$.response.body.items".to_owned()), - cursor_path: None, - status_path: None, - done_path: Some("$.response.body.done".to_owned()), - redacted_paths: Vec::new(), - truncate_item_fields: false, - max_field_length: None, - drop_duplicates: false, - sampling_rate: None, - tool_family: crank_core::ToolFamilyConfig::default(), - }); - payload - } - - #[cfg(any())] - fn test_grpc_session_operation_payload(server_addr: &str, name: &str) -> OperationPayload { - let mut payload = test_grpc_window_operation_payload(server_addr, name); - payload.display_name = "Server Echo Session".to_owned(); - if let Some(streaming) = payload.execution_config.streaming.as_mut() { - streaming.mode = ExecutionMode::Session; - streaming.poll_interval_ms = Some(1_000); - streaming.max_session_lifetime_ms = Some(60_000); - streaming.tool_family = crank_core::ToolFamilyConfig { - start_tool_name: Some("echo_session_start".to_owned()), - poll_tool_name: Some("echo_session_poll".to_owned()), - stop_tool_name: Some("echo_session_stop".to_owned()), - status_tool_name: None, - result_tool_name: None, - cancel_tool_name: None, - }; - } - payload - } - - #[cfg(any())] - fn test_rest_async_job_operation_payload(base_url: &str, name: &str) -> OperationPayload { - let mut payload = test_operation_payload(base_url, name); - payload.display_name = "Create Lead Async Job".to_owned(); - payload.execution_config.streaming = Some(crank_core::StreamingConfig { - mode: ExecutionMode::AsyncJob, - transport_behavior: TransportBehavior::DeferredResult, - window_duration_ms: None, - poll_interval_ms: Some(1_000), - upstream_timeout_ms: Some(1_000), - idle_timeout_ms: None, - max_session_lifetime_ms: Some(300_000), - max_items: None, - max_bytes: None, - aggregation_mode: crank_core::AggregationMode::SummaryPlusSamples, - summary_path: None, - items_path: None, - cursor_path: None, - status_path: None, - done_path: None, - redacted_paths: Vec::new(), - truncate_item_fields: false, - max_field_length: None, - drop_duplicates: false, - sampling_rate: None, - tool_family: crank_core::ToolFamilyConfig { - start_tool_name: Some("crm_async_start".to_owned()), - poll_tool_name: None, - stop_tool_name: None, - status_tool_name: Some("crm_async_status".to_owned()), - result_tool_name: Some("crm_async_result".to_owned()), - cancel_tool_name: Some("crm_async_cancel".to_owned()), - }, - }); - payload - } - - #[cfg(any())] - fn test_soap_operation_payload(endpoint: &str, name: &str) -> OperationPayload { - OperationPayload { - name: name.to_owned(), - display_name: "Create Lead SOAP".to_owned(), - category: "sales".to_owned(), - protocol: Protocol::Soap, - security_level: OperationSecurityLevel::Standard, - target: Target::Soap(SoapTarget { - wsdl_ref: "sample_wsdl".into(), - service_name: "LeadService".to_owned(), - port_name: "LeadPort".to_owned(), - operation_name: "CreateLead".to_owned(), - endpoint_override: Some(endpoint.to_owned()), - soap_version: SoapVersion::Soap11, - soap_action: Some("urn:createLead".to_owned()), - binding_style: SoapBindingStyle::DocumentLiteral, - headers: Vec::new(), - fault_contract: None, - metadata: SoapOperationMetadata { - input_part_names: vec!["CreateLeadRequest".to_owned()], - output_part_names: vec!["CreateLeadResponse".to_owned()], - namespaces: vec!["urn:crm".to_owned()], - }, - }), - input_schema: object_schema("email"), - output_schema: object_schema("id"), - input_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.mcp.email".to_owned(), - target: "$.request.body.email".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - output_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.response.body.id".to_owned(), - target: "$.output.id".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - }, - tool_description: ToolDescription { - title: "Create Lead SOAP".to_owned(), - description: "Creates a CRM lead through SOAP".to_owned(), - tags: vec!["crm".to_owned(), "soap".to_owned()], - examples: Vec::new(), - }, - wizard_state: None, - } - } - - #[cfg(any())] - const SOAP_TEST_WSDL: &str = r#" - - - - - - - - - - - - -"#; - fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index a79a58e..f7a2e43 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -109,13 +109,6 @@ impl ApiError { } } - pub(crate) fn forbidden_with_context(message: impl Into, context: Value) -> Self { - Self::Forbidden { - message: message.into(), - context: Some(context), - } - } - fn status_code(&self) -> StatusCode { match self { Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, @@ -224,14 +217,6 @@ impl From for ApiError { format!("secret {secret_id} was not found"), json!({ "secret_id": secret_id }), ), - RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context( - format!("stream session {session_id} was not found"), - json!({ "session_id": session_id }), - ), - RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context( - format!("async job {job_id} was not found"), - json!({ "job_id": job_id }), - ), RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context( format!("invocation log {log_id} was not found"), json!({ "log_id": log_id }), @@ -299,28 +284,6 @@ impl From for ApiError { "auth_profile_id": auth_profile_id, }), ), - RegistryError::InvalidStreamSessionTransition { - session_id, - from, - to, - } => Self::conflict_with_context( - format!("invalid stream session transition for {session_id}: {from} -> {to}"), - json!({ - "session_id": session_id, - "from": from, - "to": to, - }), - ), - RegistryError::InvalidAsyncJobTransition { job_id, from, to } => { - Self::conflict_with_context( - format!("invalid async job transition for {job_id}: {from} -> {to}"), - json!({ - "job_id": job_id, - "from": from, - "to": to, - }), - ) - } RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context( format!("user with email {email} already exists"), json!({ "email": email }), @@ -490,8 +453,7 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option { mod tests { use serde_json::json; - use super::{ApiError, runtime_error_context, runtime_test_failure}; - use crank_registry::RegistryError; + use super::{runtime_error_context, runtime_test_failure}; use crank_runtime::RuntimeError; #[test] @@ -544,27 +506,4 @@ mod tests { }) ); } - - #[test] - fn registry_errors_preserve_structured_context_in_api_error() { - let error = ApiError::from(RegistryError::InvalidAsyncJobTransition { - job_id: "job_123".to_owned(), - from: "running".to_owned(), - to: "completed".to_owned(), - }); - - match error { - ApiError::Conflict { context, .. } => { - assert_eq!( - context, - Some(json!({ - "job_id": "job_123", - "from": "running", - "to": "completed" - })) - ); - } - other => panic!("unexpected error variant: {other:?}"), - } - } } diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index 2d346fe..f07b9ee 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -3,11 +3,9 @@ pub mod agents; pub mod auth; pub mod auth_profiles; pub mod capabilities; -pub mod machine_auth; pub mod observability; pub mod operations; pub mod secrets; -pub mod streaming; pub mod upstreams; pub mod workspaces; diff --git a/apps/admin-api/src/routes/machine_auth.rs b/apps/admin-api/src/routes/machine_auth.rs deleted file mode 100644 index 4c88d52..0000000 --- a/apps/admin-api/src/routes/machine_auth.rs +++ /dev/null @@ -1,124 +0,0 @@ -use axum::{Json, extract::State}; -use crank_core::{ - IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MachineAccessMode, MembershipRole, - TokenIssuerActor, TokenIssuerError, UserId, WorkspaceId, -}; -use serde_json::json; - -use crate::{error::ApiError, state::AppState}; - -pub async fn issue_agent_token( - State(state): State, - Json(payload): Json, -) -> Result, ApiError> { - let edition = state.service.capability_profile().capabilities().edition; - let grant_type = payload.grant_type.clone(); - let response = state - .service - .token_issuer() - .issue_short_lived(payload, &community_token_issuer_actor()) - .await - .map_err(|error| { - map_token_issuer_error( - error, - edition, - MachineAccessMode::ShortLivedToken, - json!({ - "grant_type": grant_type, - "upgrade_required": true, - }), - ) - })?; - Ok(Json(serde_json::json!(response))) -} - -pub async fn issue_one_time_agent_token( - State(state): State, - Json(payload): Json, -) -> Result, ApiError> { - let edition = state.service.capability_profile().capabilities().edition; - let operation_id = payload.operation_id.as_str().to_owned(); - let response = state - .service - .token_issuer() - .issue_one_time(payload, &community_token_issuer_actor()) - .await - .map_err(|error| { - map_token_issuer_error( - error, - edition, - MachineAccessMode::OneTimeToken, - json!({ - "operation_id": operation_id, - "upgrade_required": true, - }), - ) - })?; - Ok(Json(serde_json::json!(response))) -} - -fn community_token_issuer_actor() -> TokenIssuerActor { - TokenIssuerActor { - user_id: UserId::new("user_community_public"), - workspace_id: WorkspaceId::new("ws_community_public"), - role: MembershipRole::Viewer, - } -} - -fn map_token_issuer_error( - error: TokenIssuerError, - edition: crank_core::ProductEdition, - machine_access_mode: MachineAccessMode, - extra_context: serde_json::Value, -) -> ApiError { - match error { - TokenIssuerError::NotSupportedInEdition => ApiError::forbidden_with_context( - match machine_access_mode { - MachineAccessMode::ShortLivedToken => { - "short-lived machine access is not available in Community" - } - MachineAccessMode::OneTimeToken => { - "one-time machine access is not available in Community" - } - MachineAccessMode::StaticAgentKey => { - "static agent key machine access is not available for token issue" - } - }, - merge_machine_auth_context(edition, machine_access_mode, extra_context), - ), - TokenIssuerError::InvalidGrant(reason) => ApiError::validation_with_context( - "invalid machine token grant", - json!({ "reason": reason }), - ), - TokenIssuerError::AgentKeyUnknown => ApiError::validation("agent key is unknown"), - TokenIssuerError::OperationNotStrict => ApiError::validation("operation is not strict"), - TokenIssuerError::OperationNotPublishedForAgent => { - ApiError::validation("operation is not published for agent") - } - TokenIssuerError::RegistryFailure(details) => { - ApiError::internal(format!("token issuer registry failure: {details}")) - } - TokenIssuerError::ReplayGuardFailure(details) => { - ApiError::internal(format!("token issuer replay guard failure: {details}")) - } - } -} - -fn merge_machine_auth_context( - edition: crank_core::ProductEdition, - machine_access_mode: MachineAccessMode, - extra_context: serde_json::Value, -) -> serde_json::Value { - let mut context = json!({ - "edition": edition, - "machine_access_mode": machine_access_mode, - }); - - if let (Some(base), Some(extra)) = (context.as_object_mut(), extra_context.as_object()) { - for (key, value) in extra { - base.insert(key.clone(), value.clone()); - } - } - - context -} diff --git a/apps/admin-api/src/routes/streaming.rs b/apps/admin-api/src/routes/streaming.rs deleted file mode 100644 index 319a348..0000000 --- a/apps/admin-api/src/routes/streaming.rs +++ /dev/null @@ -1,16 +0,0 @@ -use axum::{ - Json, - extract::{Path, State}, -}; -use serde_json::{Value, json}; - -use crate::{error::ApiError, routes::access::WorkspacePath, state::AppState}; - -pub async fn list_protocol_capabilities( - Path(_path): Path, - State(state): State, -) -> Result, ApiError> { - Ok(Json(json!({ - "items": state.service.list_protocol_capabilities().await - }))) -} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 60c943e..440c00d 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -8,12 +8,12 @@ use crank_core::{ AuthKind, AuthProfile, AuthProfileId, CapabilityProfile, CommunityCapabilityProfile, ConfigExport, EditionCapabilities, ExecutionMode, ExportMode, GeneratedDraft, GeneratedDraftStatus, IdentityError, IdentityProvider, InvocationLevel, InvocationLog, - InvocationLogId, InvocationSource, InvocationStatus, LoginOutcome, MachineTokenIssuer, - MembershipRole, NoMachineTokenIssuer, NoopAuditSink, OperationId, OperationSecurityLevel, - OperationStatus, OwnerOnlyPolicyEngine, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol, ResponseCachePolicy, SampleId, - Samples, Secret, SecretId, SecretKind, SecretStatus, Target, UsagePeriod, UserId, - UserSessionId, WizardState, Workspace, WorkspaceId, WorkspaceStatus, + InvocationLogId, InvocationSource, InvocationStatus, LoginOutcome, MembershipRole, + NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus, OwnerOnlyPolicyEngine, + PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, + ProductEdition, Protocol, ResponseCachePolicy, SampleId, Samples, Secret, SecretId, SecretKind, + SecretStatus, Target, UsagePeriod, UserId, UserSessionId, WizardState, Workspace, WorkspaceId, + WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_registry::{ @@ -59,7 +59,6 @@ pub struct AdminService { identity_provider: Option>, policy_engine: Arc, audit_sink: Arc, - token_issuer: Arc, capability_profile: Arc, } @@ -72,7 +71,6 @@ pub struct AdminServiceBuilder { identity_provider: Option>, policy_engine: Option>, audit_sink: Option>, - token_issuer: Option>, capability_profile: Option>, } @@ -508,21 +506,6 @@ pub struct DescriptorUploadResponse { pub version: u32, } -#[derive(Clone, Debug, Serialize)] -pub struct GrpcServiceSummary { - pub package: String, - pub service: String, - pub methods: Vec, -} - -#[derive(Clone, Debug, Serialize)] -pub struct GrpcMethodSummary { - pub name: String, - pub kind: String, - pub input_schema: Schema, - pub output_schema: Schema, -} - fn default_operation_category() -> String { "general".to_owned() } @@ -574,10 +557,6 @@ impl AdminService { &self.audit_sink } - pub fn token_issuer(&self) -> &Arc { - &self.token_issuer - } - pub fn capability_profile(&self) -> &Arc { &self.capability_profile } @@ -600,7 +579,6 @@ impl AdminServiceBuilder { identity_provider: None, policy_engine: None, audit_sink: None, - token_issuer: None, capability_profile: None, } } @@ -622,12 +600,6 @@ impl AdminServiceBuilder { self } - #[allow(dead_code)] - pub fn with_token_issuer(mut self, token_issuer: Arc) -> Self { - self.token_issuer = Some(token_issuer); - self - } - #[allow(dead_code)] pub fn with_capability_profile( mut self, @@ -649,9 +621,6 @@ impl AdminServiceBuilder { .policy_engine .unwrap_or_else(|| Arc::new(OwnerOnlyPolicyEngine)), audit_sink: self.audit_sink.unwrap_or_else(|| Arc::new(NoopAuditSink)), - token_issuer: self - .token_issuer - .unwrap_or_else(|| Arc::new(NoMachineTokenIssuer)), capability_profile: self .capability_profile .unwrap_or_else(|| Arc::new(CommunityCapabilityProfile)), @@ -789,9 +758,6 @@ impl AdminService { .await { Ok(LoginOutcome::Authenticated(identity)) => Ok(identity), - Ok(LoginOutcome::TwoFactorRequired(_)) => Err(ApiError::internal( - "two-factor login flow is not configured for this service", - )), Err(error) => Err(map_identity_error(error)), }; } @@ -1818,204 +1784,6 @@ impl AdminService { .map(Some) } - #[cfg(any())] - async fn start_stream_session_test( - &self, - workspace_id: &WorkspaceId, - operation: &RegistryOperation, - runtime: &RuntimeOperation, - input: &Value, - resolved_auth: Option<&ResolvedAuth>, - request_context: &RuntimeRequestContext, - ) -> Result { - let streaming = runtime.execution_config.streaming.as_ref().ok_or_else(|| { - RuntimeError::MissingStreamingConfig { - operation_id: runtime.operation_id.as_str().to_owned(), - } - })?; - let seed = self - .runtime - .execute_session_seed_with_auth_and_context( - runtime, - input, - resolved_auth, - Some(request_context), - ) - .await?; - 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 created_at = OffsetDateTime::now_utc(); - let expires_at = created_at - .checked_add(time::Duration::milliseconds( - streaming.max_session_lifetime_ms.unwrap_or(60_000) as i64, - )) - .ok_or_else(|| RuntimeError::SecretCrypto { - operation: "compute stream session expiration", - details: "failed to compute stream session expiration".to_owned(), - })?; - let session = StreamSession { - id: crank_core::StreamSessionId::new(new_prefixed_id("sess")), - workspace_id: workspace_id.clone(), - agent_id: None, - operation_id: operation.id.clone(), - protocol: operation.protocol, - mode: ExecutionMode::Session, - status: StreamStatus::Running, - cursor: (next_index < seed.items.len()).then(|| json!(next_index)), - state: json!(StoredSessionState { - input: input.clone(), - summary: seed.summary.clone(), - items: seed.items.clone(), - next_index, - batch_size, - }), - expires_at, - last_poll_at: None, - created_at, - closed_at: None, - }; - self.registry - .create_stream_session(CreateStreamSessionRequest { session: &session }) - .await - .map_err(|error| RuntimeError::SecretCrypto { - operation: "persist stream session", - details: error.to_string(), - })?; - - Ok(StreamSessionStartView { - session_id: session.id.as_str().to_owned(), - status: session.status, - expires_at, - poll_after_ms: streaming.poll_interval_ms.unwrap_or(1_000), - preview: json!({ - "summary": seed.summary, - "items": preview_items, - }), - }) - } - - #[cfg(any())] - async fn start_async_job_test( - &self, - workspace_id: &WorkspaceId, - operation: &RegistryOperation, - runtime: &RuntimeOperation, - input: &Value, - request_context: &RuntimeRequestContext, - ) -> Result { - let created_at = OffsetDateTime::now_utc(); - let streaming = runtime.execution_config.streaming.as_ref().ok_or_else(|| { - RuntimeError::MissingStreamingConfig { - operation_id: runtime.operation_id.as_str().to_owned(), - } - })?; - let expires_at = created_at - .checked_add(time::Duration::milliseconds( - streaming.max_session_lifetime_ms.unwrap_or(300_000) as i64, - )) - .ok_or_else(|| RuntimeError::SecretCrypto { - operation: "compute async job expiration", - details: "failed to compute async job expiration".to_owned(), - })?; - let job = AsyncJobHandle { - id: crank_core::AsyncJobId::new(new_prefixed_id("job")), - workspace_id: workspace_id.clone(), - agent_id: None, - operation_id: operation.id.clone(), - status: JobStatus::Running, - progress: json!({ "pct": 0 }), - result: None, - error: None, - expires_at: Some(expires_at), - last_poll_at: None, - created_at, - updated_at: created_at, - finished_at: None, - }; - self.registry - .create_async_job(CreateAsyncJobRequest { job: &job }) - .await - .map_err(|error| RuntimeError::SecretCrypto { - operation: "persist async job", - details: error.to_string(), - })?; - - let registry = self.registry.clone(); - let secret_crypto = self.secret_crypto.clone(); - let runtime_for_task = self.runtime.clone(); - let workspace_for_task = workspace_id.clone(); - let operation_for_task = runtime.clone(); - let input_for_task = input.clone(); - let request_context_for_task = request_context.clone(); - let job_id = job.id.clone(); - tokio::spawn(async move { - let resolved_auth = resolve_runtime_auth_for_task( - ®istry, - &secret_crypto, - &workspace_for_task, - &operation_for_task.execution_config, - ) - .await; - let result = match resolved_auth { - Ok(resolved_auth) => { - runtime_for_task - .execute_with_auth_and_context( - &operation_for_task, - &input_for_task, - resolved_auth.as_ref(), - Some(&request_context_for_task), - ) - .await - } - Err(error) => Err(error), - }; - let finished_at = OffsetDateTime::now_utc(); - let _ = 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 - } - }; - }); - - Ok(AsyncJobStartView { - job_id: job.id.as_str().to_owned(), - status: job.status, - progress: job.progress, - }) - } - async fn resolve_auth_profile( &self, workspace_id: &WorkspaceId, @@ -3419,8 +3187,6 @@ impl AdminService { "path": {}, "query": {}, "headers": { "x-demo-source": "crank-seed" }, - "variables": null, - "grpc": null, "body": demo_rest_request_sample() }), response_preview: demo_rest_response_sample(), @@ -3602,9 +3368,9 @@ fn demo_revops_agent_payload() -> AgentPayload { AgentPayload { slug: "revops-copilot".to_owned(), display_name: "RevOps Copilot".to_owned(), - description: "Revenue operations assistant with CRM and billing tools.".to_owned(), + description: "Sales operations assistant with CRM tools.".to_owned(), instructions: json!({ - "system": "Prefer CRM mutations first, then billing lookups for confirmation." + "system": "Prefer CRM mutations first, then lookup tools for confirmation." }), tool_selection_policy: json!({ "max_tools": 8, diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index bef3111..79da005 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -138,23 +138,13 @@ mod tests { routing::{get, post}, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; - #[cfg(any())] - use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, }; - #[cfg(any())] - use crank_core::{ - AggregationMode, AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionMode, - GraphqlOperationType, GraphqlTarget, GrpcTarget, JobStatus, StreamingConfig, - ToolFamilyConfig, TransportBehavior, - }; use crank_mapping::{MappingRule, MappingSet}; - #[cfg(any())] - use crank_registry::CreateAsyncJobRequest; use crank_registry::{ CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest, SaveAgentBindingsRequest, @@ -664,138 +654,6 @@ mod tests { assert!(logs.contains("initialize")); } - #[cfg(any())] - #[tokio::test] - async fn initializes_and_calls_published_graphql_tool_via_mcp() { - let registry = test_registry().await; - let endpoint = spawn_graphql_server().await; - let operation = test_graphql_operation(&endpoint, "crm_create_lead_graphql"); - - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-graphql").await; - let api_key = create_platform_api_key( - ®istry, - "sales-graphql", - "mcp-graphql", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_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-graphql"); - 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_create_lead_graphql", - "arguments": { - "email": "user@example.com" - } - } - }), - ) - .await; - - assert_eq!( - call_result["result"]["structuredContent"], - json!({ "id": "lead_123" }) - ); - assert_eq!(call_result["result"]["isError"], false); - } - - #[cfg(any())] - #[tokio::test] - async fn initializes_and_calls_published_grpc_tool_via_mcp() { - let registry = test_registry().await; - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let operation = test_grpc_operation(&server_addr, "echo_unary_grpc"); - - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-grpc").await; - let api_key = create_platform_api_key( - ®istry, - "sales-grpc", - "mcp-grpc", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_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-grpc"); - 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": "echo_unary_grpc", - "arguments": { - "message": "hello" - } - } - }), - ) - .await; - - assert_eq!( - call_result["result"]["structuredContent"], - json!({ "message": "hello" }) - ); - assert_eq!(call_result["result"]["isError"], false); - } - #[tokio::test] async fn requires_initialized_notification_before_tool_methods() { let registry = test_registry().await; @@ -1522,50 +1380,6 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } - #[tokio::test] - async fn accepts_initialize_with_verified_bearer_token_seam() { - let registry = test_registry().await; - publish_agent_with_bindings(®istry, "sales-token-seam", vec![]).await; - - let verifier = Arc::new(StubMachineCredentialVerifier { - token: "issued_token_demo".to_owned(), - credential: VerifiedMachineCredential { - machine_access_mode: crank_core::MachineAccessMode::ShortLivedToken, - max_security_level: crank_core::OperationSecurityLevel::Elevated, - scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - }, - }); - - let base_url = spawn_mcp_server(build_test_app_with_store( - registry, - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - RequestRateLimitConfig::new(10_000, 10_000).unwrap(), - std::sync::Arc::new(InMemorySessionStore::default()), - verifier, - )) - .await; - let client = reqwest::Client::new(); - let response = client - .post(agent_mcp_url(&base_url, "sales-token-seam")) - .header(header::ACCEPT, "application/json, text/event-stream") - .header(header::AUTHORIZATION, "Bearer issued_token_demo") - .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!(response.headers().get("MCP-Session-Id").is_some()); - } - #[tokio::test] async fn rejects_initialize_without_platform_api_key() { let registry = test_registry().await; @@ -1657,706 +1471,6 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); } - #[tokio::test] - async fn rejects_elevated_operation_with_static_agent_key() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let mut operation = test_operation(&upstream_base_url, "crm_elevated_static"); - operation.security_level = crank_core::OperationSecurityLevel::Elevated; - - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-elevated-static").await; - let api_key = create_platform_api_key( - ®istry, - "sales-elevated-static", - "mcp-elevated-static", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_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-elevated-static"); - 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": 2, - "method": "tools/call", - "params": { - "name": "crm_elevated_static", - "arguments": { - "email": "alice@example.com" - } - } - }), - ) - .await; - - assert_eq!(call_result["result"]["isError"], json!(true)); - assert_eq!( - call_result["result"]["structuredContent"]["error"]["code"], - "machine_access_insufficient" - ); - assert_eq!( - call_result["result"]["structuredContent"]["error"]["context"]["machine_access_mode"], - "static_agent_key" - ); - assert_eq!( - call_result["result"]["structuredContent"]["error"]["context"]["required_security_level"], - "elevated" - ); - } - - #[tokio::test] - async fn allows_elevated_operation_with_verified_short_lived_token() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let mut operation = test_operation(&upstream_base_url, "crm_elevated_token"); - operation.security_level = crank_core::OperationSecurityLevel::Elevated; - - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-elevated-token").await; - - let verifier = Arc::new(StubMachineCredentialVerifier { - token: "issued_token_elevated".to_owned(), - credential: VerifiedMachineCredential { - machine_access_mode: crank_core::MachineAccessMode::ShortLivedToken, - max_security_level: crank_core::OperationSecurityLevel::Elevated, - scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - }, - }); - - let base_url = spawn_mcp_server(build_test_app_with_store( - registry, - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - RequestRateLimitConfig::new(10_000, 10_000).unwrap(), - std::sync::Arc::new(InMemorySessionStore::default()), - verifier, - )) - .await; - let client = reqwest::Client::new(); - let mcp_url = agent_mcp_url(&base_url, "sales-elevated-token"); - let initialized_session = - initialize_session(&client, &mcp_url, "issued_token_elevated").await; - let call_result = post_jsonrpc( - &client, - &mcp_url, - "issued_token_elevated", - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "crm_elevated_token", - "arguments": { - "email": "alice@example.com" - } - } - }), - ) - .await; - - assert_eq!(call_result["result"]["isError"], json!(false)); - assert_eq!(call_result["result"]["structuredContent"]["id"], "lead_123"); - } - - #[cfg(any())] - #[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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-session").await; - let api_key = create_platform_api_key( - ®istry, - "sales-session", - "mcp-session", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_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" - }) - ); - } - - #[cfg(any())] - #[tokio::test] - async fn rejects_cross_agent_session_poll() { - let registry = test_registry().await; - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let operation_a = test_grpc_session_operation(&server_addr, "echo_stream_session_a"); - let operation_b = test_grpc_session_operation(&server_addr, "echo_stream_session_b"); - - for operation in [&operation_a, &operation_b] { - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - } - - publish_agent_for_operation(®istry, &operation_a, "sales-session-a").await; - publish_agent_for_operation(®istry, &operation_b, "sales-session-b").await; - let api_key_a = create_platform_api_key( - ®istry, - "sales-session-a", - "mcp-cross-session-a", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - let api_key_b = create_platform_api_key( - ®istry, - "sales-session-b", - "mcp-cross-session-b", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_app( - registry, - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - )) - .await; - let client = reqwest::Client::new(); - let agent_a_url = agent_mcp_url(&base_url, "sales-session-a"); - let agent_b_url = agent_mcp_url(&base_url, "sales-session-b"); - let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await; - let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await; - - let start_response = post_jsonrpc( - &client, - &agent_a_url, - &api_key_a, - Some(&session_a), - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "echo_stream_session_a_start", - "arguments": { "message": "hello" } - } - }), - ) - .await; - let foreign_session_id = start_response["result"]["structuredContent"]["session_id"] - .as_str() - .unwrap() - .to_owned(); - - let poll_response = post_jsonrpc( - &client, - &agent_b_url, - &api_key_b, - Some(&session_b), - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "echo_stream_session_b_poll", - "arguments": { "session_id": foreign_session_id } - } - }), - ) - .await; - - assert_eq!( - poll_response["result"]["structuredContent"]["error"]["code"], - json!("stream_session_not_found") - ); - assert!( - poll_response["result"]["structuredContent"]["error"]["message"] - .as_str() - .is_some_and(|message| message.starts_with("stream session ")) - ); - } - - #[cfg(any())] - #[tokio::test] - async fn rejects_rapid_repeat_session_poll() { - 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_rate"); - - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - - publish_agent_for_operation(®istry, &operation, "sales-session-rate").await; - let api_key = create_platform_api_key( - ®istry, - "sales-session-rate", - "mcp-session-rate", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_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-session-rate"); - let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; - - let start_response = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "echo_stream_session_rate_start", - "arguments": { "message": "hello" } - } - }), - ) - .await; - let session_id = start_response["result"]["structuredContent"]["session_id"] - .as_str() - .unwrap() - .to_owned(); - - let first_poll = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "echo_stream_session_rate_poll", - "arguments": { "session_id": session_id } - } - }), - ) - .await; - assert_eq!(first_poll["result"]["isError"], false); - - let second_poll = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "echo_stream_session_rate_poll", - "arguments": { "session_id": session_id } - } - }), - ) - .await; - - assert_eq!( - second_poll["result"]["structuredContent"]["error"]["code"], - json!("stream_session_poll_rate_limited") - ); - let poll_after_ms = - second_poll["result"]["structuredContent"]["error"]["context"]["poll_after_ms"] - .as_u64() - .unwrap(); - assert!((1..=250).contains(&poll_after_ms)); - } - - #[cfg(any())] - #[cfg(any())] - #[tokio::test] - async fn rejects_rapid_repeat_async_job_status_poll() { - 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_rate"); - - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-async-rate").await; - let api_key = create_platform_api_key( - ®istry, - "sales-async-rate", - "mcp-async-rate", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let now = OffsetDateTime::now_utc(); - let job_id = AsyncJobId::new("job_async_rate".to_owned()); - registry - .create_async_job(CreateAsyncJobRequest { - job: &AsyncJobHandle { - id: job_id.clone(), - workspace_id: test_workspace_id(), - agent_id: Some(AgentId::new("agent_sales-async-rate")), - operation_id: operation.id.clone(), - status: JobStatus::Running, - progress: json!({ "pct": 25 }), - result: None, - error: None, - expires_at: Some(now + time::Duration::minutes(5)), - last_poll_at: None, - created_at: now, - updated_at: now, - finished_at: None, - }, - }) - .await - .unwrap(); - - let base_url = spawn_mcp_server(build_test_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-async-rate"); - let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; - - let first_status = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "crm_async_rate_status", - "arguments": { - "job_id": job_id.as_str() - } - } - }), - ) - .await; - assert_eq!(first_status["result"]["isError"], false); - - let second_status = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "crm_async_rate_status", - "arguments": { - "job_id": job_id.as_str() - } - } - }), - ) - .await; - - assert_eq!( - second_status["result"]["structuredContent"]["error"]["code"], - json!("async_job_poll_rate_limited") - ); - let poll_after_ms = - second_status["result"]["structuredContent"]["error"]["context"]["poll_after_ms"] - .as_u64() - .unwrap(); - assert!((1..=250).contains(&poll_after_ms)); - } - - #[cfg(any())] - #[cfg(any())] - #[tokio::test(flavor = "multi_thread")] - async fn allows_completed_async_job_result_without_poll_delay() { - 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_completed"); - - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-async-completed").await; - let api_key = create_platform_api_key( - ®istry, - "sales-async-completed", - "mcp-async-completed", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let now = OffsetDateTime::now_utc(); - let job_id = AsyncJobId::new("job_async_completed".to_owned()); - registry - .create_async_job(CreateAsyncJobRequest { - job: &AsyncJobHandle { - id: job_id.clone(), - workspace_id: test_workspace_id(), - agent_id: Some(AgentId::new("agent_sales-async-completed")), - operation_id: operation.id.clone(), - status: JobStatus::Completed, - progress: json!({ "pct": 100 }), - result: Some(json!({ "id": "lead_123" })), - error: None, - expires_at: Some(now + time::Duration::minutes(5)), - last_poll_at: Some(now), - created_at: now, - updated_at: now, - finished_at: Some(now), - }, - }) - .await - .unwrap(); - - let base_url = spawn_mcp_server(build_test_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-async-completed"); - let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; - - let result_response = post_jsonrpc( - &client, - &mcp_url, - &api_key, - Some(&initialized_session), - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "crm_async_completed_result", - "arguments": { - "job_id": job_id.as_str() - } - } - }), - ) - .await; - assert_eq!( - result_response["result"]["structuredContent"]["id"], - "lead_123" - ); - } - #[tokio::test] async fn rejects_rapid_initialize_requests_with_429() { let registry = test_registry().await; @@ -2454,330 +1568,6 @@ mod tests { assert!((1..=1000).contains(&retry_after_ms)); } - #[cfg(any())] - #[cfg(any())] - #[tokio::test] - async fn rejects_cross_agent_async_job_access() { - let registry = test_registry().await; - let upstream_base_url = spawn_upstream_server().await; - let operation_a = test_rest_async_job_operation(&upstream_base_url, "crm_async_a"); - let operation_b = test_rest_async_job_operation(&upstream_base_url, "crm_async_b"); - - for operation in [&operation_a, &operation_b] { - 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - } - - publish_agent_for_operation(®istry, &operation_a, "sales-async-a").await; - publish_agent_for_operation(®istry, &operation_b, "sales-async-b").await; - let api_key_a = create_platform_api_key( - ®istry, - "sales-async-a", - "mcp-cross-async-a", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - let api_key_b = create_platform_api_key( - ®istry, - "sales-async-b", - "mcp-cross-async-b", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_app( - registry, - Duration::from_millis(0), - Some("https://crank.example.com".to_owned()), - )) - .await; - let client = reqwest::Client::new(); - let agent_a_url = agent_mcp_url(&base_url, "sales-async-a"); - let agent_b_url = agent_mcp_url(&base_url, "sales-async-b"); - let session_a = initialize_session(&client, &agent_a_url, &api_key_a).await; - let session_b = initialize_session(&client, &agent_b_url, &api_key_b).await; - - let start_response = post_jsonrpc( - &client, - &agent_a_url, - &api_key_a, - Some(&session_a), - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "crm_async_a_start", - "arguments": { "email": "user@example.com" } - } - }), - ) - .await; - let foreign_job_id = start_response["result"]["structuredContent"]["job_id"] - .as_str() - .unwrap() - .to_owned(); - - let status_response = post_jsonrpc( - &client, - &agent_b_url, - &api_key_b, - Some(&session_b), - json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": { - "name": "crm_async_b_status", - "arguments": { "job_id": foreign_job_id } - } - }), - ) - .await; - - assert_eq!( - status_response["result"]["structuredContent"]["error"]["code"], - json!("async_job_not_found") - ); - assert!( - status_response["result"]["structuredContent"]["error"]["message"] - .as_str() - .is_some_and(|message| message.starts_with("async job ")) - ); - } - - #[cfg(any())] - #[cfg(any())] - #[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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-async").await; - let api_key = create_platform_api_key( - ®istry, - "sales-async", - "mcp-async", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_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(250)).await; - } - - sleep(Duration::from_millis(250)).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" }) - ); - } - - #[cfg(any())] - #[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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_by: Some("alice"), - }) - .await - .unwrap(); - publish_agent_for_operation(®istry, &operation, "sales-window").await; - let api_key = create_platform_api_key( - ®istry, - "sales-window", - "mcp-window", - &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], - ) - .await; - - let base_url = spawn_mcp_server(build_test_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) @@ -2912,19 +1702,6 @@ mod tests { format!("http://{}", address) } - #[cfg(any())] - async fn spawn_graphql_server() -> String { - let app = Router::new().route("/", post(graphql_handler)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - async fn spawn_mcp_server(app: Router) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -3027,9 +1804,9 @@ mod tests { async fn stream_logs() -> Sse>> { let events = vec![ - json!({ "level": "info", "message": "billing started" }), + json!({ "level": "info", "message": "sync started" }), json!({ "level": "warn", "message": "cache warmup slow" }), - json!({ "level": "error", "message": "invoice timeout" }), + json!({ "level": "error", "message": "upstream timeout" }), ]; let stream = stream::iter(events.into_iter().map(|payload| { Ok::<_, std::convert::Infallible>(Event::default().data(payload.to_string())) @@ -3037,24 +1814,6 @@ mod tests { Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) } - #[cfg(any())] - async fn graphql_handler(Json(payload): Json) -> Json { - let email = payload - .get("variables") - .and_then(|variables| variables.get("email")) - .and_then(Value::as_str) - .unwrap_or_default(); - - Json(json!({ - "data": { - "createLead": { - "id": "lead_123", - "email": email - } - } - })) - } - async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:15432/crank".to_owned()); @@ -3138,301 +1897,6 @@ mod tests { } } - #[cfg(any())] - fn test_graphql_operation(endpoint: &str, name: &str) -> Operation { - Operation { - id: OperationId::new(format!("op_{name}")), - name: name.to_owned(), - display_name: "Create Lead GraphQL".to_owned(), - category: "sales".to_owned(), - protocol: Protocol::Graphql, - security_level: crank_core::OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Graphql(GraphqlTarget { - endpoint: endpoint.to_owned(), - operation_type: GraphqlOperationType::Mutation, - operation_name: "CreateLead".to_owned(), - query_template: - "mutation CreateLead($email: String!) { createLead(email: $email) { id email } }" - .to_owned(), - response_path: "$.response.body.data.createLead".to_owned(), - }), - input_schema: object_schema("email"), - output_schema: object_schema("id"), - input_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.mcp.email".to_owned(), - target: "$.request.variables.email".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - output_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.response.data.id".to_owned(), - target: "$.output.id".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Create Lead GraphQL".to_owned(), - description: "Creates a CRM lead through GraphQL".to_owned(), - tags: vec!["graphql".to_owned()], - examples: Vec::new(), - }, - samples: None, - generated_draft: None, - config_export: None, - wizard_state: None, - created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - updated_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_at: Some( - OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - ), - } - } - - #[cfg(any())] - fn test_grpc_operation(server_addr: &str, name: &str) -> Operation { - Operation { - id: OperationId::new(format!("op_{name}")), - name: name.to_owned(), - display_name: "Unary Echo gRPC".to_owned(), - category: "support".to_owned(), - protocol: Protocol::Grpc, - security_level: crank_core::OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Grpc(GrpcTarget { - server_addr: server_addr.to_owned(), - package: "echo".to_owned(), - service: "EchoService".to_owned(), - method: "UnaryEcho".to_owned(), - read_only: false, - descriptor_ref: DescriptorId::new("desc_echo"), - descriptor_set_b64: grpc_test_support::descriptor_set_b64(), - }), - input_schema: object_schema("message"), - output_schema: object_schema("message"), - 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![MappingRule { - source: "$.response.data.message".to_owned(), - target: "$.output.message".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Unary Echo gRPC".to_owned(), - description: "Echoes a unary gRPC payload".to_owned(), - tags: vec!["grpc".to_owned()], - examples: Vec::new(), - }, - samples: None, - generated_draft: None, - config_export: None, - wizard_state: None, - created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - updated_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), - published_at: Some(OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap()), - } - } - - #[cfg(any())] - 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(), - read_only: false, - 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 - } - - #[cfg(any())] - 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 - } - - #[cfg(any())] - 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, @@ -3459,47 +1923,4 @@ mod tests { variants: Vec::new(), } } - - #[cfg(any())] - 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(), - } - } - - #[cfg(any())] - 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/css/catalog.css b/apps/ui/css/catalog.css index dbae1e1..a9bd97b 100644 --- a/apps/ui/css/catalog.css +++ b/apps/ui/css/catalog.css @@ -334,9 +334,6 @@ letter-spacing: 0.02em; } .badge-rest { color: var(--blue); background: var(--blue-bg); border-color: var(--blue-border); } -.badge-graphql { color: var(--purple); background: var(--purple-bg); border-color: var(--purple-border); } -.badge-grpc { color: #14b8a6; background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); } - .status-badge { display: inline-flex; align-items: center; diff --git a/apps/ui/css/login.css b/apps/ui/css/login.css index ad23017..126d266 100644 --- a/apps/ui/css/login.css +++ b/apps/ui/css/login.css @@ -197,38 +197,6 @@ .login-divider-line { flex: 1; height: 1px; background: var(--border); } .login-divider-text { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; } -.btn-sso { - width: 100%; - padding: 9px; - background: var(--bg-overlay); - color: var(--text-secondary); - border: 1px solid var(--border); - border-radius: 7px; - font-family: 'Inter', sans-serif; - font-size: 13.5px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s; - display: flex; - align-items: center; - justify-content: center; - gap: 9px; -} - -.btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); } - -.btn-sso:disabled { - cursor: not-allowed; - opacity: 0.7; - background: var(--bg-overlay); - color: var(--text-muted); -} - -.btn-sso:disabled:hover { - background: var(--bg-overlay); - color: var(--text-muted); -} - .login-inline-badge { display: inline-flex; align-items: center; diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index 3a90046..d26e1ea 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -534,31 +534,11 @@ border: 1px solid rgba(88, 166, 255, 0.2); } -.protocol-card.graphql .protocol-icon-wrap { - background: rgba(188, 140, 255, 0.12); - border: 1px solid rgba(188, 140, 255, 0.2); -} - -.protocol-card.grpc .protocol-icon-wrap { - background: rgba(13, 148, 136, 0.12); - border: 1px solid rgba(13, 148, 136, 0.2); -} - .protocol-card.selected.rest .protocol-icon-wrap { background: rgba(88, 166, 255, 0.18); border-color: rgba(88, 166, 255, 0.35); } -.protocol-card.selected.graphql .protocol-icon-wrap { - background: rgba(188, 140, 255, 0.18); - border-color: rgba(188, 140, 255, 0.35); -} - -.protocol-card.selected.grpc .protocol-icon-wrap { - background: rgba(13, 148, 136, 0.2); - border-color: rgba(13, 148, 136, 0.4); -} - .protocol-card-name { font-size: 15px; font-weight: 700; @@ -608,12 +588,6 @@ color: #58a6ff; } -.protocol-card.graphql.selected .protocol-tag { - background: rgba(188, 140, 255, 0.1); - border-color: rgba(188, 140, 255, 0.25); - color: #bc8cff; -} - /* ── REST method picker ── */ .method-section { background: var(--bg-surface); @@ -1601,414 +1575,3 @@ .method-callout[hidden] { display: none !important; } - -/* ══════════════════════════════════════════════ - GraphQL operation type cards (Step 4 GraphQL) - ══════════════════════════════════════════════ */ - -.gql-type-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.gql-type-card { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; - padding: 16px 18px; - background: var(--bg-canvas); - border: 1.5px solid var(--border); - border-radius: 8px; - cursor: pointer; - transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; - text-align: left; - font-family: 'Inter', sans-serif; -} - -.gql-type-card:hover { - border-color: var(--accent); - background: rgba(13, 148, 136, 0.04); -} - -.gql-type-card.active { - border-color: var(--accent); - background: var(--accent-glow); - box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15); -} - -.gql-type-keyword { - font-size: 15px; - font-weight: 700; - font-family: 'JetBrains Mono', 'Fira Code', monospace; - color: var(--accent); - letter-spacing: -0.3px; -} - -.gql-type-card.active .gql-type-keyword { color: #5eead4; } - -.gql-type-desc { - font-size: 12px; - color: var(--text-muted); - line-height: 1.4; -} - -.gql-type-card.active .gql-type-desc { color: var(--text-secondary); } - - -/* ══════════════════════════════════════════════ - Protocol card — disabled tag (Streaming) -══════════════════════════════════════════════ */ -.protocol-tag-disabled { - text-decoration: line-through; - color: var(--error, #f87171); - opacity: 0.75; -} - - -/* ══════════════════════════════════════════════ - gRPC — Proto upload -══════════════════════════════════════════════ */ -.proto-dropzone { - border: 1.5px dashed var(--border); - border-radius: 10px; - padding: 36px 24px; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - cursor: pointer; - transition: border-color 0.15s, background 0.15s; - background: var(--bg-canvas); -} -.proto-dropzone:hover, -.proto-dropzone.drag-over { - border-color: var(--accent); - background: rgba(56,139,253,0.05); -} -.proto-dropzone.drag-over { border-style: solid; } - -.proto-dropzone-title { - font-size: 13px; - font-weight: 500; - color: var(--text-primary); -} -.proto-dropzone-sub { - font-size: 12px; - color: var(--text-muted); -} - -.proto-file-info { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - background: rgba(56,139,253,0.07); - border: 1px solid rgba(56,139,253,0.25); - border-radius: 8px; -} -.proto-file-name { - font-family: var(--font-mono, monospace); - font-size: 13px; - color: var(--accent); - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.proto-file-size { - font-size: 11px; - color: var(--text-muted); - white-space: nowrap; -} -.proto-file-change { - background: none; - border: 1px solid var(--border); - border-radius: 5px; - color: var(--text-secondary); - font-size: 11px; - padding: 3px 9px; - cursor: pointer; - white-space: nowrap; - transition: border-color 0.15s, color 0.15s; -} -.proto-file-change:hover { border-color: var(--accent); color: var(--accent); } - - -/* ══════════════════════════════════════════════ - gRPC — Service/method list -══════════════════════════════════════════════ */ -.proto-service-group { - padding-top: 16px; -} -.proto-service-group + .proto-service-group { - border-top: 1px solid var(--border-subtle); - margin-top: 4px; -} - -.proto-service-label { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); - margin-bottom: 10px; -} -.proto-service-label svg { opacity: 0.5; } - -.proto-method-grid { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.proto-method-card { - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 14px; - background: var(--bg-canvas); - border: 1.5px solid var(--border); - border-radius: 8px; - cursor: pointer; - text-align: left; - transition: border-color 0.15s, background 0.15s; - min-width: 160px; -} -.proto-method-card:hover { - border-color: var(--border-muted, var(--accent)); - background: rgba(56,139,253,0.04); -} -.proto-method-card.active { - border-color: var(--accent); - background: rgba(56,139,253,0.08); -} - -.proto-method-name { - font-family: var(--font-mono, monospace); - font-size: 13px; - font-weight: 600; - color: var(--text-primary); -} -.proto-method-card.active .proto-method-name { color: var(--accent); } - -.proto-method-types { - font-size: 11px; - color: var(--text-muted); - display: flex; - align-items: center; - gap: 4px; -} -.proto-method-types svg { opacity: 0.5; flex-shrink: 0; } - -.proto-empty { - padding: 24px; - text-align: center; - color: var(--text-muted); - font-size: 13px; -} - - -/* ══════════════════════════════════════════════ - gRPC — Method signature detail -══════════════════════════════════════════════ */ -.proto-path-row { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - background: var(--bg-canvas); - border: 1px solid var(--border-subtle); - border-radius: 6px; -} -.proto-path-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - white-space: nowrap; -} -.proto-path-value { - font-family: var(--font-mono, monospace); - font-size: 12px; - color: var(--accent); - word-break: break-all; -} - -.proto-sig-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} -@media (max-width: 680px) { - .proto-sig-grid { grid-template-columns: 1fr; } -} - -.proto-sig-col { - display: flex; - flex-direction: column; - gap: 0; - border: 1px solid var(--border-subtle); - border-radius: 8px; - overflow: hidden; -} - -.proto-sig-col-header { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - background: var(--bg-surface); - border-bottom: 1px solid var(--border-subtle); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-secondary); -} -.proto-sig-col-header svg { opacity: 0.6; } - -.proto-type-badge { - font-family: var(--font-mono, monospace); - font-size: 11px; - background: rgba(56,139,253,0.12); - color: var(--accent); - padding: 1px 6px; - border-radius: 4px; - margin-left: auto; -} - -.proto-field-list { - padding: 6px 0; -} - -.proto-field-row { - display: flex; - align-items: baseline; - justify-content: space-between; - padding: 5px 12px; - font-size: 12px; - border-bottom: 1px solid var(--border-subtle); -} -.proto-field-row:last-child { border-bottom: none; } - -.proto-field-name { - font-family: var(--font-mono, monospace); - color: var(--text-primary); - font-size: 12px; -} -.proto-field-type { - font-family: var(--font-mono, monospace); - font-size: 11px; - color: var(--text-muted); -} - -.proto-field-empty { - padding: 16px 12px; - font-size: 12px; - color: var(--text-secondary); - text-align: center; - line-height: 1.5; -} - - -/* ══════════════════════════════════════════════ - gRPC — Source tabs -══════════════════════════════════════════════ */ -.grpc-source-tabs { - display: flex; - gap: 0; - margin-bottom: 20px; - background: var(--bg-canvas); - border: 1px solid var(--border); - border-radius: 9px; - padding: 3px; -} - -.grpc-source-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 7px; - padding: 8px 14px; - background: none; - border: none; - border-radius: 7px; - font-size: 13px; - font-weight: 500; - color: var(--text-muted); - cursor: pointer; - transition: background 0.15s, color 0.15s; - white-space: nowrap; -} -.grpc-source-btn svg { flex-shrink: 0; opacity: 0.7; } -.grpc-source-btn:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); } -.grpc-source-btn.active { - background: var(--bg-surface); - color: var(--text-primary); - box-shadow: 0 1px 3px rgba(0,0,0,0.3); -} -.grpc-source-btn.active svg { opacity: 1; } - - -/* ══════════════════════════════════════════════ - gRPC — Reflection panel -══════════════════════════════════════════════ */ -.grpc-reflect-upstream { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 14px; - background: var(--bg-canvas); - border: 1px solid var(--border-subtle); - border-radius: 8px; -} -.grpc-reflect-upstream-info { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} -.grpc-reflect-upstream-name { - font-size: 13px; - font-weight: 500; - color: var(--text-primary); -} -.grpc-reflect-upstream-url { - font-family: var(--font-mono, monospace); - font-size: 11px; - color: var(--text-muted); -} - -.grpc-reflect-status { - display: flex; - align-items: center; - gap: 10px; - font-size: 13px; - color: var(--text-secondary); -} -.grpc-reflect-spinner { - width: 14px; - height: 14px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: grpc-spin 0.7s linear infinite; - flex-shrink: 0; -} -@keyframes grpc-spin { to { transform: rotate(360deg); } } - -.grpc-reflect-error-row { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - color: var(--error, #f87171); -} diff --git a/apps/ui/data/operations.json b/apps/ui/data/operations.json index c0d4cd9..3b7bfc6 100644 --- a/apps/ui/data/operations.json +++ b/apps/ui/data/operations.json @@ -21,17 +21,6 @@ "target_url": "https://api.acme.com/v1/users/{id}", "created_at": "2026-03-18" }, - { - "id": "op_03", - "name": "search_knowledge_base", - "display_name": "Search Knowledge Base", - "protocol": "graphql", - "method": null, - "status": "active", - "category": "Search", - "target_url": "https://graph.notion-int.acme.com/graphql", - "created_at": "2026-03-14" - }, { "id": "op_04", "name": "send_slack_message", @@ -43,17 +32,6 @@ "target_url": "https://slack.com/api/chat.postMessage", "created_at": "2026-03-10" }, - { - "id": "op_05", - "name": "stream_telemetry", - "display_name": "Stream Telemetry Events", - "protocol": "grpc", - "method": null, - "status": "draft", - "category": "Observability", - "target_url": "grpc://telemetry.internal.acme.com:9090", - "created_at": "2026-03-05" - }, { "id": "op_06", "name": "update_deal_stage", @@ -65,28 +43,6 @@ "target_url": "https://api.acme.com/v2/crm/deals/{id}", "created_at": "2026-02-28" }, - { - "id": "op_07", - "name": "fetch_invoice", - "display_name": "Fetch Invoice", - "protocol": "rest", - "method": "GET", - "status": "active", - "category": "Billing", - "target_url": "https://billing.acme.com/v1/invoices/{id}", - "created_at": "2026-02-20" - }, - { - "id": "op_08", - "name": "list_products", - "display_name": "List Products", - "protocol": "graphql", - "method": null, - "status": "active", - "category": "Catalog", - "target_url": "https://shop.acme.com/graphql", - "created_at": "2026-02-15" - }, { "id": "op_09", "name": "create_support_ticket", @@ -109,17 +65,6 @@ "target_url": "https://orders.acme.com/v2/status/{id}", "created_at": "2026-02-05" }, - { - "id": "op_11", - "name": "sync_contacts", - "display_name": "Sync Contacts", - "protocol": "grpc", - "method": null, - "status": "active", - "category": "CRM", - "target_url": "grpc://contacts.internal.acme.com:9091", - "created_at": "2026-01-28" - }, { "id": "op_12", "name": "send_email_campaign", diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index 8729c25..0cbd5d8 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -215,11 +215,6 @@ - -
-
-
-
diff --git a/apps/ui/html/wizard/step1.html b/apps/ui/html/wizard/step1.html index df26ae3..b9d88d7 100644 --- a/apps/ui/html/wizard/step1.html +++ b/apps/ui/html/wizard/step1.html @@ -34,11 +34,6 @@
-
-
-
-
- @@ -54,16 +49,6 @@ - - diff --git a/apps/ui/icons/wizard/graphql-protocol.svg b/apps/ui/icons/wizard/graphql-protocol.svg deleted file mode 100644 index 08a5fa5..0000000 --- a/apps/ui/icons/wizard/graphql-protocol.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/apps/ui/icons/wizard/grpc-protocol.svg b/apps/ui/icons/wizard/grpc-protocol.svg deleted file mode 100644 index bde9d0a..0000000 --- a/apps/ui/icons/wizard/grpc-protocol.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 53e56de..e95cdc3 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -319,9 +319,6 @@ getUsageOverview: function(workspaceId, params) { return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params)); }, - getProtocolCapabilities: function(workspaceId) { - return get('/workspaces/' + encodeURIComponent(workspaceId) + '/protocol-capabilities'); - }, getOperationUsage: function(workspaceId, operationId, params) { return get( '/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params) diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index fc90649..1992661 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -371,16 +371,10 @@ var TRANSLATIONS = { 'settings.security.saved': 'Password updated.', 'settings.security.save_error': 'Failed to change password', 'settings.capability.rest': 'REST / HTTP', - 'settings.capability.graphql': 'GraphQL', - 'settings.capability.grpc': 'gRPC unary', - 'settings.capability.websocket': 'WebSocket', - 'settings.capability.soap': 'SOAP', 'settings.capability.standard': 'standard', 'settings.capability.elevated': 'elevated', 'settings.capability.strict': 'strict', 'settings.capability.static_agent_key': 'static agent keys', - 'settings.capability.short_lived_token': 'short-lived tokens', - 'settings.capability.one_time_token': 'one-time tokens', 'settings.capability.unlimited': 'unlimited', 'settings.capability.none': 'none', 'settings.session.title': 'Current session', @@ -431,10 +425,6 @@ var TRANSLATIONS = { 'workspace.switch_error': 'Failed to switch workspace', 'workspace.switch_error_title': 'Workspace switch failed', 'workspace_setup.custom_protocol.rest': 'REST', - 'workspace_setup.custom_protocol.graphql': 'GraphQL', - 'workspace_setup.custom_protocol.grpc': 'gRPC', - 'workspace_setup.custom_protocol.websocket': 'WebSocket', - 'workspace_setup.custom_protocol.soap': 'SOAP', // Wizard 'wizard.back_catalog': 'Operations', @@ -465,23 +455,13 @@ var TRANSLATIONS = { 'wizard.step1.subtitle': 'Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.', 'wizard.step1.rest_name': 'REST / HTTP', 'wizard.step1.rest_tagline': 'Standard HTTP verbs over a RESTful endpoint. The most broadly supported option.', - 'wizard.step1.graphql_name': 'GraphQL', - 'wizard.step1.graphql_tagline': 'Query and mutate a GraphQL schema. Supports queries and mutations over one fixed operation.', - 'wizard.step1.grpc_name': 'gRPC', - 'wizard.step1.grpc_tagline': 'High-performance binary RPC over HTTP/2 for unary calls to low-latency internal services.', - 'wizard.step1.websocket_name': 'WebSocket', - 'wizard.step1.websocket_tagline': 'Stateful event streams and push-oriented integrations normalized into bounded MCP streaming tools.', - 'wizard.step1.soap_name': 'SOAP', - 'wizard.step1.soap_tagline': 'Enterprise WSDL/XSD service contracts normalized into MCP tools with structured XML mapping.', 'wizard.step1.query': 'Query', 'wizard.step1.mutation': 'Mutation', 'wizard.step1.unary': 'Unary', 'wizard.step1.tip_title': 'You can change this later', 'wizard.step1.tip_body': 'Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details.', 'wizard.step1.community_protocol_title': 'Community protocol scope', - 'wizard.step1.community_protocol_note': 'This Community build hides {protocols}. Commercial editions unlock those protocol surfaces.', 'wizard.step5.streaming_title': 'Streaming execution in Community', - 'wizard.step5.community_streaming_note': 'This Community build keeps operations in unary mode. Window, session and async job tool families require a commercial edition.', 'wizard.step2.title': 'Select upstream & endpoint', 'wizard.step2.subtitle': 'Choose an existing upstream or register a new one. Then define the specific endpoint path this operation will call.', 'wizard.required': 'Required', @@ -537,7 +517,6 @@ var TRANSLATIONS = { 'wizard.step2.path_template': 'Path template', 'wizard.step2.path_hint': 'Appended to the API host Base URL. Use {param} for path parameters.', 'wizard.step3.rest.label': 'Request config', - 'wizard.step3.graphql.label': 'GQL operation', 'wizard.step3.rest.title': 'HTTP method & format', 'wizard.step3.rest.subtitle': 'Choose the HTTP method and request format. Crank converts MCP tool parameters into the API request body.', 'wizard.step3.rest.method_title': 'HTTP method', @@ -553,16 +532,6 @@ var TRANSLATIONS = { 'wizard.step3.rest.content_type_hint': 'How the request body is encoded before sending it to the API.', 'wizard.step3.rest.accept': 'Accept', 'wizard.step3.rest.accept_hint': 'Response types accepted from the API server.', - 'wizard.step3.graphql.title': 'GraphQL operation', - 'wizard.step3.graphql.subtitle': 'Define the GraphQL operation this tool will execute. All requests are sent as HTTP POST to the endpoint configured in the previous step. Variables are populated from MCP tool arguments at runtime.', - 'wizard.step3.graphql.type_title': 'Operation type', - 'wizard.step3.graphql.type_subtitle': 'GraphQL operation kind — subscriptions are not supported', - 'wizard.step3.graphql.query_desc': 'Fetch data without side-effects', - 'wizard.step3.graphql.mutation_desc': 'Create, update or delete data', - 'wizard.step3.graphql.doc_title': 'Query document', - 'wizard.step3.graphql.doc_subtitle': 'GraphQL document with typed variable declarations', - 'wizard.step3.graphql.variables_title': 'Variables are mapped in step 5', - 'wizard.step3.graphql.variables_body': 'Declare all variables in the query document using the $variable: Type syntax. In step 5 you will map MCP input fields to these variables. Crank serializes the final {"query","variables"} payload automatically.', 'wizard.step4.title': 'Tool config', 'wizard.step4.subtitle': 'Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.', 'wizard.step4.identity_title': 'Tool parameters', @@ -679,23 +648,6 @@ var TRANSLATIONS = { 'wizard.test.window_truncated_note': 'Results were truncated.', 'wizard.test.window_more_note': 'More data is available if you continue from the streaming views.', 'wizard.test.window_incomplete_note': 'The upstream stream ended before the configured window was fully collected.', - 'wizard.test.websocket_window_completed': 'WebSocket window test completed', - 'wizard.test.websocket_window_completed_body': 'Collected {items} WebSocket event(s) inside the bounded test window.', - 'wizard.test.websocket_window_truncated_note': 'The bounded window stopped after reaching the configured item or byte limit.', - 'wizard.test.websocket_window_more_note': 'The socket produced more events than this test window retained. Increase limits or switch to session mode if you need a longer stream.', - 'wizard.test.websocket_window_incomplete_note': 'The upstream socket closed before the bounded window finished collecting all configured events.', - 'wizard.test.websocket_session_started': 'WebSocket session test started', - 'wizard.test.websocket_session_started_body': 'WebSocket session {session_id} was created. Continue from Stream sessions after a short delay.', - 'wizard.test.websocket_async_job_started': 'WebSocket async job test started', - 'wizard.test.websocket_async_job_started_body': 'WebSocket async job {job_id} was created. Continue from the async jobs view for status and result.', - 'wizard.test.websocket_failed': 'WebSocket test returned errors', - 'wizard.test.websocket_failed_body': 'The socket test failed before Crank could collect a bounded result window.', - 'wizard.test.websocket_failed_reconnect_body': 'The upstream socket closed before enough events were collected, and the configured reconnect policy was exhausted.', - 'wizard.test.websocket_failed_closed_body': 'The upstream socket closed before the bounded test window completed.', - 'wizard.test.session_started': 'Session test started', - 'wizard.test.session_started_body': 'Session {session_id} was created. Check Stream sessions in a few seconds.', - 'wizard.test.async_job_started': 'Async job test started', - 'wizard.test.async_job_started_body': 'Async job {job_id} was created. Track status and result from the async jobs view.', 'wizard.test.no_response': 'No test response yet', 'wizard.test.no_response_body': 'Run a test first, then copy the response into the output example.', 'wizard.test.response_copied': 'Response copied', @@ -712,18 +664,6 @@ var TRANSLATIONS = { 'wizard.yaml.loaded_body': 'The YAML configuration was loaded.', 'wizard.publish.done': 'Operation published', 'wizard.publish.done_body': 'Version {version} is now published and can be bound into agents.', - 'wizard.grpc.discovery_title': 'gRPC discovery moved to live descriptor flow', - 'wizard.grpc.discovery_body': 'Server reflection is not wired in this deployment. Save the draft, then use the descriptor-set tools in Step 5 to upload a real descriptor set and discover unary methods from the backend.', - 'wizard.grpc.no_methods': 'No unary RPC methods found.', - 'wizard.grpc.fields_unresolved': 'Fields not resolved.', - 'wizard.grpc.fields_unresolved_body': 'Import or external types are not expanded client-side.', - 'wizard.grpc.services_found': 'Found {services_label} and {methods_label}.', - 'wizard.grpc.services_label.one': '{count} service', - 'wizard.grpc.services_label.other': '{count} services', - 'wizard.grpc.methods_label.one': '{count} unary method', - 'wizard.grpc.methods_label.other': '{count} unary methods', - 'wizard.grpc.services_discovered': '{services} services · {methods} unary methods discovered', - 'wizard.grpc.services_none': 'No unary methods discovered yet', // Common buttons 'btn.save': 'Save changes', @@ -827,15 +767,10 @@ var TRANSLATIONS = { // Demo content 'demo.agent.revops-copilot.display_name': 'RevOps Copilot', - 'demo.agent.revops-copilot.description': 'Revenue operations assistant with CRM and billing tools.', 'demo.agent.support-triage.display_name': 'Support Triage', - 'demo.agent.support-triage.description': 'Draft support agent focused on unary gRPC workflows.', 'demo.operation.crm_create_lead.display_name': 'Create CRM Lead', 'demo.operation.crm_create_lead.description': 'Create a lead record in the CRM system.', - 'demo.operation.billing_get_invoice.display_name': 'Get Invoice Status', - 'demo.operation.billing_get_invoice.description': 'Fetch invoice state and amount from billing.', 'demo.operation.support_lookup_ticket.display_name': 'Lookup Support Ticket', - 'demo.operation.support_lookup_ticket.description': 'Draft unary gRPC support lookup using a descriptor set.', 'demo.operation.marketing_archive_contact.display_name': 'Archive Marketing Contact', 'demo.operation.marketing_archive_contact.description': 'Legacy archived flow kept for audit only.', @@ -1222,16 +1157,10 @@ var TRANSLATIONS = { 'settings.security.saved': 'Пароль обновлен.', 'settings.security.save_error': 'Не удалось изменить пароль', 'settings.capability.rest': 'REST / HTTP', - 'settings.capability.graphql': 'GraphQL', - 'settings.capability.grpc': 'gRPC unary', - 'settings.capability.websocket': 'WebSocket', - 'settings.capability.soap': 'SOAP', 'settings.capability.standard': 'standard', 'settings.capability.elevated': 'elevated', 'settings.capability.strict': 'strict', 'settings.capability.static_agent_key': 'статические ключи агентов', - 'settings.capability.short_lived_token': 'короткоживущие токены', - 'settings.capability.one_time_token': 'одноразовые токены', 'settings.capability.unlimited': 'без лимита', 'settings.capability.none': 'нет', 'settings.session.title': 'Текущая сессия', @@ -1282,10 +1211,6 @@ var TRANSLATIONS = { 'workspace.switch_error': 'Не удалось переключить воркспейс', 'workspace.switch_error_title': 'Не удалось переключить воркспейс', 'workspace_setup.custom_protocol.rest': 'REST', - 'workspace_setup.custom_protocol.graphql': 'GraphQL', - 'workspace_setup.custom_protocol.grpc': 'gRPC', - 'workspace_setup.custom_protocol.websocket': 'WebSocket', - 'workspace_setup.custom_protocol.soap': 'SOAP', // Wizard 'wizard.back_catalog': 'Операции', @@ -1316,23 +1241,13 @@ var TRANSLATIONS = { 'wizard.step1.subtitle': 'Выберите протокол вашего API. От этого зависит, как Crank будет выполнять запросы и какие настройки будут доступны на следующих шагах.', 'wizard.step1.rest_name': 'REST / HTTP', 'wizard.step1.rest_tagline': 'Стандартные HTTP-методы для REST API. Самый универсальный вариант.', - 'wizard.step1.graphql_name': 'GraphQL', - 'wizard.step1.graphql_tagline': 'Операции по GraphQL-схеме. Поддерживаются Query и Mutation для одной фиксированной операции.', - 'wizard.step1.grpc_name': 'gRPC', - 'wizard.step1.grpc_tagline': 'Бинарный RPC поверх HTTP/2 для быстрых внутренних сервисов.', - 'wizard.step1.websocket_name': 'WebSocket', - 'wizard.step1.websocket_tagline': 'Долгие соединения и потоковые события, оформленные как MCP инструменты.', - 'wizard.step1.soap_name': 'SOAP', - 'wizard.step1.soap_tagline': 'SOAP-сервисы по WSDL/XSD, оформленные как MCP инструменты.', 'wizard.step1.query': 'Query', 'wizard.step1.mutation': 'Mutation', 'wizard.step1.unary': 'Unary', 'wizard.step1.tip_title': 'Это можно изменить позже', 'wizard.step1.tip_body': 'Если сменить протокол после настройки следующих шагов, схемы и связи полей будут очищены. Перед экспериментами сохраните операцию.', 'wizard.step1.community_protocol_title': 'Граница протоколов Community', - 'wizard.step1.community_protocol_note': 'Ограничение Community версии. В редакции Community протоколы {protocols} недоступны. Если вы заинтересованы в работе с данными протоколами, обратитесь к разработчикам для приобретения коммерческой версии или воспользуйтесь облачным решением.', 'wizard.step5.streaming_title': 'Потоковое выполнение', - 'wizard.step5.community_streaming_note': 'В Community операции выполняются в обычном режиме запрос-ответ. Потоковые сценарии, сессии и фоновые задания требуют коммерческую редакцию.', 'wizard.step2.title': 'Выберите API-хост и путь', 'wizard.step2.subtitle': 'Выберите существующий API-хост или добавьте новый. Затем задайте путь, который будет вызывать эта операция.', 'wizard.required': 'Обязательно', @@ -1388,7 +1303,6 @@ var TRANSLATIONS = { 'wizard.step2.path_template': 'Шаблон пути', 'wizard.step2.path_hint': 'Добавляется к базовому URL API-хоста. Используйте {param} для параметров пути.', 'wizard.step3.rest.label': 'Настройки запроса', - 'wizard.step3.graphql.label': 'GQL операция', 'wizard.step3.rest.title': 'HTTP метод и формат', 'wizard.step3.rest.subtitle': 'Выберите HTTP-метод и формат запроса. Crank подставит параметры MCP инструмента в запрос к API.', 'wizard.step3.rest.method_title': 'HTTP метод', @@ -1404,16 +1318,6 @@ var TRANSLATIONS = { 'wizard.step3.rest.content_type_hint': 'В каком формате отправлять данные в API.', 'wizard.step3.rest.accept': 'Accept', 'wizard.step3.rest.accept_hint': 'Какой формат ответа ожидать от API.', - 'wizard.step3.graphql.title': 'GraphQL операция', - 'wizard.step3.graphql.subtitle': 'Определите GraphQL-операцию, которую будет выполнять этот инструмент. Все запросы отправляются как HTTP POST на путь, настроенный на предыдущем шаге. Переменные заполняются из аргументов MCP инструмента во время выполнения.', - 'wizard.step3.graphql.type_title': 'Тип операции', - 'wizard.step3.graphql.type_subtitle': 'Вид GraphQL-операции. Подписки не поддерживаются.', - 'wizard.step3.graphql.query_desc': 'Получение данных без побочных эффектов', - 'wizard.step3.graphql.mutation_desc': 'Создание, изменение или удаление данных', - 'wizard.step3.graphql.doc_title': 'Документ запроса', - 'wizard.step3.graphql.doc_subtitle': 'GraphQL-документ с типизированными объявлениями переменных', - 'wizard.step3.graphql.variables_title': 'Переменные связываются на шаге 5', - 'wizard.step3.graphql.variables_body': 'Объявите все переменные в документе запроса в формате $variable: Type. На шаге 5 вы свяжете поля MCP инструмента с этими переменными.', 'wizard.step4.title': 'Настройки инструмента', 'wizard.step4.subtitle': 'Задайте имя инструмента, описание для LLM и определите входную/выходную схему. Описание — главный сигнал, по которому модель решает, вызывать ли этот инструмент.', 'wizard.step4.identity_title': 'Параметры инструмента', @@ -1530,23 +1434,6 @@ var TRANSLATIONS = { 'wizard.test.window_truncated_note': 'Результат был усечен.', 'wizard.test.window_more_note': 'Доступно продолжение, если перейти к streaming-представлениям.', 'wizard.test.window_incomplete_note': 'Upstream-поток завершился раньше, чем удалось собрать всё окно.', - 'wizard.test.websocket_window_completed': 'WebSocket оконный тест завершен', - 'wizard.test.websocket_window_completed_body': 'В пределах ограниченного тестового окна собрано {items} WebSocket event(s).', - 'wizard.test.websocket_window_truncated_note': 'Ограниченное окно остановилось после достижения лимита по элементам или байтам.', - 'wizard.test.websocket_window_more_note': 'Сокет выдал больше событий, чем сохранило это тестовое окно. Увеличьте лимиты или переключитесь в session mode, если нужен более длинный поток.', - 'wizard.test.websocket_window_incomplete_note': 'Upstream WebSocket закрылся до того, как bounded окно успело собрать все настроенные события.', - 'wizard.test.websocket_session_started': 'WebSocket session-тест запущен', - 'wizard.test.websocket_session_started_body': 'WebSocket session {session_id} создана. Продолжайте в разделе Stream sessions через короткую паузу.', - 'wizard.test.websocket_async_job_started': 'WebSocket async job тест запущен', - 'wizard.test.websocket_async_job_started_body': 'Создан WebSocket async job {job_id}. Дальше отслеживайте статус и результат в представлении async jobs.', - 'wizard.test.websocket_failed': 'WebSocket тест завершился с ошибками', - 'wizard.test.websocket_failed_body': 'Тест сокета завершился до того, как Crank успел собрать bounded result window.', - 'wizard.test.websocket_failed_reconnect_body': 'Upstream WebSocket закрылся раньше, чем удалось собрать достаточно событий, и настроенная reconnect policy была исчерпана.', - 'wizard.test.websocket_failed_closed_body': 'Upstream WebSocket закрылся до завершения bounded test window.', - 'wizard.test.session_started': 'Session-тест запущен', - 'wizard.test.session_started_body': 'Сессия {session_id} создана. Проверьте статус в разделе Stream sessions через несколько секунд.', - 'wizard.test.async_job_started': 'Async job тест запущен', - 'wizard.test.async_job_started_body': 'Создан async job {job_id}. Отслеживайте статус и результат через представление async jobs.', 'wizard.test.no_response': 'Тестового ответа пока нет', 'wizard.test.no_response_body': 'Сначала выполните тест, затем скопируйте ответ в выходной пример.', 'wizard.test.response_copied': 'Ответ скопирован', @@ -1563,22 +1450,6 @@ var TRANSLATIONS = { 'wizard.yaml.loaded_body': 'YAML-конфигурация загружена.', 'wizard.publish.done': 'Операция опубликована', 'wizard.publish.done_body': 'Версия {version} опубликована и теперь может быть привязана к агентам.', - 'wizard.grpc.discovery_title': 'gRPC discovery переведен в live descriptor flow', - 'wizard.grpc.discovery_body': 'Server reflection в этом развертывании не подключен. Сохраните операцию и используйте descriptor set на шаге 5, чтобы загрузить описание сервиса и открыть unary methods.', - 'wizard.grpc.no_methods': 'Unary RPC methods не найдены.', - 'wizard.grpc.fields_unresolved': 'Поля не разрешены.', - 'wizard.grpc.fields_unresolved_body': 'Import-ы или внешние типы не разворачиваются на стороне клиента.', - 'wizard.grpc.services_found': 'Найдено {services_label} и {methods_label}.', - 'wizard.grpc.services_label.one': '{count} сервис', - 'wizard.grpc.services_label.few': '{count} сервиса', - 'wizard.grpc.services_label.many': '{count} сервисов', - 'wizard.grpc.services_label.other': '{count} сервиса', - 'wizard.grpc.methods_label.one': '{count} unary-метод', - 'wizard.grpc.methods_label.few': '{count} unary-метода', - 'wizard.grpc.methods_label.many': '{count} unary-методов', - 'wizard.grpc.methods_label.other': '{count} unary-метода', - 'wizard.grpc.services_discovered': 'Сервисов открыто: {services} · unary методов: {methods}', - 'wizard.grpc.services_none': 'Unary methods пока не открыты', // Common buttons 'btn.save': 'Сохранить', @@ -1682,15 +1553,10 @@ var TRANSLATIONS = { // Demo content 'demo.agent.revops-copilot.display_name': 'Помощник RevOps', - 'demo.agent.revops-copilot.description': 'Ассистент Revenue Operations с CRM- и billing-инструментами.', 'demo.agent.support-triage.display_name': 'Триаж поддержки', - 'demo.agent.support-triage.description': 'Черновой агент поддержки, сфокусированный на unary gRPC-сценариях.', 'demo.operation.crm_create_lead.display_name': 'Создать CRM-лид', 'demo.operation.crm_create_lead.description': 'Создает запись лида в CRM-системе.', - 'demo.operation.billing_get_invoice.display_name': 'Статус инвойса', - 'demo.operation.billing_get_invoice.description': 'Получает состояние и сумму инвойса из billing-системы.', 'demo.operation.support_lookup_ticket.display_name': 'Найти тикет поддержки', - 'demo.operation.support_lookup_ticket.description': 'Черновой unary gRPC lookup поддержки через descriptor set.', 'demo.operation.marketing_archive_contact.display_name': 'Архивировать маркетинговый контакт', 'demo.operation.marketing_archive_contact.description': 'Устаревший архивный сценарий, оставленный только для аудита.', diff --git a/apps/ui/js/slot-registry.js b/apps/ui/js/slot-registry.js index 065edbb..289ae30 100644 --- a/apps/ui/js/slot-registry.js +++ b/apps/ui/js/slot-registry.js @@ -1,15 +1,5 @@ (function() { - var allowedSlots = { - 'settings.sso_panel': true, - 'settings.totp_panel': true, - 'settings.audit_panel': true, - 'settings.billing_panel': true, - 'wizard.protocol_cards.graphql': true, - 'wizard.protocol_cards.grpc': true, - 'wizard.protocol_cards.soap': true, - 'wizard.protocol_cards.websocket': true, - }; - + var allowedSlots = {}; var handlers = Object.create(null); function noop() {} diff --git a/apps/ui/js/wizard-model.js b/apps/ui/js/wizard-model.js index 18a20cd..da8617a 100644 --- a/apps/ui/js/wizard-model.js +++ b/apps/ui/js/wizard-model.js @@ -53,8 +53,6 @@ var REQUEST_MAPPING_TARGET_PREFIXES = [ 'query', 'headers', 'body', - 'variables', - 'grpc', ]; function normalizeInputSource(path) { diff --git a/apps/ui/js/wizard-shell.js b/apps/ui/js/wizard-shell.js index 2d02b21..bcbc2a0 100644 --- a/apps/ui/js/wizard-shell.js +++ b/apps/ui/js/wizard-shell.js @@ -208,56 +208,19 @@ }); } - function applyEditionProtocolVisibility(capabilities) { - var supported = capabilities && Array.isArray(capabilities.supported_protocols) - ? capabilities.supported_protocols.slice() - : ['rest']; - var knownProtocols = ['rest', 'graphql', 'grpc', 'websocket', 'soap']; - + function applyEditionProtocolVisibility(_capabilities) { + window.wizardProtocol = 'rest'; document.querySelectorAll('.protocol-card').forEach(function(card) { - var protocol = card.dataset.protocol || 'rest'; - var isSupported = supported.indexOf(protocol) >= 0; - card.hidden = !isSupported; - card.setAttribute('aria-disabled', isSupported ? 'false' : 'true'); - if (!isSupported) { - card.classList.remove('selected'); - card.setAttribute('aria-checked', 'false'); - } + var isRest = (card.dataset.protocol || 'rest') === 'rest'; + card.hidden = !isRest; + card.setAttribute('aria-disabled', isRest ? 'false' : 'true'); + card.classList.toggle('selected', isRest); + card.setAttribute('aria-checked', isRest ? 'true' : 'false'); }); - var hiddenProtocols = knownProtocols.filter(function(protocol) { - return supported.indexOf(protocol) === -1; - }); - - if (supported.indexOf(window.wizardProtocol) === -1) { - window.wizardProtocol = supported[0] || 'rest'; - } - - var selectedCard = document.querySelector('.protocol-card[data-protocol="' + window.wizardProtocol + '"]'); - if (selectedCard) { - selectedCard.classList.add('selected'); - selectedCard.setAttribute('aria-checked', 'true'); - } - var step3Name = document.getElementById('sidebar-step-3-name'); - var labels = window.CrankWizardState.step3Labels(); if (step3Name) { - step3Name.textContent = labels[window.wizardProtocol] || tKey('wizard.step3.rest.label'); - } - - var note = document.getElementById('wizard-edition-protocol-note'); - var noteText = document.getElementById('wizard-edition-protocol-note-text'); - if (note && noteText) { - if (hiddenProtocols.length) { - note.hidden = false; - noteText.textContent = tfKey('wizard.step1.community_protocol_note', { - protocols: hiddenProtocols.map(function(protocol) { - return tKey('settings.capability.' + protocol); - }).join(', ') - }); - } else { - note.hidden = true; - } + step3Name.textContent = tKey('wizard.step3.rest.label'); } } diff --git a/apps/ui/js/wizard-state.js b/apps/ui/js/wizard-state.js index f3e6209..fdbf6ef 100644 --- a/apps/ui/js/wizard-state.js +++ b/apps/ui/js/wizard-state.js @@ -18,8 +18,6 @@ wizardAuthProfiles: [], selectedUpstreamId: null, editingUpstreamId: null, - protoParsed: null, - selectedRpcMethod: null, }; Object.keys(defaults).forEach(function(key) { @@ -71,22 +69,7 @@ } async function loadProtocolCapabilities() { - if (!window.wizardWorkspaceId || !window.CrankApi || typeof window.CrankApi.getProtocolCapabilities !== 'function') { - window.wizardProtocolCapabilities = defaultProtocolCapabilities(); - return window.wizardProtocolCapabilities; - } - - try { - var response = await window.CrankApi.getProtocolCapabilities(window.wizardWorkspaceId); - var mapped = {}; - (response.items || []).forEach(function(item) { - mapped[item.protocol] = item; - }); - window.wizardProtocolCapabilities = mapped; - } catch (_error) { - window.wizardProtocolCapabilities = defaultProtocolCapabilities(); - } - + window.wizardProtocolCapabilities = defaultProtocolCapabilities(); return window.wizardProtocolCapabilities; } diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index bd48489..262e719 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -26,18 +26,12 @@ var wizardEditId = window.wizardEditId; var wizardWorkspaceId = window.wizardWorkspaceId; var wizardCurrentOperation = window.wizardCurrentOperation; var wizardCurrentVersion = window.wizardCurrentVersion; -var wizardProtoUpload = window.wizardProtoUpload; -var wizardDescriptorSetUpload = window.wizardDescriptorSetUpload; -var wizardSoapWsdlUpload = window.wizardSoapWsdlUpload; -var wizardSoapXsdUpload = window.wizardSoapXsdUpload; var wizardTestResponsePreview = window.wizardTestResponsePreview; var wizardProtocolCapabilities = window.wizardProtocolCapabilities; var wizardSecrets = window.wizardSecrets; var wizardAuthProfiles = window.wizardAuthProfiles; var selectedUpstreamId = window.selectedUpstreamId; var editingUpstreamId = window.editingUpstreamId; -var protoParsed = window.protoParsed; -var selectedRpcMethod = window.selectedRpcMethod; async function initWizardPage() { renderSidebarBrand('create'); @@ -252,22 +246,6 @@ function selectMethod(btn) { } } -/* ── GraphQL operation type picker (Step 4 GraphQL) ── */ - -function selectGqlType(btn) { - document.querySelectorAll('.gql-type-card').forEach(function(b) { b.classList.remove('active'); }); - btn.classList.add('active'); - // Update query editor placeholder if it's still the default - var editor = document.getElementById('gql-query-editor'); - if (!editor) return; - var type = btn.dataset.gqlType; - var defaults = { - query: 'query GetContact($id: ID!) {\n contact(id: $id) {\n id\n firstName\n lastName\n email\n company {\n id\n name\n }\n createdAt\n }\n}', - mutation: 'mutation CreateContact(\n $firstName: String!\n $lastName: String!\n $email: String!\n $company: String\n) {\n createContact(input: {\n firstName: $firstName\n lastName: $lastName\n email: $email\n company: $company\n }) {\n id\n firstName\n lastName\n createdAt\n }\n}' - }; - if (defaults[type]) editor.value = defaults[type]; -} - function escapeHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } diff --git a/apps/ui/scripts/playwright-stack.sh b/apps/ui/scripts/playwright-stack.sh index 9a69e8a..62d4c48 100644 --- a/apps/ui/scripts/playwright-stack.sh +++ b/apps/ui/scripts/playwright-stack.sh @@ -10,7 +10,6 @@ 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}" diff --git a/apps/ui/tests/e2e/observability.spec.js b/apps/ui/tests/e2e/observability.spec.js index 69f3f1c..9e2597c 100644 --- a/apps/ui/tests/e2e/observability.spec.js +++ b/apps/ui/tests/e2e/observability.spec.js @@ -13,10 +13,6 @@ test('shell and wizard expose stable diagnostics hooks', async ({ page }) => { await page.goto('/wizard/'); await expect(page.locator('[data-testid="wizard-protocol-grid"]')).toBeVisible(); await expect(page.locator('[data-testid="wizard-protocol-rest"]')).toBeVisible(); - await expect(page.locator('[data-testid="wizard-protocol-graphql"]')).toBeHidden(); - await expect(page.locator('[data-testid="wizard-protocol-grpc"]')).toBeHidden(); - await expect(page.locator('[data-testid="wizard-protocol-websocket"]')).toBeHidden(); - await expect(page.locator('[data-testid="wizard-protocol-soap"]')).toBeHidden(); await expect(page.locator('html')).toHaveAttribute('data-crank-bootstrap-state', 'ready'); }); diff --git a/apps/ui/tests/e2e/wizard.spec.js b/apps/ui/tests/e2e/wizard.spec.js index 5c7d3f7..3bbb513 100644 --- a/apps/ui/tests/e2e/wizard.spec.js +++ b/apps/ui/tests/e2e/wizard.spec.js @@ -22,17 +22,10 @@ test('wizard loads and protocol selection updates flow', async ({ page }) => { await expect(page.locator('#step-panel-2 .step-panel-title')).toContainText(localized('Select upstream', 'Выберите upstream')); }); -test('community wizard hides premium protocols and explains edition scope', async ({ page }) => { +test('community wizard exposes REST-only protocol flow', async ({ page }) => { await login(page); await page.goto('/wizard/'); await expect(page.locator('[data-testid="wizard-protocol-rest"]')).toBeVisible(); - await expect(page.locator('[data-testid="wizard-protocol-graphql"]')).toBeHidden(); - await expect(page.locator('[data-testid="wizard-protocol-grpc"]')).toBeHidden(); - await expect(page.locator('[data-testid="wizard-protocol-websocket"]')).toBeHidden(); - await expect(page.locator('[data-testid="wizard-protocol-soap"]')).toBeHidden(); - await expect(page.locator('#wizard-edition-protocol-note')).toContainText( - localized('Commercial editions unlock', 'коммерческих редакциях') - ); await page.locator('[data-testid="wizard-protocol-rest"]').click(); await page.locator('#btn-continue').click(); diff --git a/crates/crank-community-mcp/src/app.rs b/crates/crank-community-mcp/src/app.rs index 10d2aad..570b31e 100644 --- a/crates/crank-community-mcp/src/app.rs +++ b/crates/crank-community-mcp/src/app.rs @@ -989,24 +989,18 @@ fn credential_allows_security_level( fn security_level_rank(level: OperationSecurityLevel) -> u8 { match level { OperationSecurityLevel::Standard => 0, - OperationSecurityLevel::Elevated => 1, - OperationSecurityLevel::Strict => 2, } } fn serialize_security_level(level: OperationSecurityLevel) -> &'static str { match level { OperationSecurityLevel::Standard => "standard", - OperationSecurityLevel::Elevated => "elevated", - OperationSecurityLevel::Strict => "strict", } } fn serialize_machine_access_mode(mode: crank_core::MachineAccessMode) -> &'static str { match mode { crank_core::MachineAccessMode::StaticAgentKey => "static_agent_key", - crank_core::MachineAccessMode::ShortLivedToken => "short_lived_token", - crank_core::MachineAccessMode::OneTimeToken => "one_time_token", } } diff --git a/crates/crank-core/src/auth.rs b/crates/crank-core/src/auth.rs index b1cc2bf..e9b1f8b 100644 --- a/crates/crank-core/src/auth.rs +++ b/crates/crank-core/src/auth.rs @@ -2,8 +2,7 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::{ - edition::{MachineAccessMode, OperationSecurityLevel}, - ids::{AgentId, AuthProfileId, OperationId, SecretId, WorkspaceId}, + ids::{AuthProfileId, SecretId, WorkspaceId}, protocol::AuthKind, }; @@ -64,55 +63,13 @@ pub struct AuthProfile { pub updated_at: OffsetDateTime, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentTokenGrantType { - AgentKey, - RefreshToken, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct IssueAgentTokenRequest { - pub grant_type: AgentTokenGrantType, - #[serde(skip_serializing_if = "Option::is_none")] - pub agent_key: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_token: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scope: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct IssueOneTimeAgentTokenRequest { - pub agent_key: String, - pub operation_id: OperationId, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scope: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct IssuedAgentTokenResponse { - pub access_token: String, - pub token_type: String, - pub expires_in: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_token: Option, - pub machine_access_mode: MachineAccessMode, - pub security_level: OperationSecurityLevel, - pub agent_id: AgentId, -} - #[cfg(test)] mod tests { use serde_json::json; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - use super::{ - AgentTokenGrantType, ApiKeyHeaderAuthConfig, AuthConfig, AuthProfile, - IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse, - }; + use super::{ApiKeyHeaderAuthConfig, AuthConfig, AuthProfile}; use crate::{ - edition::{MachineAccessMode, OperationSecurityLevel}, ids::{AuthProfileId, SecretId, WorkspaceId}, protocol::AuthKind, }; @@ -137,59 +94,4 @@ mod tests { assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z")); assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z")); } - - #[test] - fn issue_agent_token_request_serializes_stable_contract() { - let request = IssueAgentTokenRequest { - grant_type: AgentTokenGrantType::AgentKey, - agent_key: Some("crk_agent_secret".to_owned()), - refresh_token: None, - scope: vec!["tools:call".to_owned()], - }; - - let value = serde_json::to_value(&request).unwrap(); - - assert_eq!(value["grant_type"], json!("agent_key")); - assert_eq!(value["agent_key"], json!("crk_agent_secret")); - assert_eq!(value["scope"], json!(["tools:call"])); - assert_eq!(value.get("refresh_token"), None); - } - - #[test] - fn issue_one_time_agent_token_request_serializes_stable_contract() { - let request = IssueOneTimeAgentTokenRequest { - agent_key: "crk_agent_secret".to_owned(), - operation_id: "op_01".into(), - scope: vec!["tools:call".to_owned()], - }; - - let value = serde_json::to_value(&request).unwrap(); - - assert_eq!(value["agent_key"], json!("crk_agent_secret")); - assert_eq!(value["operation_id"], json!("op_01")); - assert_eq!(value["scope"], json!(["tools:call"])); - } - - #[test] - fn issued_agent_token_response_serializes_machine_access_metadata() { - let response = IssuedAgentTokenResponse { - access_token: "tok_01".to_owned(), - token_type: "Bearer".to_owned(), - expires_in: 300, - refresh_token: Some("rfr_01".to_owned()), - machine_access_mode: MachineAccessMode::ShortLivedToken, - security_level: OperationSecurityLevel::Elevated, - agent_id: "agt_01".into(), - }; - - let value = serde_json::to_value(&response).unwrap(); - - assert_eq!(value["access_token"], json!("tok_01")); - assert_eq!(value["token_type"], json!("Bearer")); - assert_eq!(value["expires_in"], json!(300)); - assert_eq!(value["refresh_token"], json!("rfr_01")); - assert_eq!(value["machine_access_mode"], json!("short_lived_token")); - assert_eq!(value["security_level"], json!("elevated")); - assert_eq!(value["agent_id"], json!("agt_01")); - } } diff --git a/crates/crank-core/src/edition.rs b/crates/crank-core/src/edition.rs index 8c4015a..b9a28de 100644 --- a/crates/crank-core/src/edition.rs +++ b/crates/crank-core/src/edition.rs @@ -6,24 +6,18 @@ use crate::Protocol; #[serde(rename_all = "snake_case")] pub enum ProductEdition { Community, - Enterprise, - Cloud, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum MachineAccessMode { StaticAgentKey, - ShortLivedToken, - OneTimeToken, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum OperationSecurityLevel { Standard, - Elevated, - Strict, } impl Default for OperationSecurityLevel { diff --git a/crates/crank-core/src/ext/auth.rs b/crates/crank-core/src/ext/auth.rs index dc231dc..8aefcf6 100644 --- a/crates/crank-core/src/ext/auth.rs +++ b/crates/crank-core/src/ext/auth.rs @@ -1,13 +1,9 @@ use std::sync::Arc; -use async_trait::async_trait; -use time::OffsetDateTime; - use crate::{ - IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, IssuedAgentTokenResponse, - MachineAccessMode, Membership, MembershipRole, OperationSecurityLevel, PlatformApiKeyScope, - User, UserId, WorkspaceId, + MachineAccessMode, Membership, OperationSecurityLevel, PlatformApiKeyScope, User, WorkspaceId, }; +use async_trait::async_trait; #[derive(Clone, Debug, PartialEq, Eq)] pub struct VerifiedMachineCredential { @@ -32,70 +28,6 @@ pub trait MachineCredentialVerifier: Send + Sync { pub type SharedMachineCredentialVerifier = Arc; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TokenIssuerActor { - pub user_id: UserId, - pub workspace_id: WorkspaceId, - pub role: MembershipRole, -} - -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum TokenIssuerError { - #[error("machine tokens are not available in this edition")] - NotSupportedInEdition, - #[error("invalid grant: {0}")] - InvalidGrant(String), - #[error("agent key is unknown")] - AgentKeyUnknown, - #[error("operation is not strict")] - OperationNotStrict, - #[error("operation is not published for agent")] - OperationNotPublishedForAgent, - #[error("registry failure: {0}")] - RegistryFailure(String), - #[error("replay guard failure: {0}")] - ReplayGuardFailure(String), -} - -#[async_trait] -pub trait MachineTokenIssuer: Send + Sync { - async fn issue_short_lived( - &self, - request: IssueAgentTokenRequest, - actor: &TokenIssuerActor, - ) -> Result; - - async fn issue_one_time( - &self, - request: IssueOneTimeAgentTokenRequest, - actor: &TokenIssuerActor, - ) -> Result; -} - -pub type SharedMachineTokenIssuer = Arc; - -#[derive(Clone, Copy, Debug, Default)] -pub struct NoMachineTokenIssuer; - -#[async_trait] -impl MachineTokenIssuer for NoMachineTokenIssuer { - async fn issue_short_lived( - &self, - _request: IssueAgentTokenRequest, - _actor: &TokenIssuerActor, - ) -> Result { - Err(TokenIssuerError::NotSupportedInEdition) - } - - async fn issue_one_time( - &self, - _request: IssueOneTimeAgentTokenRequest, - _actor: &TokenIssuerActor, - ) -> Result { - Err(TokenIssuerError::NotSupportedInEdition) - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IdentityProviderKind { Password, @@ -126,64 +58,8 @@ pub struct LoginPayload { pub password: String, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SsoAuthorizeRequest { - pub workspace_id: WorkspaceId, - pub provider_id: String, - pub base_origin: String, - pub return_to: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SsoAuthorizeRedirect { - pub authorization_url: String, - pub state: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SsoCallbackRequest { - pub workspace_id: WorkspaceId, - pub provider_id: String, - pub code: String, - pub base_origin: String, - pub return_to: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TwoFactorPendingIdentity { - pub user_id: UserId, - pub workspace_id: Option, - pub return_to: Option, - pub expires_at: OffsetDateTime, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TwoFactorStatus { - pub enabled: bool, - pub pending_setup: bool, - pub recovery_codes_remaining: usize, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TwoFactorSetup { - pub secret: String, - pub otpauth_url: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TwoFactorActivation { - pub recovery_codes: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct TwoFactorChallenge { - pub code: Option, - pub recovery_code: Option, -} - pub enum LoginOutcome { Authenticated(AuthenticatedIdentity), - TwoFactorRequired(TwoFactorPendingIdentity), } #[async_trait] @@ -193,109 +69,6 @@ pub trait IdentityProvider: Send + Sync { fn kind(&self) -> IdentityProviderKind; async fn login_password(&self, payload: LoginPayload) -> Result; - - async fn begin_sso( - &self, - _request: SsoAuthorizeRequest, - ) -> Result { - Err(IdentityError::NotSupportedForProvider) - } - - async fn complete_sso( - &self, - _request: SsoCallbackRequest, - ) -> Result { - Err(IdentityError::NotSupportedForProvider) - } - - async fn get_two_factor_status( - &self, - _user_id: &UserId, - ) -> Result { - Err(IdentityError::NotSupportedForProvider) - } - - async fn begin_two_factor_setup( - &self, - _user_id: &UserId, - _email: &str, - ) -> Result { - Err(IdentityError::NotSupportedForProvider) - } - - async fn activate_two_factor( - &self, - _user_id: &UserId, - _code: &str, - ) -> Result { - Err(IdentityError::NotSupportedForProvider) - } - - async fn disable_two_factor( - &self, - _user_id: &UserId, - _challenge: TwoFactorChallenge, - ) -> Result<(), IdentityError> { - Err(IdentityError::NotSupportedForProvider) - } - - async fn verify_two_factor_login( - &self, - _pending: &TwoFactorPendingIdentity, - _challenge: TwoFactorChallenge, - ) -> Result { - Err(IdentityError::NotSupportedForProvider) - } } pub type SharedIdentityProvider = Arc; - -#[cfg(test)] -mod tests { - use super::{MachineTokenIssuer, NoMachineTokenIssuer, TokenIssuerActor, TokenIssuerError}; - use crate::{ - AgentTokenGrantType, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, MembershipRole, - UserId, WorkspaceId, - }; - - fn actor() -> TokenIssuerActor { - TokenIssuerActor { - user_id: UserId::new("user_01"), - workspace_id: WorkspaceId::new("ws_01"), - role: MembershipRole::Owner, - } - } - - #[tokio::test] - async fn no_machine_token_issuer_rejects_short_lived_issue() { - let result = NoMachineTokenIssuer - .issue_short_lived( - IssueAgentTokenRequest { - grant_type: AgentTokenGrantType::AgentKey, - agent_key: Some("crk_agent_secret".to_owned()), - refresh_token: None, - scope: vec![], - }, - &actor(), - ) - .await; - - assert_eq!(result, Err(TokenIssuerError::NotSupportedInEdition)); - } - - #[tokio::test] - async fn no_machine_token_issuer_rejects_one_time_issue() { - let result = NoMachineTokenIssuer - .issue_one_time( - IssueOneTimeAgentTokenRequest { - agent_key: "crk_agent_secret".to_owned(), - operation_id: "op_01".into(), - scope: vec![], - }, - &actor(), - ) - .await; - - assert_eq!(result, Err(TokenIssuerError::NotSupportedInEdition)); - } -} diff --git a/crates/crank-core/src/ext/billing.rs b/crates/crank-core/src/ext/billing.rs deleted file mode 100644 index 22233bc..0000000 --- a/crates/crank-core/src/ext/billing.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; - -use crate::{TenantId, UsageRollup}; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum BillingGate { - Allow, - Deny { reason: String }, -} - -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum BillingError { - #[error("billing integration is not available in this edition")] - NotSupportedInEdition, - #[error("billing provider failure: {0}")] - Provider(String), -} - -#[async_trait] -pub trait BillingHook: Send + Sync { - async fn on_rollup(&self, rollup: UsageRollup) -> Result<(), BillingError>; - - async fn check_billing_gate(&self, tenant: &TenantId) -> Result; -} - -pub type SharedBillingHook = Arc; - -#[derive(Clone, Copy, Debug, Default)] -pub struct NoopBillingHook; - -#[async_trait] -impl BillingHook for NoopBillingHook { - async fn on_rollup(&self, _rollup: UsageRollup) -> Result<(), BillingError> { - Ok(()) - } - - async fn check_billing_gate(&self, _tenant: &TenantId) -> Result { - Ok(BillingGate::Allow) - } -} - -#[cfg(test)] -mod tests { - use super::{BillingGate, BillingHook, NoopBillingHook}; - use crate::{TenantId, UsagePeriod, UsageRollup, WorkspaceId}; - - #[tokio::test] - async fn noop_billing_hook_allows_default_gate_and_rollups() { - let hook = NoopBillingHook; - - hook.on_rollup(UsageRollup { - workspace_id: WorkspaceId::new("ws_01"), - agent_id: None, - operation_id: None, - period: UsagePeriod::Last24Hours, - calls_total: 10, - calls_ok: 9, - calls_error: 1, - p50_ms: 12, - p95_ms: 42, - p99_ms: 77, - }) - .await - .unwrap(); - - let gate = hook - .check_billing_gate(&TenantId::new("tenant_01")) - .await - .unwrap(); - - assert_eq!(gate, BillingGate::Allow); - } -} diff --git a/crates/crank-core/src/ext/mod.rs b/crates/crank-core/src/ext/mod.rs index 11952d1..73fc2c5 100644 --- a/crates/crank-core/src/ext/mod.rs +++ b/crates/crank-core/src/ext/mod.rs @@ -1,8 +1,6 @@ pub mod access; pub mod audit; pub mod auth; -pub mod billing; pub mod capability; pub mod metering; pub mod protocol; -pub mod tenancy; diff --git a/crates/crank-core/src/ext/tenancy.rs b/crates/crank-core/src/ext/tenancy.rs deleted file mode 100644 index fbb20e6..0000000 --- a/crates/crank-core/src/ext/tenancy.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; - -use crate::{TenantId, WorkspaceId}; - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct TenantResolutionContext { - pub workspace_id: Option, - pub workspace_slug: Option, - pub host: Option, -} - -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum TenancyError { - #[error("tenant lookup is not supported in this edition")] - UnsupportedLookup, - #[error("internal tenancy failure: {0}")] - Internal(String), -} - -#[async_trait] -pub trait TenantController: Send + Sync { - fn resolve_tenant(&self, request: &TenantResolutionContext) -> Result; - - async fn list_workspaces_for_tenant( - &self, - tenant: &TenantId, - ) -> Result, TenancyError>; -} - -pub type SharedTenantController = Arc; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SingleTenantController { - tenant_id: TenantId, -} - -impl Default for SingleTenantController { - fn default() -> Self { - Self { - tenant_id: TenantId::new("default"), - } - } -} - -#[async_trait] -impl TenantController for SingleTenantController { - fn resolve_tenant(&self, _request: &TenantResolutionContext) -> Result { - Ok(self.tenant_id.clone()) - } - - async fn list_workspaces_for_tenant( - &self, - _tenant: &TenantId, - ) -> Result, TenancyError> { - Ok(Vec::new()) - } -} - -#[cfg(test)] -mod tests { - use super::{SingleTenantController, TenantController, TenantResolutionContext}; - use crate::TenantId; - - #[tokio::test] - async fn single_tenant_controller_returns_default_tenant() { - let controller = SingleTenantController::default(); - - let tenant = controller - .resolve_tenant(&TenantResolutionContext::default()) - .unwrap(); - let workspaces = controller - .list_workspaces_for_tenant(&tenant) - .await - .unwrap(); - - assert_eq!(tenant, TenantId::new("default")); - assert!(workspaces.is_empty()); - } -} diff --git a/crates/crank-core/src/ids.rs b/crates/crank-core/src/ids.rs index c6e9b48..29c3617 100644 --- a/crates/crank-core/src/ids.rs +++ b/crates/crank-core/src/ids.rs @@ -48,7 +48,6 @@ define_id!(ToolId); define_id!(SampleId); define_id!(AuthProfileId); define_id!(WorkspaceId); -define_id!(TenantId); define_id!(UserId); define_id!(UserSessionId); define_id!(AgentId); diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index c3ec3e3..938a884 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -17,9 +17,8 @@ pub use access::{ }; pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion}; pub use auth::{ - AgentTokenGrantType, ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, - BasicAuthConfig, BearerAuthConfig, IssueAgentTokenRequest, IssueOneTimeAgentTokenRequest, - IssuedAgentTokenResponse, + ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig, + BearerAuthConfig, }; pub use cache::{ CacheBackend, CacheScope, CacheStoreError, CachedHeader, CachedResponse, @@ -38,14 +37,8 @@ pub use ext::audit::{ }; pub use ext::auth::{ AuthenticatedIdentity, IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome, - LoginPayload, MachineCredentialVerifier, MachineCredentialVerifierError, MachineTokenIssuer, - NoMachineTokenIssuer, SharedIdentityProvider, SharedMachineCredentialVerifier, - SharedMachineTokenIssuer, SsoAuthorizeRedirect, SsoAuthorizeRequest, SsoCallbackRequest, - TokenIssuerActor, TokenIssuerError, TwoFactorActivation, TwoFactorChallenge, - TwoFactorPendingIdentity, TwoFactorSetup, TwoFactorStatus, VerifiedMachineCredential, -}; -pub use ext::billing::{ - BillingError, BillingGate, BillingHook, NoopBillingHook, SharedBillingHook, + LoginPayload, MachineCredentialVerifier, MachineCredentialVerifierError, + SharedIdentityProvider, SharedMachineCredentialVerifier, VerifiedMachineCredential, }; pub use ext::capability::{CapabilityProfile, CommunityCapabilityProfile}; pub use ext::metering::{MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink}; @@ -54,13 +47,9 @@ pub use ext::protocol::{ ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext, SharedProtocolAdapter, }; -pub use ext::tenancy::{ - SharedTenantController, SingleTenantController, TenancyError, TenantController, - TenantResolutionContext, -}; pub use ids::{ AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId, - PlatformApiKeyId, SampleId, SecretId, TenantId, ToolId, UserId, UserSessionId, WorkspaceId, + PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId, }; pub use observability::{ InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup, diff --git a/crates/crank-mapping/src/execute.rs b/crates/crank-mapping/src/execute.rs index ccad9e5..86c7d6d 100644 --- a/crates/crank-mapping/src/execute.rs +++ b/crates/crank-mapping/src/execute.rs @@ -56,8 +56,6 @@ fn empty_target_context(context: MappingTargetContext) -> Value { ("query".to_owned(), Value::Object(Map::new())), ("headers".to_owned(), Value::Object(Map::new())), ("body".to_owned(), Value::Object(Map::new())), - ("variables".to_owned(), Value::Object(Map::new())), - ("grpc".to_owned(), Value::Object(Map::new())), ])), )])), MappingTargetContext::Output => Value::Object(Map::from_iter([( diff --git a/crates/crank-mapping/src/jsonpath.rs b/crates/crank-mapping/src/jsonpath.rs index 4872b75..522cbc9 100644 --- a/crates/crank-mapping/src/jsonpath.rs +++ b/crates/crank-mapping/src/jsonpath.rs @@ -9,11 +9,8 @@ pub enum JsonPathRoot { RequestQuery, RequestHeaders, RequestBody, - RequestVariables, - RequestGrpc, ResponseBody, ResponseData, - ResponseGrpc, Output, } @@ -25,11 +22,8 @@ impl JsonPathRoot { Self::RequestQuery => "request.query", Self::RequestHeaders => "request.headers", Self::RequestBody => "request.body", - Self::RequestVariables => "request.variables", - Self::RequestGrpc => "request.grpc", Self::ResponseBody => "response.body", Self::ResponseData => "response.data", - Self::ResponseGrpc => "response.grpc", Self::Output => "output", } } @@ -41,11 +35,8 @@ impl JsonPathRoot { Self::RequestQuery => &["request", "query"], Self::RequestHeaders => &["request", "headers"], Self::RequestBody => &["request", "body"], - Self::RequestVariables => &["request", "variables"], - Self::RequestGrpc => &["request", "grpc"], Self::ResponseBody => &["response", "body"], Self::ResponseData => &["response", "data"], - Self::ResponseGrpc => &["response", "grpc"], Self::Output => &["output"], } } @@ -125,16 +116,13 @@ impl JsonPath { } fn match_root(input: &str) -> Option<(JsonPathRoot, &str)> { - const ROOTS: [(&str, JsonPathRoot); 11] = [ - ("request.variables", JsonPathRoot::RequestVariables), + const ROOTS: [(&str, JsonPathRoot); 8] = [ ("request.headers", JsonPathRoot::RequestHeaders), ("response.body", JsonPathRoot::ResponseBody), ("response.data", JsonPathRoot::ResponseData), - ("response.grpc", JsonPathRoot::ResponseGrpc), ("request.path", JsonPathRoot::RequestPath), ("request.query", JsonPathRoot::RequestQuery), ("request.body", JsonPathRoot::RequestBody), - ("request.grpc", JsonPathRoot::RequestGrpc), ("output", JsonPathRoot::Output), ("mcp", JsonPathRoot::Mcp), ]; diff --git a/crates/crank-mapping/src/model.rs b/crates/crank-mapping/src/model.rs index ca73a96..a643e31 100644 --- a/crates/crank-mapping/src/model.rs +++ b/crates/crank-mapping/src/model.rs @@ -118,9 +118,7 @@ fn target_root_context(root: JsonPathRoot) -> Option { JsonPathRoot::RequestPath | JsonPathRoot::RequestQuery | JsonPathRoot::RequestHeaders - | JsonPathRoot::RequestBody - | JsonPathRoot::RequestVariables - | JsonPathRoot::RequestGrpc => Some(MappingTargetContext::Input), + | JsonPathRoot::RequestBody => Some(MappingTargetContext::Input), JsonPathRoot::Output => Some(MappingTargetContext::Output), _ => None, } @@ -136,9 +134,7 @@ fn validate_source_root( MappingTargetContext::Output => { matches!( root, - JsonPathRoot::ResponseBody - | JsonPathRoot::ResponseData - | JsonPathRoot::ResponseGrpc + JsonPathRoot::ResponseBody | JsonPathRoot::ResponseData ) } MappingTargetContext::Mixed => false, @@ -166,8 +162,6 @@ fn validate_target_root( | JsonPathRoot::RequestQuery | JsonPathRoot::RequestHeaders | JsonPathRoot::RequestBody - | JsonPathRoot::RequestVariables - | JsonPathRoot::RequestGrpc ), MappingTargetContext::Output => root == JsonPathRoot::Output, MappingTargetContext::Mixed => false, diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index 257d638..29d14d6 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -25,22 +25,6 @@ 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("secret {secret_id} is referenced by auth profile {auth_profile_id}")] diff --git a/crates/crank-registry/src/postgres/mod.rs b/crates/crank-registry/src/postgres/mod.rs index 944a70e..9d67635 100644 --- a/crates/crank-registry/src/postgres/mod.rs +++ b/crates/crank-registry/src/postgres/mod.rs @@ -584,7 +584,7 @@ fn join_target_url(base: &str, path: &str) -> String { return base.to_owned(); } - if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("grpc://") { + if path.starts_with("http://") || path.starts_with("https://") { return path.to_owned(); } diff --git a/crates/crank-runtime/src/cache.rs b/crates/crank-runtime/src/cache.rs index a7dfcfe..62eb8eb 100644 --- a/crates/crank-runtime/src/cache.rs +++ b/crates/crank-runtime/src/cache.rs @@ -706,18 +706,18 @@ mod tests { }; store - .put_bucket("tenant:alpha", bucket, Duration::from_secs(30)) + .put_bucket("workspace:alpha", bucket, Duration::from_secs(30)) .await .unwrap(); assert_eq!( - store.get_bucket("tenant:alpha").await.unwrap(), + store.get_bucket("workspace:alpha").await.unwrap(), Some(bucket) ); - store.delete_bucket("tenant:alpha").await.unwrap(); + store.delete_bucket("workspace:alpha").await.unwrap(); - assert_eq!(store.get_bucket("tenant:alpha").await.unwrap(), None); + assert_eq!(store.get_bucket("workspace:alpha").await.unwrap(), None); } #[tokio::test] diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 53da82f..dd19992 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -592,2238 +592,3 @@ fn non_empty_body(value: &Value) -> Option<&Value> { _ => Some(value), } } - -#[cfg(any())] -mod tests { - use std::collections::BTreeMap; - use std::io; - use std::sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, - }; - - use async_trait::async_trait; - use axum::{ - Json, Router, - extract::Query, - http::HeaderMap, - routing::{get, post}, - }; - #[cfg(any())] - use crank_adapter_grpc::test_support as grpc_test_support; - use crank_core::{ - ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, HttpMethod, InvocationSource, - InvocationStatus, MeteringEvent, MeteringSink, Operation, OperationId, - OperationSecurityLevel, OperationStatus, Protocol, RestTarget, Samples, Target, - ToolDescription, ToolExample, WorkspaceId, - }; - use crank_mapping::{MappingRule, MappingSet}; - use crank_schema::{Schema, SchemaKind}; - use serde_json::{Value, json}; - use time::{OffsetDateTime, format_description::well_known::Rfc3339}; - use tokio::net::TcpListener; - #[cfg(any())] - use tokio_tungstenite::{ - accept_async, accept_hdr_async, - tungstenite::handshake::server::{Request, Response}, - }; - use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*}; - - use crate::{ - InMemoryResponseCacheStore, PreparedRequest, RuntimeError, RuntimeExecutor, - RuntimeExecutorBuilder, RuntimeLimits, RuntimeOperation, RuntimeRequestContext, - }; - - fn timestamp(value: &str) -> OffsetDateTime { - OffsetDateTime::parse(value, &Rfc3339).unwrap() - } - - #[derive(Clone, Default)] - struct SharedLogWriter { - buffer: Arc>>, - } - - impl SharedLogWriter { - fn output(&self) -> String { - String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap() - } - } - - impl<'a> MakeWriter<'a> for SharedLogWriter { - type Writer = SharedLogGuard; - - fn make_writer(&'a self) -> Self::Writer { - SharedLogGuard { - buffer: Arc::clone(&self.buffer), - } - } - } - - struct SharedLogGuard { - buffer: Arc>>, - } - - impl io::Write for SharedLogGuard { - fn write(&mut self, bytes: &[u8]) -> io::Result { - self.buffer.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - #[derive(Clone, Default)] - struct RecordingMeteringSink { - events: Arc>>, - } - - impl RecordingMeteringSink { - fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } - } - - #[async_trait] - impl MeteringSink for RecordingMeteringSink { - async fn record(&self, event: MeteringEvent) { - self.events.lock().unwrap().push(event); - } - } - - #[tokio::test] - async fn executes_rest_operation_end_to_end() { - let base_url = spawn_runtime_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_rest_operation(&base_url, false, false); - - let output = executor - .execute(&operation, &json!({ "email": "user@example.com" })) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "lead_123" })); - } - - #[tokio::test] - async fn propagates_request_context_to_rest_headers() { - let base_url = spawn_runtime_server().await; - let executor = RuntimeExecutor::new(); - let mut operation = test_rest_operation(&base_url, false, false); - let context = RuntimeRequestContext::new("req_rest_123", "corr_rest_123"); - - if let Target::Rest(target) = &mut operation.target { - target.path_template = "/leads-context".to_owned(); - } - - let output = executor - .execute_with_context( - &operation, - &json!({ "email": "user@example.com" }), - Some(&context), - ) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "req_rest_123|corr_rest_123" })); - } - - #[tokio::test] - async fn records_metering_event_for_successful_invocation() { - let base_url = spawn_runtime_server().await; - let metering_sink = RecordingMeteringSink::default(); - let executor = RuntimeExecutorBuilder::new() - .register_adapter(Arc::new(crank_adapter_rest::RestAdapter::new())) - .with_metering_sink(Arc::new(metering_sink.clone())) - .build(); - let operation = test_rest_operation(&base_url, false, false); - let context = RuntimeRequestContext::from_request_id("req_meter_1").with_metering_context( - WorkspaceId::new("ws_meter"), - None, - InvocationSource::AgentToolCall, - ); - - let output = executor - .execute_with_context( - &operation, - &json!({ "email": "user@example.com" }), - Some(&context), - ) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "lead_123" })); - - let events = metering_sink.events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].workspace_id, WorkspaceId::new("ws_meter")); - assert_eq!(events[0].agent_id, None); - assert_eq!(events[0].operation_id, operation.operation_id); - assert_eq!(events[0].source, InvocationSource::AgentToolCall); - assert_eq!(events[0].status, InvocationStatus::Ok); - assert!(events[0].duration_ms <= 60_000); - } - - #[tokio::test] - async fn caches_rest_get_responses_within_agent_scope() { - let request_count = Arc::new(AtomicUsize::new(0)); - let base_url = spawn_cached_rest_server(Arc::clone(&request_count)).await; - let executor = RuntimeExecutor::new() - .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); - let operation = test_cached_rest_get_operation(&base_url); - let first_context = RuntimeRequestContext::from_request_id("req_cache_1") - .with_response_cache_scope("ws_cache", "agent_sales"); - let second_context = RuntimeRequestContext::from_request_id("req_cache_2") - .with_response_cache_scope("ws_cache", "agent_sales"); - - let first_output = executor - .execute_with_context(&operation, &json!({ "city": "msk" }), Some(&first_context)) - .await - .unwrap(); - let second_output = executor - .execute_with_context(&operation, &json!({ "city": "msk" }), Some(&second_context)) - .await - .unwrap(); - - assert_eq!(first_output, json!({ "city": "msk", "request_count": 1 })); - assert_eq!(second_output, json!({ "city": "msk", "request_count": 1 })); - assert_eq!(request_count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn isolates_rest_get_cache_between_agents() { - let request_count = Arc::new(AtomicUsize::new(0)); - let base_url = spawn_cached_rest_server(Arc::clone(&request_count)).await; - let executor = RuntimeExecutor::new() - .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); - let operation = test_cached_rest_get_operation(&base_url); - let agent_a_context = RuntimeRequestContext::from_request_id("req_cache_agent_a") - .with_response_cache_scope("ws_cache", "agent_a"); - let agent_b_context = RuntimeRequestContext::from_request_id("req_cache_agent_b") - .with_response_cache_scope("ws_cache", "agent_b"); - - let first_output = executor - .execute_with_context( - &operation, - &json!({ "city": "msk" }), - Some(&agent_a_context), - ) - .await - .unwrap(); - let second_output = executor - .execute_with_context( - &operation, - &json!({ "city": "msk" }), - Some(&agent_b_context), - ) - .await - .unwrap(); - - assert_eq!(first_output, json!({ "city": "msk", "request_count": 1 })); - assert_eq!(second_output, json!({ "city": "msk", "request_count": 2 })); - assert_eq!(request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn invalidates_rest_get_cache_on_operation_version_change() { - let request_count = Arc::new(AtomicUsize::new(0)); - let base_url = spawn_cached_rest_server(Arc::clone(&request_count)).await; - let executor = RuntimeExecutor::new() - .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); - let operation_v1 = test_cached_rest_get_operation(&base_url); - let mut operation_v2 = operation_v1.clone(); - let context = RuntimeRequestContext::from_request_id("req_cache_version") - .with_response_cache_scope("ws_cache", "agent_sales"); - - let first_output = executor - .execute_with_context(&operation_v1, &json!({ "city": "msk" }), Some(&context)) - .await - .unwrap(); - operation_v2.operation_version = 2; - let second_output = executor - .execute_with_context(&operation_v2, &json!({ "city": "msk" }), Some(&context)) - .await - .unwrap(); - - assert_eq!(first_output, json!({ "city": "msk", "request_count": 1 })); - assert_eq!(second_output, json!({ "city": "msk", "request_count": 2 })); - assert_eq!(request_count.load(Ordering::SeqCst), 2); - } - - #[cfg(any())] - #[tokio::test] - async fn caches_graphql_query_responses_within_agent_scope() { - let request_count = Arc::new(AtomicUsize::new(0)); - let endpoint = spawn_cached_graphql_server(Arc::clone(&request_count)).await; - let executor = RuntimeExecutor::new() - .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); - let operation = test_cached_graphql_query_operation(&endpoint); - let first_context = RuntimeRequestContext::from_request_id("req_cache_graphql_1") - .with_response_cache_scope("ws_cache", "agent_sales"); - let second_context = RuntimeRequestContext::from_request_id("req_cache_graphql_2") - .with_response_cache_scope("ws_cache", "agent_sales"); - - let first_output = executor - .execute_with_context( - &operation, - &json!({ "email": "user@example.com" }), - Some(&first_context), - ) - .await - .unwrap(); - let second_output = executor - .execute_with_context( - &operation, - &json!({ "email": "user@example.com" }), - Some(&second_context), - ) - .await - .unwrap(); - - assert_eq!(first_output, json!({ "id": "lead_123" })); - assert_eq!(second_output, json!({ "id": "lead_123" })); - assert_eq!(request_count.load(Ordering::SeqCst), 1); - } - - #[cfg(any())] - #[tokio::test] - async fn caches_read_only_grpc_unary_responses_within_agent_scope() { - let request_count = Arc::new(AtomicUsize::new(0)); - let server_addr = - grpc_test_support::spawn_counting_unary_echo_server(Arc::clone(&request_count)).await; - let executor = RuntimeExecutor::new() - .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); - let operation = test_cached_grpc_operation(&server_addr); - let first_context = RuntimeRequestContext::from_request_id("req_cache_grpc_1") - .with_response_cache_scope("ws_cache", "agent_sales"); - let second_context = RuntimeRequestContext::from_request_id("req_cache_grpc_2") - .with_response_cache_scope("ws_cache", "agent_sales"); - - let first_output = executor - .execute_with_context( - &operation, - &json!({ "message": "hello" }), - Some(&first_context), - ) - .await - .unwrap(); - let second_output = executor - .execute_with_context( - &operation, - &json!({ "message": "hello" }), - Some(&second_context), - ) - .await - .unwrap(); - - assert_eq!(first_output, json!({ "message": "hello-1" })); - assert_eq!(second_output, json!({ "message": "hello-1" })); - assert_eq!(request_count.load(Ordering::SeqCst), 1); - } - - #[cfg(any())] - #[tokio::test] - async fn skips_grpc_response_cache_without_read_only_flag() { - let request_count = Arc::new(AtomicUsize::new(0)); - let server_addr = - grpc_test_support::spawn_counting_unary_echo_server(Arc::clone(&request_count)).await; - let executor = RuntimeExecutor::new() - .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); - let mut operation = test_cached_grpc_operation(&server_addr); - if let Target::Grpc(target) = &mut operation.target { - target.read_only = false; - } - let first_context = RuntimeRequestContext::from_request_id("req_cache_grpc_skip_1") - .with_response_cache_scope("ws_cache", "agent_sales"); - let second_context = RuntimeRequestContext::from_request_id("req_cache_grpc_skip_2") - .with_response_cache_scope("ws_cache", "agent_sales"); - - let first_output = executor - .execute_with_context( - &operation, - &json!({ "message": "hello" }), - Some(&first_context), - ) - .await - .unwrap(); - let second_output = executor - .execute_with_context( - &operation, - &json!({ "message": "hello" }), - Some(&second_context), - ) - .await - .unwrap(); - - assert_eq!(first_output, json!({ "message": "hello-1" })); - assert_eq!(second_output, json!({ "message": "hello-2" })); - assert_eq!(request_count.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn emits_runtime_tracing_with_request_context() { - let base_url = spawn_runtime_server().await; - let executor = RuntimeExecutor::new(); - let mut operation = test_rest_operation(&base_url, false, false); - let context = RuntimeRequestContext::new("req_log_123", "corr_log_123"); - let writer = SharedLogWriter::default(); - let subscriber = tracing_subscriber::registry().with( - tracing_subscriber::fmt::layer() - .with_writer(writer.clone()) - .without_time() - .with_ansi(false) - .with_target(false) - .compact() - .with_filter(LevelFilter::DEBUG), - ); - let dispatch = tracing::Dispatch::new(subscriber); - - if let Target::Rest(target) = &mut operation.target { - target.path_template = "/leads-context".to_owned(); - } - - let _guard = tracing::dispatcher::set_default(&dispatch); - let output = executor - .execute_with_context( - &operation, - &json!({ "email": "user@example.com" }), - Some(&context), - ) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "req_log_123|corr_log_123" })); - - let logs = writer.output(); - assert!(logs.contains("runtime execution")); - assert!(logs.contains("unary.execute")); - assert!(logs.contains("adapter.dispatch")); - assert!(logs.contains("req_log_123")); - assert!(logs.contains("corr_log_123")); - assert!(logs.contains("op_rest_runtime")); - } - - #[tokio::test] - async fn rejects_unary_execution_when_unary_limit_is_exhausted() { - let executor = RuntimeExecutor::with_limits(RuntimeLimits { - max_concurrent_unary: 0, - ..RuntimeLimits::default() - }); - let operation = test_rest_operation("http://example.invalid", false, false); - - let error = executor - .execute(&operation, &json!({ "email": "user@example.com" })) - .await - .unwrap_err(); - - assert!(matches!( - error, - RuntimeError::ConcurrencyLimitExceeded { - kind: "unary", - limit: 0 - } - )); - } - - #[cfg(any())] - #[tokio::test] - async fn rejects_window_execution_when_window_limit_is_exhausted() { - let executor = RuntimeExecutor::with_limits(RuntimeLimits { - max_concurrent_window: 0, - ..RuntimeLimits::default() - }); - let operation = test_window_snapshot_operation( - "http://example.invalid", - AggregationMode::RawItems, - None, - None, - ); - - let error = executor - .execute_window(&operation, &json!({})) - .await - .unwrap_err(); - - assert!(matches!( - error, - RuntimeError::ConcurrencyLimitExceeded { - kind: "window", - limit: 0 - } - )); - } - - #[cfg(any())] - #[tokio::test] - async fn rejects_session_seed_when_session_limit_is_exhausted() { - let executor = RuntimeExecutor::with_limits(RuntimeLimits { - max_concurrent_sessions: 0, - ..RuntimeLimits::default() - }); - let operation = test_session_seed_operation("http://example.invalid"); - - let error = executor - .execute_session_seed(&operation, &json!({})) - .await - .unwrap_err(); - - assert!(matches!( - error, - RuntimeError::ConcurrencyLimitExceeded { - kind: "session", - limit: 0 - } - )); - } - - #[cfg(any())] - #[tokio::test] - async fn rejects_async_job_execution_when_job_limit_is_exhausted() { - let executor = RuntimeExecutor::with_limits(RuntimeLimits { - max_concurrent_jobs: 0, - ..RuntimeLimits::default() - }); - let operation = test_async_job_operation("http://example.invalid"); - - let error = executor.execute(&operation, &json!({})).await.unwrap_err(); - - assert!(matches!( - error, - RuntimeError::ConcurrencyLimitExceeded { - kind: "async_job", - limit: 0 - } - )); - } - - #[cfg(any())] - #[tokio::test] - async fn executes_graphql_operation_end_to_end() { - let endpoint = spawn_graphql_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_graphql_operation(&endpoint, false); - - let output = executor - .execute(&operation, &json!({ "email": "user@example.com" })) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "lead_123" })); - } - - #[cfg(any())] - #[tokio::test] - async fn propagates_request_context_to_graphql_headers() { - let endpoint = spawn_graphql_context_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_graphql_operation(&endpoint, false); - let context = RuntimeRequestContext::new("req_graphql_123", "corr_graphql_123"); - - let output = executor - .execute_with_context( - &operation, - &json!({ "email": "user@example.com" }), - Some(&context), - ) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "req_graphql_123|corr_graphql_123" })); - } - - #[cfg(any())] - #[tokio::test] - async fn executes_grpc_operation_end_to_end() { - let server_addr = grpc_test_support::spawn_unary_echo_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_grpc_operation(&server_addr); - - let output = executor - .execute(&operation, &json!({ "message": "hello" })) - .await - .unwrap(); - - assert_eq!(output, json!({ "message": "hello" })); - } - - #[cfg(any())] - #[tokio::test] - async fn propagates_request_context_to_grpc_metadata() { - let server_addr = grpc_test_support::spawn_metadata_echo_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_grpc_operation(&server_addr); - let context = RuntimeRequestContext::new("req_grpc_123", "corr_grpc_123"); - - let output = executor - .execute_with_context(&operation, &json!({ "message": "hello" }), Some(&context)) - .await - .unwrap(); - - assert_eq!( - output, - json!({ "message": "hello|req_grpc_123|corr_grpc_123" }) - ); - } - - #[cfg(any())] - #[tokio::test] - async fn executes_soap_operation_end_to_end() { - let endpoint = spawn_soap_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_soap_operation(&endpoint); - - let output = executor - .execute(&operation, &json!({ "email": "user@example.com" })) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "lead_123" })); - } - - #[cfg(any())] - #[tokio::test] - async fn propagates_request_context_to_soap_headers() { - let endpoint = spawn_soap_context_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_soap_operation(&endpoint); - let context = RuntimeRequestContext::new("req_soap_123", "corr_soap_123"); - - let output = executor - .execute_with_context( - &operation, - &json!({ "email": "user@example.com" }), - Some(&context), - ) - .await - .unwrap(); - - assert_eq!(output, json!({ "id": "req_soap_123|corr_soap_123" })); - } - - #[cfg(any())] - #[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); - } - - #[cfg(any())] - #[tokio::test] - async fn executes_websocket_window_mode_end_to_end() { - let target_url = spawn_websocket_server().await; - let executor = RuntimeExecutor::new(); - let operation = - test_websocket_window_operation(&target_url, AggregationMode::RawItems, Some(3)); - - let result = executor - .execute_window(&operation, &json!({})) - .await - .unwrap(); - - assert_eq!(result.items.len(), 3); - assert_eq!(result.items[0], json!({ "seq": 1, "value": 101 })); - assert_eq!(result.items[2], json!({ "seq": 3, "value": 103 })); - assert!(!result.window_complete); - assert!(result.truncated); - assert!(result.has_more); - } - - #[cfg(any())] - #[tokio::test] - async fn propagates_request_context_to_websocket_headers() { - let target_url = spawn_request_context_websocket_server().await; - let executor = RuntimeExecutor::new(); - let operation = - test_websocket_window_operation(&target_url, AggregationMode::RawItems, Some(2)); - let context = RuntimeRequestContext::new("req_ws_123", "corr_ws_123"); - - let result = executor - .execute_window_with_context(&operation, &json!({}), Some(&context)) - .await - .unwrap(); - - assert_eq!(result.items.len(), 2); - assert_eq!(result.items[0], json!({ "seq": 1, "value": 101 })); - } - - #[tokio::test] - async fn rejects_invalid_input_shape() { - let base_url = spawn_runtime_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_rest_operation(&base_url, false, false); - - let error = executor.execute(&operation, &json!({})).await.unwrap_err(); - - assert!(matches!(error, RuntimeError::Schema(_))); - } - - #[test] - fn rejects_missing_request_root_with_structured_error() { - let error = PreparedRequest::from_mapping_output(&json!({})).unwrap_err(); - - match error { - RuntimeError::InvalidPreparedRequest { field, reason } => { - assert_eq!(field, "request"); - assert_eq!(reason, "mapped input must contain request root"); - } - other => panic!("unexpected error: {other}"), - } - } - - #[tokio::test] - async fn propagates_external_rest_errors() { - let base_url = spawn_runtime_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_rest_operation(&base_url, true, false); - - let error = executor - .execute(&operation, &json!({ "email": "user@example.com" })) - .await - .unwrap_err(); - - assert!(matches!(error, RuntimeError::ProtocolAdapter(_))); - } - - #[tokio::test] - async fn fails_when_output_mapping_requires_missing_field() { - let base_url = spawn_runtime_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_rest_operation(&base_url, false, true); - - let error = executor - .execute(&operation, &json!({ "email": "user@example.com" })) - .await - .unwrap_err(); - - assert!(matches!(error, RuntimeError::Mapping(_))); - } - - #[cfg(any())] - #[tokio::test] - async fn propagates_graphql_operation_errors() { - let endpoint = spawn_graphql_server().await; - let executor = RuntimeExecutor::new(); - let operation = test_graphql_operation(&endpoint, true); - - let error = executor - .execute(&operation, &json!({ "email": "fail@example.com" })) - .await - .unwrap_err(); - - assert!(matches!(error, RuntimeError::GraphqlAdapter(_))); - } - - #[cfg(any())] - #[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_snapshot_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); - } - - #[cfg(any())] - #[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_snapshot_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()); - } - - #[cfg(any())] - #[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_snapshot_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...")); - } - - #[cfg(any())] - #[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_snapshot_operation(&base_url); - - let error = executor - .execute_window(&operation, &json!({})) - .await - .unwrap_err(); - - assert!(matches!(error, RuntimeError::RestAdapter(_))); - } - - #[cfg(any())] - #[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); - } - - #[cfg(any())] - #[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("/leads-context", post(create_lead_with_context)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - - async fn spawn_cached_rest_server(request_count: Arc) -> String { - let app = Router::new().route( - "/catalog", - get(move |Query(params): Query>| { - let request_count = Arc::clone(&request_count); - async move { - let count = request_count.fetch_add(1, Ordering::SeqCst) + 1; - let city = params.get("city").cloned().unwrap_or_default(); - ( - axum::http::StatusCode::OK, - Json(json!({ - "city": city, - "request_count": count, - })), - ) - } - }), - ); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - - #[cfg(any())] - async fn spawn_graphql_server() -> String { - let app = Router::new().route("/", post(graphql_handler)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - - #[cfg(any())] - async fn spawn_cached_graphql_server(request_count: Arc) -> String { - let app = Router::new().route( - "/", - post(move |Json(payload): Json| { - let request_count = Arc::clone(&request_count); - async move { - let count = request_count.fetch_add(1, Ordering::SeqCst) + 1; - let email = payload - .get("variables") - .and_then(|variables| variables.get("email")) - .and_then(Value::as_str) - .unwrap_or_default(); - - Json(json!({ - "data": { - "lookupLead": { - "id": "lead_123", - "email": email, - "request_count": count, - } - } - })) - } - }), - ); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - - #[cfg(any())] - async fn spawn_graphql_context_server() -> String { - let app = Router::new().route("/", post(graphql_context_handler)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - - #[cfg(any())] - async fn spawn_soap_server() -> String { - let app = Router::new().route("/", post(soap_handler)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - - #[cfg(any())] - async fn spawn_soap_context_server() -> String { - let app = Router::new().route("/", post(soap_context_handler)); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - format!("http://{}", address) - } - - #[cfg(any())] - async fn spawn_websocket_server() -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - loop { - let (stream, _) = listener.accept().await.unwrap(); - tokio::spawn(async move { - let websocket = accept_async(stream).await.unwrap(); - let (mut sink, mut source) = websocket.split(); - - if let Some(message) = source.next().await { - let message = message.unwrap(); - if !matches!(message, tokio_tungstenite::tungstenite::Message::Text(_)) { - return; - } - } - - for payload in [ - json!({ "seq": 1, "value": 101 }), - json!({ "seq": 2, "value": 102 }), - json!({ "seq": 3, "value": 103 }), - json!({ "seq": 4, "value": 104 }), - ] { - sink.send(tokio_tungstenite::tungstenite::Message::Text( - payload.to_string().into(), - )) - .await - .unwrap(); - } - let _ = sink.close().await; - }); - } - }); - - format!("ws://{}", address) - } - - #[cfg(any())] - async fn spawn_request_context_websocket_server() -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - - tokio::spawn(async move { - loop { - let (stream, _) = listener.accept().await.unwrap(); - tokio::spawn(async move { - let websocket = - accept_hdr_async(stream, |request: &Request, response: Response| { - assert_eq!( - request - .headers() - .get("x-request-id") - .and_then(|value| value.to_str().ok()), - Some("req_ws_123") - ); - assert_eq!( - request - .headers() - .get("x-correlation-id") - .and_then(|value| value.to_str().ok()), - Some("corr_ws_123") - ); - Ok(response) - }) - .await - .unwrap(); - let (mut sink, mut source) = websocket.split(); - - if let Some(message) = source.next().await { - let message = message.unwrap(); - if !matches!(message, tokio_tungstenite::tungstenite::Message::Text(_)) { - return; - } - } - - for payload in [ - json!({ "seq": 1, "value": 101 }), - json!({ "seq": 2, "value": 102 }), - ] { - sink.send(tokio_tungstenite::tungstenite::Message::Text( - payload.to_string().into(), - )) - .await - .unwrap(); - } - let _ = sink.close().await; - }); - } - }); - - format!("ws://{}", address) - } - - async fn create_lead(Json(payload): Json) -> (axum::http::StatusCode, Json) { - let should_fail = payload - .get("fail") - .and_then(Value::as_bool) - .unwrap_or(false); - - if should_fail { - return ( - axum::http::StatusCode::BAD_GATEWAY, - Json(json!({ "error": "upstream failed" })), - ); - } - - ( - axum::http::StatusCode::OK, - Json(json!({ "id": "lead_123", "email": payload["email"] })), - ) - } - - async fn create_lead_with_context( - headers: HeaderMap, - Json(_payload): Json, - ) -> (axum::http::StatusCode, Json) { - ( - axum::http::StatusCode::OK, - Json(json!({ - "id": format!( - "{}|{}", - headers - .get("x-request-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(), - headers - .get("x-correlation-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - ), - })), - ) - } - - #[cfg(any())] - 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 - })), - ) - } - - #[cfg(any())] - async fn slow_events_window() -> (axum::http::StatusCode, Json) { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - events_window().await - } - - #[cfg(any())] - 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()) - } - - #[cfg(any())] - 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()) - } - - #[cfg(any())] - async fn graphql_handler(Json(payload): Json) -> Json { - let email = payload - .get("variables") - .and_then(|variables| variables.get("email")) - .and_then(Value::as_str) - .unwrap_or_default(); - - if email == "fail@example.com" { - return Json(json!({ - "data": { "createLead": null }, - "errors": [{ "message": "lead creation failed" }] - })); - } - - Json(json!({ - "data": { - "createLead": { - "id": "lead_123", - "status": "created" - } - } - })) - } - - #[cfg(any())] - async fn graphql_context_handler( - headers: HeaderMap, - Json(_payload): Json, - ) -> Json { - Json(json!({ - "data": { - "createLead": { - "id": format!( - "{}|{}", - headers - .get("x-request-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(), - headers - .get("x-correlation-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - ), - "status": "created" - } - } - })) - } - - #[cfg(any())] - async fn soap_handler(body: String) -> (axum::http::StatusCode, String) { - assert!(body.contains("user@example.com")); - - ( - axum::http::StatusCode::OK, - r#" - - - lead_123 - created - - - "# - .to_owned(), - ) - } - - #[cfg(any())] - async fn soap_context_handler( - headers: HeaderMap, - body: String, - ) -> (axum::http::StatusCode, String) { - assert!(body.contains("user@example.com")); - let request_id = headers - .get("x-request-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - let correlation_id = headers - .get("x-correlation-id") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - - ( - axum::http::StatusCode::OK, - format!( - r#" - - - {request_id}|{correlation_id} - created - - - "# - ), - ) - } - - fn test_rest_operation( - base_url: &str, - should_fail: bool, - invalid_output: bool, - ) -> RuntimeOperation { - let mut input_rules = vec![MappingRule { - source: "$.mcp.email".to_owned(), - target: "$.request.body.email".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }]; - - if should_fail { - input_rules.push(MappingRule { - source: "$.mcp.fail".to_owned(), - target: "$.request.body.fail".to_owned(), - required: false, - default_value: Some(Value::Bool(true)), - transform: None, - condition: None, - notes: None, - }); - } - - let output_source = if invalid_output { - "$.response.body.missing" - } else { - "$.response.body.id" - }; - - RuntimeOperation::from(Operation { - id: OperationId::new("op_rest_runtime"), - name: "crm_create_lead".to_owned(), - display_name: "Create Lead".to_owned(), - category: "sales".to_owned(), - protocol: Protocol::Rest, - security_level: OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Rest(RestTarget { - base_url: base_url.to_owned(), - method: HttpMethod::Post, - path_template: "/leads".to_owned(), - static_headers: BTreeMap::new(), - }), - input_schema: object_schema("email", SchemaKind::String), - output_schema: object_schema("id", SchemaKind::String), - input_mapping: MappingSet { rules: input_rules }, - output_mapping: MappingSet { - rules: vec![MappingRule { - source: output_source.to_owned(), - target: "$.output.id".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Create Lead".to_owned(), - description: "Creates a CRM lead".to_owned(), - tags: vec!["crm".to_owned()], - examples: vec![ToolExample { - input: json!({ "email": "user@example.com" }), - }], - }, - samples: Some(Samples::default()), - generated_draft: Some(GeneratedDraft { - status: GeneratedDraftStatus::Available, - source_types: vec!["input_json".to_owned()], - generated_at: Some("2026-03-25T20:00:00Z".to_owned()), - input_schema_generated: true, - output_schema_generated: true, - input_mapping_generated: true, - output_mapping_generated: true, - warnings: Vec::new(), - }), - config_export: None, - wizard_state: None, - created_at: timestamp("2026-03-25T20:00:00Z"), - updated_at: timestamp("2026-03-25T20:00:00Z"), - published_at: Some(timestamp("2026-03-25T20:00:00Z")), - }) - } - - #[cfg(any())] - fn test_graphql_operation(endpoint: &str, _should_fail: bool) -> RuntimeOperation { - RuntimeOperation::from(Operation { - id: OperationId::new("op_graphql_runtime"), - name: "crm_create_lead_graphql".to_owned(), - display_name: "Create Lead GraphQL".to_owned(), - category: "sales".to_owned(), - protocol: Protocol::Graphql, - security_level: OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Graphql(GraphqlTarget { - endpoint: endpoint.to_owned(), - operation_type: GraphqlOperationType::Mutation, - operation_name: "CreateLead".to_owned(), - query_template: - "mutation CreateLead($email: String!) { createLead(email: $email) { id status } }" - .to_owned(), - response_path: "$.response.body.data.createLead".to_owned(), - }), - input_schema: object_schema("email", SchemaKind::String), - output_schema: object_schema("id", SchemaKind::String), - input_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.mcp.email".to_owned(), - target: "$.request.variables.email".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - output_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.response.data.id".to_owned(), - target: "$.output.id".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Create Lead GraphQL".to_owned(), - description: "Creates a CRM lead through GraphQL".to_owned(), - tags: vec!["crm".to_owned(), "graphql".to_owned()], - examples: vec![ToolExample { - input: json!({ "email": "user@example.com" }), - }], - }, - samples: Some(Samples::default()), - generated_draft: Some(GeneratedDraft { - status: GeneratedDraftStatus::Available, - source_types: vec!["input_json".to_owned(), "output_json".to_owned()], - generated_at: Some("2026-03-25T20:00:00Z".to_owned()), - input_schema_generated: true, - output_schema_generated: true, - input_mapping_generated: true, - output_mapping_generated: true, - warnings: Vec::new(), - }), - config_export: None, - wizard_state: None, - created_at: timestamp("2026-03-25T20:00:00Z"), - updated_at: timestamp("2026-03-25T20:00:00Z"), - published_at: Some(timestamp("2026-03-25T20:00:00Z")), - }) - } - - #[cfg(any())] - fn test_cached_graphql_query_operation(endpoint: &str) -> RuntimeOperation { - RuntimeOperation::from(Operation { - id: OperationId::new("op_graphql_cached_runtime"), - name: "crm_lookup_lead_graphql".to_owned(), - display_name: "Lookup Lead GraphQL".to_owned(), - category: "sales".to_owned(), - protocol: Protocol::Graphql, - security_level: OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Graphql(GraphqlTarget { - endpoint: endpoint.to_owned(), - operation_type: GraphqlOperationType::Query, - operation_name: "LookupLead".to_owned(), - query_template: - "query LookupLead($email: String!) { lookupLead(email: $email) { id email request_count } }" - .to_owned(), - response_path: "$.response.body.data.lookupLead".to_owned(), - }), - input_schema: object_schema("email", SchemaKind::String), - output_schema: object_schema("id", SchemaKind::String), - input_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.mcp.email".to_owned(), - target: "$.request.variables.email".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - output_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.response.data.id".to_owned(), - target: "$.output.id".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: Some(crank_core::ResponseCachePolicy { ttl_ms: 5_000 }), - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Lookup Lead GraphQL".to_owned(), - description: "Reads a CRM lead through GraphQL".to_owned(), - tags: vec!["crm".to_owned(), "graphql".to_owned()], - examples: vec![ToolExample { - input: json!({ "email": "user@example.com" }), - }], - }, - samples: Some(Samples::default()), - generated_draft: Some(GeneratedDraft { - status: GeneratedDraftStatus::Available, - source_types: vec!["input_json".to_owned()], - generated_at: Some("2026-03-25T20:00:00Z".to_owned()), - input_schema_generated: true, - output_schema_generated: true, - input_mapping_generated: true, - output_mapping_generated: true, - warnings: Vec::new(), - }), - config_export: None, - wizard_state: None, - created_at: timestamp("2026-03-25T20:00:00Z"), - updated_at: timestamp("2026-03-25T20:00:00Z"), - published_at: Some(timestamp("2026-03-25T20:00:00Z")), - }) - } - - #[cfg(any())] - fn test_cached_grpc_operation(server_addr: &str) -> RuntimeOperation { - let mut operation = test_grpc_operation(server_addr); - operation.operation_id = OperationId::new("op_grpc_cached_runtime"); - operation.tool_name = "echo_unary_grpc_cached".to_owned(); - if let Target::Grpc(target) = &mut operation.target { - target.read_only = true; - } - operation.execution_config.response_cache = - Some(crank_core::ResponseCachePolicy { ttl_ms: 5_000 }); - operation.tool_description = ToolDescription { - title: "Unary Echo gRPC Cached".to_owned(), - description: "Reads a cacheable unary gRPC payload".to_owned(), - tags: vec!["grpc".to_owned(), "cache".to_owned()], - examples: vec![ToolExample { - input: json!({ "message": "hello" }), - }], - }; - operation - } - - #[cfg(any())] - fn test_grpc_operation(server_addr: &str) -> RuntimeOperation { - RuntimeOperation::from(Operation { - id: OperationId::new("op_grpc_runtime"), - name: "echo_unary_grpc".to_owned(), - display_name: "Unary Echo gRPC".to_owned(), - category: "support".to_owned(), - protocol: Protocol::Grpc, - security_level: OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Grpc(GrpcTarget { - server_addr: server_addr.to_owned(), - package: "echo".to_owned(), - service: "EchoService".to_owned(), - method: "UnaryEcho".to_owned(), - read_only: false, - 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("message", 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![MappingRule { - source: "$.response.data.message".to_owned(), - target: "$.output.message".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Unary Echo gRPC".to_owned(), - description: "Echoes a unary gRPC payload".to_owned(), - tags: vec!["grpc".to_owned()], - examples: vec![ToolExample { - input: json!({ "message": "hello" }), - }], - }, - samples: Some(Samples::default()), - generated_draft: Some(GeneratedDraft { - status: GeneratedDraftStatus::Available, - source_types: vec!["descriptor_set".to_owned()], - generated_at: Some("2026-03-25T20:00:00Z".to_owned()), - input_schema_generated: true, - output_schema_generated: true, - input_mapping_generated: true, - output_mapping_generated: true, - warnings: Vec::new(), - }), - config_export: None, - wizard_state: None, - created_at: timestamp("2026-03-25T20:00:00Z"), - updated_at: timestamp("2026-03-25T20:00:00Z"), - published_at: Some(timestamp("2026-03-25T20:00:00Z")), - }) - } - - #[cfg(any())] - 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, - security_level: OperationSecurityLevel::Standard, - 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(), - read_only: false, - 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, - response_cache: 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, - wizard_state: None, - created_at: timestamp("2026-04-06T12:00:00Z"), - updated_at: timestamp("2026-04-06T12:00:00Z"), - published_at: Some(timestamp("2026-04-06T12:00:00Z")), - }) - } - - #[cfg(any())] - fn test_soap_operation(endpoint: &str) -> RuntimeOperation { - RuntimeOperation::from(Operation { - id: OperationId::new("op_soap_runtime"), - name: "crm_create_lead_soap".to_owned(), - display_name: "Create Lead SOAP".to_owned(), - category: "sales".to_owned(), - protocol: Protocol::Soap, - security_level: OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Soap(SoapTarget { - wsdl_ref: "sample_wsdl".into(), - service_name: "LeadService".to_owned(), - port_name: "LeadPort".to_owned(), - operation_name: "CreateLead".to_owned(), - endpoint_override: Some(endpoint.to_owned()), - soap_version: SoapVersion::Soap11, - soap_action: Some("urn:createLead".to_owned()), - binding_style: SoapBindingStyle::DocumentLiteral, - headers: Vec::new(), - fault_contract: None, - metadata: SoapOperationMetadata { - input_part_names: vec!["CreateLeadRequest".to_owned()], - output_part_names: vec!["CreateLeadResponse".to_owned()], - namespaces: vec!["urn:crm".to_owned()], - }, - }), - input_schema: object_schema("email", SchemaKind::String), - output_schema: object_schema("id", SchemaKind::String), - input_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.mcp.email".to_owned(), - target: "$.request.body.email".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - output_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.response.body.id".to_owned(), - target: "$.output.id".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Create Lead SOAP".to_owned(), - description: "Creates a CRM lead through SOAP".to_owned(), - tags: vec!["crm".to_owned(), "soap".to_owned()], - examples: vec![ToolExample { - input: json!({ "email": "user@example.com" }), - }], - }, - samples: Some(Samples::default()), - generated_draft: Some(GeneratedDraft { - status: GeneratedDraftStatus::Available, - source_types: vec!["wsdl".to_owned()], - generated_at: Some("2026-04-06T12:00:00Z".to_owned()), - input_schema_generated: true, - output_schema_generated: true, - input_mapping_generated: true, - output_mapping_generated: true, - warnings: Vec::new(), - }), - config_export: None, - wizard_state: None, - created_at: timestamp("2026-04-06T12:00:00Z"), - updated_at: timestamp("2026-04-06T12:00:00Z"), - published_at: Some(timestamp("2026-04-06T12:00:00Z")), - }) - } - - #[cfg(any())] - fn test_window_snapshot_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, - security_level: OperationSecurityLevel::Standard, - 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, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: Some(StreamingConfig { - mode: ExecutionMode::Window, - transport_behavior: TransportBehavior::RequestResponse, - 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, - wizard_state: None, - created_at: timestamp("2026-04-06T12:00:00Z"), - updated_at: timestamp("2026-04-06T12:00:00Z"), - published_at: Some(timestamp("2026-04-06T12:00:00Z")), - }) - } - - #[cfg(any())] - fn test_slow_window_snapshot_operation(base_url: &str) -> RuntimeOperation { - let mut operation = - 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(); - } - - operation.execution_config.timeout_ms = 10; - operation - } - - #[cfg(any())] - fn test_websocket_window_operation( - target_url: &str, - aggregation_mode: AggregationMode, - max_items: Option, - ) -> RuntimeOperation { - RuntimeOperation::from(Operation { - id: OperationId::new("op_websocket_window_runtime"), - name: "telemetry_window_ws".to_owned(), - display_name: "Telemetry Window WebSocket".to_owned(), - category: "ops".to_owned(), - protocol: Protocol::Websocket, - security_level: OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Websocket(WebsocketTarget { - url: target_url.to_owned(), - subprotocols: Vec::new(), - subscribe_message_template: Some(json!({"type":"subscribe","topic":"telemetry"})), - unsubscribe_message_template: Some( - json!({"type":"unsubscribe","topic":"telemetry"}), - ), - 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, - response_cache: None, - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: Some(ProtocolOptions { - grpc: None, - websocket: Some(WebsocketProtocolOptions { - heartbeat_interval_ms: Some(100), - reconnect_max_attempts: Some(1), - reconnect_backoff_ms: Some(10), - }), - soap: None, - }), - streaming: Some(StreamingConfig { - mode: ExecutionMode::Window, - transport_behavior: TransportBehavior::ServerStream, - window_duration_ms: Some(2_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: "Telemetry Window WebSocket".to_owned(), - description: "Collects bounded WebSocket telemetry.".to_owned(), - tags: vec!["websocket".to_owned(), "stream".to_owned()], - examples: Vec::new(), - }, - samples: None, - generated_draft: None, - config_export: None, - wizard_state: None, - created_at: timestamp("2026-04-06T12:00:00Z"), - updated_at: timestamp("2026-04-06T12:00:00Z"), - published_at: Some(timestamp("2026-04-06T12:00:00Z")), - }) - } - - #[cfg(any())] - 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 - } - - #[cfg(any())] - 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 test_cached_rest_get_operation(base_url: &str) -> RuntimeOperation { - RuntimeOperation::from(Operation { - id: OperationId::new("op_rest_cache_runtime"), - name: "catalog_lookup".to_owned(), - display_name: "Catalog Lookup".to_owned(), - category: "lookup".to_owned(), - protocol: Protocol::Rest, - security_level: OperationSecurityLevel::Standard, - status: OperationStatus::Published, - version: 1, - target: Target::Rest(RestTarget { - base_url: base_url.to_owned(), - method: HttpMethod::Get, - path_template: "/catalog".to_owned(), - static_headers: BTreeMap::new(), - }), - input_schema: object_schema("city", SchemaKind::String), - output_schema: Schema { - kind: SchemaKind::Object, - description: None, - required: true, - nullable: false, - default_value: None, - fields: BTreeMap::from([ - ( - "city".to_owned(), - Schema { - kind: SchemaKind::String, - description: None, - required: true, - nullable: false, - default_value: None, - fields: BTreeMap::new(), - items: None, - enum_values: Vec::new(), - variants: Vec::new(), - }, - ), - ( - "request_count".to_owned(), - Schema { - kind: SchemaKind::Number, - description: None, - required: true, - 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(), - }, - input_mapping: MappingSet { - rules: vec![MappingRule { - source: "$.mcp.city".to_owned(), - target: "$.request.query.city".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }], - }, - output_mapping: MappingSet { - rules: vec![ - MappingRule { - source: "$.response.body.city".to_owned(), - target: "$.output.city".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }, - MappingRule { - source: "$.response.body.request_count".to_owned(), - target: "$.output.request_count".to_owned(), - required: true, - default_value: None, - transform: None, - condition: None, - notes: None, - }, - ], - }, - execution_config: ExecutionConfig { - timeout_ms: 1_000, - retry_policy: None, - response_cache: Some(crank_core::ResponseCachePolicy { ttl_ms: 60_000 }), - auth_profile_ref: None, - headers: BTreeMap::new(), - protocol_options: None, - streaming: None, - }, - tool_description: ToolDescription { - title: "Catalog Lookup".to_owned(), - description: "Loads a read-only catalog item".to_owned(), - tags: vec!["lookup".to_owned()], - examples: vec![ToolExample { - input: json!({ "city": "msk" }), - }], - }, - samples: Some(Samples::default()), - generated_draft: None, - config_export: None, - wizard_state: None, - created_at: timestamp("2026-04-06T12:00:00Z"), - updated_at: timestamp("2026-04-06T12:00:00Z"), - published_at: Some(timestamp("2026-04-06T12:00:00Z")), - }) - } - - #[cfg(any())] - fn test_session_seed_operation(base_url: &str) -> RuntimeOperation { - let mut operation = - test_window_snapshot_operation(base_url, AggregationMode::RawItems, Some(10), None); - - operation - .execution_config - .streaming - .as_mut() - .expect("streaming config") - .mode = ExecutionMode::Session; - - operation - } - - #[cfg(any())] - fn test_async_job_operation(base_url: &str) -> RuntimeOperation { - let mut operation = test_rest_operation(base_url, false, false); - operation.execution_config.streaming = Some(StreamingConfig { - mode: ExecutionMode::AsyncJob, - transport_behavior: TransportBehavior::RequestResponse, - window_duration_ms: None, - poll_interval_ms: Some(5_000), - upstream_timeout_ms: Some(operation.execution_config.timeout_ms), - idle_timeout_ms: None, - max_session_lifetime_ms: Some(300_000), - max_items: None, - max_bytes: None, - aggregation_mode: AggregationMode::RawItems, - 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(), - }); - - operation - } - - fn object_schema(field_name: &str, kind: SchemaKind) -> Schema { - Schema { - kind: SchemaKind::Object, - description: None, - required: true, - nullable: false, - default_value: None, - fields: BTreeMap::from([( - field_name.to_owned(), - Schema { - kind, - description: None, - required: true, - 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/docs/admin-api.md b/docs/admin-api.md index 8bb468a..e8a1bd7 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -22,7 +22,6 @@ Base path: ## Capabilities - `GET /api/admin/capabilities` -- `GET /api/admin/workspaces/{workspace_id}/protocol-capabilities` Community capabilities: diff --git a/docs/public-smoke-targets.md b/docs/public-smoke-targets.md index f3a83a4..f99d4b3 100644 --- a/docs/public-smoke-targets.md +++ b/docs/public-smoke-targets.md @@ -6,8 +6,6 @@ - `REST` -Коммерческие протоколы и их smoke targets живут в private репозиториях и не входят в Community release baseline. - Все примеры ниже дублируются готовыми payload-файлами в [examples/mcp-smoke](../examples/mcp-smoke). ## 1. Источник