返回 CodeWhale
role_resolve.rs
根目录 / crates / workflow / src / 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
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
275 lines RUST