feat: complete Epic 1 production foundation
This commit is contained in:
@@ -0,0 +1,844 @@
|
||||
#![allow(dead_code, unused_imports)]
|
||||
|
||||
use super::common::*;
|
||||
|
||||
use crank_core::{
|
||||
AgentOperationBinding, AgentStatus, InvocationSource, InvocationStatus, OnboardingStepId,
|
||||
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, ProductEventId, Workspace, WorkspaceId,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
||||
CreateWorkspaceRequest, OnboardingPresentationMilestone, PublishAgentRequest, PublishRequest,
|
||||
RecordOnboardingMilestoneRequest,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
#[path = "onboarding_lifecycle.rs"]
|
||||
mod onboarding_lifecycle;
|
||||
|
||||
fn truncate_to_micros(value: OffsetDateTime) -> OffsetDateTime {
|
||||
value
|
||||
.replace_nanosecond((value.nanosecond() / 1_000) * 1_000)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_exposes_product_events_and_exact_invocation_key_scope() {
|
||||
let database = TestDatabase::new().await;
|
||||
let _registry = database.registry().await;
|
||||
let pool = database.raw_pool().await;
|
||||
|
||||
let product_events = relation_exists(&pool, "product_events").await;
|
||||
let product_rollups = relation_exists(&pool, "product_event_daily_rollups").await;
|
||||
let key_scope = column_exists(&pool, "invocation_logs", "platform_api_key_id").await;
|
||||
|
||||
assert!(product_events, "V11 must own immutable local ProductEvents");
|
||||
assert!(
|
||||
product_rollups,
|
||||
"V11 must preserve bounded UTC product-metric denominators"
|
||||
);
|
||||
assert!(
|
||||
key_scope,
|
||||
"successful MCP evidence must retain the exact non-secret key identity"
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn product_event_idempotency_is_scoped_to_workspace() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let pool = database.raw_pool().await;
|
||||
create_workspace(®istry, "ws_onboarding_other", "onboarding-other").await;
|
||||
|
||||
insert_product_event(
|
||||
&pool,
|
||||
"pe_start_default",
|
||||
"ws_default",
|
||||
"onboarding_started",
|
||||
"eligible:first-login",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let replay = insert_product_event(
|
||||
&pool,
|
||||
"pe_start_replay",
|
||||
"ws_default",
|
||||
"onboarding_started",
|
||||
"eligible:first-login",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
replay.is_err(),
|
||||
"same workspace/idempotency key must not double-count a milestone"
|
||||
);
|
||||
|
||||
insert_product_event(
|
||||
&pool,
|
||||
"pe_start_other",
|
||||
"ws_onboarding_other",
|
||||
"onboarding_started",
|
||||
"eligible:first-login",
|
||||
)
|
||||
.await
|
||||
.expect("an independent workspace may use the same local idempotency key");
|
||||
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("select count(*) from product_events where idempotency_key = $1")
|
||||
.bind("eligible:first-login")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_key_success_is_authoritative_and_revocation_regresses_progress() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let pool = database.raw_pool().await;
|
||||
let (operation_id, agent_id, key_id) = published_path(®istry).await;
|
||||
|
||||
let log = test_invocation_log(
|
||||
"log_onboarding_first_call",
|
||||
&operation_id,
|
||||
Some(agent_id.clone()),
|
||||
InvocationStatus::Ok,
|
||||
25,
|
||||
"2026-03-25T12:15:00Z",
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &log })
|
||||
.await,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
sqlx::query("update invocation_logs set platform_api_key_id = $1 where id = $2")
|
||||
.bind(key_id.as_str())
|
||||
.bind(log.id.as_str())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
authoritative_first_call(&pool, "ws_default", agent_id.as_str(), key_id.as_str()).await,
|
||||
"an active exact-scope key and real successful Agent tool invocation complete first value"
|
||||
);
|
||||
|
||||
registry
|
||||
.revoke_platform_api_key_for_agent(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&agent_id,
|
||||
&key_id,
|
||||
&OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!authoritative_first_call(&pool, "ws_default", agent_id.as_str(), key_id.as_str()).await,
|
||||
"revocation must return connection/onboarding to an actionable state"
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invocation_key_scope_rejects_cross_workspace_evidence() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let pool = database.raw_pool().await;
|
||||
assert!(
|
||||
column_exists(&pool, "invocation_logs", "platform_api_key_id").await,
|
||||
"cross-workspace rejection requires the exact-key evidence column"
|
||||
);
|
||||
let (operation_id, agent_id, _key_id) = published_path(®istry).await;
|
||||
create_workspace(®istry, "ws_onboarding_foreign", "onboarding-foreign").await;
|
||||
|
||||
let foreign_key = PlatformApiKey {
|
||||
id: PlatformApiKeyId::new("pk_onboarding_foreign"),
|
||||
workspace_id: WorkspaceId::new("ws_onboarding_foreign"),
|
||||
agent_id: None,
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
name: "foreign-onboarding-key".to_owned(),
|
||||
prefix: "crk_foreign".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
};
|
||||
registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &foreign_key,
|
||||
secret_hash: "foreign-secret-hash",
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let log = test_invocation_log(
|
||||
"log_onboarding_cross_workspace",
|
||||
&operation_id,
|
||||
Some(agent_id),
|
||||
InvocationStatus::Ok,
|
||||
25,
|
||||
"2026-03-25T12:16:00Z",
|
||||
);
|
||||
assert_eq!(
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &log })
|
||||
.await,
|
||||
crank_registry::InvocationHistoryWriteOutcome::Recorded
|
||||
);
|
||||
let poisoned = sqlx::query(
|
||||
"update invocation_logs set platform_api_key_id = $1 where workspace_id = $2 and id = $3",
|
||||
)
|
||||
.bind(foreign_key.id.as_str())
|
||||
.bind("ws_default")
|
||||
.bind(log.id.as_str())
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
poisoned.is_err(),
|
||||
"database constraints must prevent foreign-workspace key evidence"
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projection_moves_from_empty_to_exact_terminal_success_and_regresses_on_revoke() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let empty = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!empty.completed);
|
||||
assert_eq!(
|
||||
empty.steps.iter().map(|step| step.id).collect::<Vec<_>>(),
|
||||
OnboardingStepId::ORDERED
|
||||
);
|
||||
|
||||
let (operation_id, agent_id, key_id) = published_path(®istry).await;
|
||||
let mut test_log = test_invocation_log(
|
||||
"log_onboarding_admin_test",
|
||||
&operation_id,
|
||||
None,
|
||||
InvocationStatus::Ok,
|
||||
20,
|
||||
"2026-03-25T12:14:00Z",
|
||||
);
|
||||
test_log.source = InvocationSource::AdminTestRun;
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &test_log })
|
||||
.await;
|
||||
registry
|
||||
.touch_platform_api_key(&workspace_id, &key_id, &OffsetDateTime::now_utc())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut pending = test_invocation_log(
|
||||
"log_onboarding_approval_pending",
|
||||
&operation_id,
|
||||
Some(agent_id.clone()),
|
||||
InvocationStatus::Ok,
|
||||
10,
|
||||
"2026-03-25T12:15:00Z",
|
||||
);
|
||||
pending.platform_api_key_id = Some(key_id.clone());
|
||||
pending.execution_stage = Some(crank_core::ExecutionStage::MandatoryPersistence);
|
||||
pending.created_at = OffsetDateTime::now_utc();
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &pending })
|
||||
.await;
|
||||
let not_terminal = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!not_terminal.completed);
|
||||
assert!(
|
||||
!not_terminal
|
||||
.step(OnboardingStepId::FirstCall)
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
|
||||
let mut success = test_invocation_log(
|
||||
"log_onboarding_terminal_success",
|
||||
&operation_id,
|
||||
Some(agent_id.clone()),
|
||||
InvocationStatus::Ok,
|
||||
25,
|
||||
"2026-03-25T12:16:00Z",
|
||||
);
|
||||
success.platform_api_key_id = Some(key_id.clone());
|
||||
success.created_at = truncate_to_micros(OffsetDateTime::now_utc());
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &success })
|
||||
.await;
|
||||
let complete = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(complete.completed);
|
||||
assert_eq!(complete.platform_api_key_id, Some(key_id.clone()));
|
||||
assert_eq!(complete.first_call_log_id, Some(success.id));
|
||||
assert_eq!(
|
||||
complete.first_call_tool_name.as_deref(),
|
||||
Some("create_lead")
|
||||
);
|
||||
assert_eq!(complete.first_call_at, Some(success.created_at));
|
||||
|
||||
// A different active key/path must not replace the exact path that produced
|
||||
// the terminal onboarding call when that exact key is later revoked.
|
||||
let (_other_agent_id, _other_key_id) =
|
||||
publish_additional_agent_path(®istry, &operation_id).await;
|
||||
|
||||
registry
|
||||
.revoke_platform_api_key_for_agent(
|
||||
&workspace_id,
|
||||
&agent_id,
|
||||
&key_id,
|
||||
&OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let regressed = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!regressed.completed);
|
||||
assert!(!regressed.step(OnboardingStepId::Key).unwrap().completed);
|
||||
assert!(
|
||||
!regressed
|
||||
.step(OnboardingStepId::FirstCall)
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_agent_publication_and_key_expiry_invalidate_old_first_call_evidence() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let pool = database.raw_pool().await;
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let (operation_id, agent_id, key_id) = published_path(®istry).await;
|
||||
|
||||
record_onboarding_test_and_call(
|
||||
®istry,
|
||||
&operation_id,
|
||||
&agent_id,
|
||||
&key_id,
|
||||
"publication_before_republish",
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await;
|
||||
let completed = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(completed.completed);
|
||||
|
||||
let unpublished_at = OffsetDateTime::now_utc() + Duration::seconds(1);
|
||||
registry
|
||||
.unpublish_agent(&workspace_id, &agent_id, &unpublished_at, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let unpublished = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!unpublished.step(OnboardingStepId::Agent).unwrap().completed);
|
||||
|
||||
let republished_at = unpublished_at + Duration::seconds(1);
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &workspace_id,
|
||||
agent_id: &agent_id,
|
||||
version: 1,
|
||||
published_at: &republished_at,
|
||||
published_by: Some("onboarding-republish-test"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let republished = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!republished.step(OnboardingStepId::Agent).unwrap().completed,
|
||||
"republishing must not silently replace the exact catalog revision that completed onboarding"
|
||||
);
|
||||
assert!(
|
||||
!republished
|
||||
.step(OnboardingStepId::FirstCall)
|
||||
.unwrap()
|
||||
.completed,
|
||||
"a call from an older Agent publication must not complete the current catalog revision"
|
||||
);
|
||||
assert_ne!(republished.revision, completed.revision);
|
||||
|
||||
let reset = registry
|
||||
.reset_onboarding_selection(
|
||||
&workspace_id,
|
||||
republished.revision,
|
||||
republished_at + Duration::milliseconds(1),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(reset.step(OnboardingStepId::Agent).unwrap().completed);
|
||||
assert!(reset.step(OnboardingStepId::Key).unwrap().completed);
|
||||
|
||||
record_onboarding_test_and_call(
|
||||
®istry,
|
||||
&operation_id,
|
||||
&agent_id,
|
||||
&key_id,
|
||||
"publication_after_republish",
|
||||
republished_at + Duration::seconds(1),
|
||||
)
|
||||
.await;
|
||||
let completed_again = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(completed_again.completed);
|
||||
|
||||
sqlx::query(
|
||||
"update platform_api_keys set expires_at = now() - interval '1 minute' where id = $1",
|
||||
)
|
||||
.bind(key_id.as_str())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let expired = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!expired.step(OnboardingStepId::Key).unwrap().completed);
|
||||
assert!(!expired.step(OnboardingStepId::FirstCall).unwrap().completed);
|
||||
assert_ne!(expired.revision, completed_again.revision);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archiving_selected_operation_or_agent_regresses_authoritative_projection() {
|
||||
let database = TestDatabase::new().await;
|
||||
let registry = database.registry().await;
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let (operation_id, agent_id, key_id) = published_path(®istry).await;
|
||||
record_onboarding_test_and_call(
|
||||
®istry,
|
||||
&operation_id,
|
||||
&agent_id,
|
||||
&key_id,
|
||||
"archive_agent_path",
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
|
||||
// A second healthy path must not replace the exact lineage that produced
|
||||
// the authoritative first call when that lineage is later archived.
|
||||
let (alternative_operation_id, _, _) = publish_named_path(
|
||||
®istry,
|
||||
"op_onboarding_alternative",
|
||||
"agent_onboarding_alternative",
|
||||
"pk_onboarding_alternative",
|
||||
)
|
||||
.await;
|
||||
|
||||
registry
|
||||
.archive_agent(
|
||||
&workspace_id,
|
||||
&agent_id,
|
||||
&(OffsetDateTime::now_utc() + Duration::seconds(1)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let archived_agent = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(archived_agent.operation_id, Some(operation_id.clone()));
|
||||
assert_eq!(archived_agent.agent_id, Some(agent_id));
|
||||
assert_ne!(archived_agent.operation_id, Some(alternative_operation_id));
|
||||
assert!(
|
||||
!archived_agent
|
||||
.step(OnboardingStepId::Agent)
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
assert!(
|
||||
!archived_agent
|
||||
.step(OnboardingStepId::FirstCall)
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
|
||||
registry
|
||||
.archive_operation(
|
||||
&workspace_id,
|
||||
&operation_id,
|
||||
&(OffsetDateTime::now_utc() + Duration::seconds(2)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let archived_operation = registry
|
||||
.get_onboarding_projection(&workspace_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!archived_operation
|
||||
.step(OnboardingStepId::Operation)
|
||||
.unwrap()
|
||||
.completed
|
||||
);
|
||||
|
||||
database.cleanup().await;
|
||||
}
|
||||
|
||||
async fn published_path(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
) -> (
|
||||
crank_core::OperationId,
|
||||
crank_core::AgentId,
|
||||
PlatformApiKeyId,
|
||||
) {
|
||||
publish_named_path(
|
||||
registry,
|
||||
"op_onboarding",
|
||||
"agent_onboarding",
|
||||
"pk_onboarding_exact",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn publish_named_path(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
operation_id: &str,
|
||||
agent_id: &str,
|
||||
key_id: &str,
|
||||
) -> (
|
||||
crank_core::OperationId,
|
||||
crank_core::AgentId,
|
||||
PlatformApiKeyId,
|
||||
) {
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let operation = test_operation(operation_id, 1, OperationStatus::Draft);
|
||||
registry
|
||||
.create_operation(&workspace_id, &operation, None)
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &workspace_id,
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::now_utc(),
|
||||
published_by: Some("onboarding-test"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let agent = test_agent(agent_id, AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
let bindings = vec![AgentOperationBinding {
|
||||
operation_id: operation.id.clone(),
|
||||
operation_version: 1,
|
||||
tool_name: "create_lead".to_owned(),
|
||||
tool_title: "Create lead".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
}];
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: &bindings,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &workspace_id,
|
||||
agent_id: &agent.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::now_utc(),
|
||||
published_by: Some("onboarding-test"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let key_id = PlatformApiKeyId::new(key_id);
|
||||
let key = PlatformApiKey {
|
||||
id: key_id.clone(),
|
||||
workspace_id,
|
||||
agent_id: Some(agent.id.clone()),
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
name: format!("onboarding-{key_id}-key"),
|
||||
prefix: format!("crk_{}", key_id.as_str()),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
};
|
||||
registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &key,
|
||||
secret_hash: "exact-secret-hash",
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(operation.id, agent.id, key_id)
|
||||
}
|
||||
|
||||
async fn record_onboarding_test_and_call(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
operation_id: &crank_core::OperationId,
|
||||
agent_id: &crank_core::AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
suffix: &str,
|
||||
occurred_at: OffsetDateTime,
|
||||
) {
|
||||
let mut test_log = test_invocation_log(
|
||||
&format!("log_onboarding_test_{suffix}"),
|
||||
operation_id,
|
||||
None,
|
||||
InvocationStatus::Ok,
|
||||
20,
|
||||
"2026-03-25T12:14:00Z",
|
||||
);
|
||||
test_log.source = InvocationSource::AdminTestRun;
|
||||
test_log.created_at = truncate_to_micros(occurred_at);
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &test_log })
|
||||
.await;
|
||||
registry
|
||||
.touch_platform_api_key(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
key_id,
|
||||
&truncate_to_micros(occurred_at),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
record_onboarding_call(
|
||||
registry,
|
||||
operation_id,
|
||||
agent_id,
|
||||
key_id,
|
||||
suffix,
|
||||
occurred_at,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn record_onboarding_call(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
operation_id: &crank_core::OperationId,
|
||||
agent_id: &crank_core::AgentId,
|
||||
key_id: &PlatformApiKeyId,
|
||||
suffix: &str,
|
||||
occurred_at: OffsetDateTime,
|
||||
) {
|
||||
let mut success = test_invocation_log(
|
||||
&format!("log_onboarding_call_{suffix}"),
|
||||
operation_id,
|
||||
Some(agent_id.clone()),
|
||||
InvocationStatus::Ok,
|
||||
25,
|
||||
"2026-03-25T12:16:00Z",
|
||||
);
|
||||
success.platform_api_key_id = Some(key_id.clone());
|
||||
success.created_at = truncate_to_micros(occurred_at);
|
||||
registry
|
||||
.create_invocation_log(CreateInvocationLogRequest { log: &success })
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn publish_additional_agent_path(
|
||||
registry: &crank_registry::PostgresRegistry,
|
||||
operation_id: &crank_core::OperationId,
|
||||
) -> (crank_core::AgentId, PlatformApiKeyId) {
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
let agent = test_agent("agent_onboarding_later", AgentStatus::Draft);
|
||||
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
|
||||
let bindings = vec![AgentOperationBinding {
|
||||
operation_id: operation_id.clone(),
|
||||
operation_version: 1,
|
||||
tool_name: "create_lead".to_owned(),
|
||||
tool_title: "Create lead".to_owned(),
|
||||
tool_description_override: None,
|
||||
enabled: true,
|
||||
}];
|
||||
registry
|
||||
.create_agent(CreateAgentRequest {
|
||||
agent: &agent,
|
||||
version: &version,
|
||||
bindings: &bindings,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_agent(PublishAgentRequest {
|
||||
workspace_id: &workspace_id,
|
||||
agent_id: &agent.id,
|
||||
version: 1,
|
||||
published_at: &OffsetDateTime::now_utc(),
|
||||
published_by: Some("onboarding-test"),
|
||||
expected_state: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let key_id = PlatformApiKeyId::new("pk_onboarding_later");
|
||||
let key = PlatformApiKey {
|
||||
id: key_id.clone(),
|
||||
workspace_id,
|
||||
agent_id: Some(agent.id.clone()),
|
||||
key_kind: PlatformApiKeyKind::McpClient,
|
||||
name: "onboarding-later-key".to_owned(),
|
||||
prefix: "crk_later".to_owned(),
|
||||
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
status: PlatformApiKeyStatus::Active,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
allowed_origins: Vec::new(),
|
||||
};
|
||||
registry
|
||||
.create_platform_api_key(CreatePlatformApiKeyRequest {
|
||||
api_key: &key,
|
||||
secret_hash: "later-secret-hash",
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
(agent.id, key_id)
|
||||
}
|
||||
|
||||
async fn authoritative_first_call(
|
||||
pool: &sqlx::PgPool,
|
||||
workspace_id: &str,
|
||||
agent_id: &str,
|
||||
key_id: &str,
|
||||
) -> bool {
|
||||
sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1
|
||||
from invocation_logs l
|
||||
join published_agents pa on pa.agent_id = l.agent_id
|
||||
join platform_api_keys k
|
||||
on k.id = l.platform_api_key_id
|
||||
and k.workspace_id = l.workspace_id
|
||||
and k.agent_id = l.agent_id
|
||||
where l.workspace_id = $1
|
||||
and l.agent_id = $2
|
||||
and l.platform_api_key_id = $3
|
||||
and l.source = 'agent_tool_call'
|
||||
and l.status = 'ok'
|
||||
and k.status = 'active'
|
||||
and (k.expires_at is null or k.expires_at > now())
|
||||
)",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(agent_id)
|
||||
.bind(key_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn insert_product_event(
|
||||
pool: &sqlx::PgPool,
|
||||
id: &str,
|
||||
workspace_id: &str,
|
||||
event_name: &str,
|
||||
idempotency_key: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"insert into product_events (
|
||||
id, workspace_id, event_name, schema_version, occurred_at, idempotency_key,
|
||||
properties_json
|
||||
) values ($1, $2, $3, 1, now(), $4, $5)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(workspace_id)
|
||||
.bind(event_name)
|
||||
.bind(idempotency_key)
|
||||
.bind(json!({"eligible": true}))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn relation_exists(pool: &sqlx::PgPool, relation: &str) -> bool {
|
||||
sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.tables
|
||||
where table_schema = current_schema() and table_name = $1
|
||||
)",
|
||||
)
|
||||
.bind(relation)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn column_exists(pool: &sqlx::PgPool, relation: &str, column: &str) -> bool {
|
||||
sqlx::query_scalar(
|
||||
"select exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = current_schema() and table_name = $1 and column_name = $2
|
||||
)",
|
||||
)
|
||||
.bind(relation)
|
||||
.bind(column)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn create_workspace(registry: &crank_registry::PostgresRegistry, id: &str, slug: &str) {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let workspace = Workspace {
|
||||
id: WorkspaceId::new(id),
|
||||
slug: slug.to_owned(),
|
||||
display_name: slug.to_owned(),
|
||||
status: crank_core::WorkspaceStatus::Active,
|
||||
settings: json!({}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
registry
|
||||
.create_workspace(CreateWorkspaceRequest {
|
||||
workspace: &workspace,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user