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)]
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
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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct PhaseWorkflow {
104 #[serde(default)]
106 pub prompts: TransitionPrompts,
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct ComboPrompts {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub enter: Option<String>,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub exit: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct WorkflowsConfig {
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub name: Option<String>,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub description: Option<String>,
132
133 #[serde(skip)]
136 pub source_file: Option<std::path::PathBuf>,
137
138 #[serde(default)]
140 pub settings: WorkflowSettings,
141
142 #[serde(default)]
144 pub states: HashMap<String, StateWorkflow>,
145
146 #[serde(default)]
148 pub phases: HashMap<String, PhaseWorkflow>,
149
150 #[serde(default)]
152 pub combos: HashMap<String, ComboPrompts>,
153
154 #[serde(default)]
157 pub gates: HashMap<String, Vec<GateDefinition>>,
158
159 #[serde(skip)]
162 pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
163
164 #[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 pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
190 self.named_workflows.get(name)
191 }
192
193 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
201fn 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
318fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
320 let mut phases = HashMap::new();
321
322 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 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 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 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 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 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 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 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 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 let state = &rest[..idx];
463 let phase = &rest[idx + 1..];
464 self.get_combo_enter_prompt(state, phase)
465 } else {
466 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 let state = &rest[..idx];
473 let phase = &rest[idx + 1..];
474 self.get_combo_exit_prompt(state, phase)
475 } else {
476 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 pub fn list_prompt_triggers(&self) -> Vec<String> {
490 let mut triggers = Vec::new();
491
492 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 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 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 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 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
545impl 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
571impl 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 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 assert!(workflows.states.contains_key("pending"));
603 assert!(workflows.states.contains_key("working"));
604 assert!(workflows.states.contains_key("completed"));
605
606 assert!(workflows.states.get("working").unwrap().timed);
608
609 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 let prompt = workflows.get_prompt("enter~working");
620 assert!(prompt.is_some());
621 assert!(prompt.unwrap().contains("actively working"));
622
623 let prompt = workflows.get_prompt("exit~working");
625 assert!(prompt.is_some());
626 assert!(prompt.unwrap().contains("Unmark"));
627
628 let prompt = workflows.get_prompt("enter%implement");
630 assert!(prompt.is_some());
631 assert!(prompt.unwrap().contains("Implementation"));
632
633 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}