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/// Unified workflow configuration.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct WorkflowsConfig {
114    /// Short identifier for the workflow (e.g., "swarm", "relay", "solo").
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub name: Option<String>,
117
118    /// Human-readable description of the workflow's coordination model.
119    /// Should explain when to choose this workflow and how agents coordinate.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub description: Option<String>,
122
123    /// Path to the source file this workflow was loaded from.
124    /// Not deserialized from YAML - populated by the loader.
125    #[serde(skip)]
126    pub source_file: Option<std::path::PathBuf>,
127
128    /// Global workflow settings.
129    #[serde(default)]
130    pub settings: WorkflowSettings,
131
132    /// State definitions with transitions, timing, and prompts.
133    #[serde(default)]
134    pub states: HashMap<String, StateWorkflow>,
135
136    /// Phase definitions with prompts.
137    #[serde(default)]
138    pub phases: HashMap<String, PhaseWorkflow>,
139
140    /// State+phase combination prompts (key format: "state+phase").
141    #[serde(default)]
142    pub combos: HashMap<String, ComboPrompts>,
143
144    /// Gate definitions for status and phase exits.
145    /// Keys are "status:<name>" or "phase:<name>", values are lists of gate definitions.
146    #[serde(default)]
147    pub gates: HashMap<String, Vec<GateDefinition>>,
148
149    /// Cache of named workflow configs (e.g., "swarm" -> workflow-swarm.yaml).
150    /// Populated at server startup, not serialized.
151    #[serde(skip)]
152    pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
153
154    /// Key to look up the default workflow in named_workflows cache.
155    /// If set, workers without a workflow use this instead of the base config.
156    #[serde(skip)]
157    pub default_workflow_key: Option<String>,
158}
159
160impl Default for WorkflowsConfig {
161    fn default() -> Self {
162        Self {
163            name: None,
164            description: None,
165            source_file: None,
166            settings: WorkflowSettings::default(),
167            states: default_state_workflows(),
168            phases: default_phase_workflows(),
169            combos: HashMap::new(),
170            gates: HashMap::new(),
171            named_workflows: HashMap::new(),
172            default_workflow_key: None,
173        }
174    }
175}
176
177impl WorkflowsConfig {
178    /// Get a named workflow config, or None if not found.
179    pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
180        self.named_workflows.get(name)
181    }
182
183    /// Get the default workflow config from the cache, if one is configured.
184    pub fn get_default_workflow(&self) -> Option<&Arc<WorkflowsConfig>> {
185        self.default_workflow_key
186            .as_ref()
187            .and_then(|key| self.named_workflows.get(key))
188    }
189}
190
191/// Default state workflow definitions.
192fn default_state_workflows() -> HashMap<String, StateWorkflow> {
193    let mut states = HashMap::new();
194
195    states.insert(
196        "pending".to_string(),
197        StateWorkflow {
198            exits: vec![
199                "assigned".to_string(),
200                "working".to_string(),
201                "cancelled".to_string(),
202            ],
203            timed: false,
204            prompts: TransitionPrompts::default(),
205        },
206    );
207
208    states.insert(
209        "assigned".to_string(),
210        StateWorkflow {
211            exits: vec![
212                "working".to_string(),
213                "pending".to_string(),
214                "cancelled".to_string(),
215            ],
216            timed: false,
217            prompts: TransitionPrompts {
218                enter: Some(
219                    "A task has been assigned to you. Review and claim when ready.".to_string(),
220                ),
221                exit: None,
222            },
223        },
224    );
225
226    states.insert(
227        "working".to_string(),
228        StateWorkflow {
229            exits: vec![
230                "completed".to_string(),
231                "failed".to_string(),
232                "pending".to_string(),
233            ],
234            timed: true,
235            prompts: TransitionPrompts {
236                enter: Some(
237                    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.
238
239## Valid Next States
240
241From `{{current_status}}` you can transition to:
242{{valid_exits}}
243
244Use `update(status="completed")` when done, `update(status="failed")` if blocked, or `update(status="pending")` to release without completing.
245
246## Phase
247
248Current phase: {{current_phase}}
249
250Valid phases: {{valid_phases}}
251
252Set a phase with `update(phase="implement")` to categorize the type of work you're doing."#
253                        .to_string(),
254                ),
255                exit: Some(
256                    r#"Before leaving working state:
257- [ ] Unmark any files you marked
258- [ ] Attach results or notes
259- [ ] Log costs with `log_metrics()`"#
260                        .to_string(),
261                ),
262            },
263        },
264    );
265
266    states.insert(
267        "completed".to_string(),
268        StateWorkflow {
269            exits: vec!["pending".to_string()],
270            timed: false,
271            prompts: TransitionPrompts {
272                enter: Some("Task completed. Results should be attached.".to_string()),
273                exit: None,
274            },
275        },
276    );
277
278    states.insert(
279        "failed".to_string(),
280        StateWorkflow {
281            exits: vec!["pending".to_string()],
282            timed: false,
283            prompts: TransitionPrompts {
284                enter: Some(
285                    r#"Task failed. Please document:
286- What was attempted
287- What blocked progress
288- Suggested next steps"#
289                        .to_string(),
290                ),
291                exit: None,
292            },
293        },
294    );
295
296    states.insert(
297        "cancelled".to_string(),
298        StateWorkflow {
299            exits: Vec::new(),
300            timed: false,
301            prompts: TransitionPrompts::default(),
302        },
303    );
304
305    states
306}
307
308/// Default phase workflow definitions.
309fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
310    let mut phases = HashMap::new();
311
312    // Phases with prompts
313    phases.insert(
314        "explore".to_string(),
315        PhaseWorkflow {
316            prompts: TransitionPrompts {
317                enter: None,
318                exit: Some(
319                    "Capture exploration findings before moving on.\nAttach discoveries to parent task for sibling agents.".to_string(),
320                ),
321            },
322        },
323    );
324
325    phases.insert(
326        "implement".to_string(),
327        PhaseWorkflow {
328            prompts: TransitionPrompts {
329                enter: Some("Implementation phase. Mark files before editing.".to_string()),
330                exit: None,
331            },
332        },
333    );
334
335    phases.insert(
336        "review".to_string(),
337        PhaseWorkflow {
338            prompts: TransitionPrompts {
339                enter: Some(
340                    r#"## Code Review Checklist
341- [ ] Tests pass
342- [ ] No new warnings
343- [ ] Documentation updated"#
344                        .to_string(),
345                ),
346                exit: None,
347            },
348        },
349    );
350
351    phases.insert(
352        "test".to_string(),
353        PhaseWorkflow {
354            prompts: TransitionPrompts {
355                enter: Some(
356                    "Testing phase. Verify the implementation works correctly.".to_string(),
357                ),
358                exit: None,
359            },
360        },
361    );
362
363    phases.insert(
364        "security".to_string(),
365        PhaseWorkflow {
366            prompts: TransitionPrompts {
367                enter: Some(
368                    r#"## Security Review
369- [ ] Input validation
370- [ ] Auth/authz checks
371- [ ] No secrets in code"#
372                        .to_string(),
373                ),
374                exit: None,
375            },
376        },
377    );
378
379    // Phases without prompts
380    for phase in &[
381        "deliver",
382        "triage",
383        "diagnose",
384        "design",
385        "plan",
386        "doc",
387        "integrate",
388        "deploy",
389        "monitor",
390        "optimize",
391    ] {
392        phases.insert(phase.to_string(), PhaseWorkflow::default());
393    }
394
395    phases
396}
397
398impl WorkflowsConfig {
399    /// Get the enter prompt for a state.
400    pub fn get_state_enter_prompt(&self, state: &str) -> Option<&str> {
401        self.states
402            .get(state)
403            .and_then(|s| s.prompts.enter.as_deref())
404    }
405
406    /// Get the exit prompt for a state.
407    pub fn get_state_exit_prompt(&self, state: &str) -> Option<&str> {
408        self.states
409            .get(state)
410            .and_then(|s| s.prompts.exit.as_deref())
411    }
412
413    /// Get the enter prompt for a phase.
414    pub fn get_phase_enter_prompt(&self, phase: &str) -> Option<&str> {
415        self.phases
416            .get(phase)
417            .and_then(|p| p.prompts.enter.as_deref())
418    }
419
420    /// Get the exit prompt for a phase.
421    pub fn get_phase_exit_prompt(&self, phase: &str) -> Option<&str> {
422        self.phases
423            .get(phase)
424            .and_then(|p| p.prompts.exit.as_deref())
425    }
426
427    /// Get the enter prompt for a state+phase combo.
428    pub fn get_combo_enter_prompt(&self, state: &str, phase: &str) -> Option<&str> {
429        let key = format!("{}+{}", state, phase);
430        self.combos.get(&key).and_then(|c| c.enter.as_deref())
431    }
432
433    /// Get the exit prompt for a state+phase combo.
434    pub fn get_combo_exit_prompt(&self, state: &str, phase: &str) -> Option<&str> {
435        let key = format!("{}+{}", state, phase);
436        self.combos.get(&key).and_then(|c| c.exit.as_deref())
437    }
438
439    /// Get a prompt by trigger name.
440    ///
441    /// Trigger format:
442    /// - `enter~{state}` - entering a state
443    /// - `exit~{state}` - exiting a state
444    /// - `enter%{phase}` - entering a phase
445    /// - `exit%{phase}` - exiting a phase
446    /// - `enter~{state}%{phase}` - entering a state+phase combo
447    /// - `exit~{state}%{phase}` - exiting a state+phase combo
448    pub fn get_prompt(&self, trigger: &str) -> Option<&str> {
449        if let Some(rest) = trigger.strip_prefix("enter~") {
450            if let Some(idx) = rest.find('%') {
451                // Combo: enter~state%phase
452                let state = &rest[..idx];
453                let phase = &rest[idx + 1..];
454                self.get_combo_enter_prompt(state, phase)
455            } else {
456                // State: enter~state
457                self.get_state_enter_prompt(rest)
458            }
459        } else if let Some(rest) = trigger.strip_prefix("exit~") {
460            if let Some(idx) = rest.find('%') {
461                // Combo: exit~state%phase
462                let state = &rest[..idx];
463                let phase = &rest[idx + 1..];
464                self.get_combo_exit_prompt(state, phase)
465            } else {
466                // State: exit~state
467                self.get_state_exit_prompt(rest)
468            }
469        } else if let Some(phase) = trigger.strip_prefix("enter%") {
470            self.get_phase_enter_prompt(phase)
471        } else if let Some(phase) = trigger.strip_prefix("exit%") {
472            self.get_phase_exit_prompt(phase)
473        } else {
474            None
475        }
476    }
477
478    /// List all available prompt triggers.
479    pub fn list_prompt_triggers(&self) -> Vec<String> {
480        let mut triggers = Vec::new();
481
482        // State prompts
483        for (state, workflow) in &self.states {
484            if workflow.prompts.enter.is_some() {
485                triggers.push(format!("enter~{}", state));
486            }
487            if workflow.prompts.exit.is_some() {
488                triggers.push(format!("exit~{}", state));
489            }
490        }
491
492        // Phase prompts
493        for (phase, workflow) in &self.phases {
494            if workflow.prompts.enter.is_some() {
495                triggers.push(format!("enter%{}", phase));
496            }
497            if workflow.prompts.exit.is_some() {
498                triggers.push(format!("exit%{}", phase));
499            }
500        }
501
502        // Combo prompts
503        for (combo, prompts) in &self.combos {
504            if prompts.enter.is_some() {
505                triggers.push(format!("enter~{}", combo.replace('+', "%")));
506            }
507            if prompts.exit.is_some() {
508                triggers.push(format!("exit~{}", combo.replace('+', "%")));
509            }
510        }
511
512        triggers.sort();
513        triggers
514    }
515
516    /// Get exit gates for a status transition.
517    /// Returns gates defined under "status:<name>" key.
518    pub fn get_status_exit_gates(&self, status: &str) -> Vec<&GateDefinition> {
519        self.gates
520            .get(&format!("status:{}", status))
521            .map(|v| v.iter().collect())
522            .unwrap_or_default()
523    }
524
525    /// Get exit gates for a phase transition.
526    /// Returns gates defined under "phase:<name>" key.
527    pub fn get_phase_exit_gates(&self, phase: &str) -> Vec<&GateDefinition> {
528        self.gates
529            .get(&format!("phase:{}", phase))
530            .map(|v| v.iter().collect())
531            .unwrap_or_default()
532    }
533}
534
535/// Convert WorkflowsConfig to StatesConfig for backwards compatibility.
536impl From<&WorkflowsConfig> for StatesConfig {
537    fn from(workflows: &WorkflowsConfig) -> Self {
538        let definitions = workflows
539            .states
540            .iter()
541            .map(|(name, workflow)| {
542                (
543                    name.clone(),
544                    StateDefinition {
545                        exits: workflow.exits.clone(),
546                        timed: workflow.timed,
547                    },
548                )
549            })
550            .collect();
551
552        StatesConfig {
553            initial: workflows.settings.initial_state.clone(),
554            disconnect_state: workflows.settings.disconnect_state.clone(),
555            blocking_states: workflows.settings.blocking_states.clone(),
556            definitions,
557        }
558    }
559}
560
561/// Convert WorkflowsConfig to PhasesConfig for backwards compatibility.
562impl From<&WorkflowsConfig> for PhasesConfig {
563    fn from(workflows: &WorkflowsConfig) -> Self {
564        let definitions: HashSet<String> = workflows.phases.keys().cloned().collect();
565
566        PhasesConfig {
567            unknown_phase: workflows.settings.unknown_phase,
568            definitions,
569        }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_default_workflows() {
579        let workflows = WorkflowsConfig::default();
580
581        // Check settings
582        assert_eq!(workflows.settings.initial_state, "pending");
583        assert_eq!(workflows.settings.disconnect_state, "pending");
584        assert!(
585            workflows
586                .settings
587                .blocking_states
588                .contains(&"working".to_string())
589        );
590
591        // Check states
592        assert!(workflows.states.contains_key("pending"));
593        assert!(workflows.states.contains_key("working"));
594        assert!(workflows.states.contains_key("completed"));
595
596        // Check working is timed
597        assert!(workflows.states.get("working").unwrap().timed);
598
599        // Check phases
600        assert!(workflows.phases.contains_key("implement"));
601        assert!(workflows.phases.contains_key("test"));
602    }
603
604    #[test]
605    fn test_get_prompt() {
606        let workflows = WorkflowsConfig::default();
607
608        // State enter prompt
609        let prompt = workflows.get_prompt("enter~working");
610        assert!(prompt.is_some());
611        assert!(prompt.unwrap().contains("actively working"));
612
613        // State exit prompt
614        let prompt = workflows.get_prompt("exit~working");
615        assert!(prompt.is_some());
616        assert!(prompt.unwrap().contains("Unmark"));
617
618        // Phase enter prompt
619        let prompt = workflows.get_prompt("enter%implement");
620        assert!(prompt.is_some());
621        assert!(prompt.unwrap().contains("Implementation"));
622
623        // Phase exit prompt
624        let prompt = workflows.get_prompt("exit%explore");
625        assert!(prompt.is_some());
626        assert!(prompt.unwrap().contains("findings"));
627    }
628
629    #[test]
630    fn test_states_config_from_workflows() {
631        let workflows = WorkflowsConfig::default();
632        let states: StatesConfig = (&workflows).into();
633
634        assert_eq!(states.initial, "pending");
635        assert!(states.definitions.contains_key("working"));
636        assert!(states.definitions.get("working").unwrap().timed);
637    }
638
639    #[test]
640    fn test_phases_config_from_workflows() {
641        let workflows = WorkflowsConfig::default();
642        let phases: PhasesConfig = (&workflows).into();
643
644        assert!(phases.definitions.contains("implement"));
645        assert!(phases.definitions.contains("test"));
646    }
647
648    #[test]
649    fn test_list_prompt_triggers() {
650        let workflows = WorkflowsConfig::default();
651        let triggers = workflows.list_prompt_triggers();
652
653        assert!(triggers.contains(&"enter~working".to_string()));
654        assert!(triggers.contains(&"exit~working".to_string()));
655        assert!(triggers.contains(&"enter%implement".to_string()));
656    }
657}