diff --git a/.env.example b/.env.example index 0b64736..c2719c1 100644 --- a/.env.example +++ b/.env.example @@ -18,5 +18,6 @@ CRANK_SESSION_TTL_HOURS=24 CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner +CRANK_DEMO_SEED=false CRANK_PUBLIC_BASE_URL=https://crank.example.com CRANK_MCP_PUBLIC_URL=https://crank.example.com/mcp diff --git a/TASKS.md b/TASKS.md index 8980392..d932a94 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,15 +2,15 @@ ## Current -### `feat/cd-image-pipeline` +### `feat/demo-assets` Status: in_progress DoD: -- CI больше не собирает deployable images без использования результата -- Deploy собирает и пушит versioned images в registry с cache -- production deploy использует `docker compose pull && docker compose up -d`, без server-side build -- docs и runtime config описывают registry-based rollout +- project has an idempotent PostgreSQL demo seed for live UI screens +- demo seed fills workspaces, memberships, invitations, operations, agents, keys and usage +- seed is controlled by env and does not live in migrations +- docs explain how to enable and use demo data ## Next @@ -21,4 +21,3 @@ DoD: - `feat/current-workspace-session-model` - `feat/agent-lifecycle-polish` - `feat/platform-key-usage` -- `feat/demo-assets` diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 7aa6980..9897ec2 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -10,6 +10,7 @@ argon2.workspace = true axum.workspace = true axum-extra.workspace = true base64.workspace = true +crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] } crank-core = { path = "../../crates/crank-core" } crank-mapping = { path = "../../crates/crank-mapping" } crank-proto = { path = "../../crates/crank-proto" } @@ -29,7 +30,6 @@ tracing-subscriber.workspace = true uuid.workspace = true [dev-dependencies] -crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] } reqwest.workspace = true serial_test = "3" sqlx.workspace = true diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 3de6106..97d3952 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -856,6 +856,70 @@ mod tests { assert_eq!(missing_workspace.status(), reqwest::StatusCode::FORBIDDEN); } + #[tokio::test] + #[serial] + async fn seeds_demo_assets_for_live_ui() { + let registry = test_registry().await; + let storage_root = test_storage_root("demo_seed"); + let service = AdminService::new(registry.clone(), storage_root, test_auth_settings()); + + service.bootstrap_admin_user().await.unwrap(); + service.seed_demo_assets().await.unwrap(); + + let owner = registry + .get_auth_user_by_email(TEST_AUTH_EMAIL) + .await + .unwrap() + .unwrap(); + let workspaces = service + .list_workspaces_for_user(&owner.user.id) + .await + .unwrap(); + assert!( + workspaces + .iter() + .any(|item| item.workspace.slug == "default") + ); + assert!( + workspaces + .iter() + .any(|item| item.workspace.slug == "growth-lab") + ); + + let default_workspace_id = WorkspaceId::new(DEFAULT_WORKSPACE_ID); + let operations = service + .list_operations(&default_workspace_id) + .await + .unwrap(); + assert!(operations.len() >= 4); + + let agents = service.list_agents(&default_workspace_id).await.unwrap(); + assert!(agents.len() >= 2); + + let api_keys = service + .list_platform_api_keys(&default_workspace_id) + .await + .unwrap(); + assert!(api_keys.len() >= 2); + + let logs = service + .list_logs( + &default_workspace_id, + crate::service::LogsQuery { + level: None, + search: None, + source: None, + operation_id: None, + agent_id: None, + period: None, + limit: Some(20), + }, + ) + .await + .unwrap(); + assert!(!logs.is_empty()); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn updates_profile_and_changes_password() { diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 738e131..2eb1ab1 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -53,6 +53,9 @@ async fn main() -> Result<(), Box> { }; let service = AdminService::new(registry, storage_root, auth_settings); service.bootstrap_admin_user().await?; + if env_flag("CRANK_DEMO_SEED") { + service.seed_demo_assets().await?; + } let state = AppState { service }; let app = build_app(state); let listener = TcpListener::bind(socket_addr).await?; @@ -63,3 +66,14 @@ async fn main() -> Result<(), Box> { Ok(()) } + +fn env_flag(name: &str) -> bool { + matches!( + env::var(name) + .ok() + .as_deref() + .map(str::to_ascii_lowercase) + .as_deref(), + Some("1" | "true" | "yes" | "on") + ) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 750a6ec..d782b03 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1,7 +1,11 @@ use std::collections::BTreeMap; use std::path::PathBuf; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus, @@ -511,6 +515,22 @@ impl AdminService { Ok(()) } + pub async fn seed_demo_assets(&self) -> Result<(), ApiError> { + let admin_user = self + .registry + .get_auth_user_by_email(&self.auth_settings.bootstrap_admin.email) + .await? + .ok_or_else(|| ApiError::internal("bootstrap admin user was not found"))?; + let admin_user_id = admin_user.user.id.clone(); + let default_workspace_id = WorkspaceId::new("ws_default"); + + self.seed_default_workspace_demo(&admin_user_id, &default_workspace_id) + .await?; + self.seed_growth_workspace_demo(&admin_user_id).await?; + + Ok(()) + } + pub async fn get_session( &self, session_id: &UserSessionId, @@ -2423,6 +2443,617 @@ impl AdminService { self.get_workspace(workspace_id).await.map(|_| ()) } + async fn seed_default_workspace_demo( + &self, + owner_user_id: &crank_core::UserId, + workspace_id: &WorkspaceId, + ) -> Result<(), ApiError> { + let ops_admin_id = self + .ensure_demo_user("ops-manager@crank.demo", "Ops Manager") + .await?; + let analyst_id = self + .ensure_demo_user("analyst@crank.demo", "Revenue Analyst") + .await?; + let contractor_id = self + .ensure_demo_user("contractor@crank.demo", "Delivery Contractor") + .await?; + + self.registry + .ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner) + .await?; + self.registry + .ensure_membership(workspace_id, &ops_admin_id, MembershipRole::Admin) + .await?; + self.registry + .ensure_membership(workspace_id, &analyst_id, MembershipRole::Operator) + .await?; + self.registry + .ensure_membership(workspace_id, &contractor_id, MembershipRole::Viewer) + .await?; + + self.ensure_demo_invitation( + workspace_id, + InvitationPayload { + email: "partner@crank.demo".to_owned(), + role: MembershipRole::Viewer, + expires_at: None, + }, + ) + .await?; + self.ensure_demo_invitation( + workspace_id, + InvitationPayload { + email: "automation@crank.demo".to_owned(), + role: MembershipRole::Operator, + expires_at: None, + }, + ) + .await?; + + let rest_operation = self + .ensure_demo_operation(workspace_id, demo_rest_operation_payload()) + .await?; + self.ensure_operation_published(workspace_id, &rest_operation) + .await?; + self.ensure_demo_json_samples( + workspace_id, + &rest_operation.id, + &demo_rest_input_sample(), + &demo_rest_output_sample(), + ) + .await?; + + let graphql_operation = self + .ensure_demo_operation(workspace_id, demo_graphql_operation_payload()) + .await?; + self.ensure_operation_published(workspace_id, &graphql_operation) + .await?; + self.ensure_demo_json_samples( + workspace_id, + &graphql_operation.id, + &demo_graphql_input_sample(), + &demo_graphql_output_sample(), + ) + .await?; + + let grpc_operation = self + .ensure_demo_operation(workspace_id, demo_grpc_operation_payload()) + .await?; + self.ensure_demo_json_samples( + workspace_id, + &grpc_operation.id, + &demo_grpc_input_sample(), + &demo_grpc_output_sample(), + ) + .await?; + self.ensure_demo_grpc_artifacts(workspace_id, &grpc_operation.id) + .await?; + + let archived_operation = self + .ensure_demo_operation(workspace_id, demo_archived_operation_payload()) + .await?; + self.ensure_operation_archived(workspace_id, &archived_operation) + .await?; + + let revops_agent = self + .ensure_demo_agent(workspace_id, demo_revops_agent_payload()) + .await?; + self.ensure_demo_agent_bindings( + workspace_id, + &AgentId::new(revops_agent.id.clone()), + vec![ + AgentBindingPayload { + operation_id: rest_operation.id.as_str().to_owned(), + operation_version: rest_operation.current_draft_version, + tool_name: "create_crm_lead".to_owned(), + tool_title: "Create CRM Lead".to_owned(), + tool_description_override: Some( + "Create a new CRM lead in the revenue workspace.".to_owned(), + ), + enabled: true, + }, + AgentBindingPayload { + operation_id: graphql_operation.id.as_str().to_owned(), + operation_version: graphql_operation.current_draft_version, + tool_name: "get_invoice_status".to_owned(), + tool_title: "Get Invoice Status".to_owned(), + tool_description_override: Some( + "Fetch invoice state from billing GraphQL.".to_owned(), + ), + enabled: true, + }, + ], + true, + ) + .await?; + + let support_agent = self + .ensure_demo_agent(workspace_id, demo_support_agent_payload()) + .await?; + self.ensure_demo_agent_bindings( + workspace_id, + &AgentId::new(support_agent.id.clone()), + vec![AgentBindingPayload { + operation_id: grpc_operation.id.as_str().to_owned(), + operation_version: grpc_operation.current_draft_version, + tool_name: "lookup_support_ticket".to_owned(), + tool_title: "Lookup Support Ticket".to_owned(), + tool_description_override: Some( + "Draft support lookup flow backed by unary gRPC.".to_owned(), + ), + enabled: true, + }], + false, + ) + .await?; + + self.ensure_demo_platform_api_key( + workspace_id, + "Web Console Demo Key", + vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + false, + ) + .await?; + self.ensure_demo_platform_api_key( + workspace_id, + "Readonly Export Key", + vec![PlatformApiKeyScope::Read], + true, + ) + .await?; + + self.seed_demo_invocation_logs( + workspace_id, + &AgentId::new(revops_agent.id), + &rest_operation.id, + &graphql_operation.id, + &grpc_operation.id, + ) + .await?; + + Ok(()) + } + + async fn seed_growth_workspace_demo( + &self, + owner_user_id: &crank_core::UserId, + ) -> Result<(), ApiError> { + let workspace = self + .ensure_demo_workspace( + owner_user_id, + WorkspacePayload { + slug: "growth-lab".to_owned(), + display_name: "Growth Lab".to_owned(), + settings: json!({ + "tier": "demo", + "region": "eu-central", + "notes": "Secondary workspace for workspace switch testing" + }), + }, + ) + .await?; + let workspace_id = workspace.workspace.id; + + let growth_pm_id = self + .ensure_demo_user("growth.pm@crank.demo", "Growth PM") + .await?; + self.registry + .ensure_membership(&workspace_id, owner_user_id, MembershipRole::Owner) + .await?; + self.registry + .ensure_membership(&workspace_id, &growth_pm_id, MembershipRole::Admin) + .await?; + + self.ensure_demo_invitation( + &workspace_id, + InvitationPayload { + email: "agency@crank.demo".to_owned(), + role: MembershipRole::Viewer, + expires_at: None, + }, + ) + .await?; + self.ensure_demo_platform_api_key( + &workspace_id, + "Growth Readonly Key", + vec![PlatformApiKeyScope::Read], + false, + ) + .await?; + + Ok(()) + } + + async fn ensure_demo_workspace( + &self, + owner_user_id: &crank_core::UserId, + payload: WorkspacePayload, + ) -> Result { + if let Some(existing) = self + .registry + .list_workspaces_for_user(owner_user_id) + .await? + .into_iter() + .find(|record| record.workspace.slug == payload.slug) + { + return Ok(WorkspaceRecord { + workspace: existing.workspace, + }); + } + + self.create_workspace(owner_user_id, payload).await + } + + async fn ensure_demo_user( + &self, + email: &str, + display_name: &str, + ) -> Result { + let password_hash = hash_password(DEMO_USER_PASSWORD, &self.auth_settings.password_pepper)?; + self.registry + .upsert_bootstrap_user(email, display_name, &password_hash) + .await + .map_err(ApiError::from) + } + + async fn ensure_demo_invitation( + &self, + workspace_id: &WorkspaceId, + payload: InvitationPayload, + ) -> Result<(), ApiError> { + if self + .list_invitations(workspace_id) + .await? + .iter() + .any(|record| record.invitation.email == payload.email) + { + return Ok(()); + } + + self.create_invitation(workspace_id, payload).await?; + Ok(()) + } + + async fn ensure_demo_platform_api_key( + &self, + workspace_id: &WorkspaceId, + name: &str, + scopes: Vec, + revoke: bool, + ) -> Result<(), ApiError> { + let existing = self + .list_platform_api_keys(workspace_id) + .await? + .into_iter() + .find(|record| record.api_key.name == name); + let key = match existing { + Some(record) => record, + None => { + self.create_platform_api_key( + workspace_id, + PlatformApiKeyPayload { + name: name.to_owned(), + scopes, + }, + ) + .await? + .api_key + } + }; + + if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked { + self.revoke_platform_api_key(workspace_id, &key.api_key.id) + .await?; + } + + Ok(()) + } + + async fn ensure_demo_operation( + &self, + workspace_id: &WorkspaceId, + payload: OperationPayload, + ) -> Result { + if let Some(existing) = self + .find_operation_by_name(workspace_id, &payload.name) + .await? + { + return Ok(existing); + } + + let operation_name = payload.name.clone(); + self.create_operation(workspace_id, payload).await?; + self.find_operation_by_name(workspace_id, &operation_name) + .await? + .ok_or_else(|| ApiError::internal("demo operation was created but not found")) + } + + async fn ensure_operation_published( + &self, + workspace_id: &WorkspaceId, + summary: &OperationSummary, + ) -> Result<(), ApiError> { + if summary.latest_published_version.is_some() { + return Ok(()); + } + + self.publish_operation(workspace_id, &summary.id, summary.current_draft_version) + .await?; + Ok(()) + } + + async fn ensure_operation_archived( + &self, + workspace_id: &WorkspaceId, + summary: &OperationSummary, + ) -> Result<(), ApiError> { + if summary.status == OperationStatus::Archived { + return Ok(()); + } + + self.archive_operation(workspace_id, &summary.id).await?; + Ok(()) + } + + async fn ensure_demo_json_samples( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + input: &Value, + output: &Value, + ) -> Result<(), ApiError> { + let summary = self.get_operation(workspace_id, operation_id).await?; + let samples = self + .registry + .list_sample_metadata(operation_id, summary.current_draft_version) + .await?; + + if !samples + .iter() + .any(|sample| sample.sample_kind == SampleKind::InputJson) + { + self.save_json_sample(workspace_id, operation_id, SampleKind::InputJson, input) + .await?; + } + + if !samples + .iter() + .any(|sample| sample.sample_kind == SampleKind::OutputJson) + { + self.save_json_sample(workspace_id, operation_id, SampleKind::OutputJson, output) + .await?; + } + + Ok(()) + } + + async fn ensure_demo_grpc_artifacts( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + ) -> Result<(), ApiError> { + let summary = self.get_operation(workspace_id, operation_id).await?; + let descriptors = self + .registry + .list_descriptor_metadata(operation_id, summary.current_draft_version) + .await?; + let has_proto = descriptors + .iter() + .any(|item| item.descriptor_kind == crank_registry::DescriptorKind::ProtoUpload); + let has_descriptor = descriptors + .iter() + .any(|item| item.descriptor_kind == crank_registry::DescriptorKind::DescriptorSet); + + if !has_proto { + self.upload_proto_file( + workspace_id, + operation_id, + Some("echo.proto"), + DEMO_GRPC_PROTO.as_bytes(), + ) + .await?; + } + + if !has_descriptor { + let descriptor_set = STANDARD + .decode(grpc_test_support::descriptor_set_b64()) + .map_err(|error| ApiError::internal(error.to_string()))?; + self.upload_descriptor_set( + workspace_id, + operation_id, + Some("echo_descriptor.bin"), + &descriptor_set, + ) + .await?; + } + + Ok(()) + } + + async fn ensure_demo_agent( + &self, + workspace_id: &WorkspaceId, + payload: AgentPayload, + ) -> Result { + let summary = + if let Some(existing) = self.find_agent_by_slug(workspace_id, &payload.slug).await? { + existing + } else { + self.create_agent(workspace_id, payload.clone()).await?; + self.find_agent_by_slug(workspace_id, &payload.slug) + .await? + .ok_or_else(|| ApiError::internal("demo agent was created but not found"))? + }; + + self.get_agent(workspace_id, &summary.id).await + } + + async fn ensure_demo_agent_bindings( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + bindings: Vec, + publish: bool, + ) -> Result<(), ApiError> { + let summary = self.get_agent(workspace_id, agent_id).await?; + self.save_agent_bindings(workspace_id, agent_id, bindings) + .await?; + if publish && summary.latest_published_version.is_none() { + self.publish_agent(workspace_id, agent_id, summary.current_draft_version) + .await?; + } + + Ok(()) + } + + async fn seed_demo_invocation_logs( + &self, + workspace_id: &WorkspaceId, + revops_agent_id: &AgentId, + rest_operation_id: &OperationId, + graphql_operation_id: &OperationId, + grpc_operation_id: &OperationId, + ) -> Result<(), ApiError> { + if !self + .registry + .list_invocation_logs(ListInvocationLogsQuery { + workspace_id, + level: None, + search_text: None, + source: None, + operation_id: None, + agent_id: None, + created_after: None, + limit: 1, + }) + .await? + .is_empty() + { + return Ok(()); + } + + let rest_operation = self + .get_operation_version( + workspace_id, + rest_operation_id, + self.get_operation(workspace_id, rest_operation_id) + .await? + .current_draft_version, + ) + .await?; + let graphql_operation = self + .get_operation_version( + workspace_id, + graphql_operation_id, + self.get_operation(workspace_id, graphql_operation_id) + .await? + .current_draft_version, + ) + .await?; + let grpc_operation = self + .get_operation_version( + workspace_id, + grpc_operation_id, + self.get_operation(workspace_id, grpc_operation_id) + .await? + .current_draft_version, + ) + .await?; + + self.record_invocation(InvocationRecordRequest { + workspace_id, + agent_id: Some(revops_agent_id), + operation: &rest_operation.snapshot, + source: InvocationSource::AgentToolCall, + level: InvocationLevel::Info, + status: InvocationStatus::Ok, + message: "lead created in CRM".to_owned(), + status_code: Some(201), + error_kind: None, + duration_ms: 182, + request_preview: json!({ + "path": {}, + "query": {}, + "headers": { "x-demo-source": "crank-seed" }, + "variables": null, + "grpc": null, + "body": demo_rest_request_sample() + }), + response_preview: demo_rest_response_sample(), + }) + .await?; + self.record_invocation(InvocationRecordRequest { + workspace_id, + agent_id: Some(revops_agent_id), + operation: &graphql_operation.snapshot, + source: InvocationSource::AgentToolCall, + level: InvocationLevel::Info, + status: InvocationStatus::Ok, + message: "invoice status resolved".to_owned(), + status_code: Some(200), + error_kind: None, + duration_ms: 126, + request_preview: json!({ + "path": {}, + "query": {}, + "headers": { "x-demo-source": "crank-seed" }, + "variables": demo_graphql_variables_sample(), + "grpc": null, + "body": null + }), + response_preview: demo_graphql_response_sample(), + }) + .await?; + self.record_invocation(InvocationRecordRequest { + workspace_id, + agent_id: Some(revops_agent_id), + operation: &graphql_operation.snapshot, + source: InvocationSource::AgentToolCall, + level: InvocationLevel::Error, + status: InvocationStatus::Error, + message: "billing service returned operation error".to_owned(), + status_code: Some(502), + error_kind: Some("graphql_error".to_owned()), + duration_ms: 412, + request_preview: json!({ + "path": {}, + "query": {}, + "headers": { "x-demo-source": "crank-seed" }, + "variables": { "invoiceId": "inv_4099" }, + "grpc": null, + "body": null + }), + response_preview: json!({ + "errors": [{ "message": "invoice not available" }] + }), + }) + .await?; + self.record_invocation(InvocationRecordRequest { + workspace_id, + agent_id: None, + operation: &grpc_operation.snapshot, + source: InvocationSource::AdminTestRun, + level: InvocationLevel::Info, + status: InvocationStatus::Ok, + message: "descriptor-backed unary echo tested".to_owned(), + status_code: Some(200), + error_kind: None, + duration_ms: 98, + request_preview: json!({ + "path": {}, + "query": {}, + "headers": {}, + "variables": null, + "grpc": demo_grpc_request_sample(), + "body": null + }), + response_preview: demo_grpc_response_sample(), + }) + .await?; + + Ok(()) + } + async fn record_invocation( &self, request: InvocationRecordRequest<'_>, @@ -2454,6 +3085,23 @@ impl AdminService { } } +const DEMO_USER_PASSWORD: &str = "CrankDemoPass123!"; +const DEMO_GRPC_PROTO: &str = r#"syntax = "proto3"; +package echo; + +service EchoService { + rpc UnaryEcho (EchoRequest) returns (EchoResponse); +} + +message EchoRequest { + string message = 1; +} + +message EchoResponse { + string message = 1; +} +"#; + fn build_request_preview( mapping: &MappingSet, input: &Value, @@ -2521,6 +3169,316 @@ fn default_export_mode() -> ExportMode { ExportMode::Portable } +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(), + instructions: json!({ + "system": "Prefer CRM mutations first, then billing lookups for confirmation." + }), + tool_selection_policy: json!({ + "max_tools": 8, + "prefer_tag": ["sales", "finance"] + }), + } +} + +fn demo_support_agent_payload() -> AgentPayload { + AgentPayload { + slug: "support-triage".to_owned(), + display_name: "Support Triage".to_owned(), + description: "Draft support agent focused on unary gRPC workflows.".to_owned(), + instructions: json!({ + "system": "Use support tools carefully and ask for confirmation before mutating state." + }), + tool_selection_policy: json!({ + "max_tools": 4, + "prefer_tag": ["support"] + }), + } +} + +fn demo_rest_operation_payload() -> OperationPayload { + let input = demo_rest_input_sample(); + let request = demo_rest_request_sample(); + let response = demo_rest_response_sample(); + let output = demo_rest_output_sample(); + + OperationPayload { + name: "crm_create_lead".to_owned(), + display_name: "Create CRM Lead".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Rest, + target: Target::Rest(crank_core::RestTarget { + base_url: "https://crm.demo.internal".to_owned(), + method: crank_core::HttpMethod::Post, + path_template: "/v1/leads".to_owned(), + static_headers: BTreeMap::from([("x-demo-source".to_owned(), "crank-seed".to_owned())]), + }), + input_schema: Schema::from_json_sample(&input), + output_schema: Schema::from_json_sample(&output), + input_mapping: infer_mapping_from_samples( + &input, + JsonPathRoot::Mcp, + &request, + JsonPathRoot::RequestBody, + ), + output_mapping: infer_mapping_from_samples( + &response, + JsonPathRoot::ResponseBody, + &output, + JsonPathRoot::Output, + ), + execution_config: crank_core::ExecutionConfig { + timeout_ms: 10_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + }, + tool_description: crank_core::ToolDescription { + title: "Create CRM Lead".to_owned(), + description: "Create a lead record in the CRM system.".to_owned(), + tags: vec!["sales".to_owned(), "crm".to_owned()], + examples: vec![crank_core::ToolExample { + input: json!({ + "email": "sarah.connor@example.com", + "company": "Cyberdyne" + }), + }], + }, + } +} + +fn demo_graphql_operation_payload() -> OperationPayload { + let input = demo_graphql_input_sample(); + let variables = demo_graphql_variables_sample(); + let data = demo_graphql_data_sample(); + let output = demo_graphql_output_sample(); + + OperationPayload { + name: "billing_get_invoice".to_owned(), + display_name: "Get Invoice Status".to_owned(), + category: "finance".to_owned(), + protocol: Protocol::Graphql, + target: Target::Graphql(crank_core::GraphqlTarget { + endpoint: "https://billing.demo.internal/graphql".to_owned(), + operation_type: crank_core::GraphqlOperationType::Query, + operation_name: "InvoiceById".to_owned(), + query_template: "query InvoiceById($invoiceId: ID!) { invoice(id: $invoiceId) { id status total currency customerName } }".to_owned(), + response_path: "$.response.body.data.invoice".to_owned(), + }), + input_schema: Schema::from_json_sample(&input), + output_schema: Schema::from_json_sample(&output), + input_mapping: infer_mapping_from_samples( + &input, + JsonPathRoot::Mcp, + &variables, + JsonPathRoot::RequestVariables, + ), + output_mapping: infer_mapping_from_samples( + &data, + JsonPathRoot::ResponseData, + &output, + JsonPathRoot::Output, + ), + execution_config: crank_core::ExecutionConfig { + timeout_ms: 8_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + }, + tool_description: crank_core::ToolDescription { + title: "Get Invoice Status".to_owned(), + description: "Fetch invoice state and amount from billing.".to_owned(), + tags: vec!["finance".to_owned(), "billing".to_owned()], + examples: vec![crank_core::ToolExample { + input: json!({ "invoiceId": "inv_1001" }), + }], + }, + } +} + +fn demo_grpc_operation_payload() -> OperationPayload { + let input = demo_grpc_input_sample(); + let request = demo_grpc_request_sample(); + let response = demo_grpc_response_sample(); + let output = demo_grpc_output_sample(); + + OperationPayload { + name: "support_lookup_ticket".to_owned(), + display_name: "Lookup Support Ticket".to_owned(), + category: "support".to_owned(), + protocol: Protocol::Grpc, + target: Target::Grpc(crank_core::GrpcTarget { + server_addr: "http://grpc.demo.internal".to_owned(), + package: "echo".to_owned(), + service: "EchoService".to_owned(), + method: "UnaryEcho".to_owned(), + descriptor_ref: crank_core::DescriptorId::new("desc_echo_demo"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }), + input_schema: Schema::from_json_sample(&input), + output_schema: Schema::from_json_sample(&output), + input_mapping: infer_mapping_from_samples( + &input, + JsonPathRoot::Mcp, + &request, + JsonPathRoot::RequestGrpc, + ), + output_mapping: infer_mapping_from_samples( + &response, + JsonPathRoot::ResponseGrpc, + &output, + JsonPathRoot::Output, + ), + execution_config: crank_core::ExecutionConfig { + timeout_ms: 5_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: Some(crank_core::ProtocolOptions { + grpc: Some(crank_core::GrpcProtocolOptions { use_tls: false }), + }), + }, + tool_description: crank_core::ToolDescription { + title: "Lookup Support Ticket".to_owned(), + description: "Draft unary gRPC support lookup using a descriptor set.".to_owned(), + tags: vec!["support".to_owned(), "grpc".to_owned()], + examples: vec![crank_core::ToolExample { + input: json!({ "message": "ticket: T-1001" }), + }], + }, + } +} + +fn demo_archived_operation_payload() -> OperationPayload { + let input = json!({ + "contactId": "contact_123", + "reason": "Duplicate profile" + }); + let request = json!({ + "contactId": "contact_123", + "reason": "Duplicate profile" + }); + let response = json!({ + "archived": true, + "contactId": "contact_123" + }); + + OperationPayload { + name: "marketing_archive_contact".to_owned(), + display_name: "Archive Marketing Contact".to_owned(), + category: "marketing".to_owned(), + protocol: Protocol::Rest, + target: Target::Rest(crank_core::RestTarget { + base_url: "https://marketing.demo.internal".to_owned(), + method: crank_core::HttpMethod::Patch, + path_template: "/v1/contacts/archive".to_owned(), + static_headers: BTreeMap::new(), + }), + input_schema: Schema::from_json_sample(&input), + output_schema: Schema::from_json_sample(&response), + input_mapping: infer_mapping_from_samples( + &input, + JsonPathRoot::Mcp, + &request, + JsonPathRoot::RequestBody, + ), + output_mapping: infer_mapping_from_samples( + &response, + JsonPathRoot::ResponseBody, + &response, + JsonPathRoot::Output, + ), + execution_config: crank_core::ExecutionConfig { + timeout_ms: 6_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + }, + tool_description: crank_core::ToolDescription { + title: "Archive Marketing Contact".to_owned(), + description: "Legacy archived flow kept for audit only.".to_owned(), + tags: vec!["marketing".to_owned()], + examples: Vec::new(), + }, + } +} + +fn demo_rest_input_sample() -> Value { + json!({ + "firstName": "Sarah", + "lastName": "Connor", + "email": "sarah.connor@example.com", + "company": "Cyberdyne", + "source": "website" + }) +} + +fn demo_rest_request_sample() -> Value { + demo_rest_input_sample() +} + +fn demo_rest_response_sample() -> Value { + json!({ + "id": "lead_1001", + "status": "created", + "owner": "revops" + }) +} + +fn demo_rest_output_sample() -> Value { + demo_rest_response_sample() +} + +fn demo_graphql_input_sample() -> Value { + json!({ "invoiceId": "inv_1001" }) +} + +fn demo_graphql_variables_sample() -> Value { + json!({ "invoiceId": "inv_1001" }) +} + +fn demo_graphql_data_sample() -> Value { + json!({ + "id": "inv_1001", + "status": "paid", + "total": 4200, + "currency": "USD", + "customerName": "Cyberdyne" + }) +} + +fn demo_graphql_response_sample() -> Value { + json!({ + "invoice": demo_graphql_data_sample() + }) +} + +fn demo_graphql_output_sample() -> Value { + demo_graphql_data_sample() +} + +fn demo_grpc_input_sample() -> Value { + json!({ "message": "ticket: T-1001" }) +} + +fn demo_grpc_request_sample() -> Value { + demo_grpc_input_sample() +} + +fn demo_grpc_response_sample() -> Value { + json!({ "message": "ticket: T-1001 is currently open" }) +} + +fn demo_grpc_output_sample() -> Value { + demo_grpc_response_sample() +} + fn default_enabled() -> bool { true } diff --git a/docs/demo-runbook.md b/docs/demo-runbook.md index fe2613d..03b9c0a 100644 --- a/docs/demo-runbook.md +++ b/docs/demo-runbook.md @@ -10,6 +10,7 @@ - задан `CRANK_DATABASE_URL`; - задан `CRANK_STORAGE_ROOT`; - доступны `CRANK_ADMIN_BIND` и `CRANK_MCP_BIND`; +- если нужен заполненный demo state без ручного онбординга, задан `CRANK_DEMO_SEED=true`; - для UI установлен `Node.js`; - для Rust-части установлен toolchain из `rust-toolchain.toml`. @@ -21,6 +22,12 @@ cargo run -p admin-api ``` +Для предзаполненного demo state: + +```bash +CRANK_DEMO_SEED=true cargo run -p admin-api +``` + Во втором терминале: ```bash diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 8245734..759d95d 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -74,6 +74,7 @@ var/crank/ - `CRANK_ADMIN_TOKEN` - `CRANK_MASTER_KEY` +- `CRANK_DEMO_SEED` Стартовое значение для refresh published tools: @@ -111,6 +112,7 @@ Local development: - локальное окружение должно иметь доступ к `PostgreSQL`; - локальный storage; - app-level auth и bootstrap admin user через `.env`. +- при необходимости UI можно наполнить живыми demo-данными через `CRANK_DEMO_SEED=true`. Demo/deployment: @@ -131,6 +133,10 @@ Demo/deployment: - `CRANK_BOOTSTRAP_ADMIN_PASSWORD` - `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME` +Для опционального demo-seed: + +- `CRANK_DEMO_SEED=true` + ## 8.1. Delivery artifacts Для production-like запуска проект должен поставляться с: