Skip to main content

task_graph_mcp/config/
workflows.rs

1//! Workflow configuration for states, phases, and transition prompts.
2//!
3//! This module defines the unified workflow configuration that combines:
4//! - State definitions (exits, timed)
5//! - Phase definitions
6//! - Transition prompts (enter/exit for states, phases, and combos)
7
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use super::types::{
13    GateDefinition, PhasesConfig, StateDefinition, StatesConfig, UnknownKeyBehavior,
14};
15
16/// Settings for workflow behavior.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorkflowSettings {
19    /// Default state for new tasks.
20    #[serde(default = "default_initial_state")]
21    pub initial_state: String,
22
23    /// State for tasks when agent disconnects (must be untimed).
24    #[serde(default = "default_disconnect_state")]
25    pub disconnect_state: String,
26
27    /// States that block dependent tasks (tasks in these states count as "not done").
28    #[serde(default = "default_blocking_states")]
29    pub blocking_states: Vec<String>,
30
31    /// Behavior for unknown phase values (allow, warn, reject).
32    #[serde(default)]
33    pub unknown_phase: UnknownKeyBehavior,
34}
35
36fn default_initial_state() -> String {
37    "pending".to_string()
38}
39
40fn default_disconnect_state() -> String {
41    "pending".to_string()
42}
43
44fn default_blocking_states() -> Vec<String> {
45    vec![
46        "pending".to_string(),
47        "assigned".to_string(),
48        "working".to_string(),
49    ]
50}
51
52impl Default for WorkflowSettings {
53    fn default() -> Self {
54        Self {
55            initial_state: default_initial_state(),
56            disconnect_state: default_disconnect_state(),
57            blocking_states: default_blocking_states(),
58            unknown_phase: UnknownKeyBehavior::default(),
59        }
60    }
61}
62
63/// Prompts for state/phase transitions.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct TransitionPrompts {
66    /// Prompt shown when entering this state/phase.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub enter: Option<String>,
69
70    /// Prompt shown when exiting this state/phase.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub exit: Option<String>,
73}
74
75/// Definition of a single state in the workflow.
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct StateWorkflow {
78    /// Allowed states to transition to from this state.
79    #[serde(default)]
80    pub exits: Vec<String>,
81
82    /// Whether time spent in this state should be tracked.
83    #[serde(default)]
84    pub timed: bool,
85
86    /// Prompts for entering/exiting this state.
87    #[serde(default)]
88    pub prompts: TransitionPrompts,
89}
90
91/// Definition of a phase in the workflow.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct PhaseWorkflow {
94    /// Prompts for entering/exiting this phase.
95    #[serde(default)]
96    pub prompts: TransitionPrompts,
97}
98
99/// Prompts for state+phase combinations.
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct ComboPrompts {
102    /// Prompt shown when entering this state+phase combination.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub enter: Option<String>,
105
106    /// Prompt shown when exiting this state+phase combination.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub exit: Option<String>,
109}
110
111/// Definition of a role in a workflow (e.g., "lead", "worker").
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct RoleDefinition {
114    /// Human-readable description of this role.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub description: Option<String>,
117
118    /// Tags that identify agents in this role.
119    #[serde(default)]
120    pub tags: Vec<String>,
121
122    /// Maximum number of tasks this role can claim simultaneously.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub max_claims: Option<u32>,
125
126    /// Whether this role can assign tasks to other agents.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub can_assign: Option<bool>,
129
130    /// Whether this role can create subtasks.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub can_create_subtasks: Option<bool>,
133}
134
135/// Unified workflow configuration.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct WorkflowsConfig {
138    /// Short identifier for the workflow (e.g., "swarm", "relay", "solo").
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub name: Option<String>,
141
142    /// Human-readable description of the workflow's coordination model.
143    /// Should explain when to choose this workflow and how agents coordinate.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub description: Option<String>,
146
147    /// Path to the source file this workflow was loaded from.
148    /// Not deserialized from YAML - populated by the loader.
149    #[serde(skip)]
150    pub source_file: Option<std::path::PathBuf>,
151
152    /// Global workflow settings.
153    #[serde(default)]
154    pub settings: WorkflowSettings,
155
156    /// State definitions with transitions, timing, and prompts.
157    #[serde(default)]
158    pub states: HashMap<String, StateWorkflow>,
159
160    /// Phase definitions with prompts.
161    #[serde(default)]
162    pub phases: HashMap<String, PhaseWorkflow>,
163
164    /// State+phase combination prompts (key format: "state+phase").
165    #[serde(default)]
166    pub combos: HashMap<String, ComboPrompts>,
167
168    /// Gate definitions for status and phase exits.
169    /// Keys are "status:<name>" or "phase:<name>", values are lists of gate definitions.
170    #[serde(default)]
171    pub gates: HashMap<String, Vec<GateDefinition>>,
172
173    /// Role definitions (e.g., "lead", "worker") with tags, permissions, and constraints.
174    #[serde(default)]
175    pub roles: HashMap<String, RoleDefinition>,
176
177    /// Role-specific prompts. Outer key is role name, inner key is prompt name
178    /// (e.g., "claiming", "completing"), value is the prompt content.
179    #[serde(default)]
180    pub role_prompts: HashMap<String, HashMap<String, String>>,
181
182    /// Cache of named workflow configs (e.g., "swarm" -> workflow-swarm.yaml).
183    /// Populated at server startup, not serialized.
184    #[serde(skip)]
185    pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
186
187    /// Key to look up the default workflow in named_workflows cache.
188    /// If set, workers without a workflow use this instead of the base config.
189    #[serde(skip)]
190    pub default_workflow_key: Option<String>,
191}
192
193impl Default for WorkflowsConfig {
194    fn default() -> Self {
195        Self {
196            name: None,
197            description: None,
198            source_file: None,
199            settings: WorkflowSettings::default(),
200            states: default_state_workflows(),
201            phases: default_phase_workflows(),
202            combos: HashMap::new(),
203            gates: HashMap::new(),
204            roles: HashMap::new(),
205            role_prompts: HashMap::new(),
206            named_workflows: HashMap::new(),
207            default_workflow_key: None,
208        }
209    }
210}
211
212impl WorkflowsConfig {
213    /// Get a named workflow config, or None if not found.
214    pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
215        self.named_workflows.get(name)
216    }
217
218    /// Get the default workflow config from the cache, if one is configured.
219    pub fn get_default_workflow(&self) -> Option<&Arc<WorkflowsConfig>> {
220        self.default_workflow_key
221            .as_ref()
222            .and_then(|key| self.named_workflows.get(key))
223    }
224
225    /// Match worker tags to a role defined in this workflow.
226    /// Returns the role name if any role's tags overlap with the worker's tags.
227    /// If multiple roles match, returns the first match (by sorted key order for determinism).
228    pub fn match_role(&self, worker_tags: &[String]) -> Option<String> {
229        let mut role_names: Vec<&String> = self.roles.keys().collect();
230        role_names.sort();
231        for role_name in role_names {
232            if let Some(role) = self.roles.get(role_name)
233                && role.tags.iter().any(|t| worker_tags.contains(t))
234            {
235                return Some(role_name.clone());
236            }
237        }
238        None
239    }
240
241    /// Get all prompts for a matched role.
242    /// Returns an empty HashMap if the role has no prompts defined.
243    pub fn get_role_prompts(&self, role_name: &str) -> HashMap<String, String> {
244        self.role_prompts
245            .get(role_name)
246            .cloned()
247            .unwrap_or_default()
248    }
249
250    /// Get a specific role prompt by role name and prompt key.
251    pub fn get_role_prompt(&self, role_name: &str, prompt_key: &str) -> Option<&str> {
252        self.role_prompts
253            .get(role_name)
254            .and_then(|prompts| prompts.get(prompt_key))
255            .map(|s| s.as_str())
256    }
257
258    /// Get the role definition for a matched role.
259    pub fn get_role(&self, role_name: &str) -> Option<&RoleDefinition> {
260        self.roles.get(role_name)
261    }
262
263    /// Collect all unique role tags across this workflow and all named workflows.
264    /// Returns a deduplicated list of tag names used in role definitions.
265    pub fn all_role_tags(&self) -> Vec<String> {
266        let mut tags = std::collections::HashSet::new();
267        // Collect from this workflow's roles
268        for role in self.roles.values() {
269            for tag in &role.tags {
270                tags.insert(tag.clone());
271            }
272        }
273        // Collect from all named workflows
274        for workflow in self.named_workflows.values() {
275            for role in workflow.roles.values() {
276                for tag in &role.tags {
277                    tags.insert(tag.clone());
278                }
279            }
280        }
281        tags.into_iter().collect()
282    }
283}
284
285/// Default state workflow definitions.
286fn default_state_workflows() -> HashMap<String, StateWorkflow> {
287    let mut states = HashMap::new();
288
289    states.insert(
290        "pending".to_string(),
291        StateWorkflow {
292            exits: vec![
293                "assigned".to_string(),
294                "working".to_string(),
295                "cancelled".to_string(),
296            ],
297            timed: false,
298            prompts: TransitionPrompts::default(),
299        },
300    );
301
302    states.insert(
303        "assigned".to_string(),
304        StateWorkflow {
305            exits: vec![
306                "working".to_string(),
307                "pending".to_string(),
308                "cancelled".to_string(),
309            ],
310            timed: false,
311            prompts: TransitionPrompts {
312                enter: Some(
313                    "A task has been assigned to you. Review and claim when ready.".to_string(),
314                ),
315                exit: None,
316            },
317        },
318    );
319
320    states.insert(
321        "working".to_string(),
322        StateWorkflow {
323            exits: vec![
324                "completed".to_string(),
325                "failed".to_string(),
326                "pending".to_string(),
327            ],
328            timed: true,
329            prompts: TransitionPrompts {
330                enter: Some(
331                    r#"You are now actively working on this task. Keep your thinking updated regularly using the `thinking` tool to show progress and allow coordination with other agents.
332
333### Heartbeat & Coordination
334- Call `thinking(agent=your_id, thought="...")` regularly to maintain heartbeat
335- Call `mark_updates(agent=your_id)` every 30-60s during long operations to detect file conflicts
336- Stale workers (no heartbeat for 5+ min) get evicted automatically
337- The lead monitors worker heartbeats -- stay visible to avoid reassignment
338
339## Valid Next States
340
341From `working` you can transition to:
342{{valid_exits}}
343
344Use `update(status="completed")` when done, `update(status="failed")` if blocked, or `update(status="pending")` to release without completing.
345
346## Phase
347
348Current phase: {{current_phase}}
349
350Valid phases: {{valid_phases}}
351
352Set a phase with `update(phase="implement")` to categorize the type of work you're doing.
353"#
354                        .to_string(),
355                ),
356                exit: Some(
357                    "Before completing:\n- [ ] Unmark files\n- [ ] Attach results or notes\n- [ ] `log_metrics()`".to_string(),
358                ),
359            },
360        },
361    );
362
363    states.insert(
364        "completed".to_string(),
365        StateWorkflow {
366            exits: vec!["pending".to_string()],
367            timed: false,
368            prompts: TransitionPrompts {
369                enter: Some("Task completed. Results should be attached.".to_string()),
370                exit: None,
371            },
372        },
373    );
374
375    states.insert(
376        "failed".to_string(),
377        StateWorkflow {
378            exits: vec!["pending".to_string()],
379            timed: false,
380            prompts: TransitionPrompts {
381                enter: Some(
382                    "Task failed. Document: what was attempted, what blocked, suggested next steps."
383                        .to_string(),
384                ),
385                exit: None,
386            },
387        },
388    );
389
390    states.insert(
391        "cancelled".to_string(),
392        StateWorkflow {
393            exits: Vec::new(),
394            timed: false,
395            prompts: TransitionPrompts::default(),
396        },
397    );
398
399    states
400}
401
402/// Default phase workflow definitions.
403fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
404    let mut phases = HashMap::new();
405
406    // Phases with prompts
407    phases.insert(
408        "explore".to_string(),
409        PhaseWorkflow {
410            prompts: TransitionPrompts {
411                enter: None,
412                exit: Some(
413                    "Capture exploration findings before moving on.\nAttach discoveries to parent task for sibling agents.".to_string(),
414                ),
415            },
416        },
417    );
418
419    phases.insert(
420        "implement".to_string(),
421        PhaseWorkflow {
422            prompts: TransitionPrompts {
423                enter: Some("Implementation phase. Mark files before editing.".to_string()),
424                exit: None,
425            },
426        },
427    );
428
429    phases.insert(
430        "review".to_string(),
431        PhaseWorkflow {
432            prompts: TransitionPrompts {
433                enter: Some("Review: tests pass, no new warnings, docs updated.".to_string()),
434                exit: None,
435            },
436        },
437    );
438
439    phases.insert(
440        "test".to_string(),
441        PhaseWorkflow {
442            prompts: TransitionPrompts {
443                enter: Some(
444                    "Testing phase. Verify the implementation works correctly.".to_string(),
445                ),
446                exit: None,
447            },
448        },
449    );
450
451    phases.insert(
452        "security".to_string(),
453        PhaseWorkflow {
454            prompts: TransitionPrompts {
455                enter: Some(
456                    "Security: input validation, auth/authz, no secrets in code.".to_string(),
457                ),
458                exit: None,
459            },
460        },
461    );
462
463    // Phases without prompts
464    for phase in &[
465        "deliver",
466        "triage",
467        "diagnose",
468        "design",
469        "plan",
470        "doc",
471        "integrate",
472        "deploy",
473        "monitor",
474        "optimize",
475    ] {
476        phases.insert(phase.to_string(), PhaseWorkflow::default());
477    }
478
479    phases
480}
481
482impl WorkflowsConfig {
483    /// Get the enter prompt for a state.
484    pub fn get_state_enter_prompt(&self, state: &str) -> Option<&str> {
485        self.states
486            .get(state)
487            .and_then(|s| s.prompts.enter.as_deref())
488    }
489
490    /// Get the exit prompt for a state.
491    pub fn get_state_exit_prompt(&self, state: &str) -> Option<&str> {
492        self.states
493            .get(state)
494            .and_then(|s| s.prompts.exit.as_deref())
495    }
496
497    /// Get the enter prompt for a phase.
498    pub fn get_phase_enter_prompt(&self, phase: &str) -> Option<&str> {
499        self.phases
500            .get(phase)
501            .and_then(|p| p.prompts.enter.as_deref())
502    }
503
504    /// Get the exit prompt for a phase.
505    pub fn get_phase_exit_prompt(&self, phase: &str) -> Option<&str> {
506        self.phases
507            .get(phase)
508            .and_then(|p| p.prompts.exit.as_deref())
509    }
510
511    /// Get the enter prompt for a state+phase combo.
512    pub fn get_combo_enter_prompt(&self, state: &str, phase: &str) -> Option<&str> {
513        let key = format!("{}+{}", state, phase);
514        self.combos.get(&key).and_then(|c| c.enter.as_deref())
515    }
516
517    /// Get the exit prompt for a state+phase combo.
518    pub fn get_combo_exit_prompt(&self, state: &str, phase: &str) -> Option<&str> {
519        let key = format!("{}+{}", state, phase);
520        self.combos.get(&key).and_then(|c| c.exit.as_deref())
521    }
522
523    /// Get a prompt by trigger name.
524    ///
525    /// Trigger format:
526    /// - `enter~{state}` - entering a state
527    /// - `exit~{state}` - exiting a state
528    /// - `enter%{phase}` - entering a phase
529    /// - `exit%{phase}` - exiting a phase
530    /// - `enter~{state}%{phase}` - entering a state+phase combo
531    /// - `exit~{state}%{phase}` - exiting a state+phase combo
532    pub fn get_prompt(&self, trigger: &str) -> Option<&str> {
533        if let Some(rest) = trigger.strip_prefix("enter~") {
534            if let Some(idx) = rest.find('%') {
535                // Combo: enter~state%phase
536                let state = &rest[..idx];
537                let phase = &rest[idx + 1..];
538                self.get_combo_enter_prompt(state, phase)
539            } else {
540                // State: enter~state
541                self.get_state_enter_prompt(rest)
542            }
543        } else if let Some(rest) = trigger.strip_prefix("exit~") {
544            if let Some(idx) = rest.find('%') {
545                // Combo: exit~state%phase
546                let state = &rest[..idx];
547                let phase = &rest[idx + 1..];
548                self.get_combo_exit_prompt(state, phase)
549            } else {
550                // State: exit~state
551                self.get_state_exit_prompt(rest)
552            }
553        } else if let Some(phase) = trigger.strip_prefix("enter%") {
554            self.get_phase_enter_prompt(phase)
555        } else if let Some(phase) = trigger.strip_prefix("exit%") {
556            self.get_phase_exit_prompt(phase)
557        } else {
558            None
559        }
560    }
561
562    /// List all available prompt triggers.
563    pub fn list_prompt_triggers(&self) -> Vec<String> {
564        let mut triggers = Vec::new();
565
566        // State prompts
567        for (state, workflow) in &self.states {
568            if workflow.prompts.enter.is_some() {
569                triggers.push(format!("enter~{}", state));
570            }
571            if workflow.prompts.exit.is_some() {
572                triggers.push(format!("exit~{}", state));
573            }
574        }
575
576        // Phase prompts
577        for (phase, workflow) in &self.phases {
578            if workflow.prompts.enter.is_some() {
579                triggers.push(format!("enter%{}", phase));
580            }
581            if workflow.prompts.exit.is_some() {
582                triggers.push(format!("exit%{}", phase));
583            }
584        }
585
586        // Combo prompts
587        for (combo, prompts) in &self.combos {
588            if prompts.enter.is_some() {
589                triggers.push(format!("enter~{}", combo.replace('+', "%")));
590            }
591            if prompts.exit.is_some() {
592                triggers.push(format!("exit~{}", combo.replace('+', "%")));
593            }
594        }
595
596        triggers.sort();
597        triggers
598    }
599
600    /// Get exit gates for a status transition.
601    /// Returns gates defined under "status:<name>" key.
602    pub fn get_status_exit_gates(&self, status: &str) -> Vec<&GateDefinition> {
603        self.gates
604            .get(&format!("status:{}", status))
605            .map(|v| v.iter().collect())
606            .unwrap_or_default()
607    }
608
609    /// Get exit gates for a phase transition.
610    /// Returns gates defined under "phase:<name>" key.
611    pub fn get_phase_exit_gates(&self, phase: &str) -> Vec<&GateDefinition> {
612        self.gates
613            .get(&format!("phase:{}", phase))
614            .map(|v| v.iter().collect())
615            .unwrap_or_default()
616    }
617}
618
619/// Convert WorkflowsConfig to StatesConfig for backwards compatibility.
620impl From<&WorkflowsConfig> for StatesConfig {
621    fn from(workflows: &WorkflowsConfig) -> Self {
622        let definitions = workflows
623            .states
624            .iter()
625            .map(|(name, workflow)| {
626                (
627                    name.clone(),
628                    StateDefinition {
629                        exits: workflow.exits.clone(),
630                        timed: workflow.timed,
631                    },
632                )
633            })
634            .collect();
635
636        StatesConfig {
637            initial: workflows.settings.initial_state.clone(),
638            disconnect_state: workflows.settings.disconnect_state.clone(),
639            blocking_states: workflows.settings.blocking_states.clone(),
640            definitions,
641        }
642    }
643}
644
645/// Convert WorkflowsConfig to PhasesConfig for backwards compatibility.
646impl From<&WorkflowsConfig> for PhasesConfig {
647    fn from(workflows: &WorkflowsConfig) -> Self {
648        let definitions: HashSet<String> = workflows.phases.keys().cloned().collect();
649
650        PhasesConfig {
651            unknown_phase: workflows.settings.unknown_phase,
652            definitions,
653        }
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[test]
662    fn test_default_workflows() {
663        let workflows = WorkflowsConfig::default();
664
665        // Check settings
666        assert_eq!(workflows.settings.initial_state, "pending");
667        assert_eq!(workflows.settings.disconnect_state, "pending");
668        assert!(
669            workflows
670                .settings
671                .blocking_states
672                .contains(&"working".to_string())
673        );
674
675        // Check states
676        assert!(workflows.states.contains_key("pending"));
677        assert!(workflows.states.contains_key("working"));
678        assert!(workflows.states.contains_key("completed"));
679
680        // Check working is timed
681        assert!(workflows.states.get("working").unwrap().timed);
682
683        // Check phases
684        assert!(workflows.phases.contains_key("implement"));
685        assert!(workflows.phases.contains_key("test"));
686    }
687
688    #[test]
689    fn test_get_prompt() {
690        let workflows = WorkflowsConfig::default();
691
692        // State enter prompt
693        let prompt = workflows.get_prompt("enter~working");
694        assert!(prompt.is_some());
695        assert!(prompt.unwrap().contains("actively working"));
696
697        // State exit prompt
698        let prompt = workflows.get_prompt("exit~working");
699        assert!(prompt.is_some());
700        assert!(prompt.unwrap().contains("Unmark"));
701
702        // Phase enter prompt
703        let prompt = workflows.get_prompt("enter%implement");
704        assert!(prompt.is_some());
705        assert!(prompt.unwrap().contains("Implementation"));
706
707        // Phase exit prompt
708        let prompt = workflows.get_prompt("exit%explore");
709        assert!(prompt.is_some());
710        assert!(prompt.unwrap().contains("findings"));
711    }
712
713    #[test]
714    fn test_states_config_from_workflows() {
715        let workflows = WorkflowsConfig::default();
716        let states: StatesConfig = (&workflows).into();
717
718        assert_eq!(states.initial, "pending");
719        assert!(states.definitions.contains_key("working"));
720        assert!(states.definitions.get("working").unwrap().timed);
721    }
722
723    #[test]
724    fn test_phases_config_from_workflows() {
725        let workflows = WorkflowsConfig::default();
726        let phases: PhasesConfig = (&workflows).into();
727
728        assert!(phases.definitions.contains("implement"));
729        assert!(phases.definitions.contains("test"));
730    }
731
732    #[test]
733    fn test_list_prompt_triggers() {
734        let workflows = WorkflowsConfig::default();
735        let triggers = workflows.list_prompt_triggers();
736
737        assert!(triggers.contains(&"enter~working".to_string()));
738        assert!(triggers.contains(&"exit~working".to_string()));
739        assert!(triggers.contains(&"enter%implement".to_string()));
740    }
741
742    #[test]
743    fn test_all_role_tags_from_base_config() {
744        let mut workflows = WorkflowsConfig::default();
745        workflows.roles.insert(
746            "worker".to_string(),
747            RoleDefinition {
748                tags: vec!["worker".to_string(), "backend".to_string()],
749                ..Default::default()
750            },
751        );
752        workflows.roles.insert(
753            "lead".to_string(),
754            RoleDefinition {
755                tags: vec!["lead".to_string(), "coordinator".to_string()],
756                ..Default::default()
757            },
758        );
759
760        let tags = workflows.all_role_tags();
761        assert_eq!(tags.len(), 4);
762        assert!(tags.contains(&"worker".to_string()));
763        assert!(tags.contains(&"backend".to_string()));
764        assert!(tags.contains(&"lead".to_string()));
765        assert!(tags.contains(&"coordinator".to_string()));
766    }
767
768    #[test]
769    fn test_all_role_tags_includes_named_workflows() {
770        let mut workflows = WorkflowsConfig::default();
771
772        // Add a named workflow with its own roles
773        let mut named = WorkflowsConfig::default();
774        named.roles.insert(
775            "reviewer".to_string(),
776            RoleDefinition {
777                tags: vec!["reviewer".to_string()],
778                ..Default::default()
779            },
780        );
781        workflows
782            .named_workflows
783            .insert("review".to_string(), Arc::new(named));
784
785        // Base has no roles, but named workflow does
786        let tags = workflows.all_role_tags();
787        assert_eq!(tags.len(), 1);
788        assert!(tags.contains(&"reviewer".to_string()));
789    }
790
791    #[test]
792    fn test_all_role_tags_deduplicates() {
793        let mut workflows = WorkflowsConfig::default();
794        workflows.roles.insert(
795            "worker".to_string(),
796            RoleDefinition {
797                tags: vec!["shared-tag".to_string()],
798                ..Default::default()
799            },
800        );
801
802        let mut named = WorkflowsConfig::default();
803        named.roles.insert(
804            "builder".to_string(),
805            RoleDefinition {
806                tags: vec!["shared-tag".to_string()],
807                ..Default::default()
808            },
809        );
810        workflows
811            .named_workflows
812            .insert("build".to_string(), Arc::new(named));
813
814        let tags = workflows.all_role_tags();
815        assert_eq!(tags.len(), 1);
816        assert!(tags.contains(&"shared-tag".to_string()));
817    }
818}