Skip to main content

supercode_harness/
routes.rs

1//! ORCH-15 (observed tier): the routing noun — which profile / agent a
2//! surface tuple resolves to.
3//!
4//! * **Hermes** — `gateway.profile_routes` in `HERMES_HOME/config.yaml`: a list
5//!   of `{platform, guild_id?, chat_id?, thread_id?, profile}` entries, most
6//!   specific wins (thread 8 > chat 4 > guild 2 > platform 0), the default
7//!   profile otherwise (`docs/HERMES-IDEAL-SUPPORT-DESIGN.md` §1).
8//! * **OpenClaw** — `bindings[]` in `openclaw.json`: `{agentId, match{channel,
9//!   accountId, peer{kind,id}, guildId, teamId, roles}}`, evaluated exact peer →
10//!   parent peer → wildcard peer → guild+roles → guild → team → account →
11//!   channel → default agent (§1b).
12//!
13//! Read-only. Editing a route stays the harness's own config edit.
14
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20use crate::catalog::HarnessHomes;
21use crate::HarnessId;
22
23/// Wire schema of `harness.v1.routes.list`.
24pub const ROUTES_SCHEMA: &str = "supercode.routes.v1";
25
26/// Harnesses with a routing concept.
27pub const ROUTE_HARNESSES: &[&str] = &[
28    HarnessId::HERMES,
29    HarnessId::OPENCLAW,
30    HarnessId::ORCHESTRATOR,
31];
32
33/// The match side of a route, in the shared-noun vocabulary.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RouteMatch {
36    /// Transport / platform (`slack`, `telegram`, …), or `None` for a catch-all.
37    #[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    /// Chat / channel / group id, or the peer id (OpenClaw).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub chat_id: Option<String>,
48    /// OpenClaw peer kind (`user` | `channel` | `group` | `thread`).
49    #[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/// One routing entry.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RouteRow {
60    pub harness: String,
61    /// Target profile (Hermes) or agent id (OpenClaw).
62    pub target: String,
63    #[serde(rename = "match")]
64    pub matcher: RouteMatch,
65    /// The harness's own precedence rank; higher wins.
66    pub specificity: u32,
67    /// The fallback route (no match fields).
68    pub default: bool,
69    /// Config file the route was read from.
70    pub source: String,
71}
72
73/// Why a listing was refused.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum RouteError {
76    /// The harness has no routing concept.
77    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
94/// List routes, optionally for one harness and/or one target.
95pub 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
148/// Read `gateway.profile_routes` out of one Hermes-shaped home.
149///
150/// `home` is the folder holding `config.yaml` (Hermes: HERMES_HOME, the
151/// parent of `HarnessHomes::hermes`; the orchestrator: one profile folder).
152/// `with_default` emits the catch-all row for the home that owns unmatched
153/// traffic.
154/// The `profile_routes` of a Hermes-shaped home as the orchestration codec
155/// reads them: every profile's routes (the root's for Hermes, each folder's
156/// for the orchestrator) weighted by what they name, plus the root's
157/// catch-all row.
158fn 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
202/// OpenClaw's `bindings` as the orchestration codec reads them (the match
203/// vocabulary the orchestration does not model — account, team, peer kind,
204/// roles — rides in the route's residue), weighted by the documented
205/// cascade, plus the default agent's catch-all row.
206fn 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    /// ORC-7: the orchestrator's routes come from every profile folder's own
322    /// `config.yaml`, and the unmatched-traffic default row belongs to the
323    /// ROOT folder alone — a named profile's table does not get to claim it.
324    #[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        // Both folders' tables are read: two routes to `ops` (the root's
350        // slack one and the named profile's telegram one) plus the default.
351        assert_eq!(
352            rows.iter().filter(|row| row.target == "ops").count(),
353            2,
354            "{rows:?}"
355        );
356        // Hermes's own weights, unchanged: chat 4, platform-only 0. (Rows are
357        // ordered by specificity then target, which is the existing shared
358        // ordering — the default is not pinned last on a specificity tie.)
359        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 rows = openclaw_rows(&dir);
383        let spec: Vec<(String, u32)> = rows
384            .iter()
385            .map(|r| (r.target.clone(), r.specificity))
386            .collect();
387        assert_eq!(
388            spec,
389            vec![
390                ("design".into(), 1),
391                ("ops".into(), 5),
392                ("vip".into(), 8),
393                ("main".into(), 0)
394            ]
395        );
396        assert!(rows[3].default);
397    }
398
399    #[test]
400    fn unsupported_harness_is_refused() {
401        let err = list_routes(&HarnessHomes::default(), Some("codex"), None).unwrap_err();
402        assert!(err.to_string().contains("routes.list"));
403    }
404}