Skip to main content

uptrakit_surfaces/
interaction.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4use crate::{BuiltInApiOperationId, FormUiDescriptor, InteractionId, ProviderKind, SchemaContract};
5
6pub const MIN_INTERACTION_TIMEOUT_SECONDS: u16 = 1;
7pub const MAX_INTERACTION_TIMEOUT_SECONDS: u16 = 300;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum InteractionKind {
12    MutationAction,
13    FormSubmit,
14    Workflow,
15    Navigate,
16    DataLoad,
17    ConfirmableAction,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case", tag = "mode")]
22pub enum InteractionTransport {
23    ControllerLocal,
24    ProviderProxied,
25    DirectBuiltInApi { operation_id: BuiltInApiOperationId },
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct WorkflowStepDescriptor {
30    pub step_id: String,
31    pub label: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub form_ui: Option<FormUiDescriptor>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub submit_interaction_id: Option<InteractionId>,
36    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
37    pub render_previous_response: bool,
38    pub input_schema: SchemaContract,
39    pub result_schema: SchemaContract,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct InteractionDescriptor {
44    pub interaction_id: InteractionId,
45    pub kind: InteractionKind,
46    pub label: String,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub required_permission: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub input_schema: Option<SchemaContract>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub result_schema: Option<SchemaContract>,
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub sensitive_fields: Vec<String>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub timeout_seconds: Option<u16>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub confirmation: Option<InteractionConfirmation>,
59    pub transport: InteractionTransport,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub workflow_steps: Vec<WorkflowStepDescriptor>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub form_ui: Option<FormUiDescriptor>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct InteractionConfirmation {
68    pub title: String,
69    pub message: String,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub confirm_label: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub cancel_label: Option<String>,
74    pub severity: ConfirmationSeverity,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum ConfirmationSeverity {
80    Info,
81    Warning,
82    Danger,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Error)]
86pub enum InteractionValidationError {
87    #[error(
88        "provider-authored interactions cannot use direct built-in API transport (interaction `{interaction_id}`)"
89    )]
90    DirectBuiltInApiForbiddenForProvider { interaction_id: InteractionId },
91    #[error(
92        "interaction `{interaction_id}` timeout must be between {MIN_INTERACTION_TIMEOUT_SECONDS} and {MAX_INTERACTION_TIMEOUT_SECONDS} seconds"
93    )]
94    TimeoutOutOfRange { interaction_id: InteractionId },
95    #[error("workflow interaction `{interaction_id}` must declare at least one workflow step")]
96    WorkflowMissingSteps { interaction_id: InteractionId },
97    #[error("confirmable interaction `{interaction_id}` must include confirmation metadata")]
98    ConfirmableActionMissingConfirmation { interaction_id: InteractionId },
99    #[error("interaction `{interaction_id}` must include a non-empty human-authored label")]
100    BlankLabel { interaction_id: InteractionId },
101    #[error(
102        "workflow step `{step_id}` in interaction `{interaction_id}` must include a non-empty human-authored label"
103    )]
104    BlankWorkflowStepLabel {
105        interaction_id: InteractionId,
106        step_id: String,
107    },
108}
109
110impl InteractionDescriptor {
111    /// Validates provider-specific interaction contract rules.
112    ///
113    /// # Errors
114    /// Returns
115    /// [`InteractionValidationError::DirectBuiltInApiForbiddenForProvider`]
116    /// when a non-built-in provider uses `direct_built_in_api` transport.
117    /// Returns [`InteractionValidationError::TimeoutOutOfRange`] when
118    /// `timeout_seconds` falls outside
119    /// [`MIN_INTERACTION_TIMEOUT_SECONDS`]..=[`MAX_INTERACTION_TIMEOUT_SECONDS`].
120    /// Returns [`InteractionValidationError::WorkflowMissingSteps`] when
121    /// a workflow interaction declares no steps.
122    /// Returns
123    /// [`InteractionValidationError::ConfirmableActionMissingConfirmation`]
124    /// when a confirmable interaction omits confirmation metadata.
125    pub fn validate_for_provider(
126        &self,
127        provider_kind: ProviderKind,
128    ) -> Result<(), InteractionValidationError> {
129        if provider_kind != ProviderKind::BuiltIn
130            && matches!(
131                self.transport,
132                InteractionTransport::DirectBuiltInApi { .. }
133            )
134        {
135            return Err(
136                InteractionValidationError::DirectBuiltInApiForbiddenForProvider {
137                    interaction_id: self.interaction_id.clone(),
138                },
139            );
140        }
141
142        if let Some(timeout_seconds) = self.timeout_seconds
143            && !(MIN_INTERACTION_TIMEOUT_SECONDS..=MAX_INTERACTION_TIMEOUT_SECONDS)
144                .contains(&timeout_seconds)
145        {
146            return Err(InteractionValidationError::TimeoutOutOfRange {
147                interaction_id: self.interaction_id.clone(),
148            });
149        }
150
151        if self.kind == InteractionKind::Workflow && self.workflow_steps.is_empty() {
152            return Err(InteractionValidationError::WorkflowMissingSteps {
153                interaction_id: self.interaction_id.clone(),
154            });
155        }
156
157        if self.kind == InteractionKind::ConfirmableAction && self.confirmation.is_none() {
158            return Err(
159                InteractionValidationError::ConfirmableActionMissingConfirmation {
160                    interaction_id: self.interaction_id.clone(),
161                },
162            );
163        }
164
165        if self.label.trim().is_empty() {
166            return Err(InteractionValidationError::BlankLabel {
167                interaction_id: self.interaction_id.clone(),
168            });
169        }
170
171        for step in &self.workflow_steps {
172            if step.label.trim().is_empty() {
173                return Err(InteractionValidationError::BlankWorkflowStepLabel {
174                    interaction_id: self.interaction_id.clone(),
175                    step_id: step.step_id.clone(),
176                });
177            }
178        }
179
180        Ok(())
181    }
182}