845 lines
25 KiB
Rust
845 lines
25 KiB
Rust
#![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();
|
|
}
|