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
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 pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
215 self.named_workflows.get(name)
216 }
217
218 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 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 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 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 pub fn get_role(&self, role_name: &str) -> Option<&RoleDefinition> {
260 self.roles.get(role_name)
261 }
262
263 pub fn all_role_tags(&self) -> Vec<String> {
266 let mut tags = std::collections::HashSet::new();
267 for role in self.roles.values() {
269 for tag in &role.tags {
270 tags.insert(tag.clone());
271 }
272 }
273 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
285fn 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
402fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
404 let mut phases = HashMap::new();
405
406 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 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 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 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 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 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 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 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 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 let state = &rest[..idx];
537 let phase = &rest[idx + 1..];
538 self.get_combo_enter_prompt(state, phase)
539 } else {
540 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 let state = &rest[..idx];
547 let phase = &rest[idx + 1..];
548 self.get_combo_exit_prompt(state, phase)
549 } else {
550 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 pub fn list_prompt_triggers(&self) -> Vec<String> {
564 let mut triggers = Vec::new();
565
566 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 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 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 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 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
619impl 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
645impl 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 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 assert!(workflows.states.contains_key("pending"));
677 assert!(workflows.states.contains_key("working"));
678 assert!(workflows.states.contains_key("completed"));
679
680 assert!(workflows.states.get("working").unwrap().timed);
682
683 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 let prompt = workflows.get_prompt("enter~working");
694 assert!(prompt.is_some());
695 assert!(prompt.unwrap().contains("actively working"));
696
697 let prompt = workflows.get_prompt("exit~working");
699 assert!(prompt.is_some());
700 assert!(prompt.unwrap().contains("Unmark"));
701
702 let prompt = workflows.get_prompt("enter%implement");
704 assert!(prompt.is_some());
705 assert!(prompt.unwrap().contains("Implementation"));
706
707 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 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 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}