1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorkflowSettings {
19 #[serde(default = "default_initial_state")]
21 pub initial_state: String,
22
23 #[serde(default = "default_disconnect_state")]
25 pub disconnect_state: String,
26
27 #[serde(default = "default_blocking_states")]
29 pub blocking_states: Vec<String>,
30
31 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct TransitionPrompts {
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub enter: Option<String>,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub exit: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct StateWorkflow {
78 #[serde(default)]
80 pub exits: Vec<String>,
81
82 #[serde(default)]
84 pub timed: bool,
85
86 #[serde(default)]
88 pub prompts: TransitionPrompts,
89}
90
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct PhaseWorkflow {
94 #[serde(default)]
96 pub prompts: TransitionPrompts,
97}
98
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct ComboPrompts {
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub enter: Option<String>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub exit: Option<String>,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct RoleDefinition {
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub description: Option<String>,
117
118 #[serde(default)]
120 pub tags: Vec<String>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub max_claims: Option<u32>,
125
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub can_assign: Option<bool>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub can_create_subtasks: Option<bool>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct WorkflowsConfig {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub name: Option<String>,
141
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub description: Option<String>,
146
147 #[serde(skip)]
150 pub source_file: Option<std::path::PathBuf>,
151
152 #[serde(default)]
154 pub settings: WorkflowSettings,
155
156 #[serde(default)]
158 pub states: HashMap<String, StateWorkflow>,
159
160 #[serde(default)]
162 pub phases: HashMap<String, PhaseWorkflow>,
163
164 #[serde(default)]
166 pub combos: HashMap<String, ComboPrompts>,
167
168 #[serde(default)]
171 pub gates: HashMap<String, Vec<GateDefinition>>,
172
173 #[serde(default)]
175 pub roles: HashMap<String, RoleDefinition>,
176
177 #[serde(default)]
180 pub role_prompts: HashMap<String, HashMap<String, String>>,
181
182 #[serde(skip)]
185 pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
186
187 #[serde(skip)]
190 pub default_workflow_key: Option<String>,
191
192 #[serde(skip)]
195 pub named_overlays: HashMap<String, Arc<WorkflowsConfig>>,
196
197 #[serde(skip)]
199 pub active_overlays: Vec<String>,
200}
201
202impl Default for WorkflowsConfig {
203 fn default() -> Self {
204 Self {
205 name: None,
206 description: None,
207 source_file: None,
208 settings: WorkflowSettings::default(),
209 states: default_state_workflows(),
210 phases: default_phase_workflows(),
211 combos: HashMap::new(),
212 gates: HashMap::new(),
213 roles: HashMap::new(),
214 role_prompts: HashMap::new(),
215 named_workflows: HashMap::new(),
216 default_workflow_key: None,
217 named_overlays: HashMap::new(),
218 active_overlays: Vec::new(),
219 }
220 }
221}
222
223impl WorkflowsConfig {
224 pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
226 self.named_workflows.get(name)
227 }
228
229 pub fn get_default_workflow(&self) -> Option<&Arc<WorkflowsConfig>> {
231 self.default_workflow_key
232 .as_ref()
233 .and_then(|key| self.named_workflows.get(key))
234 }
235
236 pub fn match_role(&self, worker_tags: &[String]) -> Option<String> {
240 let mut role_names: Vec<&String> = self.roles.keys().collect();
241 role_names.sort();
242 for role_name in role_names {
243 if let Some(role) = self.roles.get(role_name)
244 && role.tags.iter().any(|t| worker_tags.contains(t))
245 {
246 return Some(role_name.clone());
247 }
248 }
249 None
250 }
251
252 pub fn get_role_prompts(&self, role_name: &str) -> HashMap<String, String> {
255 self.role_prompts
256 .get(role_name)
257 .cloned()
258 .unwrap_or_default()
259 }
260
261 pub fn get_role_prompt(&self, role_name: &str, prompt_key: &str) -> Option<&str> {
263 self.role_prompts
264 .get(role_name)
265 .and_then(|prompts| prompts.get(prompt_key))
266 .map(|s| s.as_str())
267 }
268
269 pub fn get_role(&self, role_name: &str) -> Option<&RoleDefinition> {
271 self.roles.get(role_name)
272 }
273
274 pub fn all_role_tags(&self) -> Vec<String> {
277 let mut tags = std::collections::HashSet::new();
278 for role in self.roles.values() {
280 for tag in &role.tags {
281 tags.insert(tag.clone());
282 }
283 }
284 for workflow in self.named_workflows.values() {
286 for role in workflow.roles.values() {
287 for tag in &role.tags {
288 tags.insert(tag.clone());
289 }
290 }
291 }
292 for overlay in self.named_overlays.values() {
294 for role in overlay.roles.values() {
295 for tag in &role.tags {
296 tags.insert(tag.clone());
297 }
298 }
299 }
300 tags.into_iter().collect()
301 }
302
303 pub fn apply_overlay(&mut self, overlay: &WorkflowsConfig) {
316 const PROMPT_SEPARATOR: &str = "\n\n---\n\n";
317
318 for (name, overlay_state) in &overlay.states {
320 if let Some(existing) = self.states.get_mut(name) {
321 for exit in &overlay_state.exits {
323 if !existing.exits.contains(exit) {
324 existing.exits.push(exit.clone());
325 }
326 }
327 existing.timed |= overlay_state.timed;
329 append_prompt(
331 &mut existing.prompts.enter,
332 &overlay_state.prompts.enter,
333 PROMPT_SEPARATOR,
334 );
335 append_prompt(
336 &mut existing.prompts.exit,
337 &overlay_state.prompts.exit,
338 PROMPT_SEPARATOR,
339 );
340 } else {
341 self.states.insert(name.clone(), overlay_state.clone());
342 }
343 }
344
345 for (name, overlay_phase) in &overlay.phases {
347 if let Some(existing) = self.phases.get_mut(name) {
348 append_prompt(
349 &mut existing.prompts.enter,
350 &overlay_phase.prompts.enter,
351 PROMPT_SEPARATOR,
352 );
353 append_prompt(
354 &mut existing.prompts.exit,
355 &overlay_phase.prompts.exit,
356 PROMPT_SEPARATOR,
357 );
358 } else {
359 self.phases.insert(name.clone(), overlay_phase.clone());
360 }
361 }
362
363 for (name, overlay_combo) in &overlay.combos {
365 if let Some(existing) = self.combos.get_mut(name) {
366 append_optional_prompt(&mut existing.enter, &overlay_combo.enter, PROMPT_SEPARATOR);
367 append_optional_prompt(&mut existing.exit, &overlay_combo.exit, PROMPT_SEPARATOR);
368 } else {
369 self.combos.insert(name.clone(), overlay_combo.clone());
370 }
371 }
372
373 for (key, overlay_gates) in &overlay.gates {
375 self.gates
376 .entry(key.clone())
377 .or_default()
378 .extend(overlay_gates.iter().cloned());
379 }
380
381 for (name, overlay_role) in &overlay.roles {
383 self.roles
384 .entry(name.clone())
385 .or_insert_with(|| overlay_role.clone());
386 }
387
388 for (role_name, overlay_prompts) in &overlay.role_prompts {
390 let existing = self.role_prompts.entry(role_name.clone()).or_default();
391 for (key, overlay_value) in overlay_prompts {
392 existing
393 .entry(key.clone())
394 .and_modify(|v| {
395 v.push_str(PROMPT_SEPARATOR);
396 v.push_str(overlay_value);
397 })
398 .or_insert_with(|| overlay_value.clone());
399 }
400 }
401
402 if overlay.settings.initial_state != default_initial_state() {
404 self.settings.initial_state = overlay.settings.initial_state.clone();
405 }
406 for state in &overlay.settings.blocking_states {
408 if !self.settings.blocking_states.contains(state) {
409 self.settings.blocking_states.push(state.clone());
410 }
411 }
412 }
413
414 pub fn compute_overlay_diff(&self, base: &WorkflowsConfig) -> serde_json::Value {
417 let mut states_added: Vec<String> = Vec::new();
418 let mut exits_added: HashMap<String, Vec<String>> = HashMap::new();
419 let mut gates_added: Vec<String> = Vec::new();
420 let mut prompts_modified: Vec<String> = Vec::new();
421
422 for (name, state) in &self.states {
423 if !base.states.contains_key(name) {
424 states_added.push(name.clone());
425 } else {
426 let base_state = &base.states[name];
427 let new_exits: Vec<String> = state
429 .exits
430 .iter()
431 .filter(|e| !base_state.exits.contains(e))
432 .cloned()
433 .collect();
434 if !new_exits.is_empty() {
435 exits_added.insert(name.clone(), new_exits);
436 }
437 if state.prompts.enter != base_state.prompts.enter {
439 prompts_modified.push(format!("enter~{}", name));
440 }
441 if state.prompts.exit != base_state.prompts.exit {
442 prompts_modified.push(format!("exit~{}", name));
443 }
444 }
445 }
446
447 for key in self.gates.keys() {
448 if !base.gates.contains_key(key) {
449 gates_added.push(key.clone());
450 } else if self.gates[key].len() > base.gates[key].len() {
451 gates_added.push(format!(
452 "{}(+{})",
453 key,
454 self.gates[key].len() - base.gates[key].len()
455 ));
456 }
457 }
458
459 serde_json::json!({
460 "states_added": states_added,
461 "exits_added": exits_added,
462 "gates_added": gates_added,
463 "prompts_modified": prompts_modified,
464 })
465 }
466}
467
468fn append_prompt(target: &mut Option<String>, source: &Option<String>, separator: &str) {
470 if let Some(src) = source {
471 match target {
472 Some(existing) => {
473 existing.push_str(separator);
474 existing.push_str(src);
475 }
476 None => *target = Some(src.clone()),
477 }
478 }
479}
480
481fn append_optional_prompt(target: &mut Option<String>, source: &Option<String>, separator: &str) {
483 append_prompt(target, source, separator);
484}
485
486fn default_state_workflows() -> HashMap<String, StateWorkflow> {
488 let mut states = HashMap::new();
489
490 states.insert(
491 "pending".to_string(),
492 StateWorkflow {
493 exits: vec![
494 "assigned".to_string(),
495 "working".to_string(),
496 "cancelled".to_string(),
497 ],
498 timed: false,
499 prompts: TransitionPrompts::default(),
500 },
501 );
502
503 states.insert(
504 "assigned".to_string(),
505 StateWorkflow {
506 exits: vec![
507 "working".to_string(),
508 "pending".to_string(),
509 "cancelled".to_string(),
510 ],
511 timed: false,
512 prompts: TransitionPrompts {
513 enter: Some(
514 "A task has been assigned to you. Review and claim when ready.".to_string(),
515 ),
516 exit: None,
517 },
518 },
519 );
520
521 states.insert(
522 "working".to_string(),
523 StateWorkflow {
524 exits: vec![
525 "completed".to_string(),
526 "failed".to_string(),
527 "pending".to_string(),
528 ],
529 timed: true,
530 prompts: TransitionPrompts {
531 enter: Some(
532 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.
533
534### Heartbeat & Coordination
535- Call `thinking(agent=your_id, thought="...")` regularly to maintain heartbeat
536- Call `mark_updates(agent=your_id)` every 30-60s during long operations to detect file conflicts
537- Stale workers (no heartbeat for 5+ min) get evicted automatically
538- The lead monitors worker heartbeats -- stay visible to avoid reassignment
539
540## Valid Next States
541
542From `working` you can transition to:
543{{valid_exits}}
544
545Use `update(status="completed")` when done, `update(status="failed")` if blocked, or `update(status="pending")` to release without completing.
546
547## Phase
548
549Current phase: {{current_phase}}
550
551Valid phases: {{valid_phases}}
552
553Set a phase with `update(phase="implement")` to categorize the type of work you're doing.
554"#
555 .to_string(),
556 ),
557 exit: Some(
558 "Before completing:\n- [ ] Unmark files\n- [ ] Attach results or notes\n- [ ] `log_metrics()`".to_string(),
559 ),
560 },
561 },
562 );
563
564 states.insert(
565 "completed".to_string(),
566 StateWorkflow {
567 exits: vec!["pending".to_string()],
568 timed: false,
569 prompts: TransitionPrompts {
570 enter: Some("Task completed. Results should be attached.".to_string()),
571 exit: None,
572 },
573 },
574 );
575
576 states.insert(
577 "failed".to_string(),
578 StateWorkflow {
579 exits: vec!["pending".to_string()],
580 timed: false,
581 prompts: TransitionPrompts {
582 enter: Some(
583 "Task failed. Document: what was attempted, what blocked, suggested next steps."
584 .to_string(),
585 ),
586 exit: None,
587 },
588 },
589 );
590
591 states.insert(
592 "cancelled".to_string(),
593 StateWorkflow {
594 exits: Vec::new(),
595 timed: false,
596 prompts: TransitionPrompts::default(),
597 },
598 );
599
600 states
601}
602
603fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
605 let mut phases = HashMap::new();
606
607 phases.insert(
609 "explore".to_string(),
610 PhaseWorkflow {
611 prompts: TransitionPrompts {
612 enter: None,
613 exit: Some(
614 "Capture exploration findings before moving on.\nAttach discoveries to parent task for sibling agents.".to_string(),
615 ),
616 },
617 },
618 );
619
620 phases.insert(
621 "implement".to_string(),
622 PhaseWorkflow {
623 prompts: TransitionPrompts {
624 enter: Some("Implementation phase. Mark files before editing.".to_string()),
625 exit: None,
626 },
627 },
628 );
629
630 phases.insert(
631 "review".to_string(),
632 PhaseWorkflow {
633 prompts: TransitionPrompts {
634 enter: Some("Review: tests pass, no new warnings, docs updated.".to_string()),
635 exit: None,
636 },
637 },
638 );
639
640 phases.insert(
641 "test".to_string(),
642 PhaseWorkflow {
643 prompts: TransitionPrompts {
644 enter: Some(
645 "Testing phase. Verify the implementation works correctly.".to_string(),
646 ),
647 exit: None,
648 },
649 },
650 );
651
652 phases.insert(
653 "security".to_string(),
654 PhaseWorkflow {
655 prompts: TransitionPrompts {
656 enter: Some(
657 "Security: input validation, auth/authz, no secrets in code.".to_string(),
658 ),
659 exit: None,
660 },
661 },
662 );
663
664 for phase in &[
666 "deliver",
667 "triage",
668 "diagnose",
669 "design",
670 "plan",
671 "doc",
672 "integrate",
673 "deploy",
674 "monitor",
675 "optimize",
676 ] {
677 phases.insert(phase.to_string(), PhaseWorkflow::default());
678 }
679
680 phases
681}
682
683impl WorkflowsConfig {
684 pub fn get_state_enter_prompt(&self, state: &str) -> Option<&str> {
686 self.states
687 .get(state)
688 .and_then(|s| s.prompts.enter.as_deref())
689 }
690
691 pub fn get_state_exit_prompt(&self, state: &str) -> Option<&str> {
693 self.states
694 .get(state)
695 .and_then(|s| s.prompts.exit.as_deref())
696 }
697
698 pub fn get_phase_enter_prompt(&self, phase: &str) -> Option<&str> {
700 self.phases
701 .get(phase)
702 .and_then(|p| p.prompts.enter.as_deref())
703 }
704
705 pub fn get_phase_exit_prompt(&self, phase: &str) -> Option<&str> {
707 self.phases
708 .get(phase)
709 .and_then(|p| p.prompts.exit.as_deref())
710 }
711
712 pub fn get_combo_enter_prompt(&self, state: &str, phase: &str) -> Option<&str> {
714 let key = format!("{}+{}", state, phase);
715 self.combos.get(&key).and_then(|c| c.enter.as_deref())
716 }
717
718 pub fn get_combo_exit_prompt(&self, state: &str, phase: &str) -> Option<&str> {
720 let key = format!("{}+{}", state, phase);
721 self.combos.get(&key).and_then(|c| c.exit.as_deref())
722 }
723
724 pub fn get_prompt(&self, trigger: &str) -> Option<&str> {
734 if let Some(rest) = trigger.strip_prefix("enter~") {
735 if let Some(idx) = rest.find('%') {
736 let state = &rest[..idx];
738 let phase = &rest[idx + 1..];
739 self.get_combo_enter_prompt(state, phase)
740 } else {
741 self.get_state_enter_prompt(rest)
743 }
744 } else if let Some(rest) = trigger.strip_prefix("exit~") {
745 if let Some(idx) = rest.find('%') {
746 let state = &rest[..idx];
748 let phase = &rest[idx + 1..];
749 self.get_combo_exit_prompt(state, phase)
750 } else {
751 self.get_state_exit_prompt(rest)
753 }
754 } else if let Some(phase) = trigger.strip_prefix("enter%") {
755 self.get_phase_enter_prompt(phase)
756 } else if let Some(phase) = trigger.strip_prefix("exit%") {
757 self.get_phase_exit_prompt(phase)
758 } else {
759 None
760 }
761 }
762
763 pub fn list_prompt_triggers(&self) -> Vec<String> {
765 let mut triggers = Vec::new();
766
767 for (state, workflow) in &self.states {
769 if workflow.prompts.enter.is_some() {
770 triggers.push(format!("enter~{}", state));
771 }
772 if workflow.prompts.exit.is_some() {
773 triggers.push(format!("exit~{}", state));
774 }
775 }
776
777 for (phase, workflow) in &self.phases {
779 if workflow.prompts.enter.is_some() {
780 triggers.push(format!("enter%{}", phase));
781 }
782 if workflow.prompts.exit.is_some() {
783 triggers.push(format!("exit%{}", phase));
784 }
785 }
786
787 for (combo, prompts) in &self.combos {
789 if prompts.enter.is_some() {
790 triggers.push(format!("enter~{}", combo.replace('+', "%")));
791 }
792 if prompts.exit.is_some() {
793 triggers.push(format!("exit~{}", combo.replace('+', "%")));
794 }
795 }
796
797 triggers.sort();
798 triggers
799 }
800
801 pub fn get_status_exit_gates(&self, status: &str) -> Vec<&GateDefinition> {
804 self.gates
805 .get(&format!("status:{}", status))
806 .map(|v| v.iter().collect())
807 .unwrap_or_default()
808 }
809
810 pub fn get_phase_exit_gates(&self, phase: &str) -> Vec<&GateDefinition> {
813 self.gates
814 .get(&format!("phase:{}", phase))
815 .map(|v| v.iter().collect())
816 .unwrap_or_default()
817 }
818}
819
820impl From<&WorkflowsConfig> for StatesConfig {
822 fn from(workflows: &WorkflowsConfig) -> Self {
823 let definitions = workflows
824 .states
825 .iter()
826 .map(|(name, workflow)| {
827 (
828 name.clone(),
829 StateDefinition {
830 exits: workflow.exits.clone(),
831 timed: workflow.timed,
832 },
833 )
834 })
835 .collect();
836
837 StatesConfig {
838 initial: workflows.settings.initial_state.clone(),
839 disconnect_state: workflows.settings.disconnect_state.clone(),
840 blocking_states: workflows.settings.blocking_states.clone(),
841 definitions,
842 }
843 }
844}
845
846impl From<&WorkflowsConfig> for PhasesConfig {
848 fn from(workflows: &WorkflowsConfig) -> Self {
849 let definitions: HashSet<String> = workflows.phases.keys().cloned().collect();
850
851 PhasesConfig {
852 unknown_phase: workflows.settings.unknown_phase,
853 definitions,
854 }
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861
862 #[test]
863 fn test_default_workflows() {
864 let workflows = WorkflowsConfig::default();
865
866 assert_eq!(workflows.settings.initial_state, "pending");
868 assert_eq!(workflows.settings.disconnect_state, "pending");
869 assert!(
870 workflows
871 .settings
872 .blocking_states
873 .contains(&"working".to_string())
874 );
875
876 assert!(workflows.states.contains_key("pending"));
878 assert!(workflows.states.contains_key("working"));
879 assert!(workflows.states.contains_key("completed"));
880
881 assert!(workflows.states.get("working").unwrap().timed);
883
884 assert!(workflows.phases.contains_key("implement"));
886 assert!(workflows.phases.contains_key("test"));
887 }
888
889 #[test]
890 fn test_get_prompt() {
891 let workflows = WorkflowsConfig::default();
892
893 let prompt = workflows.get_prompt("enter~working");
895 assert!(prompt.is_some());
896 assert!(prompt.unwrap().contains("actively working"));
897
898 let prompt = workflows.get_prompt("exit~working");
900 assert!(prompt.is_some());
901 assert!(prompt.unwrap().contains("Unmark"));
902
903 let prompt = workflows.get_prompt("enter%implement");
905 assert!(prompt.is_some());
906 assert!(prompt.unwrap().contains("Implementation"));
907
908 let prompt = workflows.get_prompt("exit%explore");
910 assert!(prompt.is_some());
911 assert!(prompt.unwrap().contains("findings"));
912 }
913
914 #[test]
915 fn test_states_config_from_workflows() {
916 let workflows = WorkflowsConfig::default();
917 let states: StatesConfig = (&workflows).into();
918
919 assert_eq!(states.initial, "pending");
920 assert!(states.definitions.contains_key("working"));
921 assert!(states.definitions.get("working").unwrap().timed);
922 }
923
924 #[test]
925 fn test_phases_config_from_workflows() {
926 let workflows = WorkflowsConfig::default();
927 let phases: PhasesConfig = (&workflows).into();
928
929 assert!(phases.definitions.contains("implement"));
930 assert!(phases.definitions.contains("test"));
931 }
932
933 #[test]
934 fn test_list_prompt_triggers() {
935 let workflows = WorkflowsConfig::default();
936 let triggers = workflows.list_prompt_triggers();
937
938 assert!(triggers.contains(&"enter~working".to_string()));
939 assert!(triggers.contains(&"exit~working".to_string()));
940 assert!(triggers.contains(&"enter%implement".to_string()));
941 }
942
943 #[test]
944 fn test_all_role_tags_from_base_config() {
945 let mut workflows = WorkflowsConfig::default();
946 workflows.roles.insert(
947 "worker".to_string(),
948 RoleDefinition {
949 tags: vec!["worker".to_string(), "backend".to_string()],
950 ..Default::default()
951 },
952 );
953 workflows.roles.insert(
954 "lead".to_string(),
955 RoleDefinition {
956 tags: vec!["lead".to_string(), "coordinator".to_string()],
957 ..Default::default()
958 },
959 );
960
961 let tags = workflows.all_role_tags();
962 assert_eq!(tags.len(), 4);
963 assert!(tags.contains(&"worker".to_string()));
964 assert!(tags.contains(&"backend".to_string()));
965 assert!(tags.contains(&"lead".to_string()));
966 assert!(tags.contains(&"coordinator".to_string()));
967 }
968
969 #[test]
970 fn test_all_role_tags_includes_named_workflows() {
971 let mut workflows = WorkflowsConfig::default();
972
973 let mut named = WorkflowsConfig::default();
975 named.roles.insert(
976 "reviewer".to_string(),
977 RoleDefinition {
978 tags: vec!["reviewer".to_string()],
979 ..Default::default()
980 },
981 );
982 workflows
983 .named_workflows
984 .insert("review".to_string(), Arc::new(named));
985
986 let tags = workflows.all_role_tags();
988 assert_eq!(tags.len(), 1);
989 assert!(tags.contains(&"reviewer".to_string()));
990 }
991
992 #[test]
993 fn test_all_role_tags_deduplicates() {
994 let mut workflows = WorkflowsConfig::default();
995 workflows.roles.insert(
996 "worker".to_string(),
997 RoleDefinition {
998 tags: vec!["shared-tag".to_string()],
999 ..Default::default()
1000 },
1001 );
1002
1003 let mut named = WorkflowsConfig::default();
1004 named.roles.insert(
1005 "builder".to_string(),
1006 RoleDefinition {
1007 tags: vec!["shared-tag".to_string()],
1008 ..Default::default()
1009 },
1010 );
1011 workflows
1012 .named_workflows
1013 .insert("build".to_string(), Arc::new(named));
1014
1015 let tags = workflows.all_role_tags();
1016 assert_eq!(tags.len(), 1);
1017 assert!(tags.contains(&"shared-tag".to_string()));
1018 }
1019
1020 #[test]
1021 fn test_apply_overlay_adds_new_state() {
1022 let mut base = WorkflowsConfig::default();
1023 let mut overlay = WorkflowsConfig {
1024 states: HashMap::new(),
1025 phases: HashMap::new(),
1026 combos: HashMap::new(),
1027 gates: HashMap::new(),
1028 roles: HashMap::new(),
1029 role_prompts: HashMap::new(),
1030 ..Default::default()
1031 };
1032 overlay.states.insert(
1033 "reviewing".to_string(),
1034 StateWorkflow {
1035 exits: vec!["completed".to_string()],
1036 timed: true,
1037 prompts: TransitionPrompts {
1038 enter: Some("Review the changes.".to_string()),
1039 exit: None,
1040 },
1041 },
1042 );
1043
1044 base.apply_overlay(&overlay);
1045 assert!(base.states.contains_key("reviewing"));
1046 assert!(base.states["reviewing"].timed);
1047 assert_eq!(
1048 base.states["reviewing"].prompts.enter.as_deref(),
1049 Some("Review the changes.")
1050 );
1051 }
1052
1053 #[test]
1054 fn test_apply_overlay_appends_prompts() {
1055 let mut base = WorkflowsConfig::default();
1056 let original_enter = base.states["working"].prompts.enter.clone();
1057
1058 let mut overlay = WorkflowsConfig {
1059 states: HashMap::new(),
1060 phases: HashMap::new(),
1061 combos: HashMap::new(),
1062 gates: HashMap::new(),
1063 roles: HashMap::new(),
1064 role_prompts: HashMap::new(),
1065 ..Default::default()
1066 };
1067 overlay.states.insert(
1068 "working".to_string(),
1069 StateWorkflow {
1070 exits: vec![],
1071 timed: false,
1072 prompts: TransitionPrompts {
1073 enter: Some("Create a feature branch.".to_string()),
1074 exit: None,
1075 },
1076 },
1077 );
1078
1079 base.apply_overlay(&overlay);
1080 let enter = base.states["working"].prompts.enter.as_ref().unwrap();
1081 assert!(enter.contains(&original_enter.unwrap()));
1082 assert!(enter.contains("Create a feature branch."));
1083 assert!(enter.contains("---"));
1084 }
1085
1086 #[test]
1087 fn test_apply_overlay_unions_exits() {
1088 let mut base = WorkflowsConfig::default();
1089 let original_exits = base.states["working"].exits.clone();
1090
1091 let mut overlay = WorkflowsConfig {
1092 states: HashMap::new(),
1093 phases: HashMap::new(),
1094 combos: HashMap::new(),
1095 gates: HashMap::new(),
1096 roles: HashMap::new(),
1097 role_prompts: HashMap::new(),
1098 ..Default::default()
1099 };
1100 overlay.states.insert(
1101 "working".to_string(),
1102 StateWorkflow {
1103 exits: vec!["reviewing".to_string(), "completed".to_string()],
1104 timed: false,
1105 prompts: TransitionPrompts::default(),
1106 },
1107 );
1108
1109 base.apply_overlay(&overlay);
1110 assert!(
1112 base.states["working"]
1113 .exits
1114 .contains(&"reviewing".to_string())
1115 );
1116 for exit in &original_exits {
1117 assert!(base.states["working"].exits.contains(exit));
1118 }
1119 }
1120
1121 #[test]
1122 fn test_apply_overlay_extends_gates() {
1123 let mut base = WorkflowsConfig::default();
1124 let mut overlay = WorkflowsConfig {
1125 states: HashMap::new(),
1126 phases: HashMap::new(),
1127 combos: HashMap::new(),
1128 gates: HashMap::new(),
1129 roles: HashMap::new(),
1130 role_prompts: HashMap::new(),
1131 ..Default::default()
1132 };
1133 overlay.gates.insert(
1134 "status:completed".to_string(),
1135 vec![GateDefinition {
1136 gate_type: "gate/commit".to_string(),
1137 enforcement: super::super::types::GateEnforcement::Warn,
1138 description: "Changes should be committed.".to_string(),
1139 }],
1140 );
1141
1142 base.apply_overlay(&overlay);
1143 assert_eq!(base.gates["status:completed"].len(), 1);
1144 assert_eq!(base.gates["status:completed"][0].gate_type, "gate/commit");
1145 }
1146
1147 #[test]
1148 fn test_apply_overlay_roles_first_wins() {
1149 let mut base = WorkflowsConfig::default();
1150 base.roles.insert(
1151 "worker".to_string(),
1152 RoleDefinition {
1153 description: Some("Base worker".to_string()),
1154 tags: vec!["worker".to_string()],
1155 ..Default::default()
1156 },
1157 );
1158
1159 let mut overlay = WorkflowsConfig {
1160 states: HashMap::new(),
1161 phases: HashMap::new(),
1162 combos: HashMap::new(),
1163 gates: HashMap::new(),
1164 roles: HashMap::new(),
1165 role_prompts: HashMap::new(),
1166 ..Default::default()
1167 };
1168 overlay.roles.insert(
1169 "worker".to_string(),
1170 RoleDefinition {
1171 description: Some("Overlay worker".to_string()),
1172 tags: vec!["overlay-worker".to_string()],
1173 ..Default::default()
1174 },
1175 );
1176
1177 base.apply_overlay(&overlay);
1178 assert_eq!(
1180 base.roles["worker"].description.as_deref(),
1181 Some("Base worker")
1182 );
1183 }
1184
1185 #[test]
1186 fn test_compute_overlay_diff() {
1187 let base = WorkflowsConfig::default();
1188 let mut merged = base.clone();
1189
1190 let mut overlay = WorkflowsConfig {
1191 states: HashMap::new(),
1192 phases: HashMap::new(),
1193 combos: HashMap::new(),
1194 gates: HashMap::new(),
1195 roles: HashMap::new(),
1196 role_prompts: HashMap::new(),
1197 ..Default::default()
1198 };
1199 overlay.states.insert(
1200 "reviewing".to_string(),
1201 StateWorkflow {
1202 exits: vec!["completed".to_string()],
1203 timed: true,
1204 prompts: TransitionPrompts::default(),
1205 },
1206 );
1207 overlay.states.insert(
1208 "working".to_string(),
1209 StateWorkflow {
1210 exits: vec![],
1211 timed: false,
1212 prompts: TransitionPrompts {
1213 enter: Some("Git overlay prompt.".to_string()),
1214 exit: None,
1215 },
1216 },
1217 );
1218
1219 merged.apply_overlay(&overlay);
1220 let diff = merged.compute_overlay_diff(&base);
1221
1222 let states_added = diff["states_added"].as_array().unwrap();
1223 assert!(states_added.iter().any(|v| v.as_str() == Some("reviewing")));
1224
1225 let prompts_modified = diff["prompts_modified"].as_array().unwrap();
1226 assert!(
1227 prompts_modified
1228 .iter()
1229 .any(|v| v.as_str() == Some("enter~working"))
1230 );
1231 }
1232}