| 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 | |
| 17 | use std::collections::BTreeMap; |
| 18 | |
| 19 | use 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)] |
| 26 | pub struct FleetRoleMap { |
| 27 | /// Lowercased role → profile id (as configured; not re-cased). |
| 28 | roles: BTreeMap<String, String>, |
| 29 | } |
| 30 | |
| 31 | impl 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)] |
| 83 | pub 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)] |
| 94 | pub 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 / `=`. |
| 111 | pub 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. |
| 126 | pub 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). |
| 139 | pub 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)] |
| 200 | mod 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 | } |
| 275 |