1use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17use crate::principal::PrincipalSummary;
18use crate::typed_id::{
19 AgentId, AgentIdentityId, AgentVersionId, AppChannelId, AppId, HarnessId, PrincipalId,
20};
21
22#[cfg(feature = "openapi")]
23use utoipa::ToSchema;
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31#[cfg_attr(feature = "openapi", derive(ToSchema))]
32#[cfg_attr(feature = "openapi", schema(example = "published"))]
33#[serde(rename_all = "lowercase")]
34pub enum AppStatus {
35 Draft,
36 Published,
37 Archived,
38 Deleted,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
43#[cfg_attr(feature = "openapi", derive(ToSchema))]
44#[cfg_attr(feature = "openapi", schema(example = "pinned"))]
45#[serde(rename_all = "lowercase")]
46pub enum AgentVersionPolicy {
47 #[default]
49 Default,
50 Latest,
52 Pinned,
54}
55
56impl std::fmt::Display for AgentVersionPolicy {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 AgentVersionPolicy::Default => write!(f, "default"),
60 AgentVersionPolicy::Latest => write!(f, "latest"),
61 AgentVersionPolicy::Pinned => write!(f, "pinned"),
62 }
63 }
64}
65
66impl From<&str> for AgentVersionPolicy {
67 fn from(s: &str) -> Self {
68 match s {
69 "latest" => AgentVersionPolicy::Latest,
70 "pinned" => AgentVersionPolicy::Pinned,
71 _ => AgentVersionPolicy::Default,
72 }
73 }
74}
75
76impl std::fmt::Display for AppStatus {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 AppStatus::Draft => write!(f, "draft"),
80 AppStatus::Published => write!(f, "published"),
81 AppStatus::Archived => write!(f, "archived"),
82 AppStatus::Deleted => write!(f, "deleted"),
83 }
84 }
85}
86
87impl From<&str> for AppStatus {
88 fn from(s: &str) -> Self {
89 match s {
90 "published" => AppStatus::Published,
91 "archived" => AppStatus::Archived,
92 "deleted" => AppStatus::Deleted,
93 _ => AppStatus::Draft,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101#[cfg_attr(feature = "openapi", schema(example = "webhook"))]
102#[serde(rename_all = "lowercase")]
103pub enum ChannelType {
104 Slack,
105 #[serde(rename = "ag_ui")]
106 AgUi,
107 Schedule,
108 Webhook,
109 A2a,
111 Fcp,
115}
116
117impl std::fmt::Display for ChannelType {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match self {
120 ChannelType::Slack => write!(f, "slack"),
121 ChannelType::AgUi => write!(f, "ag_ui"),
122 ChannelType::Schedule => write!(f, "schedule"),
123 ChannelType::Webhook => write!(f, "webhook"),
124 ChannelType::A2a => write!(f, "a2a"),
125 ChannelType::Fcp => write!(f, "fcp"),
126 }
127 }
128}
129
130impl ChannelType {
131 pub fn from_str_opt(s: &str) -> Option<Self> {
132 match s {
133 "slack" => Some(ChannelType::Slack),
134 "ag_ui" => Some(ChannelType::AgUi),
135 "schedule" => Some(ChannelType::Schedule),
136 "webhook" => Some(ChannelType::Webhook),
137 "a2a" => Some(ChannelType::A2a),
138 "fcp" => Some(ChannelType::Fcp),
139 _ => None,
140 }
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
148#[cfg_attr(feature = "openapi", derive(ToSchema))]
149pub struct App {
150 #[serde(rename = "id")]
152 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "app_01933b5a000070008000000000000001"))]
153 pub public_id: AppId,
154 #[serde(skip, default = "Uuid::nil")]
156 pub internal_id: Uuid,
157 #[serde(skip, default)]
159 pub org_id: i64,
160 pub name: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub description: Option<String>,
165 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
167 pub harness_id: HarnessId,
168 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
170 pub agent_id: Option<AgentId>,
171 #[serde(default)]
173 pub agent_version_policy: AgentVersionPolicy,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
177 pub agent_version_id: Option<AgentVersionId>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "identity_01933b5a00007000800000000000001"))]
181 pub agent_identity_id: Option<AgentIdentityId>,
182 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
184 pub owner_principal_id: PrincipalId,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub resolved_owner_user_id: Option<Uuid>,
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub owner: Option<PrincipalSummary>,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub effective_owner: Option<PrincipalSummary>,
194 #[serde(default)]
196 pub channels: Vec<AppChannel>,
197 pub status: AppStatus,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub published_at: Option<DateTime<Utc>>,
202 pub created_at: DateTime<Utc>,
204 pub updated_at: DateTime<Utc>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub archived_at: Option<DateTime<Utc>>,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub deleted_at: Option<DateTime<Utc>>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(ToSchema))]
218pub struct AppChannel {
219 #[serde(rename = "id")]
221 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "appchan_01933b5a000070008000000000000001"))]
222 pub public_id: AppChannelId,
223 #[serde(skip, default = "Uuid::nil")]
225 pub internal_id: Uuid,
226 pub channel_type: ChannelType,
228 #[serde(default)]
230 pub channel_config: serde_json::Value,
231 #[serde(default = "default_true")]
233 pub enabled: bool,
234 pub created_at: DateTime<Utc>,
236 pub updated_at: DateTime<Utc>,
238}
239
240fn default_true() -> bool {
241 true
242}
243
244impl AppChannel {
245 pub fn slack_config(&self) -> Option<SlackChannelConfig> {
248 if self.channel_type != ChannelType::Slack {
249 return None;
250 }
251 serde_json::from_value(self.channel_config.clone()).ok()
252 }
253
254 pub fn ag_ui_config(&self) -> Option<AgUiChannelConfig> {
257 if self.channel_type != ChannelType::AgUi {
258 return None;
259 }
260 serde_json::from_value(self.channel_config.clone()).ok()
261 }
262
263 pub fn schedule_config(&self) -> Option<ScheduleChannelConfig> {
266 if self.channel_type != ChannelType::Schedule {
267 return None;
268 }
269 serde_json::from_value(self.channel_config.clone()).ok()
270 }
271
272 pub fn webhook_config(&self) -> Option<WebhookChannelConfig> {
275 if self.channel_type != ChannelType::Webhook {
276 return None;
277 }
278 serde_json::from_value(self.channel_config.clone()).ok()
279 }
280
281 pub fn fcp_config(&self) -> Option<FcpChannelConfig> {
284 if self.channel_type != ChannelType::Fcp {
285 return None;
286 }
287 serde_json::from_value(self.channel_config.clone()).ok()
288 }
289
290 pub fn a2a_config(&self) -> Option<A2aChannelConfig> {
293 if self.channel_type != ChannelType::A2a {
294 return None;
295 }
296 serde_json::from_value(self.channel_config.clone()).ok()
297 }
298}
299
300impl App {
301 pub fn slack_channel(&self) -> Option<&AppChannel> {
303 self.channels
304 .iter()
305 .find(|ch| ch.channel_type == ChannelType::Slack && ch.enabled)
306 }
307
308 pub fn ag_ui_channel(&self) -> Option<&AppChannel> {
310 self.channels
311 .iter()
312 .find(|ch| ch.channel_type == ChannelType::AgUi && ch.enabled)
313 }
314
315 pub fn schedule_channel(&self) -> Option<&AppChannel> {
317 self.channels
318 .iter()
319 .find(|ch| ch.channel_type == ChannelType::Schedule && ch.enabled)
320 }
321
322 pub fn webhook_channel(&self) -> Option<&AppChannel> {
324 self.channels
325 .iter()
326 .find(|ch| ch.channel_type == ChannelType::Webhook && ch.enabled)
327 }
328
329 pub fn fcp_channel(&self) -> Option<&AppChannel> {
331 self.channels
332 .iter()
333 .find(|ch| ch.channel_type == ChannelType::Fcp && ch.enabled)
334 }
335
336 pub fn a2a_channel(&self) -> Option<&AppChannel> {
338 self.channels
339 .iter()
340 .find(|ch| ch.channel_type == ChannelType::A2a && ch.enabled)
341 }
342
343 pub fn channel_by_id(&self, id: &AppChannelId) -> Option<&AppChannel> {
345 self.channels.iter().find(|ch| ch.public_id == *id)
346 }
347}
348
349#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
354#[cfg_attr(feature = "openapi", derive(ToSchema))]
355#[serde(rename_all = "snake_case")]
356pub enum SessionStrategy {
357 #[default]
359 PerThread,
360 PerChannel,
362 PerUser,
364}
365
366impl From<SessionStrategy> for crate::channel::SessionRoutingStrategy {
367 fn from(s: SessionStrategy) -> Self {
368 match s {
369 SessionStrategy::PerThread => Self::PerThread,
370 SessionStrategy::PerChannel => Self::PerChannel,
371 SessionStrategy::PerUser => Self::PerUser,
372 }
373 }
374}
375
376impl From<crate::channel::SessionRoutingStrategy> for SessionStrategy {
377 fn from(s: crate::channel::SessionRoutingStrategy) -> Self {
378 match s {
379 crate::channel::SessionRoutingStrategy::PerThread => Self::PerThread,
380 crate::channel::SessionRoutingStrategy::PerChannel => Self::PerChannel,
381 crate::channel::SessionRoutingStrategy::PerUser => Self::PerUser,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
391#[cfg_attr(feature = "openapi", derive(ToSchema))]
392#[serde(rename_all = "snake_case")]
393pub enum SlackReplyMode {
394 #[default]
396 AllMessages,
397 ReportProgressOnly,
399}
400
401impl From<SlackReplyMode> for crate::channel::ChannelReplyMode {
402 fn from(m: SlackReplyMode) -> Self {
403 match m {
404 SlackReplyMode::AllMessages => Self::AllMessages,
405 SlackReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
406 }
407 }
408}
409
410impl From<crate::channel::ChannelReplyMode> for SlackReplyMode {
411 fn from(m: crate::channel::ChannelReplyMode) -> Self {
412 match m {
413 crate::channel::ChannelReplyMode::AllMessages => Self::AllMessages,
414 crate::channel::ChannelReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
415 }
416 }
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
422#[cfg_attr(feature = "openapi", derive(ToSchema))]
423pub struct SlackChannelConfig {
424 pub signing_secret: String,
426 pub bot_token: String,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub channel_id: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none")]
433 pub team_id: Option<String>,
434 #[serde(default)]
436 pub session_strategy: SessionStrategy,
437 #[serde(default)]
439 pub reply_mode: SlackReplyMode,
440 #[serde(skip_serializing_if = "Option::is_none")]
442 pub webhook_verified_at: Option<DateTime<Utc>>,
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub first_message_received_at: Option<DateTime<Utc>>,
446}
447
448pub const DEFAULT_SESSION_EXPIRATION_SECONDS: u32 = 6 * 60 * 60;
450
451pub const DEFAULT_AG_UI_GENERIC_TOOL_TEXT: &str = "Working...";
453
454#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
456#[cfg_attr(feature = "openapi", derive(ToSchema))]
457#[serde(rename_all = "snake_case")]
458pub enum AgUiToolVisibility {
459 None,
461 #[default]
463 Generic,
464 Narrated,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
473#[cfg_attr(feature = "openapi", derive(ToSchema))]
474#[serde(rename_all = "snake_case")]
475pub enum AppEndpointAuthMode {
476 Anonymous,
477 SharedSecret,
478 ApiKey,
479 GoogleOidc,
480 Oidc,
481 OAuth2Introspection,
482 HttpBasic,
483 Mtls,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
488#[cfg_attr(feature = "openapi", derive(ToSchema))]
489#[serde(tag = "type", rename_all = "snake_case")]
490pub enum AppEndpointAuthProviderConfig {
491 GoogleOidc {
492 client_id: String,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
494 allowed_domains: Vec<String>,
495 },
496 Oidc {
497 issuer: String,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 jwks_url: Option<String>,
500 },
501 OAuth2Introspection {
502 introspection_url: String,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
504 client_id: Option<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 client_secret: Option<String>,
507 },
508 HttpBasic {
509 username: String,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 password: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 password_hash: Option<String>,
514 },
515 Mtls {
516 header_name: String,
517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
518 allowed_values: Vec<String>,
519 },
520}
521
522#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
524#[cfg_attr(feature = "openapi", derive(ToSchema))]
525pub struct AppEndpointAuthRequirements {
526 #[serde(default, skip_serializing_if = "Vec::is_empty")]
528 pub audiences: Vec<String>,
529 #[serde(default, skip_serializing_if = "Vec::is_empty")]
531 pub scopes: Vec<String>,
532 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
534 pub claims: serde_json::Map<String, serde_json::Value>,
535 #[serde(default, skip_serializing_if = "Vec::is_empty")]
537 pub subjects: Vec<String>,
538 #[serde(default, skip_serializing_if = "Vec::is_empty")]
540 pub groups: Vec<String>,
541 #[serde(default, skip_serializing_if = "Vec::is_empty")]
543 pub domains: Vec<String>,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
548#[cfg_attr(feature = "openapi", derive(ToSchema))]
549#[cfg_attr(
550 feature = "openapi",
551 schema(example = json!({"mode": "api_key", "requirements": {"audiences": ["everruns-api"], "scopes": ["app:invoke"]}}))
552)]
553pub struct AppEndpointAuthConfig {
554 pub mode: AppEndpointAuthMode,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub provider: Option<AppEndpointAuthProviderConfig>,
557 #[serde(default)]
558 pub requirements: AppEndpointAuthRequirements,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
565#[cfg_attr(feature = "openapi", derive(ToSchema))]
566pub struct AgUiChannelConfig {
567 #[serde(default = "default_true")]
570 pub anonymous: bool,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub token: Option<String>,
575 #[serde(default = "default_session_expiration_seconds")]
580 pub session_expiration_seconds: u32,
581 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub rate_limit_per_minute: Option<u32>,
587 #[serde(default)]
589 pub tool_visibility: AgUiToolVisibility,
590 #[serde(
592 default = "default_ag_ui_generic_tool_text",
593 skip_serializing_if = "is_default_ag_ui_generic_tool_text"
594 )]
595 pub generic_tool_text: String,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub auth: Option<AppEndpointAuthConfig>,
600}
601
602fn default_session_expiration_seconds() -> u32 {
603 DEFAULT_SESSION_EXPIRATION_SECONDS
604}
605
606fn default_ag_ui_generic_tool_text() -> String {
607 DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string()
608}
609
610fn is_default_ag_ui_generic_tool_text(value: &str) -> bool {
611 value == DEFAULT_AG_UI_GENERIC_TOOL_TEXT
612}
613
614pub const DEFAULT_FCP_HANDSHAKE: &str = "FCP endpoint.\n\nPOST plain text or `application/json` (`{\"message\": \"...\"}`) to\nthis URL to talk to the agent. Replies are returned as `text/markdown`.\n\nSession state, when supported, is carried by the `fcp_session` cookie.";
618
619pub const DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS: u32 = 120;
621
622#[derive(Debug, Clone, Serialize, Deserialize)]
638#[cfg_attr(feature = "openapi", derive(ToSchema))]
639pub struct FcpChannelConfig {
640 #[serde(default = "default_true")]
643 pub anonymous: bool,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub token: Option<String>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub handshake: Option<String>,
655 #[serde(default = "default_session_expiration_seconds")]
659 pub session_expiration_seconds: u32,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub rate_limit_per_minute: Option<u32>,
666 #[serde(default = "default_fcp_response_timeout_seconds")]
669 pub response_timeout_seconds: u32,
670}
671
672fn default_fcp_response_timeout_seconds() -> u32 {
673 DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
674}
675
676#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
678#[cfg_attr(feature = "openapi", derive(ToSchema))]
679#[cfg_attr(feature = "openapi", schema(example = "shared_session"))]
680#[serde(rename_all = "snake_case")]
681pub enum InvocationSessionMode {
682 #[default]
684 SharedSession,
685 SessionPerInvocation,
687}
688
689#[derive(Debug, Clone, Serialize, Deserialize)]
694#[cfg_attr(feature = "openapi", derive(ToSchema))]
695pub struct ScheduleChannelConfig {
696 pub cron_expression: String,
698 #[serde(default = "default_timezone")]
700 pub timezone: String,
701 #[serde(default)]
703 pub session_mode: InvocationSessionMode,
704 pub message: String,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
713#[cfg_attr(feature = "openapi", derive(ToSchema))]
714pub struct WebhookChannelConfig {
715 pub token: String,
717 #[serde(default)]
719 pub session_mode: InvocationSessionMode,
720 pub message: String,
722}
723
724fn default_timezone() -> String {
725 "UTC".to_string()
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
738#[cfg_attr(feature = "openapi", derive(ToSchema))]
739pub struct A2aChannelConfig {
740 pub api_key_hash: String,
742 pub api_key_prefix: String,
744 #[serde(default)]
746 pub session_mode: InvocationSessionMode,
747 pub message: String,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub agent_card_name: Option<String>,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub agent_card_description: Option<String>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub rate_limit_per_minute: Option<u32>,
762 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub auth: Option<AppEndpointAuthConfig>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub signing_secret: Option<String>,
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn test_app_status_display() {
786 assert_eq!(AppStatus::Draft.to_string(), "draft");
787 assert_eq!(AppStatus::Published.to_string(), "published");
788 assert_eq!(AppStatus::Archived.to_string(), "archived");
789 assert_eq!(AppStatus::Deleted.to_string(), "deleted");
790 }
791
792 #[test]
793 fn test_app_status_from_str() {
794 assert_eq!(AppStatus::from("draft"), AppStatus::Draft);
795 assert_eq!(AppStatus::from("published"), AppStatus::Published);
796 assert_eq!(AppStatus::from("archived"), AppStatus::Archived);
797 assert_eq!(AppStatus::from("deleted"), AppStatus::Deleted);
798 assert_eq!(AppStatus::from("unknown"), AppStatus::Draft);
799 assert_eq!(AppStatus::from(""), AppStatus::Draft);
800 }
801
802 #[test]
803 fn test_app_status_serde_roundtrip() {
804 let json = serde_json::to_string(&AppStatus::Published).unwrap();
805 assert_eq!(json, r#""published""#);
806 let parsed: AppStatus = serde_json::from_str(&json).unwrap();
807 assert_eq!(parsed, AppStatus::Published);
808 }
809
810 #[test]
811 fn test_channel_type_display() {
812 assert_eq!(ChannelType::Slack.to_string(), "slack");
813 assert_eq!(ChannelType::AgUi.to_string(), "ag_ui");
814 assert_eq!(ChannelType::Schedule.to_string(), "schedule");
815 assert_eq!(ChannelType::Webhook.to_string(), "webhook");
816 assert_eq!(ChannelType::A2a.to_string(), "a2a");
817 assert_eq!(ChannelType::Fcp.to_string(), "fcp");
818 }
819
820 #[test]
821 fn test_channel_type_from_str_opt() {
822 assert_eq!(ChannelType::from_str_opt("slack"), Some(ChannelType::Slack));
823 assert_eq!(ChannelType::from_str_opt("ag_ui"), Some(ChannelType::AgUi));
824 assert_eq!(
825 ChannelType::from_str_opt("schedule"),
826 Some(ChannelType::Schedule)
827 );
828 assert_eq!(
829 ChannelType::from_str_opt("webhook"),
830 Some(ChannelType::Webhook)
831 );
832 assert_eq!(ChannelType::from_str_opt("a2a"), Some(ChannelType::A2a));
833 assert_eq!(ChannelType::from_str_opt("fcp"), Some(ChannelType::Fcp));
834 assert_eq!(ChannelType::from_str_opt("unknown"), None);
835 assert_eq!(ChannelType::from_str_opt(""), None);
836 }
837
838 #[test]
839 fn test_channel_type_serde_roundtrip() {
840 let json = serde_json::to_string(&ChannelType::Slack).unwrap();
841 assert_eq!(json, r#""slack""#);
842 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
843 assert_eq!(parsed, ChannelType::Slack);
844
845 let json = serde_json::to_string(&ChannelType::AgUi).unwrap();
846 assert_eq!(json, r#""ag_ui""#);
847 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
848 assert_eq!(parsed, ChannelType::AgUi);
849
850 let json = serde_json::to_string(&ChannelType::Schedule).unwrap();
851 assert_eq!(json, r#""schedule""#);
852 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
853 assert_eq!(parsed, ChannelType::Schedule);
854
855 let json = serde_json::to_string(&ChannelType::Webhook).unwrap();
856 assert_eq!(json, r#""webhook""#);
857 let parsed: ChannelType = serde_json::from_str(&json).unwrap();
858 assert_eq!(parsed, ChannelType::Webhook);
859 }
860
861 #[test]
862 fn test_session_strategy_default() {
863 assert_eq!(SessionStrategy::default(), SessionStrategy::PerThread);
864 }
865
866 #[test]
867 fn test_session_strategy_serde() {
868 let json = serde_json::to_string(&SessionStrategy::PerChannel).unwrap();
869 assert_eq!(json, r#""per_channel""#);
870 let parsed: SessionStrategy = serde_json::from_str(&json).unwrap();
871 assert_eq!(parsed, SessionStrategy::PerChannel);
872
873 let json = serde_json::to_string(&SessionStrategy::PerUser).unwrap();
874 assert_eq!(json, r#""per_user""#);
875 }
876
877 #[test]
878 fn test_slack_channel_config_full() {
879 let json = r#"{
880 "signing_secret": "sec123",
881 "bot_token": "xoxb-tok",
882 "channel_id": "C123",
883 "team_id": "T123",
884 "session_strategy": "per_channel",
885 "reply_mode": "report_progress_only"
886 }"#;
887 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
888 assert_eq!(config.signing_secret, "sec123");
889 assert_eq!(config.bot_token, "xoxb-tok");
890 assert_eq!(config.channel_id.as_deref(), Some("C123"));
891 assert_eq!(config.team_id.as_deref(), Some("T123"));
892 assert_eq!(config.session_strategy, SessionStrategy::PerChannel);
893 assert_eq!(config.reply_mode, SlackReplyMode::ReportProgressOnly);
894 }
895
896 #[test]
897 fn test_slack_channel_config_minimal() {
898 let json = r#"{"signing_secret": "s", "bot_token": "t"}"#;
899 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
900 assert!(config.channel_id.is_none());
901 assert!(config.team_id.is_none());
902 assert_eq!(config.session_strategy, SessionStrategy::PerThread);
903 assert_eq!(config.reply_mode, SlackReplyMode::AllMessages);
904 assert!(config.webhook_verified_at.is_none());
905 assert!(config.first_message_received_at.is_none());
906 }
907
908 #[test]
909 fn test_slack_channel_config_with_verification_timestamps() {
910 let json = r#"{
911 "signing_secret": "s",
912 "bot_token": "t",
913 "webhook_verified_at": "2025-01-01T00:00:00Z",
914 "first_message_received_at": "2025-01-01T01:00:00Z"
915 }"#;
916 let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
917 assert!(config.webhook_verified_at.is_some());
918 assert!(config.first_message_received_at.is_some());
919
920 let serialized = serde_json::to_value(&config).unwrap();
922 assert!(serialized.get("webhook_verified_at").is_some());
923 assert!(serialized.get("first_message_received_at").is_some());
924 }
925
926 #[test]
927 fn test_slack_channel_config_timestamps_skipped_when_none() {
928 let config = SlackChannelConfig {
929 signing_secret: "s".into(),
930 bot_token: "t".into(),
931 channel_id: None,
932 team_id: None,
933 session_strategy: SessionStrategy::PerThread,
934 reply_mode: SlackReplyMode::AllMessages,
935 webhook_verified_at: None,
936 first_message_received_at: None,
937 };
938 let json = serde_json::to_value(&config).unwrap();
939 assert!(json.get("webhook_verified_at").is_none());
940 assert!(json.get("first_message_received_at").is_none());
941 }
942
943 #[test]
944 fn test_slack_reply_mode_serde_roundtrip() {
945 let json = serde_json::to_string(&SlackReplyMode::ReportProgressOnly).unwrap();
946 assert_eq!(json, r#""report_progress_only""#);
947 let parsed: SlackReplyMode = serde_json::from_str(&json).unwrap();
948 assert_eq!(parsed, SlackReplyMode::ReportProgressOnly);
949 }
950
951 #[test]
952 fn test_slack_channel_config_missing_required_field() {
953 let json = r#"{"signing_secret": "s"}"#;
954 assert!(serde_json::from_str::<SlackChannelConfig>(json).is_err());
955 }
956
957 #[test]
958 fn test_ag_ui_channel_config_defaults_to_anonymous() {
959 let config: AgUiChannelConfig = serde_json::from_str("{}").unwrap();
960 assert!(config.anonymous);
961 assert_eq!(
962 config.session_expiration_seconds,
963 DEFAULT_SESSION_EXPIRATION_SECONDS
964 );
965 assert!(config.rate_limit_per_minute.is_none());
966 assert!(config.token.is_none());
967 assert!(config.auth.is_none());
968 assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
969 assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
970 }
971
972 #[test]
973 fn test_ag_ui_channel_config_roundtrip() {
974 let config = AgUiChannelConfig {
975 anonymous: true,
976 token: Some("agui-token".to_string()),
977 session_expiration_seconds: 3600,
978 rate_limit_per_minute: Some(120),
979 tool_visibility: AgUiToolVisibility::None,
980 generic_tool_text: "Please wait".to_string(),
981 auth: None,
982 };
983 let json = serde_json::to_string(&config).unwrap();
984 let parsed: AgUiChannelConfig = serde_json::from_str(&json).unwrap();
985 assert!(parsed.anonymous);
986 assert_eq!(parsed.token.as_deref(), Some("agui-token"));
987 assert_eq!(parsed.session_expiration_seconds, 3600);
988 assert_eq!(parsed.rate_limit_per_minute, Some(120));
989 assert_eq!(parsed.tool_visibility, AgUiToolVisibility::None);
990 assert_eq!(parsed.generic_tool_text, "Please wait");
991 }
992
993 #[test]
994 fn test_ag_ui_channel_config_zero_disables_expiration() {
995 let config: AgUiChannelConfig =
996 serde_json::from_str(r#"{"session_expiration_seconds": 0}"#).unwrap();
997 assert_eq!(config.session_expiration_seconds, 0);
998 }
999
1000 #[test]
1001 fn test_ag_ui_channel_config_omits_rate_limit_when_unset() {
1002 let config = AgUiChannelConfig {
1003 anonymous: true,
1004 token: None,
1005 session_expiration_seconds: DEFAULT_SESSION_EXPIRATION_SECONDS,
1006 rate_limit_per_minute: None,
1007 tool_visibility: AgUiToolVisibility::Generic,
1008 generic_tool_text: DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string(),
1009 auth: None,
1010 };
1011 let json = serde_json::to_value(&config).unwrap();
1012 assert!(json.get("rate_limit_per_minute").is_none());
1013 assert!(json.get("generic_tool_text").is_none());
1014 }
1015
1016 #[test]
1017 fn test_invocation_session_mode_defaults_to_shared_session() {
1018 assert_eq!(
1019 InvocationSessionMode::default(),
1020 InvocationSessionMode::SharedSession
1021 );
1022 }
1023
1024 #[test]
1025 fn test_schedule_channel_config_defaults() {
1026 let config: ScheduleChannelConfig =
1027 serde_json::from_str(r#"{"cron_expression":"0 * * * * * *","message":"Run checks"}"#)
1028 .unwrap();
1029 assert_eq!(config.timezone, "UTC");
1030 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1031 }
1032
1033 #[test]
1034 fn test_webhook_channel_config_defaults() {
1035 let config: WebhookChannelConfig =
1036 serde_json::from_str(r#"{"token":"top-secret","message":"{{payload.action}}"}"#)
1037 .unwrap();
1038 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1039 }
1040
1041 fn test_app(channels: Vec<AppChannel>) -> App {
1042 App {
1043 public_id: AppId::from_uuid(Uuid::nil()),
1044 internal_id: Uuid::nil(),
1045 org_id: 1,
1046 name: "test".into(),
1047 description: None,
1048 harness_id: HarnessId::from_uuid(Uuid::nil()),
1049 agent_id: Some(AgentId::from_uuid(Uuid::nil())),
1050 agent_version_policy: AgentVersionPolicy::Default,
1051 agent_version_id: None,
1052 agent_identity_id: None,
1053 owner_principal_id: PrincipalId::from_seed(1),
1054 resolved_owner_user_id: None,
1055 owner: None,
1056 effective_owner: None,
1057 channels,
1058 status: AppStatus::Draft,
1059 published_at: None,
1060 created_at: Utc::now(),
1061 updated_at: Utc::now(),
1062 archived_at: None,
1063 deleted_at: None,
1064 }
1065 }
1066
1067 fn test_channel(channel_type: ChannelType, config: serde_json::Value) -> AppChannel {
1068 AppChannel {
1069 public_id: AppChannelId::from_uuid(Uuid::nil()),
1070 internal_id: Uuid::nil(),
1071 channel_type,
1072 channel_config: config,
1073 enabled: true,
1074 created_at: Utc::now(),
1075 updated_at: Utc::now(),
1076 }
1077 }
1078
1079 #[test]
1080 fn test_app_channel_slack_config_valid() {
1081 let ch = test_channel(
1082 ChannelType::Slack,
1083 serde_json::json!({"signing_secret": "sec", "bot_token": "tok"}),
1084 );
1085 let config = ch.slack_config().unwrap();
1086 assert_eq!(config.signing_secret, "sec");
1087 }
1088
1089 #[test]
1090 fn test_app_channel_slack_config_invalid_json() {
1091 let ch = test_channel(ChannelType::Slack, serde_json::json!({"bad": "data"}));
1092 assert!(ch.slack_config().is_none());
1093 }
1094
1095 #[test]
1096 fn test_app_slack_channel_lookup() {
1097 let ch = test_channel(
1098 ChannelType::Slack,
1099 serde_json::json!({"signing_secret": "s", "bot_token": "t"}),
1100 );
1101 let app = test_app(vec![ch]);
1102 assert!(app.slack_channel().is_some());
1103 }
1104
1105 #[test]
1106 fn test_app_slack_channel_none_when_empty() {
1107 let app = test_app(vec![]);
1108 assert!(app.slack_channel().is_none());
1109 }
1110
1111 #[test]
1112 fn test_app_channel_ag_ui_config_valid() {
1113 let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1114 let config = ch.ag_ui_config().unwrap();
1115 assert!(config.anonymous);
1116 }
1117
1118 #[test]
1119 fn test_app_ag_ui_channel_lookup() {
1120 let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1121 let app = test_app(vec![ch]);
1122 assert!(app.ag_ui_channel().is_some());
1123 }
1124
1125 #[test]
1126 fn test_app_channel_fcp_config_defaults() {
1127 let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1128 let config = ch.fcp_config().unwrap();
1129 assert!(config.anonymous);
1130 assert!(config.token.is_none());
1131 assert!(config.handshake.is_none());
1132 assert_eq!(
1133 config.session_expiration_seconds,
1134 DEFAULT_SESSION_EXPIRATION_SECONDS
1135 );
1136 assert_eq!(
1137 config.response_timeout_seconds,
1138 DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
1139 );
1140 }
1141
1142 #[test]
1143 fn test_app_fcp_channel_lookup() {
1144 let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1145 let app = test_app(vec![ch]);
1146 assert!(app.fcp_channel().is_some());
1147 }
1148
1149 #[test]
1150 fn test_app_channel_schedule_config_valid() {
1151 let ch = test_channel(
1152 ChannelType::Schedule,
1153 serde_json::json!({
1154 "cron_expression": "0 * * * * * *",
1155 "message": "Run checks"
1156 }),
1157 );
1158 let config = ch.schedule_config().unwrap();
1159 assert_eq!(config.message, "Run checks");
1160 }
1161
1162 #[test]
1163 fn test_app_schedule_channel_lookup() {
1164 let ch = test_channel(
1165 ChannelType::Schedule,
1166 serde_json::json!({
1167 "cron_expression": "0 * * * * * *",
1168 "message": "Run checks"
1169 }),
1170 );
1171 let app = test_app(vec![ch]);
1172 assert!(app.schedule_channel().is_some());
1173 }
1174
1175 #[test]
1176 fn test_app_channel_webhook_config_valid() {
1177 let ch = test_channel(
1178 ChannelType::Webhook,
1179 serde_json::json!({
1180 "token": "secret",
1181 "message": "{{payload.ref}}"
1182 }),
1183 );
1184 let config = ch.webhook_config().unwrap();
1185 assert_eq!(config.token, "secret");
1186 }
1187
1188 #[test]
1189 fn test_app_webhook_channel_lookup() {
1190 let ch = test_channel(
1191 ChannelType::Webhook,
1192 serde_json::json!({
1193 "token": "secret",
1194 "message": "{{payload.ref}}"
1195 }),
1196 );
1197 let app = test_app(vec![ch]);
1198 assert!(app.webhook_channel().is_some());
1199 }
1200
1201 #[test]
1202 fn test_a2a_channel_config_defaults() {
1203 let config: A2aChannelConfig = serde_json::from_str(
1204 r#"{"api_key_hash":"abc","api_key_prefix":"evra2a_abc1...","message":"{{a2a.text}}"}"#,
1205 )
1206 .unwrap();
1207 assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1208 assert!(config.agent_card_name.is_none());
1209 assert!(config.agent_card_description.is_none());
1210 assert!(config.rate_limit_per_minute.is_none());
1211 assert!(config.auth.is_none());
1212 assert!(config.signing_secret.is_none());
1213 }
1214
1215 #[test]
1216 fn test_a2a_channel_config_roundtrip() {
1217 let config = A2aChannelConfig {
1218 api_key_hash: "deadbeef".into(),
1219 api_key_prefix: "evra2a_dead...".into(),
1220 session_mode: InvocationSessionMode::SessionPerInvocation,
1221 message: "{{a2a.text}}".into(),
1222 agent_card_name: Some("Inbox triage".into()),
1223 agent_card_description: Some("Triages github events".into()),
1224 rate_limit_per_minute: Some(120),
1225 auth: None,
1226 signing_secret: None,
1227 };
1228 let json = serde_json::to_string(&config).unwrap();
1229 let parsed: A2aChannelConfig = serde_json::from_str(&json).unwrap();
1230 assert_eq!(parsed.api_key_hash, "deadbeef");
1231 assert_eq!(
1232 parsed.session_mode,
1233 InvocationSessionMode::SessionPerInvocation
1234 );
1235 assert_eq!(parsed.agent_card_name.as_deref(), Some("Inbox triage"));
1236 assert_eq!(parsed.rate_limit_per_minute, Some(120));
1237 }
1238
1239 #[test]
1240 fn test_a2a_channel_config_omits_optional_fields() {
1241 let config = A2aChannelConfig {
1242 api_key_hash: "h".into(),
1243 api_key_prefix: "evra2a_h...".into(),
1244 session_mode: InvocationSessionMode::SharedSession,
1245 message: "m".into(),
1246 agent_card_name: None,
1247 agent_card_description: None,
1248 rate_limit_per_minute: None,
1249 auth: None,
1250 signing_secret: None,
1251 };
1252 let json = serde_json::to_value(&config).unwrap();
1253 assert!(json.get("agent_card_name").is_none());
1254 assert!(json.get("agent_card_description").is_none());
1255 assert!(json.get("rate_limit_per_minute").is_none());
1256 assert!(json.get("signing_secret").is_none());
1257 }
1258
1259 #[test]
1260 fn test_app_channel_a2a_config_valid() {
1261 let ch = test_channel(
1262 ChannelType::A2a,
1263 serde_json::json!({
1264 "api_key_hash": "h",
1265 "api_key_prefix": "evra2a_h...",
1266 "message": "{{a2a.text}}"
1267 }),
1268 );
1269 let config = ch.a2a_config().unwrap();
1270 assert_eq!(config.api_key_prefix, "evra2a_h...");
1271 }
1272
1273 #[test]
1274 fn test_app_a2a_channel_lookup() {
1275 let ch = test_channel(
1276 ChannelType::A2a,
1277 serde_json::json!({
1278 "api_key_hash": "h",
1279 "api_key_prefix": "evra2a_h...",
1280 "message": "{{a2a.text}}"
1281 }),
1282 );
1283 let app = test_app(vec![ch]);
1284 assert!(app.a2a_channel().is_some());
1285 }
1286
1287 #[test]
1288 fn test_app_serde_skips_internal_fields() {
1289 let app = test_app(vec![]);
1290 let json = serde_json::to_value(&app).unwrap();
1291 assert!(json.get("id").is_some()); assert!(json.get("internal_id").is_none()); assert!(json.get("org_id").is_none()); assert!(json.get("published_at").is_none()); }
1296}