use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::catalog::HarnessHomes;
use crate::profiles::{read_json5, yaml_child, yaml_key};
use crate::HarnessId;
pub const ROUTES_SCHEMA: &str = "supercode.routes.v1";
pub const ROUTE_HARNESSES: &[&str] = &[
HarnessId::HERMES,
HarnessId::OPENCLAW,
HarnessId::ORCHESTRATOR,
];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guild: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chat_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peer_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteRow {
pub harness: String,
pub target: String,
#[serde(rename = "match")]
pub matcher: RouteMatch,
pub specificity: u32,
pub default: bool,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteError {
UnsupportedHarness { harness: String },
}
impl std::fmt::Display for RouteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RouteError::UnsupportedHarness { harness } => write!(
f,
"`{harness}` has no routing concept; `routes.list` is supported for: {}",
ROUTE_HARNESSES.join(", ")
),
}
}
}
impl std::error::Error for RouteError {}
pub fn list_routes(
homes: &HarnessHomes,
harness: Option<&str>,
target: Option<&str>,
) -> Result<Vec<RouteRow>, RouteError> {
let harnesses: Vec<&str> = match harness {
Some(id) if ROUTE_HARNESSES.contains(&id) => vec![id],
Some(id) => {
return Err(RouteError::UnsupportedHarness {
harness: id.to_string(),
})
}
None => ROUTE_HARNESSES.to_vec(),
};
let mut rows = Vec::new();
for id in harnesses {
match id {
HarnessId::HERMES => rows.extend(hermes_rows(
HarnessId::HERMES,
homes.hermes.parent().unwrap_or(Path::new(".")),
true,
)),
HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
HarnessId::ORCHESTRATOR => {
for (name, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
rows.extend(hermes_rows(
HarnessId::ORCHESTRATOR,
&dir,
name == "default",
));
}
}
_ => {}
}
}
if let Some(target) = target {
rows.retain(|row| row.target == target);
}
rows.sort_by(|a, b| {
a.harness
.cmp(&b.harness)
.then(b.specificity.cmp(&a.specificity))
.then(a.target.cmp(&b.target))
});
Ok(rows)
}
fn hermes_rows(harness: &str, home: &Path, with_default: bool) -> Vec<RouteRow> {
let config_path = home.join("config.yaml");
let Ok(config) = std::fs::read_to_string(&config_path) else {
return Vec::new();
};
let source = config_path.display().to_string();
let gateway = yaml_child(&config, "gateway");
let block = yaml_child(&gateway, "profile_routes");
let mut rows = Vec::new();
let mut current: Option<BTreeMap<String, String>> = None;
let flush = |entry: Option<BTreeMap<String, String>>, rows: &mut Vec<RouteRow>| {
let Some(entry) = entry else { return };
let Some(profile) = entry.get("profile").filter(|p| !p.is_empty()) else {
return;
};
let matcher = RouteMatch {
platform: entry.get("platform").cloned(),
guild: entry.get("guild_id").cloned(),
chat_id: entry.get("chat_id").cloned(),
thread_id: entry.get("thread_id").cloned(),
..RouteMatch::default()
};
let specificity = matcher.thread_id.as_ref().map_or(0, |_| 8)
+ matcher.chat_id.as_ref().map_or(0, |_| 4)
+ matcher.guild.as_ref().map_or(0, |_| 2);
rows.push(RouteRow {
harness: harness.into(),
target: profile.clone(),
matcher,
specificity,
default: false,
source: source.clone(),
});
};
for line in block.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (body, starts_entry) = match trimmed.strip_prefix("- ") {
Some(rest) => (rest, true),
None => (trimmed, false),
};
if starts_entry {
flush(current.take(), &mut rows);
current = Some(BTreeMap::new());
}
let (Some(key), Some(value)) = (yaml_key(body), yaml_scalar_value(body)) else {
continue;
};
current
.get_or_insert_with(BTreeMap::new)
.insert(key.to_string(), value);
}
flush(current.take(), &mut rows);
if with_default {
rows.push(RouteRow {
harness: harness.into(),
target: "default".into(),
matcher: RouteMatch::default(),
specificity: 0,
default: true,
source,
});
}
rows
}
fn yaml_scalar_value(line: &str) -> Option<String> {
let (_, tail) = line.split_once(':')?;
let tail = tail.trim();
let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
Some(
tail.trim()
.trim_matches(|ch| ch == '"' || ch == '\'')
.to_string(),
)
}
fn openclaw_rows(home: &Path) -> Vec<RouteRow> {
let config_path = home.join("openclaw.json");
let config = read_json5(&config_path);
if config.is_null() {
return Vec::new();
}
let source = config_path.display().to_string();
let mut rows = Vec::new();
if let Some(bindings) = config.pointer("/bindings").and_then(Value::as_array) {
for binding in bindings {
let Some(agent) = binding.get("agentId").and_then(Value::as_str) else {
continue;
};
let m = binding.get("match").cloned().unwrap_or(Value::Null);
let text = |key: &str| m.get(key).and_then(Value::as_str).map(str::to_string);
let peer = m.get("peer").cloned().unwrap_or(Value::Null);
let peer_id = peer.get("id").and_then(Value::as_str).map(str::to_string);
let peer_kind = peer.get("kind").and_then(Value::as_str).map(str::to_string);
let roles: Vec<String> = m
.get("roles")
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let guild = text("guildId");
let team = text("teamId");
let account = text("accountId");
let channel = text("channel");
let specificity = match (&peer_id, &peer_kind) {
(Some(id), _) if id == "*" => 6,
(Some(_), Some(kind)) if kind == "parent" => 7,
(Some(_), _) => 8,
_ if guild.is_some() && !roles.is_empty() => 5,
_ if guild.is_some() => 4,
_ if team.is_some() => 3,
_ if account.is_some() => 2,
_ if channel.is_some() => 1,
_ => 0,
};
rows.push(RouteRow {
harness: HarnessId::OPENCLAW.into(),
target: agent.to_string(),
matcher: RouteMatch {
platform: channel,
account,
guild,
team,
chat_id: peer_id,
peer_kind,
thread_id: None,
roles,
},
specificity,
default: false,
source: source.clone(),
});
}
}
let default_agent = openclaw_default_agent(&config).unwrap_or_else(|| "main".into());
rows.push(RouteRow {
harness: HarnessId::OPENCLAW.into(),
target: default_agent,
matcher: RouteMatch::default(),
specificity: 0,
default: true,
source,
});
rows
}
fn openclaw_default_agent(config: &Value) -> Option<String> {
if let Some(list) = config.pointer("/agents/list").and_then(Value::as_array) {
let flagged = list
.iter()
.find(|entry| entry.get("default").and_then(Value::as_bool) == Some(true))
.or_else(|| list.first());
return flagged
.and_then(|entry| entry.get("id").and_then(Value::as_str))
.map(str::to_string);
}
if let Some(entries) = config.pointer("/agents/entries").and_then(Value::as_object) {
let flagged = entries
.iter()
.find(|(_, entry)| entry.get("default").and_then(Value::as_bool) == Some(true))
.or_else(|| entries.iter().next());
return flagged.map(|(id, _)| id.clone());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"supercode-routes-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn hermes_routes_parse_entries_and_weight_them() {
let dir = scratch("hermes");
std::fs::write(
dir.join("config.yaml"),
"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",
)
.unwrap();
let rows = hermes_rows(HarnessId::HERMES, &dir, true);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].target, "coder");
assert_eq!(rows[0].specificity, 12);
assert_eq!(rows[0].matcher.thread_id.as_deref(), Some("T9"));
assert_eq!(rows[1].target, "ops");
assert_eq!(rows[1].specificity, 0);
assert!(rows[2].default);
}
#[test]
fn orchestrator_routes_are_read_per_profile_folder_with_one_default() {
let dir = scratch("orchestrator");
std::fs::create_dir_all(dir.join("profiles/ops")).unwrap();
std::fs::write(
dir.join("config.yaml"),
"gateway:\n profile_routes:\n - platform: slack\n chat_id: C1\n profile: ops\n",
)
.unwrap();
std::fs::write(
dir.join("profiles/ops/config.yaml"),
"gateway:\n profile_routes:\n - platform: telegram\n profile: ops\n",
)
.unwrap();
let homes = HarnessHomes {
orchestrator: dir.clone(),
..HarnessHomes::default()
};
let rows = list_routes(&homes, Some(HarnessId::ORCHESTRATOR), None).unwrap();
assert!(
rows.iter()
.all(|row| row.harness == HarnessId::ORCHESTRATOR),
"{rows:?}"
);
assert_eq!(rows.iter().filter(|row| row.default).count(), 1, "{rows:?}");
assert_eq!(
rows.iter().filter(|row| row.target == "ops").count(),
2,
"{rows:?}"
);
assert_eq!(rows[0].specificity, 4);
assert_eq!(rows[0].matcher.chat_id.as_deref(), Some("C1"));
assert!(
rows.iter()
.any(|row| row.matcher.platform.as_deref() == Some("telegram")
&& row.specificity == 0)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn openclaw_bindings_follow_the_documented_cascade() {
let dir = scratch("openclaw");
std::fs::write(
dir.join("openclaw.json"),
r#"{ "agents": { "list": [ { "id": "main", "default": true }, { "id": "design" } ] },
"bindings": [
{ "type": "route", "agentId": "design", "match": { "channel": "slack" } },
{ "type": "route", "agentId": "ops", "match": { "channel": "discord", "guildId": "G1", "roles": ["admin"] } },
{ "type": "route", "agentId": "vip", "match": { "channel": "telegram", "peer": { "kind": "user", "id": "U1" } } }
] }"#,
)
.unwrap();
let rows = openclaw_rows(&dir);
let spec: Vec<(String, u32)> = rows
.iter()
.map(|r| (r.target.clone(), r.specificity))
.collect();
assert_eq!(
spec,
vec![
("design".into(), 1),
("ops".into(), 5),
("vip".into(), 8),
("main".into(), 0)
]
);
assert!(rows[3].default);
}
#[test]
fn unsupported_harness_is_refused() {
let err = list_routes(&HarnessHomes::default(), Some("codex"), None).unwrap_err();
assert!(err.to_string().contains("routes.list"));
}
}