feat: complete Epic 1 production foundation
This commit is contained in:
@@ -33,6 +33,7 @@ pub enum InvitationStatus {
|
||||
pub enum PlatformApiKeyStatus {
|
||||
Active,
|
||||
Revoked,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct ApprovalRequest {
|
||||
pub operation_version: u32,
|
||||
pub status: ApprovalRequestStatus,
|
||||
pub risk_level: OperationApprovalRiskLevel,
|
||||
pub request_id: Option<String>,
|
||||
pub trace_id: Option<String>,
|
||||
pub request_payload: Value,
|
||||
pub response_payload: Option<Value>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
|
||||
@@ -40,6 +40,15 @@ pub enum AuthConfig {
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub const fn kind(&self) -> AuthKind {
|
||||
match self {
|
||||
Self::Bearer(_) => AuthKind::Bearer,
|
||||
Self::Basic(_) => AuthKind::Basic,
|
||||
Self::ApiKeyHeader(_) => AuthKind::ApiKeyHeader,
|
||||
Self::ApiKeyQuery(_) => AuthKind::ApiKeyQuery,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret_ids(&self) -> Vec<&SecretId> {
|
||||
match self {
|
||||
Self::Bearer(config) => vec![&config.secret_id],
|
||||
@@ -94,4 +103,16 @@ mod tests {
|
||||
assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z"));
|
||||
assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_reports_its_semantic_kind() {
|
||||
assert_eq!(
|
||||
AuthConfig::ApiKeyHeader(ApiKeyHeaderAuthConfig {
|
||||
header_name: "X-Api-Key".to_owned(),
|
||||
secret_id: SecretId::new("secret_01"),
|
||||
})
|
||||
.kind(),
|
||||
AuthKind::ApiKeyHeader
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{AgentId, CorrelationContext, OperationId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionOrigin {
|
||||
AdminDraft,
|
||||
AgentSnapshot,
|
||||
}
|
||||
|
||||
impl ExecutionOrigin {
|
||||
pub fn validate_agent(self, agent_id: Option<&AgentId>) -> Result<(), ExecutionOriginError> {
|
||||
match (self, agent_id) {
|
||||
(Self::AdminDraft, None) | (Self::AgentSnapshot, Some(_)) => Ok(()),
|
||||
(Self::AdminDraft, Some(_)) => Err(ExecutionOriginError::UnexpectedAgent),
|
||||
(Self::AgentSnapshot, None) => Err(ExecutionOriginError::MissingAgent),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ExecutionOriginError {
|
||||
#[error("admin draft execution cannot carry an agent identity")]
|
||||
UnexpectedAgent,
|
||||
#[error("agent snapshot execution requires an agent identity")]
|
||||
MissingAgent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionStage {
|
||||
Authorization,
|
||||
InputSchema,
|
||||
InputMapping,
|
||||
RequestPreparation,
|
||||
Admission,
|
||||
Adapter,
|
||||
Upstream,
|
||||
OutputMapping,
|
||||
OutputSchema,
|
||||
MandatoryPersistence,
|
||||
Runtime,
|
||||
}
|
||||
|
||||
impl ExecutionStage {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Authorization => "authorization",
|
||||
Self::InputSchema => "input_schema",
|
||||
Self::InputMapping => "input_mapping",
|
||||
Self::RequestPreparation => "request_preparation",
|
||||
Self::Admission => "admission",
|
||||
Self::Adapter => "adapter",
|
||||
Self::Upstream => "upstream",
|
||||
Self::OutputMapping => "output_mapping",
|
||||
Self::OutputSchema => "output_schema",
|
||||
Self::MandatoryPersistence => "mandatory_persistence",
|
||||
Self::Runtime => "runtime",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Retryability {
|
||||
Never,
|
||||
Safe,
|
||||
AfterDelay,
|
||||
ManualReconcile,
|
||||
RequiresConfirmation,
|
||||
}
|
||||
|
||||
impl Retryability {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Never => "never",
|
||||
Self::Safe => "safe",
|
||||
Self::AfterDelay => "after_delay",
|
||||
Self::ManualReconcile => "manual_reconcile",
|
||||
Self::RequiresConfirmation => "requires_confirmation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OutcomeCertainty {
|
||||
Certain,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
impl OutcomeCertainty {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Certain => "certain",
|
||||
Self::OutcomeUnknown => "outcome_unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionErrorCode {
|
||||
AuthorizationDenied,
|
||||
AuthProfileNotFound,
|
||||
SecretNotFound,
|
||||
SecretInvalid,
|
||||
InputSchemaInvalid,
|
||||
InputMappingInvalid,
|
||||
PreparedRequestInvalid,
|
||||
ExecutionOverloaded,
|
||||
SafetyStoreUnavailable,
|
||||
ProtocolUnsupported,
|
||||
ExecutionModeUnsupported,
|
||||
AdapterConfigurationInvalid,
|
||||
OutboundTargetRejected,
|
||||
UpstreamAuthError,
|
||||
UpstreamNotFound,
|
||||
UpstreamRateLimited,
|
||||
UpstreamServerError,
|
||||
UpstreamStatusError,
|
||||
UpstreamTimeout,
|
||||
UpstreamTransportError,
|
||||
UpstreamRequestTooLarge,
|
||||
UpstreamResponseTooLarge,
|
||||
OutputMappingInvalid,
|
||||
OutputSchemaInvalid,
|
||||
PersistenceUnavailable,
|
||||
RuntimeInternal,
|
||||
ConfirmationRequired,
|
||||
ConfirmationInvalid,
|
||||
IdempotencyInProgress,
|
||||
IdempotencyConflict,
|
||||
IdempotencyOutcomeUnknown,
|
||||
}
|
||||
|
||||
impl ExecutionErrorCode {
|
||||
pub const ALL: [Self; 31] = [
|
||||
Self::AuthorizationDenied,
|
||||
Self::AuthProfileNotFound,
|
||||
Self::SecretNotFound,
|
||||
Self::SecretInvalid,
|
||||
Self::InputSchemaInvalid,
|
||||
Self::InputMappingInvalid,
|
||||
Self::PreparedRequestInvalid,
|
||||
Self::ExecutionOverloaded,
|
||||
Self::SafetyStoreUnavailable,
|
||||
Self::ProtocolUnsupported,
|
||||
Self::ExecutionModeUnsupported,
|
||||
Self::AdapterConfigurationInvalid,
|
||||
Self::OutboundTargetRejected,
|
||||
Self::UpstreamAuthError,
|
||||
Self::UpstreamNotFound,
|
||||
Self::UpstreamRateLimited,
|
||||
Self::UpstreamServerError,
|
||||
Self::UpstreamStatusError,
|
||||
Self::UpstreamTimeout,
|
||||
Self::UpstreamTransportError,
|
||||
Self::UpstreamRequestTooLarge,
|
||||
Self::UpstreamResponseTooLarge,
|
||||
Self::OutputMappingInvalid,
|
||||
Self::OutputSchemaInvalid,
|
||||
Self::PersistenceUnavailable,
|
||||
Self::RuntimeInternal,
|
||||
Self::ConfirmationRequired,
|
||||
Self::ConfirmationInvalid,
|
||||
Self::IdempotencyInProgress,
|
||||
Self::IdempotencyConflict,
|
||||
Self::IdempotencyOutcomeUnknown,
|
||||
];
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationDenied => "authorization_denied",
|
||||
Self::AuthProfileNotFound => "auth_profile_not_found",
|
||||
Self::SecretNotFound => "secret_not_found",
|
||||
Self::SecretInvalid => "secret_invalid",
|
||||
Self::InputSchemaInvalid => "input_schema_invalid",
|
||||
Self::InputMappingInvalid => "input_mapping_invalid",
|
||||
Self::PreparedRequestInvalid => "prepared_request_invalid",
|
||||
Self::ExecutionOverloaded => "execution_overloaded",
|
||||
Self::SafetyStoreUnavailable => "safety_store_unavailable",
|
||||
Self::ProtocolUnsupported => "protocol_unsupported",
|
||||
Self::ExecutionModeUnsupported => "execution_mode_unsupported",
|
||||
Self::AdapterConfigurationInvalid => "adapter_configuration_invalid",
|
||||
Self::OutboundTargetRejected => "outbound_target_rejected",
|
||||
Self::UpstreamAuthError => "upstream_auth_error",
|
||||
Self::UpstreamNotFound => "upstream_not_found",
|
||||
Self::UpstreamRateLimited => "upstream_rate_limited",
|
||||
Self::UpstreamServerError => "upstream_server_error",
|
||||
Self::UpstreamStatusError => "upstream_status_error",
|
||||
Self::UpstreamTimeout => "upstream_timeout",
|
||||
Self::UpstreamTransportError => "upstream_transport_error",
|
||||
Self::UpstreamRequestTooLarge => "upstream_request_too_large",
|
||||
Self::UpstreamResponseTooLarge => "upstream_response_too_large",
|
||||
Self::OutputMappingInvalid => "output_mapping_invalid",
|
||||
Self::OutputSchemaInvalid => "output_schema_invalid",
|
||||
Self::PersistenceUnavailable => "persistence_unavailable",
|
||||
Self::RuntimeInternal => "runtime_internal",
|
||||
Self::ConfirmationRequired => "confirmation_required",
|
||||
Self::ConfirmationInvalid => "confirmation_invalid",
|
||||
Self::IdempotencyInProgress => "idempotency_in_progress",
|
||||
Self::IdempotencyConflict => "idempotency_conflict",
|
||||
Self::IdempotencyOutcomeUnknown => "idempotency_outcome_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn stage(self) -> ExecutionStage {
|
||||
match self {
|
||||
Self::AuthorizationDenied
|
||||
| Self::AuthProfileNotFound
|
||||
| Self::SecretNotFound
|
||||
| Self::SecretInvalid => ExecutionStage::Authorization,
|
||||
Self::InputSchemaInvalid => ExecutionStage::InputSchema,
|
||||
Self::InputMappingInvalid => ExecutionStage::InputMapping,
|
||||
Self::PreparedRequestInvalid => ExecutionStage::RequestPreparation,
|
||||
Self::ExecutionOverloaded
|
||||
| Self::SafetyStoreUnavailable
|
||||
| Self::ConfirmationRequired
|
||||
| Self::ConfirmationInvalid
|
||||
| Self::IdempotencyInProgress
|
||||
| Self::IdempotencyConflict
|
||||
| Self::IdempotencyOutcomeUnknown => ExecutionStage::Admission,
|
||||
Self::ProtocolUnsupported
|
||||
| Self::ExecutionModeUnsupported
|
||||
| Self::AdapterConfigurationInvalid
|
||||
| Self::OutboundTargetRejected => ExecutionStage::Adapter,
|
||||
Self::UpstreamRequestTooLarge => ExecutionStage::RequestPreparation,
|
||||
Self::UpstreamAuthError
|
||||
| Self::UpstreamNotFound
|
||||
| Self::UpstreamRateLimited
|
||||
| Self::UpstreamServerError
|
||||
| Self::UpstreamStatusError
|
||||
| Self::UpstreamTimeout
|
||||
| Self::UpstreamTransportError
|
||||
| Self::UpstreamResponseTooLarge => ExecutionStage::Upstream,
|
||||
Self::OutputMappingInvalid => ExecutionStage::OutputMapping,
|
||||
Self::OutputSchemaInvalid => ExecutionStage::OutputSchema,
|
||||
Self::PersistenceUnavailable => ExecutionStage::MandatoryPersistence,
|
||||
Self::RuntimeInternal => ExecutionStage::Runtime,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn retryability(self) -> Retryability {
|
||||
match self {
|
||||
Self::ExecutionOverloaded
|
||||
| Self::SafetyStoreUnavailable
|
||||
| Self::UpstreamRateLimited
|
||||
| Self::UpstreamServerError
|
||||
| Self::UpstreamTimeout
|
||||
| Self::UpstreamTransportError
|
||||
| Self::PersistenceUnavailable
|
||||
| Self::IdempotencyInProgress => Retryability::AfterDelay,
|
||||
Self::ConfirmationRequired => Retryability::RequiresConfirmation,
|
||||
Self::IdempotencyOutcomeUnknown => Retryability::ManualReconcile,
|
||||
_ => Retryability::Never,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn outcome_certainty(self) -> OutcomeCertainty {
|
||||
match self {
|
||||
Self::IdempotencyOutcomeUnknown => OutcomeCertainty::OutcomeUnknown,
|
||||
_ => OutcomeCertainty::Certain,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn message(self, locale: ExecutionLocale) -> &'static str {
|
||||
match locale {
|
||||
ExecutionLocale::Ru => self.message_ru(),
|
||||
ExecutionLocale::En => self.message_en(),
|
||||
}
|
||||
}
|
||||
|
||||
const fn message_ru(self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationDenied => "Выполнение операции запрещено.",
|
||||
Self::AuthProfileNotFound => "Профиль авторизации не найден.",
|
||||
Self::SecretNotFound => "Секрет авторизации не найден.",
|
||||
Self::SecretInvalid => "Секрет авторизации имеет неподходящий формат.",
|
||||
Self::InputSchemaInvalid => "Входные параметры не прошли проверку схемы.",
|
||||
Self::InputMappingInvalid => "Не удалось сопоставить входные параметры.",
|
||||
Self::PreparedRequestInvalid => "Не удалось подготовить корректный API-запрос.",
|
||||
Self::ExecutionOverloaded => "Сервис временно перегружен.",
|
||||
Self::SafetyStoreUnavailable => "Обязательное хранилище безопасности недоступно.",
|
||||
Self::ProtocolUnsupported => "Протокол операции не поддерживается.",
|
||||
Self::ExecutionModeUnsupported => "Режим выполнения не поддерживается.",
|
||||
Self::AdapterConfigurationInvalid => "Конфигурация адаптера некорректна.",
|
||||
Self::OutboundTargetRejected => "Целевой адрес отклонён политикой безопасности.",
|
||||
Self::UpstreamAuthError => "Внешний API отклонил авторизацию.",
|
||||
Self::UpstreamNotFound => "Ресурс внешнего API не найден.",
|
||||
Self::UpstreamRateLimited => "Внешний API ограничил частоту запросов.",
|
||||
Self::UpstreamServerError => "Внешний API временно недоступен.",
|
||||
Self::UpstreamStatusError => "Внешний API вернул ошибочный статус.",
|
||||
Self::UpstreamTimeout => "Истекло время ожидания внешнего API.",
|
||||
Self::UpstreamTransportError => "Не удалось подключиться к внешнему API.",
|
||||
Self::UpstreamRequestTooLarge => "Запрос во внешний API превышает лимит.",
|
||||
Self::UpstreamResponseTooLarge => "Ответ внешнего API превышает лимит.",
|
||||
Self::OutputMappingInvalid => "Не удалось сопоставить ответ внешнего API.",
|
||||
Self::OutputSchemaInvalid => "Ответ не прошёл проверку схемы.",
|
||||
Self::PersistenceUnavailable => "Обязательное сохранение результата недоступно.",
|
||||
Self::RuntimeInternal => "Внутренняя ошибка выполнения.",
|
||||
Self::ConfirmationRequired => "Операция требует подтверждения.",
|
||||
Self::ConfirmationInvalid => "Подтверждение недействительно или истекло.",
|
||||
Self::IdempotencyInProgress => "Операция с этим ключом уже выполняется.",
|
||||
Self::IdempotencyConflict => "Ключ идемпотентности использован с другими параметрами.",
|
||||
Self::IdempotencyOutcomeUnknown => {
|
||||
"Результат предыдущего выполнения неизвестен; автоматический повтор запрещён."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn message_en(self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationDenied => "Operation execution is denied.",
|
||||
Self::AuthProfileNotFound => "Authorization profile was not found.",
|
||||
Self::SecretNotFound => "Authorization secret was not found.",
|
||||
Self::SecretInvalid => "Authorization secret has an invalid format.",
|
||||
Self::InputSchemaInvalid => "Input does not satisfy the operation schema.",
|
||||
Self::InputMappingInvalid => "Input parameters could not be mapped.",
|
||||
Self::PreparedRequestInvalid => "A valid upstream request could not be prepared.",
|
||||
Self::ExecutionOverloaded => "The service is temporarily overloaded.",
|
||||
Self::SafetyStoreUnavailable => "A mandatory safety store is unavailable.",
|
||||
Self::ProtocolUnsupported => "The operation protocol is unsupported.",
|
||||
Self::ExecutionModeUnsupported => "The execution mode is unsupported.",
|
||||
Self::AdapterConfigurationInvalid => "The adapter configuration is invalid.",
|
||||
Self::OutboundTargetRejected => "The target was rejected by the safety policy.",
|
||||
Self::UpstreamAuthError => "The upstream API rejected authorization.",
|
||||
Self::UpstreamNotFound => "The upstream resource was not found.",
|
||||
Self::UpstreamRateLimited => "The upstream API rate-limited the request.",
|
||||
Self::UpstreamServerError => "The upstream API is temporarily unavailable.",
|
||||
Self::UpstreamStatusError => "The upstream API returned an error status.",
|
||||
Self::UpstreamTimeout => "The upstream API timed out.",
|
||||
Self::UpstreamTransportError => "The upstream API could not be reached.",
|
||||
Self::UpstreamRequestTooLarge => "The upstream request exceeded its limit.",
|
||||
Self::UpstreamResponseTooLarge => "The upstream response exceeded its limit.",
|
||||
Self::OutputMappingInvalid => "The upstream response could not be mapped.",
|
||||
Self::OutputSchemaInvalid => "The output does not satisfy the operation schema.",
|
||||
Self::PersistenceUnavailable => "Mandatory result persistence is unavailable.",
|
||||
Self::RuntimeInternal => "Internal execution failure.",
|
||||
Self::ConfirmationRequired => "The operation requires confirmation.",
|
||||
Self::ConfirmationInvalid => "The confirmation is invalid or expired.",
|
||||
Self::IdempotencyInProgress => "The operation is already running for this key.",
|
||||
Self::IdempotencyConflict => "The idempotency key was used with different input.",
|
||||
Self::IdempotencyOutcomeUnknown => {
|
||||
"The previous outcome is unknown; automatic retry is unsafe."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExecutionLocale {
|
||||
Ru,
|
||||
En,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ConfirmationChallenge {
|
||||
token: String,
|
||||
expires_in_ms: u64,
|
||||
}
|
||||
|
||||
impl ConfirmationChallenge {
|
||||
fn try_new(
|
||||
token: impl Into<String>,
|
||||
expires_in_ms: u64,
|
||||
) -> Result<Self, ExecutionFailureBuildError> {
|
||||
let token = token.into();
|
||||
if token.is_empty() || token.len() > 4_096 || token.chars().any(char::is_control) {
|
||||
return Err(ExecutionFailureBuildError::InvalidConfirmation);
|
||||
}
|
||||
if !(1..=3_600_000).contains(&expires_in_ms) {
|
||||
return Err(ExecutionFailureBuildError::InvalidConfirmation);
|
||||
}
|
||||
Ok(Self {
|
||||
token,
|
||||
expires_in_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn expires_in_ms(&self) -> u64 {
|
||||
self.expires_in_ms
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ExecutionFailureBuildError {
|
||||
#[error("failure metadata is incompatible with the execution error code")]
|
||||
IncompatibleMetadata,
|
||||
#[error("confirmation metadata is invalid or exceeds its bound")]
|
||||
InvalidConfirmation,
|
||||
#[error("retry metadata is invalid or exceeds its bound")]
|
||||
InvalidRetryAfter,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ConfirmationChallenge {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ConfirmationChallenge")
|
||||
.field("token", &"[REDACTED]")
|
||||
.field("expires_in_ms", &self.expires_in_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExecutionFailure {
|
||||
code: ExecutionErrorCode,
|
||||
retryability: Retryability,
|
||||
outcome_certainty: OutcomeCertainty,
|
||||
correlation: CorrelationContext,
|
||||
upstream_status: Option<u16>,
|
||||
retry_after_ms: Option<u64>,
|
||||
confirmation: Option<ConfirmationChallenge>,
|
||||
}
|
||||
|
||||
impl ExecutionFailure {
|
||||
pub fn new(code: ExecutionErrorCode, correlation: CorrelationContext) -> Self {
|
||||
Self {
|
||||
code,
|
||||
retryability: code.retryability(),
|
||||
outcome_certainty: code.outcome_certainty(),
|
||||
correlation,
|
||||
upstream_status: None,
|
||||
retry_after_ms: None,
|
||||
confirmation: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_code(&self) -> ExecutionErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn stage(&self) -> ExecutionStage {
|
||||
self.code.stage()
|
||||
}
|
||||
|
||||
pub fn retryability(&self) -> Retryability {
|
||||
self.retryability
|
||||
}
|
||||
|
||||
pub fn outcome_certainty(&self) -> OutcomeCertainty {
|
||||
self.outcome_certainty
|
||||
}
|
||||
|
||||
pub fn correlation(&self) -> &CorrelationContext {
|
||||
&self.correlation
|
||||
}
|
||||
|
||||
pub fn upstream_status(&self) -> Option<u16> {
|
||||
self.upstream_status
|
||||
}
|
||||
|
||||
pub fn retry_after_ms(&self) -> Option<u64> {
|
||||
self.retry_after_ms
|
||||
}
|
||||
|
||||
pub fn confirmation(&self) -> Option<&ConfirmationChallenge> {
|
||||
self.confirmation.as_ref()
|
||||
}
|
||||
|
||||
pub fn with_upstream_status(mut self, status: u16) -> Self {
|
||||
self.upstream_status = (100..=599).contains(&status).then_some(status);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn try_with_retry_after_ms(
|
||||
mut self,
|
||||
retry_after_ms: u64,
|
||||
) -> Result<Self, ExecutionFailureBuildError> {
|
||||
if self.retryability != Retryability::AfterDelay {
|
||||
return Err(ExecutionFailureBuildError::IncompatibleMetadata);
|
||||
}
|
||||
if !(1..=3_600_000).contains(&retry_after_ms) {
|
||||
return Err(ExecutionFailureBuildError::InvalidRetryAfter);
|
||||
}
|
||||
self.retry_after_ms = Some(retry_after_ms);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn try_with_confirmation(
|
||||
mut self,
|
||||
token: impl Into<String>,
|
||||
expires_in_ms: u64,
|
||||
) -> Result<Self, ExecutionFailureBuildError> {
|
||||
if self.code != ExecutionErrorCode::ConfirmationRequired {
|
||||
return Err(ExecutionFailureBuildError::IncompatibleMetadata);
|
||||
}
|
||||
self.confirmation = Some(ConfirmationChallenge::try_new(token, expires_in_ms)?);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn with_dispatch_uncertainty(mut self) -> Self {
|
||||
if matches!(
|
||||
self.code,
|
||||
ExecutionErrorCode::UpstreamTimeout
|
||||
| ExecutionErrorCode::UpstreamTransportError
|
||||
| ExecutionErrorCode::IdempotencyOutcomeUnknown
|
||||
| ExecutionErrorCode::PersistenceUnavailable
|
||||
) {
|
||||
self.retryability = Retryability::ManualReconcile;
|
||||
self.outcome_certainty = OutcomeCertainty::OutcomeUnknown;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ExecutionSuccess {
|
||||
pub operation_id: OperationId,
|
||||
pub operation_version: u32,
|
||||
pub origin: ExecutionOrigin,
|
||||
pub correlation: CorrelationContext,
|
||||
pub request_preview: Value,
|
||||
pub output: Value,
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
MachineAccessMode, Membership, OperationSecurityLevel, PlatformApiKeyScope, User, WorkspaceId,
|
||||
MachineAccessMode, Membership, OperationSecurityLevel, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
User, WorkspaceId,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -10,6 +11,7 @@ pub struct VerifiedMachineCredential {
|
||||
pub machine_access_mode: MachineAccessMode,
|
||||
pub max_security_level: OperationSecurityLevel,
|
||||
pub scopes: Vec<PlatformApiKeyScope>,
|
||||
pub platform_api_key_id: Option<PlatformApiKeyId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -21,14 +27,26 @@ pub struct ResponseCacheScope {
|
||||
pub agent_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeRequestContext {
|
||||
pub request_id: RequestId,
|
||||
pub trace_context: TraceContext,
|
||||
pub response_cache_scope: Option<ResponseCacheScope>,
|
||||
pub metering_context: Option<MeteringContext>,
|
||||
dispatch_started: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl PartialEq for RuntimeRequestContext {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.request_id == other.request_id
|
||||
&& self.trace_context == other.trace_context
|
||||
&& self.response_cache_scope == other.response_cache_scope
|
||||
&& self.metering_context == other.metering_context
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for RuntimeRequestContext {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MeteringContext {
|
||||
pub workspace_id: WorkspaceId,
|
||||
@@ -43,6 +61,7 @@ impl RuntimeRequestContext {
|
||||
trace_context,
|
||||
response_cache_scope: None,
|
||||
metering_context: None,
|
||||
dispatch_started: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,22 +130,43 @@ impl RuntimeRequestContext {
|
||||
pub fn metering_context(&self) -> Option<&MeteringContext> {
|
||||
self.metering_context.as_ref()
|
||||
}
|
||||
|
||||
pub fn with_dispatch_started(mut self, dispatch_started: Arc<AtomicBool>) -> Self {
|
||||
self.dispatch_started = Some(dispatch_started);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn mark_dispatch_started(&self) {
|
||||
if let Some(dispatch_started) = &self.dispatch_started {
|
||||
dispatch_started.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
pub struct PreparedRequest {
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub path_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub query_params: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trusted_header_names: BTreeSet<String>,
|
||||
pub body: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PreparedRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PreparedRequest")
|
||||
.field("path_param_count", &self.path_params.len())
|
||||
.field("query_param_count", &self.query_params.len())
|
||||
.field("header_count", &self.headers.len())
|
||||
.field("trusted_header_count", &self.trusted_header_names.len())
|
||||
.field("body_configured", &self.body.is_some())
|
||||
.field("timeout_ms", &self.timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AdapterResponse {
|
||||
pub status_code: u16,
|
||||
@@ -143,8 +183,33 @@ pub enum ProtocolAdapterError {
|
||||
protocol: Protocol,
|
||||
mode: ExecutionMode,
|
||||
},
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
#[error("adapter configuration is invalid")]
|
||||
InvalidConfiguration,
|
||||
#[error("prepared request is invalid")]
|
||||
InvalidPreparedRequest,
|
||||
#[error("upstream request exceeded the configured limit")]
|
||||
RequestTooLarge,
|
||||
#[error("outbound target was rejected")]
|
||||
TargetRejected,
|
||||
#[error("upstream transport failed")]
|
||||
Transport { dispatch: DispatchEvidence },
|
||||
#[error("upstream request timed out")]
|
||||
Timeout { dispatch: DispatchEvidence },
|
||||
#[error("upstream response exceeded the configured limit")]
|
||||
ResponseTooLarge { dispatch: DispatchEvidence },
|
||||
#[error("upstream returned status {status}")]
|
||||
UnexpectedStatus {
|
||||
status: u16,
|
||||
dispatch: DispatchEvidence,
|
||||
},
|
||||
#[error("upstream response could not be decoded")]
|
||||
InvalidResponse { dispatch: DispatchEvidence },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DispatchEvidence {
|
||||
NotDispatched,
|
||||
MayHaveDispatched,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -55,6 +55,7 @@ define_id!(InvitationId);
|
||||
define_id!(PlatformApiKeyId);
|
||||
define_id!(ApprovalRequestId);
|
||||
define_id!(InvocationLogId);
|
||||
define_id!(ProductEventId);
|
||||
define_id!(AuditEventId);
|
||||
define_id!(SecretId);
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod correlation;
|
||||
pub mod edition;
|
||||
pub mod execution;
|
||||
pub mod ext;
|
||||
pub mod ids;
|
||||
pub mod observability;
|
||||
pub mod onboarding;
|
||||
pub mod operation;
|
||||
pub mod product_event;
|
||||
pub mod protocol;
|
||||
pub mod secret;
|
||||
pub mod tool_catalog;
|
||||
@@ -38,15 +41,20 @@ pub mod domain {
|
||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel,
|
||||
ProductEdition,
|
||||
};
|
||||
pub use crate::execution::{
|
||||
ExecutionErrorCode, ExecutionFailure, ExecutionLocale, ExecutionOrigin,
|
||||
ExecutionOriginError, ExecutionStage, ExecutionSuccess, OutcomeCertainty, Retryability,
|
||||
};
|
||||
pub use crate::ids::{
|
||||
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
|
||||
UserSessionId, WorkspaceId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, ProductEventId, SampleId, SecretId, ToolId,
|
||||
UserId, UserSessionId, WorkspaceId,
|
||||
};
|
||||
pub use crate::observability::{
|
||||
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
|
||||
};
|
||||
pub use crate::onboarding::{OnboardingProjection, OnboardingStep, OnboardingStepId};
|
||||
pub use crate::operation::{
|
||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
|
||||
@@ -54,6 +62,10 @@ pub mod domain {
|
||||
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy,
|
||||
RestTarget, RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
|
||||
};
|
||||
pub use crate::product_event::{
|
||||
OnboardingMilestone, PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES, PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX, ProductEvent, ProductEventKind,
|
||||
};
|
||||
pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
pub use crate::tool_catalog::{ToolCatalogAnalysis, ToolCatalogBudget};
|
||||
@@ -89,9 +101,9 @@ pub mod ports {
|
||||
MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink,
|
||||
};
|
||||
pub use crate::ext::protocol::{
|
||||
AdapterRegistry, AdapterResponse, ExecutionMode, MeteringContext, PreparedRequest,
|
||||
ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext,
|
||||
SharedProtocolAdapter,
|
||||
AdapterRegistry, AdapterResponse, DispatchEvidence, ExecutionMode, MeteringContext,
|
||||
PreparedRequest, ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope,
|
||||
RuntimeRequestContext, SharedProtocolAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +130,10 @@ pub use correlation::{CorrelationContext, CorrelationError, RequestId, TraceCont
|
||||
pub use edition::{
|
||||
EditionCapabilities, EditionLimits, MachineAccessMode, OperationSecurityLevel, ProductEdition,
|
||||
};
|
||||
pub use execution::{
|
||||
ConfirmationChallenge, ExecutionErrorCode, ExecutionFailure, ExecutionLocale, ExecutionOrigin,
|
||||
ExecutionOriginError, ExecutionStage, ExecutionSuccess, OutcomeCertainty, Retryability,
|
||||
};
|
||||
pub use ext::access::{
|
||||
OwnerOnlyPolicyEngine, PolicyAction, PolicyDecision, PolicyEngine, PolicyScope, SessionActor,
|
||||
};
|
||||
@@ -133,25 +149,32 @@ pub use ext::auth::{
|
||||
pub use ext::capability::{CapabilityProfile, CommunityCapabilityProfile};
|
||||
pub use ext::metering::{MeteringEvent, MeteringSink, NoopMeteringSink, SharedMeteringSink};
|
||||
pub use ext::protocol::{
|
||||
AdapterRegistry, AdapterResponse, ExecutionMode, MeteringContext, PreparedRequest,
|
||||
ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope, RuntimeRequestContext,
|
||||
SharedProtocolAdapter,
|
||||
AdapterRegistry, AdapterResponse, DispatchEvidence, ExecutionMode, MeteringContext,
|
||||
PreparedRequest, ProtocolAdapter, ProtocolAdapterError, ResponseCacheScope,
|
||||
RuntimeRequestContext, SharedProtocolAdapter,
|
||||
};
|
||||
pub use ids::{
|
||||
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
|
||||
UserSessionId, WorkspaceId,
|
||||
InvocationLogId, OperationId, PlatformApiKeyId, ProductEventId, SampleId, SecretId, ToolId,
|
||||
UserId, UserSessionId, WorkspaceId,
|
||||
};
|
||||
pub use observability::{
|
||||
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||
InvocationStatus, UsagePeriod, UsageRollup, sanitize_invocation_preview,
|
||||
};
|
||||
pub use onboarding::{OnboardingProjection, OnboardingStep, OnboardingStepId};
|
||||
pub use operation::{
|
||||
ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus,
|
||||
IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalMode,
|
||||
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
|
||||
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget,
|
||||
RetryPolicy, Samples, Target, ToolDescription, ToolExample, WizardState,
|
||||
OperationAvailability, OperationLifecycle, OperationLifecycleAction, OperationLifecycleError,
|
||||
OperationSafetyClass, OperationSafetyPolicy, OperationStatus, OperationVersionState,
|
||||
ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target, ToolDescription, ToolExample,
|
||||
WizardState,
|
||||
};
|
||||
pub use product_event::{
|
||||
OnboardingMilestone, PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES, PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX, ProductEvent, ProductEventKind,
|
||||
};
|
||||
pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol};
|
||||
pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion};
|
||||
|
||||
@@ -2,12 +2,18 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AgentId, OperationId, WorkspaceId};
|
||||
use crate::{
|
||||
AgentId, ExecutionErrorCode, ExecutionStage, OperationId, OutcomeCertainty, Retryability,
|
||||
WorkspaceId,
|
||||
};
|
||||
|
||||
pub const INVOCATION_PREVIEW_MAX_BYTES: usize = 16 * 1024;
|
||||
pub const INVOCATION_PREVIEW_MAX_DEPTH: usize = 12;
|
||||
pub const INVOCATION_PREVIEW_MAX_OBJECT_FIELDS: usize = 64;
|
||||
pub const INVOCATION_PREVIEW_MAX_ARRAY_ITEMS: usize = 64;
|
||||
|
||||
pub fn sanitize_invocation_preview(value: &Value) -> Value {
|
||||
let redacted = redact_sensitive_fields(value);
|
||||
let redacted = redact_sensitive_fields(value, 0);
|
||||
let Ok(encoded) = serde_json::to_vec(&redacted) else {
|
||||
return Value::Null;
|
||||
};
|
||||
@@ -17,27 +23,63 @@ pub fn sanitize_invocation_preview(value: &Value) -> Value {
|
||||
let preview = String::from_utf8_lossy(&encoded[..INVOCATION_PREVIEW_MAX_BYTES]).into_owned();
|
||||
json!({
|
||||
"truncated": true,
|
||||
"reason": "max_bytes",
|
||||
"original_bytes": encoded.len(),
|
||||
"preview": preview,
|
||||
})
|
||||
}
|
||||
|
||||
fn redact_sensitive_fields(value: &Value) -> Value {
|
||||
fn truncation_marker(reason: &'static str, omitted: usize) -> Value {
|
||||
json!({
|
||||
"truncated": true,
|
||||
"reason": reason,
|
||||
"omitted": omitted,
|
||||
})
|
||||
}
|
||||
|
||||
fn redact_sensitive_fields(value: &Value, depth: usize) -> Value {
|
||||
if depth >= INVOCATION_PREVIEW_MAX_DEPTH {
|
||||
return truncation_marker("max_depth", 1);
|
||||
}
|
||||
match value {
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
Value::Object(object) => {
|
||||
let mut redacted = Map::new();
|
||||
let mut omitted = 0usize;
|
||||
for (index, (key, value)) in object.iter().enumerate() {
|
||||
if index >= INVOCATION_PREVIEW_MAX_OBJECT_FIELDS {
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
let value = if is_sensitive_key(key) {
|
||||
Value::String("[REDACTED]".to_owned())
|
||||
} else {
|
||||
redact_sensitive_fields(value, depth + 1)
|
||||
};
|
||||
redacted.insert(key.clone(), value);
|
||||
}
|
||||
if omitted > 0 {
|
||||
redacted.insert(
|
||||
"_crank_truncated".to_owned(),
|
||||
truncation_marker("max_object_fields", omitted),
|
||||
);
|
||||
}
|
||||
Value::Object(redacted)
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let mut redacted = items
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let value = if is_sensitive_key(key) {
|
||||
Value::String("[REDACTED]".to_owned())
|
||||
} else {
|
||||
redact_sensitive_fields(value)
|
||||
};
|
||||
(key.clone(), value)
|
||||
})
|
||||
.collect::<Map<String, Value>>(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_fields).collect()),
|
||||
.take(INVOCATION_PREVIEW_MAX_ARRAY_ITEMS)
|
||||
.map(|value| redact_sensitive_fields(value, depth + 1))
|
||||
.collect::<Vec<_>>();
|
||||
let omitted = items.len().saturating_sub(redacted.len());
|
||||
if omitted > 0 {
|
||||
redacted.push(truncation_marker("max_array_items", omitted));
|
||||
}
|
||||
Value::Array(redacted)
|
||||
}
|
||||
Value::String(value) if looks_like_sensitive_value(value) => {
|
||||
Value::String("[REDACTED]".to_owned())
|
||||
}
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
@@ -63,6 +105,22 @@ fn is_sensitive_key(key: &str) -> bool {
|
||||
.any(|sensitive| compact.contains(sensitive))
|
||||
}
|
||||
|
||||
fn looks_like_sensitive_value(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
lower.starts_with("bearer ")
|
||||
|| lower.starts_with("basic ")
|
||||
|| lower.contains("password=")
|
||||
|| lower.contains("token=")
|
||||
|| lower.contains("secret=")
|
||||
|| lower.contains("api_key=")
|
||||
|| lower.contains("apikey=")
|
||||
|| lower.contains("authorization:")
|
||||
|| trimmed.starts_with("sk_")
|
||||
|| trimmed.starts_with("crk_")
|
||||
|| trimmed.contains("SECRET_")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationSource {
|
||||
@@ -111,7 +169,10 @@ pub struct InvocationLog {
|
||||
pub id: crate::ids::InvocationLogId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub agent_id: Option<AgentId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub platform_api_key_id: Option<crate::ids::PlatformApiKeyId>,
|
||||
pub operation_id: OperationId,
|
||||
pub operation_version: Option<u32>,
|
||||
pub source: InvocationSource,
|
||||
pub level: InvocationLevel,
|
||||
pub status: InvocationStatus,
|
||||
@@ -122,6 +183,10 @@ pub struct InvocationLog {
|
||||
pub status_code: Option<u16>,
|
||||
pub duration_ms: u64,
|
||||
pub error_kind: Option<String>,
|
||||
pub execution_stage: Option<ExecutionStage>,
|
||||
pub execution_error_code: Option<ExecutionErrorCode>,
|
||||
pub retryability: Option<Retryability>,
|
||||
pub outcome_certainty: Option<OutcomeCertainty>,
|
||||
pub request_preview: Value,
|
||||
pub response_preview: Value,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
@@ -148,10 +213,12 @@ mod tests {
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
use super::{
|
||||
INVOCATION_PREVIEW_MAX_BYTES, InvocationLevel, InvocationLog, InvocationSource,
|
||||
InvocationStatus, UsagePeriod, sanitize_invocation_preview,
|
||||
INVOCATION_PREVIEW_MAX_ARRAY_ITEMS, INVOCATION_PREVIEW_MAX_BYTES,
|
||||
INVOCATION_PREVIEW_MAX_DEPTH, INVOCATION_PREVIEW_MAX_OBJECT_FIELDS, InvocationLevel,
|
||||
InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
|
||||
sanitize_invocation_preview,
|
||||
};
|
||||
use crate::{AgentId, OperationId, WorkspaceId, ids::InvocationLogId};
|
||||
use crate::{AgentId, OperationId, OutcomeCertainty, WorkspaceId, ids::InvocationLogId};
|
||||
|
||||
fn timestamp(value: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
||||
@@ -163,7 +230,9 @@ mod tests {
|
||||
id: InvocationLogId::new("log_01"),
|
||||
workspace_id: WorkspaceId::new("ws_01"),
|
||||
agent_id: Some(AgentId::new("agent_01")),
|
||||
platform_api_key_id: None,
|
||||
operation_id: OperationId::new("op_01"),
|
||||
operation_version: Some(1),
|
||||
source: InvocationSource::AdminTestRun,
|
||||
level: InvocationLevel::Info,
|
||||
status: InvocationStatus::Ok,
|
||||
@@ -174,6 +243,10 @@ mod tests {
|
||||
status_code: Some(200),
|
||||
duration_ms: 123,
|
||||
error_kind: None,
|
||||
execution_stage: None,
|
||||
execution_error_code: None,
|
||||
retryability: None,
|
||||
outcome_certainty: Some(OutcomeCertainty::Certain),
|
||||
request_preview: json!({"input": "value"}),
|
||||
response_preview: json!({"ok": true}),
|
||||
created_at: timestamp("2026-04-19T12:34:56Z"),
|
||||
@@ -202,6 +275,8 @@ mod tests {
|
||||
"api_key": "key",
|
||||
"refreshToken": "token",
|
||||
"client_secret_value": "secret",
|
||||
"body": "Bearer SECRET_APPROVAL_CANARY",
|
||||
"neutral": "token=SECRET_APPROVAL_CANARY",
|
||||
"value": 42
|
||||
}
|
||||
}));
|
||||
@@ -210,6 +285,8 @@ mod tests {
|
||||
assert_eq!(preview["nested"]["api_key"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["refreshToken"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["client_secret_value"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["body"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["neutral"], "[REDACTED]");
|
||||
assert_eq!(preview["nested"]["value"], 42);
|
||||
}
|
||||
|
||||
@@ -220,6 +297,45 @@ mod tests {
|
||||
}));
|
||||
|
||||
assert_eq!(preview["truncated"], true);
|
||||
assert_eq!(preview["reason"], "max_bytes");
|
||||
assert!(preview["original_bytes"].as_u64().unwrap() > INVOCATION_PREVIEW_MAX_BYTES as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_preview_depth_fields_and_array_items() {
|
||||
let mut nested = json!({"secret_token": "SECRET_VALUE"});
|
||||
for _ in 0..(INVOCATION_PREVIEW_MAX_DEPTH + 4) {
|
||||
nested = json!({ "nested": nested });
|
||||
}
|
||||
let deep_preview = sanitize_invocation_preview(&nested);
|
||||
assert!(
|
||||
serde_json::to_string(&deep_preview)
|
||||
.unwrap()
|
||||
.contains("\"reason\":\"max_depth\"")
|
||||
);
|
||||
assert!(
|
||||
!serde_json::to_string(&deep_preview)
|
||||
.unwrap()
|
||||
.contains("SECRET_VALUE")
|
||||
);
|
||||
|
||||
let wide = serde_json::Value::Object(
|
||||
(0..(INVOCATION_PREVIEW_MAX_OBJECT_FIELDS + 5))
|
||||
.map(|index| (format!("field_{index:03}"), json!(index)))
|
||||
.collect(),
|
||||
);
|
||||
let wide_preview = sanitize_invocation_preview(&wide);
|
||||
assert_eq!(
|
||||
wide_preview["_crank_truncated"]["reason"],
|
||||
"max_object_fields"
|
||||
);
|
||||
|
||||
let array_preview = sanitize_invocation_preview(&json!(
|
||||
(0..(INVOCATION_PREVIEW_MAX_ARRAY_ITEMS + 3)).collect::<Vec<_>>()
|
||||
));
|
||||
assert_eq!(
|
||||
array_preview.as_array().unwrap().last().unwrap()["reason"],
|
||||
"max_array_items"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{AgentId, InvocationLogId, OperationId, PlatformApiKeyId, WorkspaceId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnboardingStepId {
|
||||
Operation,
|
||||
Test,
|
||||
PublishOperation,
|
||||
Agent,
|
||||
Key,
|
||||
McpConnection,
|
||||
FirstCall,
|
||||
}
|
||||
|
||||
impl OnboardingStepId {
|
||||
pub const ORDERED: [Self; 7] = [
|
||||
Self::Operation,
|
||||
Self::Test,
|
||||
Self::PublishOperation,
|
||||
Self::Agent,
|
||||
Self::Key,
|
||||
Self::McpConnection,
|
||||
Self::FirstCall,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnboardingStep {
|
||||
pub id: OnboardingStepId,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnboardingProjection {
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub revision: i64,
|
||||
pub completed: bool,
|
||||
pub was_completed: bool,
|
||||
pub steps: Vec<OnboardingStep>,
|
||||
pub operation_id: Option<OperationId>,
|
||||
pub operation_version: Option<u32>,
|
||||
pub agent_id: Option<AgentId>,
|
||||
pub catalog_revision: Option<i64>,
|
||||
pub platform_api_key_id: Option<PlatformApiKeyId>,
|
||||
pub first_call_log_id: Option<InvocationLogId>,
|
||||
pub first_call_tool_name: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub first_call_at: Option<OffsetDateTime>,
|
||||
pub first_call_request_id: Option<String>,
|
||||
pub first_call_trace_id: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub eligible_since: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl OnboardingProjection {
|
||||
pub fn step(&self, id: OnboardingStepId) -> Option<&OnboardingStep> {
|
||||
self.steps.iter().find(|step| step.id == id)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,73 @@ pub enum OperationStatus {
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationAvailability {
|
||||
Active,
|
||||
Archived,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OperationVersionState {
|
||||
Draft,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OperationLifecycleAction {
|
||||
SaveDraft,
|
||||
Publish,
|
||||
Archive,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum OperationLifecycleError {
|
||||
#[error("operation is archived")]
|
||||
Archived,
|
||||
#[error("operation lifecycle transition is invalid")]
|
||||
InvalidTransition,
|
||||
#[error("operation deletion would destroy durable history")]
|
||||
DurableHistory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OperationLifecycle {
|
||||
pub availability: OperationAvailability,
|
||||
pub current: OperationVersionState,
|
||||
pub ever_published: bool,
|
||||
pub has_durable_references: bool,
|
||||
}
|
||||
|
||||
impl OperationLifecycle {
|
||||
pub fn validate(self, action: OperationLifecycleAction) -> Result<(), OperationLifecycleError> {
|
||||
if self.availability == OperationAvailability::Archived {
|
||||
return match action {
|
||||
OperationLifecycleAction::Archive => Ok(()),
|
||||
_ => Err(OperationLifecycleError::Archived),
|
||||
};
|
||||
}
|
||||
match action {
|
||||
OperationLifecycleAction::SaveDraft => Ok(()),
|
||||
OperationLifecycleAction::Publish if self.current == OperationVersionState::Draft => {
|
||||
Ok(())
|
||||
}
|
||||
OperationLifecycleAction::Archive => Ok(()),
|
||||
OperationLifecycleAction::Delete
|
||||
if !self.ever_published && !self.has_durable_references =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
OperationLifecycleAction::Delete => Err(OperationLifecycleError::DurableHistory),
|
||||
OperationLifecycleAction::Publish => Err(OperationLifecycleError::InvalidTransition),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RestTarget {
|
||||
pub base_url: String,
|
||||
pub method: HttpMethod,
|
||||
@@ -44,11 +110,13 @@ pub enum Target {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RetryPolicy {
|
||||
pub max_attempts: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResponseCachePolicy {
|
||||
pub ttl_ms: u64,
|
||||
}
|
||||
@@ -62,6 +130,7 @@ pub enum IdempotencyMode {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IdempotencyPolicy {
|
||||
pub mode: IdempotencyMode,
|
||||
pub ttl_ms: u64,
|
||||
@@ -92,11 +161,13 @@ impl OperationSafetyClass {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfirmationPolicy {
|
||||
pub ttl_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationSafetyPolicy {
|
||||
pub class: OperationSafetyClass,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -130,6 +201,7 @@ pub enum OperationApprovalMode {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OperationApprovalPolicy {
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
@@ -143,6 +215,7 @@ pub struct OperationApprovalPolicy {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionConfig {
|
||||
pub timeout_ms: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -162,11 +235,13 @@ pub struct ExecutionConfig {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ToolExample {
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ToolDescription {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
@@ -284,6 +359,23 @@ impl<TSchema, TMapping> Operation<TSchema, TMapping> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<TSchema: PartialEq, TMapping: PartialEq> Operation<TSchema, TMapping> {
|
||||
pub fn portable_semantically_eq(&self, other: &Self) -> bool {
|
||||
self.name == other.name
|
||||
&& self.display_name == other.display_name
|
||||
&& self.category == other.category
|
||||
&& self.protocol == other.protocol
|
||||
&& self.security_level == other.security_level
|
||||
&& self.target == other.target
|
||||
&& self.input_schema == other.input_schema
|
||||
&& self.output_schema == other.output_schema
|
||||
&& self.input_mapping == other.input_mapping
|
||||
&& self.output_mapping == other.output_mapping
|
||||
&& self.execution_config == other.execution_config
|
||||
&& self.tool_description == other.tool_description
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
@@ -296,8 +388,9 @@ mod tests {
|
||||
edition::OperationSecurityLevel,
|
||||
ids::{AuthProfileId, OperationId},
|
||||
operation::{
|
||||
ConfigExport, ExecutionConfig, Operation, OperationStatus, RestTarget, Samples, Target,
|
||||
ToolDescription, ToolExample,
|
||||
ConfigExport, ExecutionConfig, Operation, OperationAvailability, OperationLifecycle,
|
||||
OperationLifecycleAction, OperationLifecycleError, OperationStatus,
|
||||
OperationVersionState, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
||||
},
|
||||
protocol::{AuthKind, ExportMode, HttpMethod, Protocol},
|
||||
};
|
||||
@@ -335,6 +428,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_rejects_publish_rewind_and_durable_delete() {
|
||||
let published = OperationLifecycle {
|
||||
availability: OperationAvailability::Active,
|
||||
current: OperationVersionState::Published,
|
||||
ever_published: true,
|
||||
has_durable_references: true,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
published.validate(OperationLifecycleAction::Publish),
|
||||
Err(OperationLifecycleError::InvalidTransition)
|
||||
);
|
||||
assert_eq!(
|
||||
published.validate(OperationLifecycleAction::Delete),
|
||||
Err(OperationLifecycleError::DurableHistory)
|
||||
);
|
||||
assert_eq!(
|
||||
published.validate(OperationLifecycleAction::SaveDraft),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archived_lifecycle_is_terminal_but_archive_retry_is_idempotent() {
|
||||
let archived = OperationLifecycle {
|
||||
availability: OperationAvailability::Archived,
|
||||
current: OperationVersionState::Published,
|
||||
ever_published: true,
|
||||
has_durable_references: true,
|
||||
};
|
||||
|
||||
assert_eq!(archived.validate(OperationLifecycleAction::Archive), Ok(()));
|
||||
assert_eq!(
|
||||
archived.validate(OperationLifecycleAction::SaveDraft),
|
||||
Err(OperationLifecycleError::Archived)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_semantic_equality_ignores_persistence_identity() {
|
||||
let left = test_operation(OperationStatus::Draft);
|
||||
let mut right = left.clone();
|
||||
right.id = OperationId::new("op_other");
|
||||
right.version = 9;
|
||||
right.status = OperationStatus::Published;
|
||||
right.updated_at = timestamp("2026-03-25T09:00:00Z");
|
||||
right.published_at = Some(timestamp("2026-03-25T09:00:00Z"));
|
||||
|
||||
assert!(left.portable_semantically_eq(&right));
|
||||
right.display_name = "Changed".to_owned();
|
||||
assert!(!left.portable_semantically_eq(&right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_profile_serializes_secret_ids_without_secret_values() {
|
||||
let profile = AuthProfile {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Date, OffsetDateTime};
|
||||
|
||||
use crate::{ProductEventId, WorkspaceId};
|
||||
|
||||
pub const PRODUCT_EVENT_SCHEMA_VERSION: u16 = 1;
|
||||
pub const PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES: usize = 256;
|
||||
pub const PRODUCT_EVENT_SERVER_IDEMPOTENCY_PREFIX: &str = "onboarding:";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProductEventKind {
|
||||
OnboardingEligible,
|
||||
OnboardingStarted,
|
||||
OnboardingResumed,
|
||||
OnboardingDismissed,
|
||||
OnboardingAbandoned,
|
||||
OnboardingCompleted,
|
||||
}
|
||||
|
||||
impl ProductEventKind {
|
||||
pub const ALL: [Self; 6] = [
|
||||
Self::OnboardingEligible,
|
||||
Self::OnboardingStarted,
|
||||
Self::OnboardingResumed,
|
||||
Self::OnboardingDismissed,
|
||||
Self::OnboardingAbandoned,
|
||||
Self::OnboardingCompleted,
|
||||
];
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OnboardingEligible => "onboarding_eligible",
|
||||
Self::OnboardingStarted => "onboarding_started",
|
||||
Self::OnboardingResumed => "onboarding_resumed",
|
||||
Self::OnboardingDismissed => "onboarding_dismissed",
|
||||
Self::OnboardingAbandoned => "onboarding_abandoned",
|
||||
Self::OnboardingCompleted => "onboarding_completed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OnboardingMilestone {
|
||||
Operation,
|
||||
Test,
|
||||
PublishOperation,
|
||||
Agent,
|
||||
Key,
|
||||
McpConnection,
|
||||
FirstCall,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProductEvent {
|
||||
pub id: ProductEventId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub kind: ProductEventKind,
|
||||
pub schema_version: u16,
|
||||
pub milestone: Option<OnboardingMilestone>,
|
||||
pub eligible: bool,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub eligible_since: Option<OffsetDateTime>,
|
||||
pub idempotency_key: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub occurred_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl ProductEvent {
|
||||
pub fn occurred_on_utc(&self) -> Date {
|
||||
self.occurred_at.to_offset(time::UtcOffset::UTC).date()
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let eligibility_is_valid = match self.kind {
|
||||
ProductEventKind::OnboardingEligible => self.eligible && self.eligible_since.is_some(),
|
||||
_ => !self.eligible || self.eligible_since.is_some(),
|
||||
};
|
||||
self.schema_version == PRODUCT_EVENT_SCHEMA_VERSION
|
||||
&& !self.idempotency_key.is_empty()
|
||||
&& self.idempotency_key.len() <= PRODUCT_EVENT_IDEMPOTENCY_KEY_MAX_BYTES
|
||||
&& eligibility_is_valid
|
||||
}
|
||||
|
||||
pub fn is_semantic_replay_of(&self, recorded: &Self) -> bool {
|
||||
self.workspace_id == recorded.workspace_id
|
||||
&& self.kind == recorded.kind
|
||||
&& self.schema_version == recorded.schema_version
|
||||
&& self.milestone == recorded.milestone
|
||||
&& self.eligible == recorded.eligible
|
||||
&& self.eligible_since == recorded.eligible_since
|
||||
&& self.idempotency_key == recorded.idempotency_key
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ProductEventId, WorkspaceId};
|
||||
|
||||
#[test]
|
||||
fn vocabulary_is_closed_and_stable() {
|
||||
assert_eq!(ProductEventKind::ALL.len(), 6);
|
||||
assert_eq!(
|
||||
ProductEventKind::OnboardingCompleted.as_str(),
|
||||
"onboarding_completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eligible_event_requires_explicit_cohort_timestamp() {
|
||||
let event = ProductEvent {
|
||||
id: ProductEventId::new("pe_eligible"),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
kind: ProductEventKind::OnboardingEligible,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: true,
|
||||
eligible_since: None,
|
||||
idempotency_key: "eligible:first-login".to_owned(),
|
||||
occurred_at: OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
assert!(!event.is_valid());
|
||||
assert!(
|
||||
ProductEvent {
|
||||
eligible_since: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..event
|
||||
}
|
||||
.is_valid()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_replay_ignores_transport_identity_and_retry_time() {
|
||||
let recorded = ProductEvent {
|
||||
id: ProductEventId::new("pe_recorded"),
|
||||
workspace_id: WorkspaceId::new("ws_default"),
|
||||
kind: ProductEventKind::OnboardingStarted,
|
||||
schema_version: PRODUCT_EVENT_SCHEMA_VERSION,
|
||||
milestone: None,
|
||||
eligible: false,
|
||||
eligible_since: None,
|
||||
idempotency_key: "ui:started:1".to_owned(),
|
||||
occurred_at: OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
let replay = ProductEvent {
|
||||
id: ProductEventId::new("pe_retry"),
|
||||
occurred_at: OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(1),
|
||||
..recorded.clone()
|
||||
};
|
||||
assert!(replay.is_semantic_replay_of(&recorded));
|
||||
assert!(
|
||||
!ProductEvent {
|
||||
kind: ProductEventKind::OnboardingDismissed,
|
||||
..replay
|
||||
}
|
||||
.is_semantic_replay_of(&recorded)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user