1use std::collections::BTreeMap;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::catalog::HarnessHomes;
22use crate::profiles::{read_json5, yaml_child, yaml_key};
23use crate::HarnessId;
24
25pub const ROUTES_SCHEMA: &str = "supercode.routes.v1";
27
28pub const ROUTE_HARNESSES: &[&str] = &[
30 HarnessId::HERMES,
31 HarnessId::OPENCLAW,
32 HarnessId::ORCHESTRATOR,
33];
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct RouteMatch {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub platform: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub account: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub guild: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub team: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub chat_id: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub peer_kind: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub thread_id: Option<String>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub roles: Vec<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct RouteRow {
62 pub harness: String,
63 pub target: String,
65 #[serde(rename = "match")]
66 pub matcher: RouteMatch,
67 pub specificity: u32,
69 pub default: bool,
71 pub source: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RouteError {
78 UnsupportedHarness { harness: String },
80}
81
82impl std::fmt::Display for RouteError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 RouteError::UnsupportedHarness { harness } => write!(
86 f,
87 "`{harness}` has no routing concept; `routes.list` is supported for: {}",
88 ROUTE_HARNESSES.join(", ")
89 ),
90 }
91 }
92}
93
94impl std::error::Error for RouteError {}
95
96pub fn list_routes(
98 homes: &HarnessHomes,
99 harness: Option<&str>,
100 target: Option<&str>,
101) -> Result<Vec<RouteRow>, RouteError> {
102 let harnesses: Vec<&str> = match harness {
103 Some(id) if ROUTE_HARNESSES.contains(&id) => vec![id],
104 Some(id) => {
105 return Err(RouteError::UnsupportedHarness {
106 harness: id.to_string(),
107 })
108 }
109 None => ROUTE_HARNESSES.to_vec(),
110 };
111 let mut rows = Vec::new();
112 for id in harnesses {
113 match id {
114 HarnessId::HERMES => rows.extend(hermes_rows(
115 HarnessId::HERMES,
116 homes.hermes.parent().unwrap_or(Path::new(".")),
117 true,
118 )),
119 HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
120 HarnessId::ORCHESTRATOR => {
126 for (name, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
127 rows.extend(hermes_rows(
128 HarnessId::ORCHESTRATOR,
129 &dir,
130 name == "default",
131 ));
132 }
133 }
134 _ => {}
135 }
136 }
137 if let Some(target) = target {
138 rows.retain(|row| row.target == target);
139 }
140 rows.sort_by(|a, b| {
141 a.harness
142 .cmp(&b.harness)
143 .then(b.specificity.cmp(&a.specificity))
144 .then(a.target.cmp(&b.target))
145 });
146 Ok(rows)
147}
148
149fn hermes_rows(harness: &str, home: &Path, with_default: bool) -> Vec<RouteRow> {
156 let config_path = home.join("config.yaml");
157 let Ok(config) = std::fs::read_to_string(&config_path) else {
158 return Vec::new();
159 };
160 let source = config_path.display().to_string();
161 let gateway = yaml_child(&config, "gateway");
162 let block = yaml_child(&gateway, "profile_routes");
163 let mut rows = Vec::new();
164 let mut current: Option<BTreeMap<String, String>> = None;
165 let flush = |entry: Option<BTreeMap<String, String>>, rows: &mut Vec<RouteRow>| {
166 let Some(entry) = entry else { return };
167 let Some(profile) = entry.get("profile").filter(|p| !p.is_empty()) else {
168 return;
169 };
170 let matcher = RouteMatch {
171 platform: entry.get("platform").cloned(),
172 guild: entry.get("guild_id").cloned(),
173 chat_id: entry.get("chat_id").cloned(),
174 thread_id: entry.get("thread_id").cloned(),
175 ..RouteMatch::default()
176 };
177 let specificity = matcher.thread_id.as_ref().map_or(0, |_| 8)
179 + matcher.chat_id.as_ref().map_or(0, |_| 4)
180 + matcher.guild.as_ref().map_or(0, |_| 2);
181 rows.push(RouteRow {
182 harness: harness.into(),
183 target: profile.clone(),
184 matcher,
185 specificity,
186 default: false,
187 source: source.clone(),
188 });
189 };
190 for line in block.lines() {
191 let trimmed = line.trim_start();
192 if trimmed.is_empty() || trimmed.starts_with('#') {
193 continue;
194 }
195 let (body, starts_entry) = match trimmed.strip_prefix("- ") {
196 Some(rest) => (rest, true),
197 None => (trimmed, false),
198 };
199 if starts_entry {
200 flush(current.take(), &mut rows);
201 current = Some(BTreeMap::new());
202 }
203 let (Some(key), Some(value)) = (yaml_key(body), yaml_scalar_value(body)) else {
204 continue;
205 };
206 current
207 .get_or_insert_with(BTreeMap::new)
208 .insert(key.to_string(), value);
209 }
210 flush(current.take(), &mut rows);
211 if with_default {
213 rows.push(RouteRow {
214 harness: harness.into(),
215 target: "default".into(),
216 matcher: RouteMatch::default(),
217 specificity: 0,
218 default: true,
219 source,
220 });
221 }
222 rows
223}
224
225fn yaml_scalar_value(line: &str) -> Option<String> {
226 let (_, tail) = line.split_once(':')?;
227 let tail = tail.trim();
228 let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
229 Some(
230 tail.trim()
231 .trim_matches(|ch| ch == '"' || ch == '\'')
232 .to_string(),
233 )
234}
235
236fn openclaw_rows(home: &Path) -> Vec<RouteRow> {
239 let config_path = home.join("openclaw.json");
240 let config = read_json5(&config_path);
241 if config.is_null() {
242 return Vec::new();
243 }
244 let source = config_path.display().to_string();
245 let mut rows = Vec::new();
246 if let Some(bindings) = config.pointer("/bindings").and_then(Value::as_array) {
247 for binding in bindings {
248 let Some(agent) = binding.get("agentId").and_then(Value::as_str) else {
249 continue;
250 };
251 let m = binding.get("match").cloned().unwrap_or(Value::Null);
252 let text = |key: &str| m.get(key).and_then(Value::as_str).map(str::to_string);
253 let peer = m.get("peer").cloned().unwrap_or(Value::Null);
254 let peer_id = peer.get("id").and_then(Value::as_str).map(str::to_string);
255 let peer_kind = peer.get("kind").and_then(Value::as_str).map(str::to_string);
256 let roles: Vec<String> = m
257 .get("roles")
258 .and_then(Value::as_array)
259 .map(|list| {
260 list.iter()
261 .filter_map(Value::as_str)
262 .map(str::to_string)
263 .collect()
264 })
265 .unwrap_or_default();
266 let guild = text("guildId");
267 let team = text("teamId");
268 let account = text("accountId");
269 let channel = text("channel");
270 let specificity = match (&peer_id, &peer_kind) {
274 (Some(id), _) if id == "*" => 6,
275 (Some(_), Some(kind)) if kind == "parent" => 7,
276 (Some(_), _) => 8,
277 _ if guild.is_some() && !roles.is_empty() => 5,
278 _ if guild.is_some() => 4,
279 _ if team.is_some() => 3,
280 _ if account.is_some() => 2,
281 _ if channel.is_some() => 1,
282 _ => 0,
283 };
284 rows.push(RouteRow {
285 harness: HarnessId::OPENCLAW.into(),
286 target: agent.to_string(),
287 matcher: RouteMatch {
288 platform: channel,
289 account,
290 guild,
291 team,
292 chat_id: peer_id,
293 peer_kind,
294 thread_id: None,
295 roles,
296 },
297 specificity,
298 default: false,
299 source: source.clone(),
300 });
301 }
302 }
303 let default_agent = openclaw_default_agent(&config).unwrap_or_else(|| "main".into());
304 rows.push(RouteRow {
305 harness: HarnessId::OPENCLAW.into(),
306 target: default_agent,
307 matcher: RouteMatch::default(),
308 specificity: 0,
309 default: true,
310 source,
311 });
312 rows
313}
314
315fn openclaw_default_agent(config: &Value) -> Option<String> {
316 if let Some(list) = config.pointer("/agents/list").and_then(Value::as_array) {
317 let flagged = list
318 .iter()
319 .find(|entry| entry.get("default").and_then(Value::as_bool) == Some(true))
320 .or_else(|| list.first());
321 return flagged
322 .and_then(|entry| entry.get("id").and_then(Value::as_str))
323 .map(str::to_string);
324 }
325 if let Some(entries) = config.pointer("/agents/entries").and_then(Value::as_object) {
326 let flagged = entries
327 .iter()
328 .find(|(_, entry)| entry.get("default").and_then(Value::as_bool) == Some(true))
329 .or_else(|| entries.iter().next());
330 return flagged.map(|(id, _)| id.clone());
331 }
332 None
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 fn scratch(tag: &str) -> std::path::PathBuf {
340 let dir = std::env::temp_dir().join(format!(
341 "supercode-routes-{tag}-{}-{}",
342 std::process::id(),
343 std::time::SystemTime::now()
344 .duration_since(std::time::UNIX_EPOCH)
345 .unwrap()
346 .as_nanos()
347 ));
348 std::fs::create_dir_all(&dir).unwrap();
349 dir
350 }
351
352 #[test]
353 fn hermes_routes_parse_entries_and_weight_them() {
354 let dir = scratch("hermes");
355 std::fs::write(
356 dir.join("config.yaml"),
357 "gateway:\n profile_routes:\n - platform: slack\n chat_id: C1\n thread_id: T9\n profile: coder\n - platform: telegram\n profile: ops # comment\n",
358 )
359 .unwrap();
360 let rows = hermes_rows(HarnessId::HERMES, &dir, true);
361 assert_eq!(rows.len(), 3);
362 assert_eq!(rows[0].target, "coder");
363 assert_eq!(rows[0].specificity, 12);
364 assert_eq!(rows[0].matcher.thread_id.as_deref(), Some("T9"));
365 assert_eq!(rows[1].target, "ops");
366 assert_eq!(rows[1].specificity, 0);
367 assert!(rows[2].default);
368 }
369
370 #[test]
374 fn orchestrator_routes_are_read_per_profile_folder_with_one_default() {
375 let dir = scratch("orchestrator");
376 std::fs::create_dir_all(dir.join("profiles/ops")).unwrap();
377 std::fs::write(
378 dir.join("config.yaml"),
379 "gateway:\n profile_routes:\n - platform: slack\n chat_id: C1\n profile: ops\n",
380 )
381 .unwrap();
382 std::fs::write(
383 dir.join("profiles/ops/config.yaml"),
384 "gateway:\n profile_routes:\n - platform: telegram\n profile: ops\n",
385 )
386 .unwrap();
387 let homes = HarnessHomes {
388 orchestrator: dir.clone(),
389 ..HarnessHomes::default()
390 };
391 let rows = list_routes(&homes, Some(HarnessId::ORCHESTRATOR), None).unwrap();
392 assert!(
393 rows.iter()
394 .all(|row| row.harness == HarnessId::ORCHESTRATOR),
395 "{rows:?}"
396 );
397 assert_eq!(rows.iter().filter(|row| row.default).count(), 1, "{rows:?}");
398 assert_eq!(
401 rows.iter().filter(|row| row.target == "ops").count(),
402 2,
403 "{rows:?}"
404 );
405 assert_eq!(rows[0].specificity, 4);
409 assert_eq!(rows[0].matcher.chat_id.as_deref(), Some("C1"));
410 assert!(
411 rows.iter()
412 .any(|row| row.matcher.platform.as_deref() == Some("telegram")
413 && row.specificity == 0)
414 );
415 std::fs::remove_dir_all(&dir).ok();
416 }
417
418 #[test]
419 fn openclaw_bindings_follow_the_documented_cascade() {
420 let dir = scratch("openclaw");
421 std::fs::write(
422 dir.join("openclaw.json"),
423 r#"{ "agents": { "list": [ { "id": "main", "default": true }, { "id": "design" } ] },
424 "bindings": [
425 { "type": "route", "agentId": "design", "match": { "channel": "slack" } },
426 { "type": "route", "agentId": "ops", "match": { "channel": "discord", "guildId": "G1", "roles": ["admin"] } },
427 { "type": "route", "agentId": "vip", "match": { "channel": "telegram", "peer": { "kind": "user", "id": "U1" } } }
428 ] }"#,
429 )
430 .unwrap();
431 let rows = openclaw_rows(&dir);
432 let spec: Vec<(String, u32)> = rows
433 .iter()
434 .map(|r| (r.target.clone(), r.specificity))
435 .collect();
436 assert_eq!(
437 spec,
438 vec![
439 ("design".into(), 1),
440 ("ops".into(), 5),
441 ("vip".into(), 8),
442 ("main".into(), 0)
443 ]
444 );
445 assert!(rows[3].default);
446 }
447
448 #[test]
449 fn unsupported_harness_is_refused() {
450 let err = list_routes(&HarnessHomes::default(), Some("codex"), None).unwrap_err();
451 assert!(err.to_string().contains("routes.list"));
452 }
453}