Skip to main content

routa_core/models/
kanban.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::models::task::TaskStatus;
5
6/// Transport protocol for Kanban automation
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
8#[serde(rename_all = "lowercase")]
9pub enum KanbanTransport {
10    /// Agent Chat Protocol (default)
11    #[default]
12    Acp,
13    /// Agent-to-Agent protocol
14    A2a,
15}
16
17/// Automation configuration for a Kanban column.
18/// When a card is moved to this column, the automation can trigger an agent session.
19#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
20#[serde(rename_all = "camelCase")]
21pub struct KanbanAutomationStep {
22    pub id: String,
23    /// Transport protocol for this automation step
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub transport: Option<KanbanTransport>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub provider_id: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub role: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub specialist_id: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub specialist_name: Option<String>,
34    /// A2A-specific: URL of the agent card to invoke
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub agent_card_url: Option<String>,
37    /// A2A-specific: Skill ID to invoke on the agent
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub skill_id: Option<String>,
40    /// A2A-specific: Auth configuration ID for the request
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub auth_config_id: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
46#[serde(rename_all = "camelCase")]
47pub struct KanbanColumnAutomation {
48    /// Whether automation is enabled for this column
49    #[serde(default)]
50    pub enabled: bool,
51    /// Ordered automation steps to run within the same lane
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub steps: Option<Vec<KanbanAutomationStep>>,
54    /// Provider ID to use for the automation
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub provider_id: Option<String>,
57    /// Role for the agent (CRAFTER, ROUTA, GATE, DEVELOPER)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub role: Option<String>,
60    /// Specialist ID to use
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub specialist_id: Option<String>,
63    /// Specialist name (for display)
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub specialist_name: Option<String>,
66    /// When to trigger: entry, exit, or both
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub transition_type: Option<String>,
69    /// Required artifacts before advancing
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub required_artifacts: Option<Vec<String>>,
72    /// Automatically advance card on session success
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub auto_advance_on_success: Option<bool>,
75}
76
77impl KanbanColumnAutomation {
78    pub fn primary_step(&self) -> Option<KanbanAutomationStep> {
79        if !self.enabled {
80            return None;
81        }
82
83        if let Some(step) = self.steps.as_ref().and_then(|steps| {
84            steps.iter().find(|step| {
85                matches!(step.transport, Some(KanbanTransport::A2a))
86                    || step.provider_id.is_some()
87                    || step.role.is_some()
88                    || step.specialist_id.is_some()
89                    || step.specialist_name.is_some()
90                    || step.agent_card_url.is_some()
91                    || step.skill_id.is_some()
92                    || step.auth_config_id.is_some()
93            })
94        }) {
95            return Some(step.clone());
96        }
97
98        Some(KanbanAutomationStep {
99            id: "step-1".to_string(),
100            transport: None, // defaults to Acp
101            provider_id: self.provider_id.clone(),
102            role: self.role.clone(),
103            specialist_id: self.specialist_id.clone(),
104            specialist_name: self.specialist_name.clone(),
105            agent_card_url: None,
106            skill_id: None,
107            auth_config_id: None,
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::{KanbanAutomationStep, KanbanColumnAutomation, KanbanTransport};
115
116    #[test]
117    fn primary_step_keeps_a2a_only_steps() {
118        let automation = KanbanColumnAutomation {
119            enabled: true,
120            steps: Some(vec![KanbanAutomationStep {
121                id: "step-a2a".to_string(),
122                transport: Some(KanbanTransport::A2a),
123                provider_id: None,
124                role: None,
125                specialist_id: None,
126                specialist_name: None,
127                agent_card_url: Some("https://example.com/agent-card.json".to_string()),
128                skill_id: Some("skill-1".to_string()),
129                auth_config_id: Some("auth-1".to_string()),
130            }]),
131            ..Default::default()
132        };
133
134        let step = automation
135            .primary_step()
136            .expect("a2a step should be preserved");
137        assert_eq!(step.id, "step-a2a");
138        assert_eq!(step.transport, Some(KanbanTransport::A2a));
139        assert_eq!(
140            step.agent_card_url.as_deref(),
141            Some("https://example.com/agent-card.json")
142        );
143        assert_eq!(step.skill_id.as_deref(), Some("skill-1"));
144        assert_eq!(step.auth_config_id.as_deref(), Some("auth-1"));
145    }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct KanbanColumn {
151    pub id: String,
152    pub name: String,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub color: Option<String>,
155    pub position: i64,
156    pub stage: String,
157    /// Whether the column is visible on the board
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub visible: Option<bool>,
160    /// Column visual width configuration
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub width: Option<String>,
163    /// Automation configuration for this column
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub automation: Option<KanbanColumnAutomation>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct KanbanBoard {
171    pub id: String,
172    pub workspace_id: String,
173    pub name: String,
174    pub is_default: bool,
175    pub columns: Vec<KanbanColumn>,
176    pub created_at: DateTime<Utc>,
177    pub updated_at: DateTime<Utc>,
178}
179
180pub fn default_kanban_columns() -> Vec<KanbanColumn> {
181    vec![
182        KanbanColumn {
183            id: "backlog".to_string(),
184            name: "Backlog".to_string(),
185            color: Some("slate".to_string()),
186            position: 0,
187            stage: "backlog".to_string(),
188            automation: None,
189            visible: Some(true),
190            width: None,
191        },
192        KanbanColumn {
193            id: "todo".to_string(),
194            name: "Todo".to_string(),
195            color: Some("sky".to_string()),
196            position: 1,
197            stage: "todo".to_string(),
198            automation: None,
199            visible: Some(true),
200            width: None,
201        },
202        KanbanColumn {
203            id: "dev".to_string(),
204            name: "Dev".to_string(),
205            color: Some("amber".to_string()),
206            position: 2,
207            stage: "dev".to_string(),
208            automation: None,
209            visible: Some(true),
210            width: None,
211        },
212        KanbanColumn {
213            id: "review".to_string(),
214            name: "Review".to_string(),
215            color: Some("slate".to_string()),
216            position: 3,
217            stage: "review".to_string(),
218            automation: None,
219            visible: Some(true),
220            width: None,
221        },
222        KanbanColumn {
223            id: "done".to_string(),
224            name: "Done".to_string(),
225            color: Some("emerald".to_string()),
226            position: 4,
227            stage: "done".to_string(),
228            automation: None,
229            visible: Some(true),
230            width: None,
231        },
232        KanbanColumn {
233            id: "blocked".to_string(),
234            name: "Blocked".to_string(),
235            color: Some("rose".to_string()),
236            position: 5,
237            stage: "blocked".to_string(),
238            automation: None,
239            visible: Some(true),
240            width: None,
241        },
242    ]
243}
244
245pub fn default_kanban_board(workspace_id: String) -> KanbanBoard {
246    let now = Utc::now();
247
248    KanbanBoard {
249        id: uuid::Uuid::new_v4().to_string(),
250        workspace_id,
251        name: "Board".to_string(),
252        is_default: true,
253        columns: default_kanban_columns(),
254        created_at: now,
255        updated_at: now,
256    }
257}
258
259pub fn column_id_to_task_status(column_id: Option<&str>) -> TaskStatus {
260    match column_id.unwrap_or("backlog").to_ascii_lowercase().as_str() {
261        "dev" => TaskStatus::InProgress,
262        "review" => TaskStatus::ReviewRequired,
263        "blocked" => TaskStatus::Blocked,
264        "done" => TaskStatus::Completed,
265        _ => TaskStatus::Pending,
266    }
267}
268
269pub fn task_status_to_column_id(status: &TaskStatus) -> &'static str {
270    match status {
271        TaskStatus::InProgress => "dev",
272        TaskStatus::ReviewRequired => "review",
273        TaskStatus::Blocked => "blocked",
274        TaskStatus::Completed => "done",
275        _ => "backlog",
276    }
277}