feat: complete Epic 1 production foundation
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user