use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use super::types::{
GateDefinition, PhasesConfig, StateDefinition, StatesConfig, UnknownKeyBehavior,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowSettings {
#[serde(default = "default_initial_state")]
pub initial_state: String,
#[serde(default = "default_disconnect_state")]
pub disconnect_state: String,
#[serde(default = "default_blocking_states")]
pub blocking_states: Vec<String>,
#[serde(default)]
pub unknown_phase: UnknownKeyBehavior,
}
fn default_initial_state() -> String {
"pending".to_string()
}
fn default_disconnect_state() -> String {
"pending".to_string()
}
fn default_blocking_states() -> Vec<String> {
vec![
"pending".to_string(),
"assigned".to_string(),
"working".to_string(),
]
}
impl Default for WorkflowSettings {
fn default() -> Self {
Self {
initial_state: default_initial_state(),
disconnect_state: default_disconnect_state(),
blocking_states: default_blocking_states(),
unknown_phase: UnknownKeyBehavior::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TransitionPrompts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StateWorkflow {
#[serde(default)]
pub exits: Vec<String>,
#[serde(default)]
pub timed: bool,
#[serde(default)]
pub prompts: TransitionPrompts,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PhaseWorkflow {
#[serde(default)]
pub prompts: TransitionPrompts,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComboPrompts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RoleDefinition {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_claims: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub can_assign: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub can_create_subtasks: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip)]
pub source_file: Option<std::path::PathBuf>,
#[serde(default)]
pub settings: WorkflowSettings,
#[serde(default)]
pub states: HashMap<String, StateWorkflow>,
#[serde(default)]
pub phases: HashMap<String, PhaseWorkflow>,
#[serde(default)]
pub combos: HashMap<String, ComboPrompts>,
#[serde(default)]
pub gates: HashMap<String, Vec<GateDefinition>>,
#[serde(default)]
pub roles: HashMap<String, RoleDefinition>,
#[serde(default)]
pub role_prompts: HashMap<String, HashMap<String, String>>,
#[serde(skip)]
pub named_workflows: HashMap<String, Arc<WorkflowsConfig>>,
#[serde(skip)]
pub default_workflow_key: Option<String>,
#[serde(skip)]
pub named_overlays: HashMap<String, Arc<WorkflowsConfig>>,
#[serde(skip)]
pub active_overlays: Vec<String>,
}
impl Default for WorkflowsConfig {
fn default() -> Self {
Self {
name: None,
description: None,
source_file: None,
settings: WorkflowSettings::default(),
states: default_state_workflows(),
phases: default_phase_workflows(),
combos: HashMap::new(),
gates: HashMap::new(),
roles: HashMap::new(),
role_prompts: HashMap::new(),
named_workflows: HashMap::new(),
default_workflow_key: None,
named_overlays: HashMap::new(),
active_overlays: Vec::new(),
}
}
}
impl WorkflowsConfig {
pub fn get_named_workflow(&self, name: &str) -> Option<&Arc<WorkflowsConfig>> {
self.named_workflows.get(name)
}
pub fn get_default_workflow(&self) -> Option<&Arc<WorkflowsConfig>> {
self.default_workflow_key
.as_ref()
.and_then(|key| self.named_workflows.get(key))
}
pub fn match_role(&self, worker_tags: &[String]) -> Option<String> {
let mut role_names: Vec<&String> = self.roles.keys().collect();
role_names.sort();
for role_name in role_names {
if let Some(role) = self.roles.get(role_name)
&& role.tags.iter().any(|t| worker_tags.contains(t))
{
return Some(role_name.clone());
}
}
None
}
pub fn get_role_prompts(&self, role_name: &str) -> HashMap<String, String> {
self.role_prompts
.get(role_name)
.cloned()
.unwrap_or_default()
}
pub fn get_role_prompt(&self, role_name: &str, prompt_key: &str) -> Option<&str> {
self.role_prompts
.get(role_name)
.and_then(|prompts| prompts.get(prompt_key))
.map(|s| s.as_str())
}
pub fn get_role(&self, role_name: &str) -> Option<&RoleDefinition> {
self.roles.get(role_name)
}
pub fn all_role_tags(&self) -> Vec<String> {
let mut tags = std::collections::HashSet::new();
for role in self.roles.values() {
for tag in &role.tags {
tags.insert(tag.clone());
}
}
for workflow in self.named_workflows.values() {
for role in workflow.roles.values() {
for tag in &role.tags {
tags.insert(tag.clone());
}
}
}
for overlay in self.named_overlays.values() {
for role in overlay.roles.values() {
for tag in &role.tags {
tags.insert(tag.clone());
}
}
}
tags.into_iter().collect()
}
pub fn apply_overlay(&mut self, overlay: &WorkflowsConfig) {
const PROMPT_SEPARATOR: &str = "\n\n---\n\n";
for (name, overlay_state) in &overlay.states {
if let Some(existing) = self.states.get_mut(name) {
for exit in &overlay_state.exits {
if !existing.exits.contains(exit) {
existing.exits.push(exit.clone());
}
}
existing.timed |= overlay_state.timed;
append_prompt(
&mut existing.prompts.enter,
&overlay_state.prompts.enter,
PROMPT_SEPARATOR,
);
append_prompt(
&mut existing.prompts.exit,
&overlay_state.prompts.exit,
PROMPT_SEPARATOR,
);
} else {
self.states.insert(name.clone(), overlay_state.clone());
}
}
for (name, overlay_phase) in &overlay.phases {
if let Some(existing) = self.phases.get_mut(name) {
append_prompt(
&mut existing.prompts.enter,
&overlay_phase.prompts.enter,
PROMPT_SEPARATOR,
);
append_prompt(
&mut existing.prompts.exit,
&overlay_phase.prompts.exit,
PROMPT_SEPARATOR,
);
} else {
self.phases.insert(name.clone(), overlay_phase.clone());
}
}
for (name, overlay_combo) in &overlay.combos {
if let Some(existing) = self.combos.get_mut(name) {
append_optional_prompt(&mut existing.enter, &overlay_combo.enter, PROMPT_SEPARATOR);
append_optional_prompt(&mut existing.exit, &overlay_combo.exit, PROMPT_SEPARATOR);
} else {
self.combos.insert(name.clone(), overlay_combo.clone());
}
}
for (key, overlay_gates) in &overlay.gates {
self.gates
.entry(key.clone())
.or_default()
.extend(overlay_gates.iter().cloned());
}
for (name, overlay_role) in &overlay.roles {
self.roles
.entry(name.clone())
.or_insert_with(|| overlay_role.clone());
}
for (role_name, overlay_prompts) in &overlay.role_prompts {
let existing = self.role_prompts.entry(role_name.clone()).or_default();
for (key, overlay_value) in overlay_prompts {
existing
.entry(key.clone())
.and_modify(|v| {
v.push_str(PROMPT_SEPARATOR);
v.push_str(overlay_value);
})
.or_insert_with(|| overlay_value.clone());
}
}
if overlay.settings.initial_state != default_initial_state() {
self.settings.initial_state = overlay.settings.initial_state.clone();
}
for state in &overlay.settings.blocking_states {
if !self.settings.blocking_states.contains(state) {
self.settings.blocking_states.push(state.clone());
}
}
}
pub fn compute_overlay_diff(&self, base: &WorkflowsConfig) -> serde_json::Value {
let mut states_added: Vec<String> = Vec::new();
let mut exits_added: HashMap<String, Vec<String>> = HashMap::new();
let mut gates_added: Vec<String> = Vec::new();
let mut prompts_modified: Vec<String> = Vec::new();
for (name, state) in &self.states {
if !base.states.contains_key(name) {
states_added.push(name.clone());
} else {
let base_state = &base.states[name];
let new_exits: Vec<String> = state
.exits
.iter()
.filter(|e| !base_state.exits.contains(e))
.cloned()
.collect();
if !new_exits.is_empty() {
exits_added.insert(name.clone(), new_exits);
}
if state.prompts.enter != base_state.prompts.enter {
prompts_modified.push(format!("enter~{}", name));
}
if state.prompts.exit != base_state.prompts.exit {
prompts_modified.push(format!("exit~{}", name));
}
}
}
for key in self.gates.keys() {
if !base.gates.contains_key(key) {
gates_added.push(key.clone());
} else if self.gates[key].len() > base.gates[key].len() {
gates_added.push(format!(
"{}(+{})",
key,
self.gates[key].len() - base.gates[key].len()
));
}
}
serde_json::json!({
"states_added": states_added,
"exits_added": exits_added,
"gates_added": gates_added,
"prompts_modified": prompts_modified,
})
}
}
fn append_prompt(target: &mut Option<String>, source: &Option<String>, separator: &str) {
if let Some(src) = source {
match target {
Some(existing) => {
existing.push_str(separator);
existing.push_str(src);
}
None => *target = Some(src.clone()),
}
}
}
fn append_optional_prompt(target: &mut Option<String>, source: &Option<String>, separator: &str) {
append_prompt(target, source, separator);
}
fn default_state_workflows() -> HashMap<String, StateWorkflow> {
let mut states = HashMap::new();
states.insert(
"pending".to_string(),
StateWorkflow {
exits: vec![
"assigned".to_string(),
"working".to_string(),
"cancelled".to_string(),
],
timed: false,
prompts: TransitionPrompts::default(),
},
);
states.insert(
"assigned".to_string(),
StateWorkflow {
exits: vec![
"working".to_string(),
"pending".to_string(),
"cancelled".to_string(),
],
timed: false,
prompts: TransitionPrompts {
enter: Some(
"A task has been assigned to you. Review and claim when ready.".to_string(),
),
exit: None,
},
},
);
states.insert(
"working".to_string(),
StateWorkflow {
exits: vec![
"completed".to_string(),
"failed".to_string(),
"pending".to_string(),
],
timed: true,
prompts: TransitionPrompts {
enter: Some(
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.
### Heartbeat & Coordination
- Call `thinking(agent=your_id, thought="...")` regularly to maintain heartbeat
- Call `mark_updates(agent=your_id)` every 30-60s during long operations to detect file conflicts
- Stale workers (no heartbeat for 5+ min) get evicted automatically
- The lead monitors worker heartbeats -- stay visible to avoid reassignment
## Valid Next States
From `working` you can transition to:
{{valid_exits}}
Use `update(status="completed")` when done, `update(status="failed")` if blocked, or `update(status="pending")` to release without completing.
## Phase
Current phase: {{current_phase}}
Valid phases: {{valid_phases}}
Set a phase with `update(phase="implement")` to categorize the type of work you're doing.
"#
.to_string(),
),
exit: Some(
"Before completing:\n- [ ] Unmark files\n- [ ] Attach results or notes\n- [ ] `log_metrics()`".to_string(),
),
},
},
);
states.insert(
"completed".to_string(),
StateWorkflow {
exits: vec!["pending".to_string()],
timed: false,
prompts: TransitionPrompts {
enter: Some("Task completed. Results should be attached.".to_string()),
exit: None,
},
},
);
states.insert(
"failed".to_string(),
StateWorkflow {
exits: vec!["pending".to_string()],
timed: false,
prompts: TransitionPrompts {
enter: Some(
"Task failed. Document: what was attempted, what blocked, suggested next steps."
.to_string(),
),
exit: None,
},
},
);
states.insert(
"cancelled".to_string(),
StateWorkflow {
exits: Vec::new(),
timed: false,
prompts: TransitionPrompts::default(),
},
);
states
}
fn default_phase_workflows() -> HashMap<String, PhaseWorkflow> {
let mut phases = HashMap::new();
phases.insert(
"explore".to_string(),
PhaseWorkflow {
prompts: TransitionPrompts {
enter: None,
exit: Some(
"Capture exploration findings before moving on.\nAttach discoveries to parent task for sibling agents.".to_string(),
),
},
},
);
phases.insert(
"implement".to_string(),
PhaseWorkflow {
prompts: TransitionPrompts {
enter: Some("Implementation phase. Mark files before editing.".to_string()),
exit: None,
},
},
);
phases.insert(
"review".to_string(),
PhaseWorkflow {
prompts: TransitionPrompts {
enter: Some("Review: tests pass, no new warnings, docs updated.".to_string()),
exit: None,
},
},
);
phases.insert(
"test".to_string(),
PhaseWorkflow {
prompts: TransitionPrompts {
enter: Some(
"Testing phase. Verify the implementation works correctly.".to_string(),
),
exit: None,
},
},
);
phases.insert(
"security".to_string(),
PhaseWorkflow {
prompts: TransitionPrompts {
enter: Some(
"Security: input validation, auth/authz, no secrets in code.".to_string(),
),
exit: None,
},
},
);
for phase in &[
"deliver",
"triage",
"diagnose",
"design",
"plan",
"doc",
"integrate",
"deploy",
"monitor",
"optimize",
] {
phases.insert(phase.to_string(), PhaseWorkflow::default());
}
phases
}
impl WorkflowsConfig {
pub fn get_state_enter_prompt(&self, state: &str) -> Option<&str> {
self.states
.get(state)
.and_then(|s| s.prompts.enter.as_deref())
}
pub fn get_state_exit_prompt(&self, state: &str) -> Option<&str> {
self.states
.get(state)
.and_then(|s| s.prompts.exit.as_deref())
}
pub fn get_phase_enter_prompt(&self, phase: &str) -> Option<&str> {
self.phases
.get(phase)
.and_then(|p| p.prompts.enter.as_deref())
}
pub fn get_phase_exit_prompt(&self, phase: &str) -> Option<&str> {
self.phases
.get(phase)
.and_then(|p| p.prompts.exit.as_deref())
}
pub fn get_combo_enter_prompt(&self, state: &str, phase: &str) -> Option<&str> {
let key = format!("{}+{}", state, phase);
self.combos.get(&key).and_then(|c| c.enter.as_deref())
}
pub fn get_combo_exit_prompt(&self, state: &str, phase: &str) -> Option<&str> {
let key = format!("{}+{}", state, phase);
self.combos.get(&key).and_then(|c| c.exit.as_deref())
}
pub fn get_prompt(&self, trigger: &str) -> Option<&str> {
if let Some(rest) = trigger.strip_prefix("enter~") {
if let Some(idx) = rest.find('%') {
let state = &rest[..idx];
let phase = &rest[idx + 1..];
self.get_combo_enter_prompt(state, phase)
} else {
self.get_state_enter_prompt(rest)
}
} else if let Some(rest) = trigger.strip_prefix("exit~") {
if let Some(idx) = rest.find('%') {
let state = &rest[..idx];
let phase = &rest[idx + 1..];
self.get_combo_exit_prompt(state, phase)
} else {
self.get_state_exit_prompt(rest)
}
} else if let Some(phase) = trigger.strip_prefix("enter%") {
self.get_phase_enter_prompt(phase)
} else if let Some(phase) = trigger.strip_prefix("exit%") {
self.get_phase_exit_prompt(phase)
} else {
None
}
}
pub fn list_prompt_triggers(&self) -> Vec<String> {
let mut triggers = Vec::new();
for (state, workflow) in &self.states {
if workflow.prompts.enter.is_some() {
triggers.push(format!("enter~{}", state));
}
if workflow.prompts.exit.is_some() {
triggers.push(format!("exit~{}", state));
}
}
for (phase, workflow) in &self.phases {
if workflow.prompts.enter.is_some() {
triggers.push(format!("enter%{}", phase));
}
if workflow.prompts.exit.is_some() {
triggers.push(format!("exit%{}", phase));
}
}
for (combo, prompts) in &self.combos {
if prompts.enter.is_some() {
triggers.push(format!("enter~{}", combo.replace('+', "%")));
}
if prompts.exit.is_some() {
triggers.push(format!("exit~{}", combo.replace('+', "%")));
}
}
triggers.sort();
triggers
}
pub fn get_status_exit_gates(&self, status: &str) -> Vec<&GateDefinition> {
self.gates
.get(&format!("status:{}", status))
.map(|v| v.iter().collect())
.unwrap_or_default()
}
pub fn get_phase_exit_gates(&self, phase: &str) -> Vec<&GateDefinition> {
self.gates
.get(&format!("phase:{}", phase))
.map(|v| v.iter().collect())
.unwrap_or_default()
}
}
impl From<&WorkflowsConfig> for StatesConfig {
fn from(workflows: &WorkflowsConfig) -> Self {
let definitions = workflows
.states
.iter()
.map(|(name, workflow)| {
(
name.clone(),
StateDefinition {
exits: workflow.exits.clone(),
timed: workflow.timed,
},
)
})
.collect();
StatesConfig {
initial: workflows.settings.initial_state.clone(),
disconnect_state: workflows.settings.disconnect_state.clone(),
blocking_states: workflows.settings.blocking_states.clone(),
definitions,
}
}
}
impl From<&WorkflowsConfig> for PhasesConfig {
fn from(workflows: &WorkflowsConfig) -> Self {
let definitions: HashSet<String> = workflows.phases.keys().cloned().collect();
PhasesConfig {
unknown_phase: workflows.settings.unknown_phase,
definitions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_workflows() {
let workflows = WorkflowsConfig::default();
assert_eq!(workflows.settings.initial_state, "pending");
assert_eq!(workflows.settings.disconnect_state, "pending");
assert!(
workflows
.settings
.blocking_states
.contains(&"working".to_string())
);
assert!(workflows.states.contains_key("pending"));
assert!(workflows.states.contains_key("working"));
assert!(workflows.states.contains_key("completed"));
assert!(workflows.states.get("working").unwrap().timed);
assert!(workflows.phases.contains_key("implement"));
assert!(workflows.phases.contains_key("test"));
}
#[test]
fn test_get_prompt() {
let workflows = WorkflowsConfig::default();
let prompt = workflows.get_prompt("enter~working");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("actively working"));
let prompt = workflows.get_prompt("exit~working");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("Unmark"));
let prompt = workflows.get_prompt("enter%implement");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("Implementation"));
let prompt = workflows.get_prompt("exit%explore");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("findings"));
}
#[test]
fn test_states_config_from_workflows() {
let workflows = WorkflowsConfig::default();
let states: StatesConfig = (&workflows).into();
assert_eq!(states.initial, "pending");
assert!(states.definitions.contains_key("working"));
assert!(states.definitions.get("working").unwrap().timed);
}
#[test]
fn test_phases_config_from_workflows() {
let workflows = WorkflowsConfig::default();
let phases: PhasesConfig = (&workflows).into();
assert!(phases.definitions.contains("implement"));
assert!(phases.definitions.contains("test"));
}
#[test]
fn test_list_prompt_triggers() {
let workflows = WorkflowsConfig::default();
let triggers = workflows.list_prompt_triggers();
assert!(triggers.contains(&"enter~working".to_string()));
assert!(triggers.contains(&"exit~working".to_string()));
assert!(triggers.contains(&"enter%implement".to_string()));
}
#[test]
fn test_all_role_tags_from_base_config() {
let mut workflows = WorkflowsConfig::default();
workflows.roles.insert(
"worker".to_string(),
RoleDefinition {
tags: vec!["worker".to_string(), "backend".to_string()],
..Default::default()
},
);
workflows.roles.insert(
"lead".to_string(),
RoleDefinition {
tags: vec!["lead".to_string(), "coordinator".to_string()],
..Default::default()
},
);
let tags = workflows.all_role_tags();
assert_eq!(tags.len(), 4);
assert!(tags.contains(&"worker".to_string()));
assert!(tags.contains(&"backend".to_string()));
assert!(tags.contains(&"lead".to_string()));
assert!(tags.contains(&"coordinator".to_string()));
}
#[test]
fn test_all_role_tags_includes_named_workflows() {
let mut workflows = WorkflowsConfig::default();
let mut named = WorkflowsConfig::default();
named.roles.insert(
"reviewer".to_string(),
RoleDefinition {
tags: vec!["reviewer".to_string()],
..Default::default()
},
);
workflows
.named_workflows
.insert("review".to_string(), Arc::new(named));
let tags = workflows.all_role_tags();
assert_eq!(tags.len(), 1);
assert!(tags.contains(&"reviewer".to_string()));
}
#[test]
fn test_all_role_tags_deduplicates() {
let mut workflows = WorkflowsConfig::default();
workflows.roles.insert(
"worker".to_string(),
RoleDefinition {
tags: vec!["shared-tag".to_string()],
..Default::default()
},
);
let mut named = WorkflowsConfig::default();
named.roles.insert(
"builder".to_string(),
RoleDefinition {
tags: vec!["shared-tag".to_string()],
..Default::default()
},
);
workflows
.named_workflows
.insert("build".to_string(), Arc::new(named));
let tags = workflows.all_role_tags();
assert_eq!(tags.len(), 1);
assert!(tags.contains(&"shared-tag".to_string()));
}
#[test]
fn test_apply_overlay_adds_new_state() {
let mut base = WorkflowsConfig::default();
let mut overlay = WorkflowsConfig {
states: HashMap::new(),
phases: HashMap::new(),
combos: HashMap::new(),
gates: HashMap::new(),
roles: HashMap::new(),
role_prompts: HashMap::new(),
..Default::default()
};
overlay.states.insert(
"reviewing".to_string(),
StateWorkflow {
exits: vec!["completed".to_string()],
timed: true,
prompts: TransitionPrompts {
enter: Some("Review the changes.".to_string()),
exit: None,
},
},
);
base.apply_overlay(&overlay);
assert!(base.states.contains_key("reviewing"));
assert!(base.states["reviewing"].timed);
assert_eq!(
base.states["reviewing"].prompts.enter.as_deref(),
Some("Review the changes.")
);
}
#[test]
fn test_apply_overlay_appends_prompts() {
let mut base = WorkflowsConfig::default();
let original_enter = base.states["working"].prompts.enter.clone();
let mut overlay = WorkflowsConfig {
states: HashMap::new(),
phases: HashMap::new(),
combos: HashMap::new(),
gates: HashMap::new(),
roles: HashMap::new(),
role_prompts: HashMap::new(),
..Default::default()
};
overlay.states.insert(
"working".to_string(),
StateWorkflow {
exits: vec![],
timed: false,
prompts: TransitionPrompts {
enter: Some("Create a feature branch.".to_string()),
exit: None,
},
},
);
base.apply_overlay(&overlay);
let enter = base.states["working"].prompts.enter.as_ref().unwrap();
assert!(enter.contains(&original_enter.unwrap()));
assert!(enter.contains("Create a feature branch."));
assert!(enter.contains("---"));
}
#[test]
fn test_apply_overlay_unions_exits() {
let mut base = WorkflowsConfig::default();
let original_exits = base.states["working"].exits.clone();
let mut overlay = WorkflowsConfig {
states: HashMap::new(),
phases: HashMap::new(),
combos: HashMap::new(),
gates: HashMap::new(),
roles: HashMap::new(),
role_prompts: HashMap::new(),
..Default::default()
};
overlay.states.insert(
"working".to_string(),
StateWorkflow {
exits: vec!["reviewing".to_string(), "completed".to_string()],
timed: false,
prompts: TransitionPrompts::default(),
},
);
base.apply_overlay(&overlay);
assert!(
base.states["working"]
.exits
.contains(&"reviewing".to_string())
);
for exit in &original_exits {
assert!(base.states["working"].exits.contains(exit));
}
}
#[test]
fn test_apply_overlay_extends_gates() {
let mut base = WorkflowsConfig::default();
let mut overlay = WorkflowsConfig {
states: HashMap::new(),
phases: HashMap::new(),
combos: HashMap::new(),
gates: HashMap::new(),
roles: HashMap::new(),
role_prompts: HashMap::new(),
..Default::default()
};
overlay.gates.insert(
"status:completed".to_string(),
vec![GateDefinition {
gate_type: "gate/commit".to_string(),
enforcement: super::super::types::GateEnforcement::Warn,
description: "Changes should be committed.".to_string(),
}],
);
base.apply_overlay(&overlay);
assert_eq!(base.gates["status:completed"].len(), 1);
assert_eq!(base.gates["status:completed"][0].gate_type, "gate/commit");
}
#[test]
fn test_apply_overlay_roles_first_wins() {
let mut base = WorkflowsConfig::default();
base.roles.insert(
"worker".to_string(),
RoleDefinition {
description: Some("Base worker".to_string()),
tags: vec!["worker".to_string()],
..Default::default()
},
);
let mut overlay = WorkflowsConfig {
states: HashMap::new(),
phases: HashMap::new(),
combos: HashMap::new(),
gates: HashMap::new(),
roles: HashMap::new(),
role_prompts: HashMap::new(),
..Default::default()
};
overlay.roles.insert(
"worker".to_string(),
RoleDefinition {
description: Some("Overlay worker".to_string()),
tags: vec!["overlay-worker".to_string()],
..Default::default()
},
);
base.apply_overlay(&overlay);
assert_eq!(
base.roles["worker"].description.as_deref(),
Some("Base worker")
);
}
#[test]
fn test_compute_overlay_diff() {
let base = WorkflowsConfig::default();
let mut merged = base.clone();
let mut overlay = WorkflowsConfig {
states: HashMap::new(),
phases: HashMap::new(),
combos: HashMap::new(),
gates: HashMap::new(),
roles: HashMap::new(),
role_prompts: HashMap::new(),
..Default::default()
};
overlay.states.insert(
"reviewing".to_string(),
StateWorkflow {
exits: vec!["completed".to_string()],
timed: true,
prompts: TransitionPrompts::default(),
},
);
overlay.states.insert(
"working".to_string(),
StateWorkflow {
exits: vec![],
timed: false,
prompts: TransitionPrompts {
enter: Some("Git overlay prompt.".to_string()),
exit: None,
},
},
);
merged.apply_overlay(&overlay);
let diff = merged.compute_overlay_diff(&base);
let states_added = diff["states_added"].as_array().unwrap();
assert!(states_added.iter().any(|v| v.as_str() == Some("reviewing")));
let prompts_modified = diff["prompts_modified"].as_array().unwrap();
assert!(
prompts_modified
.iter()
.any(|v| v.as_str() == Some("enter~working"))
);
}
}