use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::residue::Residue;
use super::surface::{CrossSurface, Recurrence, SurfaceKey, Trigger};
use super::HarnessId;
use crate::session::OrchestrationNouns;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EndReason {
Idle,
Daily,
Reset,
New,
Handoff,
Error,
}
impl EndReason {
pub fn parse(word: &str) -> Option<Self> {
Some(match word {
"idle" => Self::Idle,
"daily" => Self::Daily,
"reset" => Self::Reset,
"new" => Self::New,
"handoff" => Self::Handoff,
"error" => Self::Error,
_ => return None,
})
}
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Daily => "daily",
Self::Reset => "reset",
Self::New => "new",
Self::Handoff => "handoff",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Worker {
pub harness: HarnessId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locator: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Handoff {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl Default for Worker {
fn default() -> Self {
Self {
harness: HarnessId::new(""),
session_id: None,
locator: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Binding {
pub key: SurfaceKey,
#[serde(default)]
pub profile: Option<String>,
pub worker: Worker,
#[serde(default)]
pub trigger: Trigger,
#[serde(default)]
pub recurrence: Option<Recurrence>,
#[serde(default)]
pub handoff: Option<Handoff>,
#[serde(default)]
pub started_at: Option<String>,
#[serde(default)]
pub last_activity_at: Option<String>,
#[serde(default)]
pub ended_at: Option<String>,
#[serde(default)]
pub end_reason: Option<EndReason>,
#[serde(default)]
pub residue: Residue,
}
impl Default for Binding {
fn default() -> Self {
Self {
key: SurfaceKey::default(),
profile: None,
worker: Worker::default(),
trigger: Trigger::Unknown,
recurrence: None,
handoff: None,
started_at: None,
last_activity_at: None,
ended_at: None,
end_reason: None,
residue: Residue::default(),
}
}
}
impl Binding {
pub fn surface(&self) -> Option<SurfaceKey> {
let k = &self.key;
if k.key.is_some() || k.platform.is_some() || k.chat_id.is_some() {
Some(k.clone())
} else {
None
}
}
pub fn nouns(&self) -> OrchestrationNouns {
OrchestrationNouns {
trigger: Some(self.trigger),
surface: self.surface(),
profile: self.profile.clone(),
recurrence: self.recurrence.clone(),
cross_surface: self.handoff.as_ref().map(|h| CrossSurface {
state: h.state.clone(),
platform: h.to.clone(),
error: h.error.clone(),
}),
workspace: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct HermesSessionRow {
pub id: String,
pub source: Option<String>,
pub lineage_kind: Option<String>,
pub session_key: Option<String>,
pub chat_id: Option<String>,
pub chat_type: Option<String>,
pub thread_id: Option<String>,
pub user_id: Option<String>,
pub profile_name: Option<String>,
pub handoff_state: Option<String>,
pub handoff_platform: Option<String>,
pub handoff_error: Option<String>,
pub started_at: Option<f64>,
pub ended_at: Option<f64>,
pub end_reason: Option<String>,
}
pub fn hermes_trigger_for_source(source: &str) -> Trigger {
match source {
"" => Trigger::Unknown,
"cron" => Trigger::Cron,
"webhook" => Trigger::Webhook,
"cli" | "tui" | "acp" | "console" => Trigger::Human,
"api_server" | "api" => Trigger::Api,
_ => Trigger::Channel,
}
}
pub fn hermes_cron_job_id(session_id: &str) -> Option<String> {
let rest = session_id.strip_prefix("cron_")?;
let (job, stamp) = rest.rsplit_once('_')?;
let (job, date) = job.rsplit_once('_')?;
let ok = date.len() == 8
&& stamp.len() == 6
&& date.chars().all(|c| c.is_ascii_digit())
&& stamp.chars().all(|c| c.is_ascii_digit());
if ok && !job.is_empty() {
Some(job.to_string())
} else {
None
}
}
pub fn parse_hermes_session_key(key: &str) -> Option<(SurfaceKey, Option<String>)> {
let parts: Vec<&str> = key.split(':').collect();
if parts.len() < 4 || parts[0] != "agent" {
return None;
}
let profile = match parts[1] {
"" | "main" | "default" => None,
p => Some(p.to_string()),
};
let surface = SurfaceKey {
key: Some(key.to_string()),
platform: Some(parts[2].to_string()),
kind: Some(parts[3].to_string()),
chat_id: parts.get(4).map(|s| s.to_string()),
thread_id: parts.get(5).map(|s| s.to_string()),
participant_id: parts.get(6).map(|s| s.to_string()),
};
Some((surface, profile))
}
pub fn render_hermes_session_key(profile: &str, key: &SurfaceKey) -> String {
let mut parts = vec![
"agent".to_string(),
if profile.is_empty() {
"main".to_string()
} else {
profile.to_string()
},
key.platform.clone().unwrap_or_default(),
key.kind.clone().unwrap_or_default(),
];
parts.extend(
[
key.chat_id.clone(),
key.thread_id.clone(),
key.participant_id.clone(),
]
.into_iter()
.flatten(),
);
parts.join(":")
}
fn epoch_to_rfc3339(seconds: f64) -> String {
let millis = (seconds * 1000.0).round() as i64;
let secs = millis.div_euclid(1000);
let sub = millis.rem_euclid(1000) as u32;
let days = secs.div_euclid(86_400);
let sod = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{sub:03}Z",
sod / 3600,
(sod % 3600) / 60,
sod % 60
)
}
impl Binding {
pub fn from_hermes_row(row: &HermesSessionRow, locator: Option<&str>) -> Self {
let nonempty = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
let source = nonempty(&row.source).unwrap_or_default();
let mut trigger = hermes_trigger_for_source(&source);
if row.lineage_kind.as_deref() == Some("delegate") {
trigger = Trigger::Parent;
}
let mut recurrence = None;
if let Some(job_id) = hermes_cron_job_id(&row.id) {
recurrence = Some(Recurrence {
job_id,
kind: "cron".into(),
});
trigger = Trigger::Cron;
}
let mut profile = None;
let mut key = nonempty(&row.session_key)
.and_then(|k| parse_hermes_session_key(&k))
.map(|(surface, key_profile)| {
profile = key_profile;
surface
})
.unwrap_or_default();
if key.key.is_none() {
key.key = nonempty(&row.session_key);
}
if let Some(v) = nonempty(&row.chat_id) {
key.chat_id = Some(v);
}
if let Some(v) = nonempty(&row.chat_type) {
key.kind = Some(v);
}
if let Some(v) = nonempty(&row.thread_id) {
key.thread_id = Some(v);
}
if let Some(v) = nonempty(&row.user_id) {
key.participant_id = Some(v);
}
if key.platform.is_none() && trigger == Trigger::Channel {
key.platform = Some(source.clone());
}
if let Some(p) = nonempty(&row.profile_name) {
profile = Some(p);
}
let handoff = nonempty(&row.handoff_state).map(|state| Handoff {
to: nonempty(&row.handoff_platform),
state,
error: nonempty(&row.handoff_error),
});
let mut residue = Residue::default();
let end_reason = match nonempty(&row.end_reason) {
Some(word) => match EndReason::parse(&word) {
Some(r) => Some(r),
None => {
residue.keep("end_reason", serde_json::Value::String(word));
None
}
},
None => None,
};
Self {
key,
profile,
worker: Worker {
harness: HarnessId::new(HarnessId::HERMES),
session_id: Some(row.id.clone()),
locator: locator.map(str::to_string),
},
trigger,
recurrence,
handoff,
started_at: row.started_at.map(epoch_to_rfc3339),
last_activity_at: row.ended_at.or(row.started_at).map(epoch_to_rfc3339),
ended_at: row.ended_at.map(epoch_to_rfc3339),
end_reason,
residue,
}
}
}
pub fn parse_openclaw_session_key(
key: &str,
) -> Option<(Option<String>, SurfaceKey, Trigger, Option<Recurrence>)> {
let parts: Vec<&str> = key.split(':').collect();
match parts.first().copied() {
Some("agent") if parts.len() >= 3 => {
let agent = Some(parts[1].to_string());
if parts[2] == "main" {
let surface = SurfaceKey {
key: Some(key.to_string()),
kind: Some("main".to_string()),
..SurfaceKey::default()
};
return Some((agent, surface, Trigger::Unknown, None));
}
if parts.len() < 5 {
return None;
}
let thread_id = match (parts.get(5), parts.get(6)) {
(Some(&"thread"), Some(t)) | (Some(&"topic"), Some(t)) => Some(t.to_string()),
_ => None,
};
let surface = SurfaceKey {
key: Some(key.to_string()),
platform: Some(parts[2].to_string()),
kind: Some(parts[3].to_string()),
chat_id: Some(parts[4].to_string()),
thread_id,
participant_id: None,
};
Some((agent, surface, Trigger::Channel, None))
}
Some("cron") if parts.len() >= 2 => Some((
None,
SurfaceKey {
key: Some(key.to_string()),
..SurfaceKey::default()
},
Trigger::Cron,
Some(Recurrence {
job_id: parts[1..].join(":"),
kind: "cron".into(),
}),
)),
Some("hook") if parts.len() >= 2 => Some((
None,
SurfaceKey {
key: Some(key.to_string()),
..SurfaceKey::default()
},
Trigger::Webhook,
None,
)),
Some("acp-bridge") => Some((
None,
SurfaceKey {
key: Some(key.to_string()),
platform: Some("acp".into()),
..SurfaceKey::default()
},
Trigger::Api,
None,
)),
_ => None,
}
}
impl Binding {
pub fn from_openclaw_key(
key: &str,
agent_from_path: Option<&str>,
session_id: Option<&str>,
locator: Option<&str>,
) -> Option<Self> {
let (agent, surface, trigger, recurrence) = parse_openclaw_session_key(key)?;
Some(Self {
key: surface,
profile: agent.or_else(|| agent_from_path.map(str::to_string)),
worker: Worker {
harness: HarnessId::new(HarnessId::OPENCLAW),
session_id: session_id.map(str::to_string),
locator: locator.map(str::to_string),
},
trigger,
recurrence,
..Self::default()
})
}
}
#[derive(Debug, Clone, Default)]
pub struct OrchestratorBindingRow {
pub platform: String,
pub chat_type: String,
pub chat_id: Option<String>,
pub thread_id: Option<String>,
pub participant_id: Option<String>,
pub worker_harness: String,
pub worker_session_id: Option<String>,
pub worker_locator: Option<String>,
pub started_at: Option<String>,
pub last_activity_at: Option<String>,
pub ended_at: Option<String>,
pub end_reason: Option<String>,
pub handoff_to: Option<String>,
pub handoff_state: Option<String>,
pub handoff_error: Option<String>,
pub recurrence_job_id: Option<String>,
}
impl Binding {
pub fn from_orchestrator_row(profile: &str, row: &OrchestratorBindingRow) -> Self {
let mut key = SurfaceKey {
key: None,
platform: Some(row.platform.clone()),
kind: Some(row.chat_type.clone()),
chat_id: row.chat_id.clone(),
thread_id: row.thread_id.clone(),
participant_id: row.participant_id.clone(),
};
key.key = Some(render_hermes_session_key(profile, &key));
let trigger = if row.recurrence_job_id.is_some() {
Trigger::Cron
} else if row.platform == "webhook" {
Trigger::Webhook
} else {
Trigger::Channel
};
let mut residue = Residue::default();
let end_reason = match row.end_reason.as_deref() {
Some(word) => match EndReason::parse(word) {
Some(r) => Some(r),
None => {
residue.keep("end_reason", serde_json::Value::String(word.to_string()));
None
}
},
None => None,
};
Self {
key,
profile: Some(profile.to_string()),
worker: Worker {
harness: HarnessId::new(&row.worker_harness),
session_id: row.worker_session_id.clone().filter(|s| !s.is_empty()),
locator: row.worker_locator.clone(),
},
trigger,
recurrence: row.recurrence_job_id.clone().map(|job_id| Recurrence {
job_id,
kind: "cron".into(),
}),
handoff: row.handoff_state.clone().map(|state| Handoff {
to: row.handoff_to.clone(),
state,
error: row.handoff_error.clone(),
}),
started_at: row.started_at.clone(),
last_activity_at: row.last_activity_at.clone(),
ended_at: row.ended_at.clone(),
end_reason,
residue,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hermes_row_columns_win_over_the_key_and_api_server_keeps_its_key() {
let row = HermesSessionRow {
id: "s1".into(),
source: Some("telegram".into()),
session_key: Some("agent:coder:telegram:group:-100777:55".into()),
chat_id: Some("-100999".into()),
profile_name: Some("coder".into()),
started_at: Some(1_788_000_000.5),
..Default::default()
};
let b = Binding::from_hermes_row(&row, Some("state.db"));
assert_eq!(b.trigger, Trigger::Channel);
assert_eq!(b.key.chat_id.as_deref(), Some("-100999"));
assert_eq!(b.key.thread_id.as_deref(), Some("55"));
assert_eq!(b.profile.as_deref(), Some("coder"));
assert_eq!(b.started_at.as_deref(), Some("2026-08-29T10:40:00.500Z"));
let n = b.nouns();
assert_eq!(
n.surface
.as_ref()
.and_then(|s| s.platform.clone())
.as_deref(),
Some("telegram")
);
let api = HermesSessionRow {
id: "s2".into(),
source: Some("api_server".into()),
session_key: Some("agent:main:chat:dm:ada-dm".into()),
..Default::default()
};
let b = Binding::from_hermes_row(&api, None);
assert_eq!(b.trigger, Trigger::Api);
assert_eq!(b.key.chat_id.as_deref(), Some("ada-dm"));
assert_eq!(b.profile, None);
}
#[test]
fn hermes_cron_and_delegate_and_terminal_rows() {
let cron = HermesSessionRow {
id: "cron_job42_20260902_120000".into(),
source: Some("cron".into()),
..Default::default()
};
let b = Binding::from_hermes_row(&cron, None);
assert_eq!(b.trigger, Trigger::Cron);
assert_eq!(
b.recurrence.as_ref().map(|r| r.job_id.as_str()),
Some("job42")
);
let child = HermesSessionRow {
id: "c".into(),
source: Some("cli".into()),
lineage_kind: Some("delegate".into()),
..Default::default()
};
assert_eq!(
Binding::from_hermes_row(&child, None).trigger,
Trigger::Parent
);
let terminal = HermesSessionRow {
id: "t".into(),
source: Some("cli".into()),
end_reason: Some("weird".into()),
..Default::default()
};
let b = Binding::from_hermes_row(&terminal, None);
assert_eq!(b.surface(), None, "a terminal session has a degenerate key");
assert_eq!(b.nouns().surface, None);
assert_eq!(b.end_reason, None);
assert_eq!(
b.residue.0.get("end_reason").and_then(|v| v.as_str()),
Some("weird")
);
}
#[test]
fn openclaw_keys_and_orchestrator_rows() {
let b = Binding::from_openclaw_key(
"agent:ops:telegram:group:-1:thread:7",
None,
Some("u1"),
None,
)
.unwrap();
assert_eq!(b.profile.as_deref(), Some("ops"));
assert_eq!(b.key.thread_id.as_deref(), Some("7"));
assert_eq!(b.trigger, Trigger::Channel);
let c = Binding::from_openclaw_key("cron:abc:def", Some("ops"), None, None).unwrap();
assert_eq!(
c.recurrence.as_ref().map(|r| r.job_id.as_str()),
Some("abc:def")
);
assert_eq!(c.profile.as_deref(), Some("ops"));
assert!(Binding::from_openclaw_key("nonsense", None, None, None).is_none());
let row = OrchestratorBindingRow {
platform: "telegram".into(),
chat_type: "dm".into(),
chat_id: Some("123456".into()),
worker_harness: "codex".into(),
worker_session_id: Some("sess-1".into()),
end_reason: Some("idle".into()),
ended_at: Some("2026-09-04T10:00:00.000Z".into()),
..Default::default()
};
let b = Binding::from_orchestrator_row("default", &row);
assert_eq!(
b.key.key.as_deref(),
Some("agent:default:telegram:dm:123456")
);
assert_eq!(b.trigger, Trigger::Channel);
assert_eq!(b.end_reason, Some(EndReason::Idle));
let fire = OrchestratorBindingRow {
platform: "cron".into(),
chat_type: "dm".into(),
chat_id: Some("job42".into()),
recurrence_job_id: Some("job42".into()),
worker_harness: "hermes".into(),
worker_session_id: Some("f".into()),
..Default::default()
};
assert_eq!(
Binding::from_orchestrator_row("default", &fire)
.nouns()
.trigger,
Some(Trigger::Cron)
);
let hook = OrchestratorBindingRow {
platform: "webhook".into(),
chat_type: "dm".into(),
worker_harness: "hermes".into(),
worker_session_id: Some("w".into()),
..Default::default()
};
assert_eq!(
Binding::from_orchestrator_row("default", &hook).trigger,
Trigger::Webhook
);
let (parsed, profile) = parse_hermes_session_key(b.key.key.as_deref().unwrap()).unwrap();
assert_eq!(parsed.chat_id, b.key.chat_id);
assert_eq!(profile, None);
}
}