Extract admin demo seeding module
This commit is contained in:
@@ -4,19 +4,16 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
AgentId, AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile,
|
AuditSink, AuthProfile, CapabilityProfile, CommunityCapabilityProfile, EditionCapabilities,
|
||||||
EditionCapabilities, ExecutionMode, IdentityError, IdentityProvider, InvocationLevel,
|
ExecutionMode, IdentityError, IdentityProvider, InvocationLog, InvocationLogId, NoopAuditSink,
|
||||||
InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, MembershipRole,
|
OperationSecurityLevel, OwnerOnlyPolicyEngine, PolicyEngine, ProductEdition, Protocol,
|
||||||
NoopAuditSink, OperationId, OperationSecurityLevel, OperationStatus, OwnerOnlyPolicyEngine,
|
|
||||||
PlatformApiKeyScope, PlatformApiKeyStatus, PolicyEngine, ProductEdition, Protocol,
|
|
||||||
ResponseCachePolicy, Target, ToolQualityMappingRule, ToolQualityMappingSet,
|
ResponseCachePolicy, Target, ToolQualityMappingRule, ToolQualityMappingSet,
|
||||||
ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WizardState, WorkspaceId,
|
ToolQualitySchemaKind, ToolQualitySchemaNode, UsagePeriod, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::{JsonPathRoot, MappingRule, MappingSet, infer_mapping_from_samples};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
AgentSummary, CreateInvocationLogRequest, ListInvocationLogsQuery, OperationAgentRef,
|
AgentSummary, CreateInvocationLogRequest, OperationAgentRef, OperationSummary,
|
||||||
OperationSummary, OperationUsageSummary, PostgresRegistry, RegistryOperation, SampleKind,
|
OperationUsageSummary, PostgresRegistry, RegistryOperation, UsageBucket,
|
||||||
UsageBucket,
|
|
||||||
};
|
};
|
||||||
use crank_runtime::{PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto};
|
use crank_runtime::{PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, SecretCrypto};
|
||||||
use crank_schema::{Schema, SchemaKind};
|
use crank_schema::{Schema, SchemaKind};
|
||||||
@@ -28,6 +25,7 @@ use uuid::Uuid;
|
|||||||
mod agents;
|
mod agents;
|
||||||
mod api_keys;
|
mod api_keys;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod demo;
|
||||||
mod import_export;
|
mod import_export;
|
||||||
mod observability;
|
mod observability;
|
||||||
mod operations;
|
mod operations;
|
||||||
@@ -379,282 +377,6 @@ impl AdminService {
|
|||||||
self.get_workspace(workspace_id).await.map(|_| ())
|
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> {
|
|
||||||
self.registry
|
|
||||||
.ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner)
|
|
||||||
.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 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,
|
|
||||||
}],
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self.ensure_demo_platform_api_key(
|
|
||||||
workspace_id,
|
|
||||||
&AgentId::new(revops_agent.id.clone()),
|
|
||||||
"Web Console Demo Key",
|
|
||||||
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self.seed_demo_invocation_logs(
|
|
||||||
workspace_id,
|
|
||||||
&AgentId::new(revops_agent.id),
|
|
||||||
&rest_operation.id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_demo_platform_api_key(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
agent_id: &AgentId,
|
|
||||||
name: &str,
|
|
||||||
scopes: Vec<PlatformApiKeyScope>,
|
|
||||||
revoke: bool,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
let existing = self
|
|
||||||
.registry
|
|
||||||
.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_agent_platform_api_key(
|
|
||||||
workspace_id,
|
|
||||||
agent_id,
|
|
||||||
PlatformApiKeyPayload {
|
|
||||||
name: name.to_owned(),
|
|
||||||
scopes,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.api_key
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
|
|
||||||
self.revoke_agent_platform_api_key(workspace_id, agent_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_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,
|
|
||||||
) -> 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?;
|
|
||||||
self.record_invocation(InvocationRecordRequest {
|
|
||||||
workspace_id,
|
|
||||||
agent_id: Some(revops_agent_id),
|
|
||||||
operation: &rest_operation.snapshot,
|
|
||||||
request_id: None,
|
|
||||||
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" },
|
|
||||||
"body": demo_rest_request_sample()
|
|
||||||
}),
|
|
||||||
response_preview: demo_rest_response_sample(),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn record_invocation(
|
async fn record_invocation(
|
||||||
&self,
|
&self,
|
||||||
request: InvocationRecordRequest<'_>,
|
request: InvocationRecordRequest<'_>,
|
||||||
@@ -754,170 +476,6 @@ fn validate_profile_email(value: &str) -> Result<String, ApiError> {
|
|||||||
Ok(email)
|
Ok(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn demo_revops_agent_payload() -> AgentPayload {
|
|
||||||
AgentPayload {
|
|
||||||
slug: "revops-copilot".to_owned(),
|
|
||||||
display_name: "RevOps Copilot".to_owned(),
|
|
||||||
description: "Sales operations assistant with CRM tools.".to_owned(),
|
|
||||||
instructions: json!({
|
|
||||||
"system": "Prefer CRM mutations first, then lookup tools for confirmation."
|
|
||||||
}),
|
|
||||||
tool_selection_policy: json!({
|
|
||||||
"max_tools": 8,
|
|
||||||
"prefer_tag": ["sales", "finance"]
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
security_level: OperationSecurityLevel::Standard,
|
|
||||||
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,
|
|
||||||
response_cache: None,
|
|
||||||
idempotency: None,
|
|
||||||
safety: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
},
|
|
||||||
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"
|
|
||||||
}),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
wizard_state: Some(WizardState {
|
|
||||||
input_sample: Some(input),
|
|
||||||
output_sample: Some(output),
|
|
||||||
test_input: Some(demo_rest_input_sample()),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
security_level: OperationSecurityLevel::Standard,
|
|
||||||
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,
|
|
||||||
response_cache: None,
|
|
||||||
idempotency: None,
|
|
||||||
safety: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
},
|
|
||||||
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(),
|
|
||||||
},
|
|
||||||
wizard_state: Some(WizardState {
|
|
||||||
input_sample: Some(input),
|
|
||||||
output_sample: Some(response),
|
|
||||||
test_input: Some(request),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 new_prefixed_id(prefix: &str) -> String {
|
fn new_prefixed_id(prefix: &str) -> String {
|
||||||
format!("{prefix}_{}", Uuid::now_v7().simple())
|
format!("{prefix}_{}", Uuid::now_v7().simple())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crank_core::{
|
||||||
|
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
||||||
|
OperationSecurityLevel, OperationStatus, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol,
|
||||||
|
Target, WizardState, WorkspaceId,
|
||||||
|
};
|
||||||
|
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
|
||||||
|
use crank_registry::{ListInvocationLogsQuery, OperationSummary, SampleKind};
|
||||||
|
use crank_schema::Schema;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::ApiError,
|
||||||
|
service::{
|
||||||
|
AdminService, AgentBindingPayload, AgentPayload, AgentSummaryView, InvocationRecordRequest,
|
||||||
|
OperationPayload, PlatformApiKeyPayload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl AdminService {
|
||||||
|
pub(super) async fn seed_default_workspace_demo(
|
||||||
|
&self,
|
||||||
|
owner_user_id: &crank_core::UserId,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
self.registry
|
||||||
|
.ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner)
|
||||||
|
.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 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,
|
||||||
|
}],
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.ensure_demo_platform_api_key(
|
||||||
|
workspace_id,
|
||||||
|
&AgentId::new(revops_agent.id.clone()),
|
||||||
|
"Web Console Demo Key",
|
||||||
|
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
self.seed_demo_invocation_logs(
|
||||||
|
workspace_id,
|
||||||
|
&AgentId::new(revops_agent.id),
|
||||||
|
&rest_operation.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_demo_platform_api_key(
|
||||||
|
&self,
|
||||||
|
workspace_id: &WorkspaceId,
|
||||||
|
agent_id: &AgentId,
|
||||||
|
name: &str,
|
||||||
|
scopes: Vec<PlatformApiKeyScope>,
|
||||||
|
revoke: bool,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let existing = self
|
||||||
|
.registry
|
||||||
|
.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_agent_platform_api_key(
|
||||||
|
workspace_id,
|
||||||
|
agent_id,
|
||||||
|
PlatformApiKeyPayload {
|
||||||
|
name: name.to_owned(),
|
||||||
|
scopes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.api_key
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if revoke && key.api_key.status != PlatformApiKeyStatus::Revoked {
|
||||||
|
self.revoke_agent_platform_api_key(workspace_id, agent_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_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,
|
||||||
|
) -> 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?;
|
||||||
|
self.record_invocation(InvocationRecordRequest {
|
||||||
|
workspace_id,
|
||||||
|
agent_id: Some(revops_agent_id),
|
||||||
|
operation: &rest_operation.snapshot,
|
||||||
|
request_id: None,
|
||||||
|
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" },
|
||||||
|
"body": demo_rest_request_sample()
|
||||||
|
}),
|
||||||
|
response_preview: demo_rest_response_sample(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn demo_revops_agent_payload() -> AgentPayload {
|
||||||
|
AgentPayload {
|
||||||
|
slug: "revops-copilot".to_owned(),
|
||||||
|
display_name: "RevOps Copilot".to_owned(),
|
||||||
|
description: "Sales operations assistant with CRM tools.".to_owned(),
|
||||||
|
instructions: json!({
|
||||||
|
"system": "Prefer CRM mutations first, then lookup tools for confirmation."
|
||||||
|
}),
|
||||||
|
tool_selection_policy: json!({
|
||||||
|
"max_tools": 8,
|
||||||
|
"prefer_tag": ["sales", "finance"]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
security_level: OperationSecurityLevel::Standard,
|
||||||
|
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,
|
||||||
|
response_cache: None,
|
||||||
|
idempotency: None,
|
||||||
|
safety: None,
|
||||||
|
auth_profile_ref: None,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
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"
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
wizard_state: Some(WizardState {
|
||||||
|
input_sample: Some(input),
|
||||||
|
output_sample: Some(output),
|
||||||
|
test_input: Some(demo_rest_input_sample()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
security_level: OperationSecurityLevel::Standard,
|
||||||
|
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,
|
||||||
|
response_cache: None,
|
||||||
|
idempotency: None,
|
||||||
|
safety: None,
|
||||||
|
auth_profile_ref: None,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
wizard_state: Some(WizardState {
|
||||||
|
input_sample: Some(input),
|
||||||
|
output_sample: Some(response),
|
||||||
|
test_input: Some(request),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user