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