Skip to main content

codewhale_workflow/
role_resolve.rs

1//! Fleet role resolution for Workflow steps (#4177).
2//!
3//! Workflow owns **what order**; Fleet owns **who**. Steps declare a fleet
4//! `role` (and optional task prompt). At run time the fleet roster maps
5//! `role → AgentProfile id`. This module is the pure resolution path used by
6//! unit tests and by the dispatcher before spawn — it never imports tmux or
7//! session management.
8//!
9//! Precedence (aligned with #4111 / #4136):
10//! 1. Explicit `profile` on the step
11//! 2. Fleet role map entry for `role`
12//! 3. Role name used as profile id when the map has no alias
13//!
14//! Inline provider/model are **not** identity fields. They remain optional
15//! overrides on [`crate::ModelPolicy`]; step identity is role/profile only.
16
17use std::collections::BTreeMap;
18
19use thiserror::Error;
20
21/// Named fleet roster: role name → AgentProfile id.
22///
23/// Role and profile tokens are compared case-insensitively after trim +
24/// lowercase normalization.
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct FleetRoleMap {
27    /// Lowercased role → profile id (as configured; not re-cased).
28    roles: BTreeMap<String, String>,
29}
30
31impl FleetRoleMap {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Insert a role → profile binding. Empty tokens are rejected.
37    pub fn insert(
38        &mut self,
39        role: impl Into<String>,
40        profile: impl Into<String>,
41    ) -> Result<(), FleetRoleResolveError> {
42        let role = normalize_token(&role.into()).ok_or(FleetRoleResolveError::EmptyRole)?;
43        let profile =
44            normalize_token(&profile.into()).ok_or(FleetRoleResolveError::EmptyProfile)?;
45        self.roles.insert(role, profile);
46        Ok(())
47    }
48
49    pub fn from_pairs<I, R, P>(pairs: I) -> Result<Self, FleetRoleResolveError>
50    where
51        I: IntoIterator<Item = (R, P)>,
52        R: Into<String>,
53        P: Into<String>,
54    {
55        let mut map = Self::new();
56        for (role, profile) in pairs {
57            map.insert(role, profile)?;
58        }
59        Ok(map)
60    }
61
62    /// Look up the profile id bound to `role`, if any.
63    pub fn get(&self, role: &str) -> Option<&str> {
64        let key = normalize_token(role)?;
65        self.roles.get(&key).map(String::as_str)
66    }
67
68    pub fn contains_role(&self, role: &str) -> bool {
69        self.get(role).is_some()
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.roles.is_empty()
74    }
75
76    pub fn len(&self) -> usize {
77        self.roles.len()
78    }
79}
80
81/// Result of resolving a workflow step against a fleet roster.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ResolvedWorkflowAgent {
84    /// Fleet role declared on the step, if any.
85    pub resolved_role: Option<String>,
86    /// AgentProfile id to spawn.
87    pub resolved_profile: String,
88    /// How the profile was chosen: `explicit_profile`, `fleet_role`, or
89    /// `role_as_profile`.
90    pub route_source: &'static str,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Error)]
94pub enum FleetRoleResolveError {
95    #[error("fleet role name must be a non-empty token")]
96    EmptyRole,
97    #[error("fleet profile id must be a non-empty token")]
98    EmptyProfile,
99    #[error("unknown fleet role `{role}`: not present in fleet roster (known roles: {known})")]
100    UnknownRole { role: String, known: String },
101    #[error(
102        "workflow step requires a fleet role or explicit profile; provider/model alone are not identity"
103    )]
104    MissingRoleOrProfile,
105    #[error("role `{role}` must be a non-empty token without whitespace, quotes, or `=`")]
106    InvalidRoleToken { role: String },
107}
108
109/// Normalize a role/profile token: trim, lowercase. Returns `None` if empty
110/// or if the token contains whitespace / quotes / backticks / `=`.
111pub fn normalize_token(raw: &str) -> Option<String> {
112    let trimmed = raw.trim();
113    if trimmed.is_empty() {
114        return None;
115    }
116    if trimmed
117        .chars()
118        .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='))
119    {
120        return None;
121    }
122    Some(trimmed.to_ascii_lowercase())
123}
124
125/// Validate a role token the same way leaf profiles are validated.
126pub fn validate_role_token(role: &str) -> Result<String, FleetRoleResolveError> {
127    normalize_token(role).ok_or_else(|| FleetRoleResolveError::InvalidRoleToken {
128        role: role.to_string(),
129    })
130}
131
132/// Resolve step identity from optional `role` + optional explicit `profile`
133/// against a fleet role map.
134///
135/// When `require_known_role` is true and `role` is set without an explicit
136/// profile, the role must exist in `fleet` (unknown roles fail clearly).
137/// When false, an unknown role falls through to `role_as_profile` (useful
138/// when the dispatcher will validate membership later against a full roster).
139pub fn resolve_workflow_agent(
140    role: Option<&str>,
141    profile: Option<&str>,
142    fleet: &FleetRoleMap,
143    require_known_role: bool,
144) -> Result<ResolvedWorkflowAgent, FleetRoleResolveError> {
145    let role_norm = match role {
146        Some(raw) => Some(validate_role_token(raw)?),
147        None => None,
148    };
149    let profile_norm =
150        match profile {
151            Some(raw) => Some(validate_role_token(raw).map_err(|_| {
152                FleetRoleResolveError::InvalidRoleToken {
153                    role: raw.to_string(),
154                }
155            })?),
156            None => None,
157        };
158
159    // Explicit profile always wins (task-field precedence).
160    if let Some(resolved_profile) = profile_norm {
161        return Ok(ResolvedWorkflowAgent {
162            resolved_role: role_norm,
163            resolved_profile,
164            route_source: "explicit_profile",
165        });
166    }
167
168    let Some(role_name) = role_norm else {
169        return Err(FleetRoleResolveError::MissingRoleOrProfile);
170    };
171
172    if let Some(mapped) = fleet.get(&role_name) {
173        return Ok(ResolvedWorkflowAgent {
174            resolved_role: Some(role_name),
175            resolved_profile: mapped.to_string(),
176            route_source: "fleet_role",
177        });
178    }
179
180    if require_known_role {
181        let known = if fleet.is_empty() {
182            "(none)".to_string()
183        } else {
184            fleet.roles.keys().cloned().collect::<Vec<_>>().join(", ")
185        };
186        return Err(FleetRoleResolveError::UnknownRole {
187            role: role_name,
188            known,
189        });
190    }
191
192    Ok(ResolvedWorkflowAgent {
193        resolved_role: Some(role_name.clone()),
194        resolved_profile: role_name,
195        route_source: "role_as_profile",
196    })
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn stopship_fleet() -> FleetRoleMap {
204        FleetRoleMap::from_pairs([
205            ("scout", "scout"),
206            ("implementer", "builder"),
207            ("reviewer", "reviewer"),
208            ("verifier", "verifier"),
209            ("release_lead", "manager"),
210        ])
211        .expect("valid fleet pairs")
212    }
213
214    #[test]
215    fn known_role_resolves_to_configured_profile() {
216        let fleet = stopship_fleet();
217        let resolved =
218            resolve_workflow_agent(Some("implementer"), None, &fleet, true).expect("resolve");
219        assert_eq!(resolved.resolved_role.as_deref(), Some("implementer"));
220        assert_eq!(resolved.resolved_profile, "builder");
221        assert_eq!(resolved.route_source, "fleet_role");
222    }
223
224    #[test]
225    fn unknown_role_fails_clearly() {
226        let fleet = stopship_fleet();
227        let err = resolve_workflow_agent(Some("wizard"), None, &fleet, true)
228            .expect_err("unknown role must fail");
229        match err {
230            FleetRoleResolveError::UnknownRole { role, known } => {
231                assert_eq!(role, "wizard");
232                assert!(known.contains("scout"), "known={known}");
233                assert!(known.contains("implementer"), "known={known}");
234            }
235            other => panic!("expected UnknownRole, got {other:?}"),
236        }
237    }
238
239    #[test]
240    fn explicit_profile_wins_over_role_map() {
241        let fleet = stopship_fleet();
242        let resolved = resolve_workflow_agent(Some("scout"), Some("custom-scout"), &fleet, true)
243            .expect("resolve");
244        assert_eq!(resolved.resolved_role.as_deref(), Some("scout"));
245        assert_eq!(resolved.resolved_profile, "custom-scout");
246        assert_eq!(resolved.route_source, "explicit_profile");
247    }
248
249    #[test]
250    fn missing_role_and_profile_fails() {
251        let fleet = stopship_fleet();
252        let err = resolve_workflow_agent(None, None, &fleet, true).expect_err("identity required");
253        assert!(matches!(err, FleetRoleResolveError::MissingRoleOrProfile));
254    }
255
256    #[test]
257    fn role_token_rejects_whitespace_and_equals() {
258        for bad in ["", "has space", "role=x", "quote\"y"] {
259            assert!(
260                validate_role_token(bad).is_err(),
261                "token {bad:?} should be rejected"
262            );
263        }
264        assert_eq!(validate_role_token("  Scout  ").unwrap(), "scout");
265    }
266
267    #[test]
268    fn require_known_role_false_falls_back_to_role_as_profile() {
269        let fleet = FleetRoleMap::new();
270        let resolved = resolve_workflow_agent(Some("scout"), None, &fleet, false).expect("resolve");
271        assert_eq!(resolved.resolved_profile, "scout");
272        assert_eq!(resolved.route_source, "role_as_profile");
273    }
274}