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    /// Required task fields before advancing
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub required_task_fields: Option<Vec<String>>,
75    /// Automatically advance card on session success
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub auto_advance_on_success: Option<bool>,
78}
79
80impl KanbanColumnAutomation {
81    pub fn primary_step(&self) -> Option<KanbanAutomationStep> {
82        if !self.enabled {
83            return None;
84        }
85
86        if let Some(step) = self.steps.as_ref().and_then(|steps| {
87            steps.iter().find(|step| {
88                matches!(step.transport, Some(KanbanTransport::A2a))
89                    || step.provider_id.is_some()
90                    || step.role.is_some()
91                    || step.specialist_id.is_some()
92                    || step.specialist_name.is_some()
93                    || step.agent_card_url.is_some()
94                    || step.skill_id.is_some()
95                    || step.auth_config_id.is_some()
96            })
97        }) {
98            return Some(step.clone());
99        }
100
101        Some(KanbanAutomationStep {
102            id: "step-1".to_string(),
103            transport: None, // defaults to Acp
104            provider_id: self.provider_id.clone(),
105            role: self.role.clone(),
106            specialist_id: self.specialist_id.clone(),
107            specialist_name: self.specialist_name.clone(),
108            agent_card_url: None,
109            skill_id: None,
110            auth_config_id: None,
111        })
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::{KanbanAutomationStep, KanbanColumnAutomation, KanbanTransport};
118
119    #[test]
120    fn primary_step_keeps_a2a_only_steps() {
121        let automation = KanbanColumnAutomation {
122            enabled: true,
123            steps: Some(vec![KanbanAutomationStep {
124                id: "step-a2a".to_string(),
125                transport: Some(KanbanTransport::A2a),
126                provider_id: None,
127                role: None,
128                specialist_id: None,
129                specialist_name: None,
130                agent_card_url: Some("https://example.com/agent-card.json".to_string()),
131                skill_id: Some("skill-1".to_string()),
132                auth_config_id: Some("auth-1".to_string()),
133            }]),
134            ..Default::default()
135        };
136
137        let step = automation
138            .primary_step()
139            .expect("a2a step should be preserved");
140        assert_eq!(step.id, "step-a2a");
141        assert_eq!(step.transport, Some(KanbanTransport::A2a));
142        assert_eq!(
143            step.agent_card_url.as_deref(),
144            Some("https://example.com/agent-card.json")
145        );
146        assert_eq!(step.skill_id.as_deref(), Some("skill-1"));
147        assert_eq!(step.auth_config_id.as_deref(), Some("auth-1"));
148    }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152#[serde(rename_all = "camelCase")]
153pub struct KanbanColumn {
154    pub id: String,
155    pub name: String,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub color: Option<String>,
158    pub position: i64,
159    pub stage: String,
160    /// Whether the column is visible on the board
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub visible: Option<bool>,
163    /// Column visual width configuration
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub width: Option<String>,
166    /// Automation configuration for this column
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub automation: Option<KanbanColumnAutomation>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct KanbanBoard {
174    pub id: String,
175    pub workspace_id: String,
176    pub name: String,
177    pub is_default: bool,
178    pub columns: Vec<KanbanColumn>,
179    pub created_at: DateTime<Utc>,
180    pub updated_at: DateTime<Utc>,
181}
182
183pub fn default_kanban_columns() -> Vec<KanbanColumn> {
184    vec![
185        KanbanColumn {
186            id: "backlog".to_string(),
187            name: "Backlog".to_string(),
188            color: Some("slate".to_string()),
189            position: 0,
190            stage: "backlog".to_string(),
191            automation: None,
192            visible: Some(true),
193            width: None,
194        },
195        KanbanColumn {
196            id: "todo".to_string(),
197            name: "Todo".to_string(),
198            color: Some("sky".to_string()),
199            position: 1,
200            stage: "todo".to_string(),
201            automation: None,
202            visible: Some(true),
203            width: None,
204        },
205        KanbanColumn {
206            id: "dev".to_string(),
207            name: "Dev".to_string(),
208            color: Some("amber".to_string()),
209            position: 2,
210            stage: "dev".to_string(),
211            automation: None,
212            visible: Some(true),
213            width: None,
214        },
215        KanbanColumn {
216            id: "review".to_string(),
217            name: "Review".to_string(),
218            color: Some("slate".to_string()),
219            position: 3,
220            stage: "review".to_string(),
221            automation: None,
222            visible: Some(true),
223            width: None,
224        },
225        KanbanColumn {
226            id: "done".to_string(),
227            name: "Done".to_string(),
228            color: Some("emerald".to_string()),
229            position: 4,
230            stage: "done".to_string(),
231            automation: None,
232            visible: Some(true),
233            width: None,
234        },
235        KanbanColumn {
236            id: "blocked".to_string(),
237            name: "Blocked".to_string(),
238            color: Some("rose".to_string()),
239            position: 5,
240            stage: "blocked".to_string(),
241            automation: None,
242            visible: Some(true),
243            width: None,
244        },
245    ]
246}
247
248fn normalize_kanban_automation_step_ids(
249    mut steps: Vec<KanbanAutomationStep>,
250) -> Vec<KanbanAutomationStep> {
251    for (index, step) in steps.iter_mut().enumerate() {
252        if step.id.trim().is_empty() {
253            step.id = format!("step-{}", index + 1);
254        }
255    }
256
257    steps
258        .into_iter()
259        .filter(|step| {
260            matches!(step.transport, Some(KanbanTransport::A2a))
261                || step.provider_id.is_some()
262                || step.role.is_some()
263                || step.specialist_id.is_some()
264                || step.specialist_name.is_some()
265                || step.agent_card_url.is_some()
266                || step.skill_id.is_some()
267                || step.auth_config_id.is_some()
268        })
269        .collect()
270}
271
272fn normalize_kanban_automation(mut automation: KanbanColumnAutomation) -> KanbanColumnAutomation {
273    let mut steps =
274        normalize_kanban_automation_step_ids(automation.steps.clone().unwrap_or_default());
275    if automation.enabled && steps.is_empty() {
276        steps = vec![KanbanAutomationStep {
277            id: "step-1".to_string(),
278            transport: None,
279            provider_id: automation.provider_id.clone(),
280            role: automation.role.clone(),
281            specialist_id: automation.specialist_id.clone(),
282            specialist_name: automation.specialist_name.clone(),
283            agent_card_url: None,
284            skill_id: None,
285            auth_config_id: None,
286        }];
287    }
288    if steps.is_empty() {
289        return automation;
290    }
291
292    automation.steps = Some(steps.clone());
293    let primary = steps[0].clone();
294    automation.provider_id = primary.provider_id;
295    automation.role = primary.role;
296    automation.specialist_id = primary.specialist_id;
297    automation.specialist_name = primary.specialist_name;
298    automation
299}
300
301fn automation_steps(automation: &KanbanColumnAutomation) -> Vec<KanbanAutomationStep> {
302    normalize_kanban_automation_step_ids(automation.steps.clone().unwrap_or_default())
303}
304
305fn legacy_specialist_ids_for_stage(stage: &str) -> &'static [&'static str] {
306    match stage {
307        "backlog" => &["issue-enricher", "kanban-workflow", "kanban-agent"],
308        "todo" => &["routa", "developer", "kanban-workflow"],
309        "dev" => &["pr-reviewer", "developer", "claude-code", "kanban-workflow"],
310        "review" => &[
311            "desk-check",
312            "gate",
313            "pr-reviewer",
314            "kanban-workflow",
315            "kanban-review-guard",
316        ],
317        "blocked" => &["claude-code", "developer", "routa", "kanban-workflow"],
318        "done" => &["gate", "verifier", "claude-code", "kanban-workflow"],
319        _ => &[],
320    }
321}
322
323fn recommended_step(id: &str, role: &str, specialist_name: &str) -> KanbanAutomationStep {
324    KanbanAutomationStep {
325        id: id.to_string(),
326        transport: None,
327        provider_id: None,
328        role: Some(role.to_string()),
329        specialist_id: Some(format!("kanban-{id}")),
330        specialist_name: Some(specialist_name.to_string()),
331        agent_card_url: None,
332        skill_id: None,
333        auth_config_id: None,
334    }
335}
336
337fn build_recommended_automation(
338    steps: Vec<KanbanAutomationStep>,
339    auto_advance_on_success: bool,
340) -> KanbanColumnAutomation {
341    normalize_kanban_automation(KanbanColumnAutomation {
342        enabled: true,
343        steps: Some(steps),
344        transition_type: Some("entry".to_string()),
345        auto_advance_on_success: Some(auto_advance_on_success),
346        required_artifacts: None,
347        required_task_fields: None,
348        provider_id: None,
349        role: None,
350        specialist_id: None,
351        specialist_name: None,
352    })
353}
354
355fn recommended_automation_for_stage(stage: &str) -> Option<KanbanColumnAutomation> {
356    match stage {
357        "backlog" => Some(build_recommended_automation(
358            vec![recommended_step(
359                "backlog-refiner",
360                "CRAFTER",
361                "Backlog Refiner",
362            )],
363            true,
364        )),
365        "todo" => Some(build_recommended_automation(
366            vec![recommended_step(
367                "todo-orchestrator",
368                "CRAFTER",
369                "Todo Orchestrator",
370            )],
371            false,
372        )),
373        "dev" => Some(build_recommended_automation(
374            vec![recommended_step("dev-executor", "CRAFTER", "Dev Crafter")],
375            false,
376        )),
377        "review" => Some(build_recommended_automation(
378            vec![
379                recommended_step("qa-frontend", "GATE", "QA Frontend"),
380                recommended_step("review-guard", "GATE", "Review Guard"),
381            ],
382            false,
383        ))
384        .map(|mut automation| {
385            automation.required_artifacts =
386                Some(vec!["screenshot".to_string(), "test_results".to_string()]);
387            automation
388        }),
389        "blocked" => Some(build_recommended_automation(
390            vec![recommended_step(
391                "blocked-resolver",
392                "CRAFTER",
393                "Blocked Resolver",
394            )],
395            false,
396        )),
397        "done" => Some(build_recommended_automation(
398            vec![recommended_step("done-reporter", "GATE", "Done Reporter")],
399            false,
400        )),
401        _ => None,
402    }
403}
404
405fn default_column_position_for_stage(stage: &str) -> usize {
406    match stage {
407        "backlog" => 0,
408        "todo" => 1,
409        "dev" => 2,
410        "review" => 3,
411        "done" => 4,
412        "blocked" => 5,
413        _ => 99,
414    }
415}
416
417pub fn normalize_default_kanban_column_positions(columns: Vec<KanbanColumn>) -> Vec<KanbanColumn> {
418    let mut normalized = columns;
419    normalized.sort_by(|left, right| {
420        let left_index = default_column_position_for_stage(&left.id);
421        let right_index = default_column_position_for_stage(&right.id);
422        left_index
423            .cmp(&right_index)
424            .then(left.position.cmp(&right.position))
425    });
426
427    normalized
428        .into_iter()
429        .enumerate()
430        .map(|(index, mut column)| {
431            column.position = index as i64;
432            column
433        })
434        .collect()
435}
436
437pub fn apply_recommended_automation_to_columns(columns: Vec<KanbanColumn>) -> Vec<KanbanColumn> {
438    let columns = columns
439        .into_iter()
440        .map(|mut column| {
441            if let Some(recommended) = recommended_automation_for_stage(&column.stage) {
442                let normalized_recommended = normalize_kanban_automation(recommended);
443                let recommended_primary = get_primary_step(&normalized_recommended);
444                let recommended_steps = automation_steps(&normalized_recommended);
445                let recommended_specialist_ids: Vec<&str> = recommended_steps
446                    .iter()
447                    .filter_map(|step| step.specialist_id.as_deref())
448                    .collect();
449                let recommended_specialist_names: Vec<&str> = recommended_steps
450                    .iter()
451                    .filter_map(|step| step.specialist_name.as_deref())
452                    .collect();
453                let recommended_primary_provider_id =
454                    recommended_primary.as_ref().and_then(|step| step.provider_id.clone());
455                let recommended_primary_role = recommended_primary
456                    .as_ref()
457                    .and_then(|step| step.role.clone());
458                let recommended_primary_specialist_id =
459                    recommended_primary.as_ref().and_then(|step| step.specialist_id.clone());
460                let recommended_primary_specialist_name =
461                    recommended_primary.as_ref().and_then(|step| step.specialist_name.clone());
462
463                let with_default = if let Some(automation) = column.automation.clone() {
464                    let current = normalize_kanban_automation(automation);
465                    let current_steps = automation_steps(&current);
466                    let legacy_specialists = legacy_specialist_ids_for_stage(&column.stage);
467                    let has_custom_steps = current_steps.iter().any(|step| {
468                        if let Some(id) = step.specialist_id.as_deref() {
469                            !legacy_specialists.contains(&id)
470                                && !recommended_specialist_ids.iter().any(|specialist_id| specialist_id == &id)
471                        } else if let Some(name) = step.specialist_name.as_deref() {
472                            !recommended_specialist_names
473                                .iter()
474                                .any(|recommended_name| recommended_name == &name)
475                        } else {
476                            false
477                        }
478                    });
479
480                    let should_migrate_legacy_specialist = current
481                        .specialist_id
482                        .as_deref()
483                        .is_some_and(|specialist_id| legacy_specialists.contains(&specialist_id));
484
485                    let should_migrate_recommended_specialist = current
486                        .specialist_id
487                        .as_deref()
488                        .is_some_and(|specialist_id| {
489                            Some(specialist_id) == recommended_primary_specialist_id.as_deref()
490                        })
491                        || current.specialist_id.is_none()
492                        && current
493                            .specialist_name
494                            .as_deref()
495                            .is_some_and(|specialist_name| {
496                                Some(specialist_name) == recommended_primary_specialist_name.as_deref()
497                            });
498
499                    let should_refresh_artifact_policy = (should_migrate_legacy_specialist
500                        || should_migrate_recommended_specialist)
501                        && matches!(current.required_artifacts.as_deref(), Some([artifact]) if artifact == "screenshot");
502
503                    if has_custom_steps
504                        || ((current.specialist_id.is_some() || current.specialist_name.is_some())
505                            && !should_migrate_legacy_specialist
506                            && !should_migrate_recommended_specialist)
507                    {
508                        current
509                    } else {
510                        let merged_steps = recommended_steps
511                            .into_iter()
512                            .enumerate()
513                            .map(|(index, recommended_step)| {
514                                let current_step = current_steps.get(index);
515
516                                KanbanAutomationStep {
517                                    id: recommended_step.id,
518                                    transport: current_step.and_then(|step| step.transport.clone()),
519                                    provider_id: current_step
520                                        .and_then(|step| step.provider_id.clone())
521                                        .or_else(|| recommended_step.provider_id.clone()),
522                                    role: current_step
523                                        .and_then(|step| step.role.clone())
524                                        .or_else(|| recommended_step.role.clone()),
525                                    specialist_id: current_step
526                                        .and_then(|step| step.specialist_id.clone())
527                                        .or_else(|| recommended_step.specialist_id.clone()),
528                                    specialist_name: current_step
529                                        .and_then(|step| step.specialist_name.clone())
530                                        .or_else(|| recommended_step.specialist_name.clone()),
531                                    agent_card_url: current_step
532                                        .and_then(|step| step.agent_card_url.clone())
533                                        .or_else(|| recommended_step.agent_card_url.clone()),
534                                    skill_id: current_step
535                                        .and_then(|step| step.skill_id.clone())
536                                        .or_else(|| recommended_step.skill_id.clone()),
537                                    auth_config_id: current_step
538                                        .and_then(|step| step.auth_config_id.clone())
539                                        .or_else(|| recommended_step.auth_config_id.clone()),
540                                }
541                            })
542                            .collect();
543
544                        let merged = KanbanColumnAutomation {
545                            enabled: current.enabled,
546                            steps: Some(merged_steps),
547                            provider_id: current.provider_id.or(recommended_primary_provider_id),
548                            role: current.role.or(recommended_primary_role),
549                            specialist_id: recommended_primary_specialist_id,
550                            specialist_name: recommended_primary_specialist_name,
551                            transition_type: current
552                                .transition_type
553                                .or(normalized_recommended.transition_type),
554                            required_artifacts: if should_refresh_artifact_policy {
555                                normalized_recommended.required_artifacts.clone()
556                            } else {
557                                current
558                                    .required_artifacts
559                                    .or(normalized_recommended.required_artifacts.clone())
560                            },
561                            required_task_fields: current.required_task_fields,
562                            auto_advance_on_success: normalized_recommended.auto_advance_on_success,
563                        };
564
565                        normalize_kanban_automation(merged)
566                    }
567                } else {
568                    normalized_recommended
569                };
570
571                column.automation = Some(with_default);
572            }
573
574            column
575        })
576        .collect();
577
578    normalize_default_kanban_column_positions(columns)
579}
580
581pub fn apply_new_board_story_readiness_defaults(columns: Vec<KanbanColumn>) -> Vec<KanbanColumn> {
582    columns
583        .into_iter()
584        .map(|mut column| {
585            if column.stage == "dev" {
586                if let Some(automation) = column.automation.as_mut() {
587                    automation.required_task_fields = Some(vec![
588                        "scope".to_string(),
589                        "acceptance_criteria".to_string(),
590                        "verification_plan".to_string(),
591                    ]);
592                }
593            }
594            column
595        })
596        .collect()
597}
598
599fn get_primary_step(automation: &KanbanColumnAutomation) -> Option<KanbanAutomationStep> {
600    automation_steps(automation).into_iter().next()
601}
602
603pub fn default_kanban_board(workspace_id: String) -> KanbanBoard {
604    let now = Utc::now();
605
606    KanbanBoard {
607        id: uuid::Uuid::new_v4().to_string(),
608        workspace_id,
609        name: "Board".to_string(),
610        is_default: true,
611        columns: default_kanban_columns(),
612        created_at: now,
613        updated_at: now,
614    }
615}
616
617pub fn column_id_to_task_status(column_id: Option<&str>) -> TaskStatus {
618    match column_id.unwrap_or("backlog").to_ascii_lowercase().as_str() {
619        "dev" => TaskStatus::InProgress,
620        "review" => TaskStatus::ReviewRequired,
621        "blocked" => TaskStatus::Blocked,
622        "done" => TaskStatus::Completed,
623        _ => TaskStatus::Pending,
624    }
625}
626
627pub fn task_status_to_column_id(status: &TaskStatus) -> &'static str {
628    match status {
629        TaskStatus::InProgress => "dev",
630        TaskStatus::ReviewRequired => "review",
631        TaskStatus::Blocked => "blocked",
632        TaskStatus::Completed => "done",
633        _ => "backlog",
634    }
635}