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, Serialize, Deserialize)]
113pub struct WorkflowsConfig {
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub name: Option<String>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub description: Option<String>,
122
123 #[serde(skip)]
126 pub source_file: Option<std::path::PathBuf>,
127
128 #[serde(default)]
130 pub settings: WorkflowSettings,
131
132 #[serde(default)]
134 pub states: HashMap<String, StateWorkflow>,
135
136 #[serde(default)]
138 pub phases: HashMap<String, PhaseWorkflow>,
139
140 #[serde(default)]
142 pub combos: HashMap<String, ComboPrompts>,
143
144 #[serde(default)]
147 pub gates: HashMap<String, Vec<GateDefinition>>,
148
149 #[serde(skip)]
152 pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
153
154 #[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 pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
180 self.named_workflows.get(name)
181 }
182
183 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
191fn 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
308fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
310 let mut phases = HashMap::new();
311
312 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 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 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 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 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 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 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 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 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 let state = &rest[..idx];
453 let phase = &rest[idx + 1..];
454 self.get_combo_enter_prompt(state, phase)
455 } else {
456 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 let state = &rest[..idx];
463 let phase = &rest[idx + 1..];
464 self.get_combo_exit_prompt(state, phase)
465 } else {
466 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 pub fn list_prompt_triggers(&self) -> Vec<String> {
480 let mut triggers = Vec::new();
481
482 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 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 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 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 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
535impl 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
561impl 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 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 assert!(workflows.states.contains_key("pending"));
593 assert!(workflows.states.contains_key("working"));
594 assert!(workflows.states.contains_key("completed"));
595
596 assert!(workflows.states.get("working").unwrap().timed);
598
599 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 let prompt = workflows.get_prompt("enter~working");
610 assert!(prompt.is_some());
611 assert!(prompt.unwrap().contains("actively working"));
612
613 let prompt = workflows.get_prompt("exit~working");
615 assert!(prompt.is_some());
616 assert!(prompt.unwrap().contains("Unmark"));
617
618 let prompt = workflows.get_prompt("enter%implement");
620 assert!(prompt.is_some());
621 assert!(prompt.unwrap().contains("Implementation"));
622
623 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}