feat: add demo seed for live ui
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -53,6 +53,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
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<dyn std::error::Error>> {
|
||||
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceRecord, ApiError> {
|
||||
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<crank_core::UserId, ApiError> {
|
||||
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<PlatformApiKeyScope>,
|
||||
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<OperationSummary, ApiError> {
|
||||
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<AgentSummaryView, ApiError> {
|
||||
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<AgentBindingPayload>,
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user