1use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::catalog::HarnessHomes;
21use crate::HarnessId;
22
23pub const ROUTES_SCHEMA: &str = "supercode.routes.v1";
25
26pub const ROUTE_HARNESSES: &[&str] = &[
28 HarnessId::HERMES,
29 HarnessId::OPENCLAW,
30 HarnessId::ORCHESTRATOR,
31];
32
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RouteMatch {
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub platform: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub account: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub guild: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub team: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub chat_id: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub peer_kind: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub thread_id: Option<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub roles: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RouteRow {
60 pub harness: String,
61 pub target: String,
63 #[serde(rename = "match")]
64 pub matcher: RouteMatch,
65 pub specificity: u32,
67 pub default: bool,
69 pub source: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum RouteError {
76 UnsupportedHarness { harness: String },
78}
79
80impl std::fmt::Display for RouteError {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 RouteError::UnsupportedHarness { harness } => write!(
84 f,
85 "`{harness}` has no routing concept; `routes.list` is supported for: {}",
86 ROUTE_HARNESSES.join(", ")
87 ),
88 }
89 }
90}
91
92impl std::error::Error for RouteError {}
93
94pub fn list_routes(
96 homes: &HarnessHomes,
97 harness: Option<&str>,
98 target: Option<&str>,
99) -> Result<Vec<RouteRow>, RouteError> {
100 let harnesses: Vec<&str> = match harness {
101 Some(id) if ROUTE_HARNESSES.contains(&id) => vec![id],
102 Some(id) => {
103 return Err(RouteError::UnsupportedHarness {
104 harness: id.to_string(),
105 })
106 }
107 None => ROUTE_HARNESSES.to_vec(),
108 };
109 use supercode_interchange::orchestration::codec::{
110 from_hermes, from_openclaw, load_home, Flavor,
111 };
112 let mut rows = Vec::new();
113 for id in harnesses {
114 match id {
115 HarnessId::HERMES => {
116 if let Ok(loaded) = from_hermes(homes.hermes.parent().unwrap_or(Path::new("."))) {
117 rows.extend(hermes_shaped_rows(HarnessId::HERMES, &loaded.orchestration));
118 }
119 }
120 HarnessId::OPENCLAW => {
121 if let Ok(loaded) = from_openclaw(&homes.openclaw) {
122 rows.extend(openclaw_rows(&loaded));
123 }
124 }
125 HarnessId::ORCHESTRATOR => {
126 if let Ok(loaded) = load_home(&homes.orchestrator, Flavor::Orchestrator) {
127 rows.extend(hermes_shaped_rows(
128 HarnessId::ORCHESTRATOR,
129 &loaded.orchestration,
130 ));
131 }
132 }
133 _ => {}
134 }
135 }
136 if let Some(target) = target {
137 rows.retain(|row| row.target == target);
138 }
139 rows.sort_by(|a, b| {
140 a.harness
141 .cmp(&b.harness)
142 .then(b.specificity.cmp(&a.specificity))
143 .then(a.target.cmp(&b.target))
144 });
145 Ok(rows)
146}
147
148fn hermes_shaped_rows(
159 harness: &str,
160 orchestration: &supercode_interchange::orchestration::Orchestration,
161) -> Vec<RouteRow> {
162 let mut rows = Vec::new();
163 let mut names: Vec<&String> = orchestration.profiles.keys().collect();
164 names.sort_by_key(|name| (name.as_str() != "default", name.as_str()));
165 for name in names {
166 let profile = &orchestration.profiles[name];
167 let source = profile.dir.join("config.yaml").display().to_string();
168 for route in &profile.routes {
169 let matcher = RouteMatch {
170 platform: Some(route.matches.platform.clone()).filter(|p| !p.is_empty()),
171 guild: route.matches.guild_id.clone(),
172 chat_id: route.matches.chat_id.clone(),
173 thread_id: route.matches.thread_id.clone(),
174 ..RouteMatch::default()
175 };
176 let specificity = matcher.thread_id.as_ref().map_or(0, |_| 8)
177 + matcher.chat_id.as_ref().map_or(0, |_| 4)
178 + matcher.guild.as_ref().map_or(0, |_| 2);
179 rows.push(RouteRow {
180 harness: harness.into(),
181 target: route.profile.clone(),
182 matcher,
183 specificity,
184 default: false,
185 source: source.clone(),
186 });
187 }
188 if name == "default" {
189 rows.push(RouteRow {
190 harness: harness.into(),
191 target: "default".into(),
192 matcher: RouteMatch::default(),
193 specificity: 0,
194 default: true,
195 source,
196 });
197 }
198 }
199 rows
200}
201
202fn openclaw_rows(
207 loaded: &supercode_interchange::orchestration::codec::OpenclawLoaded,
208) -> Vec<RouteRow> {
209 let source = loaded
210 .root
211 .state_dir
212 .join("openclaw.json")
213 .display()
214 .to_string();
215 let mut routes: Vec<_> = loaded
216 .orchestration
217 .profiles
218 .values()
219 .flat_map(|profile| profile.routes.iter())
220 .collect();
221 routes.sort_by_key(|route| route.residue.0.get("index").and_then(Value::as_u64));
222 let mut rows = Vec::new();
223 for route in routes {
224 let residue = &route.residue.0;
225 let m = residue.get("match").and_then(Value::as_object);
226 let text = |key: &str| {
227 m.and_then(|m| m.get(key)).and_then(|v| match v {
228 Value::String(s) => Some(s.clone()),
229 Value::Number(n) => Some(n.to_string()),
230 _ => None,
231 })
232 };
233 let peer_kind = m
234 .and_then(|m| m.get("peer"))
235 .and_then(|p| p.get("kind"))
236 .and_then(Value::as_str)
237 .map(str::to_string);
238 let roles: Vec<String> = m
239 .and_then(|m| m.get("roles"))
240 .and_then(Value::as_array)
241 .map(|list| {
242 list.iter()
243 .filter_map(Value::as_str)
244 .map(str::to_string)
245 .collect()
246 })
247 .unwrap_or_default();
248 let guild = route.matches.guild_id.clone();
249 let team = text("teamId");
250 let account = text("accountId");
251 let channel = Some(route.matches.platform.clone()).filter(|p| !p.is_empty());
252 let peer_id = route.matches.chat_id.clone();
253 let specificity = match (&peer_id, &peer_kind) {
254 (Some(id), _) if id == "*" => 6,
255 (Some(_), Some(kind)) if kind == "parent" => 7,
256 (Some(_), _) => 8,
257 _ if guild.is_some() && !roles.is_empty() => 5,
258 _ if guild.is_some() => 4,
259 _ if team.is_some() => 3,
260 _ if account.is_some() => 2,
261 _ if channel.is_some() => 1,
262 _ => 0,
263 };
264 let target = residue
265 .get("agent_id")
266 .and_then(Value::as_str)
267 .map(str::to_string)
268 .or_else(|| {
269 loaded
270 .profiles
271 .get(&route.profile)
272 .map(|io| io.agent_id.clone())
273 })
274 .unwrap_or_else(|| route.profile.clone());
275 rows.push(RouteRow {
276 harness: HarnessId::OPENCLAW.into(),
277 target,
278 matcher: RouteMatch {
279 platform: channel,
280 account,
281 guild,
282 team,
283 chat_id: peer_id,
284 peer_kind,
285 thread_id: None,
286 roles,
287 },
288 specificity,
289 default: false,
290 source: source.clone(),
291 });
292 }
293 rows.push(RouteRow {
294 harness: HarnessId::OPENCLAW.into(),
295 target: loaded.root.default_agent.clone(),
296 matcher: RouteMatch::default(),
297 specificity: 0,
298 default: true,
299 source,
300 });
301 rows
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 fn scratch(tag: &str) -> std::path::PathBuf {
309 let dir = std::env::temp_dir().join(format!(
310 "supercode-routes-{tag}-{}-{}",
311 std::process::id(),
312 std::time::SystemTime::now()
313 .duration_since(std::time::UNIX_EPOCH)
314 .unwrap()
315 .as_nanos()
316 ));
317 std::fs::create_dir_all(&dir).unwrap();
318 dir
319 }
320
321 #[test]
325 fn orchestrator_routes_are_read_per_profile_folder_with_one_default() {
326 let dir = scratch("orchestrator");
327 std::fs::create_dir_all(dir.join("profiles/ops")).unwrap();
328 std::fs::write(
329 dir.join("config.yaml"),
330 "gateway:\n profile_routes:\n - platform: slack\n chat_id: C1\n profile: ops\n",
331 )
332 .unwrap();
333 std::fs::write(
334 dir.join("profiles/ops/config.yaml"),
335 "gateway:\n profile_routes:\n - platform: telegram\n profile: ops\n",
336 )
337 .unwrap();
338 let homes = HarnessHomes {
339 orchestrator: dir.clone(),
340 ..HarnessHomes::default()
341 };
342 let rows = list_routes(&homes, Some(HarnessId::ORCHESTRATOR), None).unwrap();
343 assert!(
344 rows.iter()
345 .all(|row| row.harness == HarnessId::ORCHESTRATOR),
346 "{rows:?}"
347 );
348 assert_eq!(rows.iter().filter(|row| row.default).count(), 1, "{rows:?}");
349 assert_eq!(
352 rows.iter().filter(|row| row.target == "ops").count(),
353 2,
354 "{rows:?}"
355 );
356 assert_eq!(rows[0].specificity, 4);
360 assert_eq!(rows[0].matcher.chat_id.as_deref(), Some("C1"));
361 assert!(
362 rows.iter()
363 .any(|row| row.matcher.platform.as_deref() == Some("telegram")
364 && row.specificity == 0)
365 );
366 std::fs::remove_dir_all(&dir).ok();
367 }
368
369 #[test]
370 fn openclaw_bindings_follow_the_documented_cascade() {
371 let dir = scratch("openclaw");
372 std::fs::write(
373 dir.join("openclaw.json"),
374 r#"{ "agents": { "list": [ { "id": "main", "default": true }, { "id": "design" } ] },
375 "bindings": [
376 { "type": "route", "agentId": "design", "match": { "channel": "slack" } },
377 { "type": "route", "agentId": "ops", "match": { "channel": "discord", "guildId": "G1", "roles": ["admin"] } },
378 { "type": "route", "agentId": "vip", "match": { "channel": "telegram", "peer": { "kind": "user", "id": "U1" } } }
379 ] }"#,
380 )
381 .unwrap();
382 let loaded = supercode_interchange::orchestration::codec::from_openclaw(&dir).unwrap();
383 let rows = openclaw_rows(&loaded);
384 let spec: Vec<(String, u32)> = rows
385 .iter()
386 .map(|r| (r.target.clone(), r.specificity))
387 .collect();
388 assert_eq!(
389 spec,
390 vec![
391 ("design".into(), 1),
392 ("ops".into(), 5),
393 ("vip".into(), 8),
394 ("main".into(), 0)
395 ]
396 );
397 assert!(rows[3].default);
398 }
399
400 #[test]
401 fn unsupported_harness_is_refused() {
402 let err = list_routes(&HarnessHomes::default(), Some("codex"), None).unwrap_err();
403 assert!(err.to_string().contains("routes.list"));
404 }
405}