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 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}