1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use super::residue::Residue;
11use super::surface::{CrossSurface, Recurrence, SurfaceKey, Trigger};
12use super::HarnessId;
13use crate::session::OrchestrationNouns;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum EndReason {
19 Idle,
21 Daily,
23 Reset,
25 New,
27 Handoff,
29 Error,
31}
32
33impl EndReason {
34 pub fn parse(word: &str) -> Option<Self> {
36 Some(match word {
37 "idle" => Self::Idle,
38 "daily" => Self::Daily,
39 "reset" => Self::Reset,
40 "new" => Self::New,
41 "handoff" => Self::Handoff,
42 "error" => Self::Error,
43 _ => return None,
44 })
45 }
46
47 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Idle => "idle",
51 Self::Daily => "daily",
52 Self::Reset => "reset",
53 Self::New => "new",
54 Self::Handoff => "handoff",
55 Self::Error => "error",
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62pub struct Worker {
63 pub harness: HarnessId,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub session_id: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub locator: Option<String>,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct Handoff {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub to: Option<String>,
79 pub state: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub error: Option<String>,
84}
85
86impl Default for Worker {
87 fn default() -> Self {
88 Self {
89 harness: HarnessId::new(""),
90 session_id: None,
91 locator: None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
98pub struct Binding {
99 pub key: SurfaceKey,
101 #[serde(default)]
103 pub profile: Option<String>,
104 pub worker: Worker,
106 #[serde(default)]
110 pub trigger: Trigger,
111 #[serde(default)]
112 pub recurrence: Option<Recurrence>,
114 #[serde(default)]
115 pub handoff: Option<Handoff>,
117 #[serde(default)]
119 pub started_at: Option<String>,
120 #[serde(default)]
121 pub last_activity_at: Option<String>,
123 #[serde(default)]
124 pub ended_at: Option<String>,
126 #[serde(default)]
127 pub end_reason: Option<EndReason>,
129 #[serde(default)]
131 pub residue: Residue,
132}
133
134impl Default for Binding {
135 fn default() -> Self {
136 Self {
137 key: SurfaceKey::default(),
138 profile: None,
139 worker: Worker::default(),
140 trigger: Trigger::Unknown,
141 recurrence: None,
142 handoff: None,
143 started_at: None,
144 last_activity_at: None,
145 ended_at: None,
146 end_reason: None,
147 residue: Residue::default(),
148 }
149 }
150}
151
152impl Binding {
153 pub fn surface(&self) -> Option<SurfaceKey> {
156 let k = &self.key;
157 if k.key.is_some() || k.platform.is_some() || k.chat_id.is_some() {
158 Some(k.clone())
159 } else {
160 None
161 }
162 }
163
164 pub fn nouns(&self) -> OrchestrationNouns {
168 OrchestrationNouns {
169 trigger: Some(self.trigger),
170 surface: self.surface(),
171 profile: self.profile.clone(),
172 recurrence: self.recurrence.clone(),
173 cross_surface: self.handoff.as_ref().map(|h| CrossSurface {
174 state: h.state.clone(),
175 platform: h.to.clone(),
176 error: h.error.clone(),
177 }),
178 workspace: None,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Default)]
188pub struct HermesSessionRow {
189 pub id: String,
191 pub source: Option<String>,
193 pub lineage_kind: Option<String>,
195 pub session_key: Option<String>,
197 pub chat_id: Option<String>,
199 pub chat_type: Option<String>,
201 pub thread_id: Option<String>,
203 pub user_id: Option<String>,
205 pub profile_name: Option<String>,
207 pub handoff_state: Option<String>,
209 pub handoff_platform: Option<String>,
211 pub handoff_error: Option<String>,
213 pub started_at: Option<f64>,
215 pub ended_at: Option<f64>,
217 pub end_reason: Option<String>,
219}
220
221pub fn hermes_trigger_for_source(source: &str) -> Trigger {
225 match source {
226 "" => Trigger::Unknown,
227 "cron" => Trigger::Cron,
228 "webhook" => Trigger::Webhook,
229 "cli" | "tui" | "acp" | "console" => Trigger::Human,
230 "api_server" | "api" => Trigger::Api,
231 "kanban" => Trigger::Task,
232 _ => Trigger::Channel,
233 }
234}
235
236pub fn hermes_cron_job_id(session_id: &str) -> Option<String> {
239 let rest = session_id.strip_prefix("cron_")?;
240 let (job, stamp) = rest.rsplit_once('_')?;
241 let (job, date) = job.rsplit_once('_')?;
242 let ok = date.len() == 8
243 && stamp.len() == 6
244 && date.chars().all(|c| c.is_ascii_digit())
245 && stamp.chars().all(|c| c.is_ascii_digit());
246 if ok && !job.is_empty() {
247 Some(job.to_string())
248 } else {
249 None
250 }
251}
252
253pub fn parse_hermes_session_key(key: &str) -> Option<(SurfaceKey, Option<String>)> {
257 let parts: Vec<&str> = key.split(':').collect();
258 if parts.len() < 4 || parts[0] != "agent" {
259 return None;
260 }
261 let profile = match parts[1] {
262 "" | "main" | "default" => None,
263 p => Some(p.to_string()),
264 };
265 let surface = SurfaceKey {
266 key: Some(key.to_string()),
267 platform: Some(parts[2].to_string()),
268 kind: Some(parts[3].to_string()),
269 chat_id: parts.get(4).map(|s| s.to_string()),
270 thread_id: parts.get(5).map(|s| s.to_string()),
271 participant_id: parts.get(6).map(|s| s.to_string()),
272 };
273 Some((surface, profile))
274}
275
276pub fn render_hermes_session_key(profile: &str, key: &SurfaceKey) -> String {
279 let mut parts = vec![
280 "agent".to_string(),
281 if profile.is_empty() {
282 "main".to_string()
283 } else {
284 profile.to_string()
285 },
286 key.platform.clone().unwrap_or_default(),
287 key.kind.clone().unwrap_or_default(),
288 ];
289 parts.extend(
290 [
291 key.chat_id.clone(),
292 key.thread_id.clone(),
293 key.participant_id.clone(),
294 ]
295 .into_iter()
296 .flatten(),
297 );
298 parts.join(":")
299}
300
301fn epoch_to_rfc3339(seconds: f64) -> String {
302 let millis = (seconds * 1000.0).round() as i64;
303 let secs = millis.div_euclid(1000);
304 let sub = millis.rem_euclid(1000) as u32;
305 let days = secs.div_euclid(86_400);
307 let sod = secs.rem_euclid(86_400);
308 let z = days + 719_468;
309 let era = z.div_euclid(146_097);
310 let doe = z - era * 146_097;
311 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
312 let y = yoe + era * 400;
313 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
314 let mp = (5 * doy + 2) / 153;
315 let d = doy - (153 * mp + 2) / 5 + 1;
316 let m = if mp < 10 { mp + 3 } else { mp - 9 };
317 let y = if m <= 2 { y + 1 } else { y };
318 format!(
319 "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{sub:03}Z",
320 sod / 3600,
321 (sod % 3600) / 60,
322 sod % 60
323 )
324}
325
326impl Binding {
327 pub fn from_hermes_row(row: &HermesSessionRow, locator: Option<&str>) -> Self {
332 let nonempty = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
333 let source = nonempty(&row.source).unwrap_or_default();
334 let mut trigger = hermes_trigger_for_source(&source);
335 if row.lineage_kind.as_deref() == Some("delegate") {
336 trigger = Trigger::Parent;
337 }
338 let mut recurrence = None;
339 if let Some(job_id) = hermes_cron_job_id(&row.id) {
340 recurrence = Some(Recurrence {
341 job_id,
342 kind: "cron".into(),
343 });
344 trigger = Trigger::Cron;
345 }
346 let mut profile = None;
347 let mut key = nonempty(&row.session_key)
348 .and_then(|k| parse_hermes_session_key(&k))
349 .map(|(surface, key_profile)| {
350 profile = key_profile;
351 surface
352 })
353 .unwrap_or_default();
354 if key.key.is_none() {
355 key.key = nonempty(&row.session_key);
356 }
357 if let Some(v) = nonempty(&row.chat_id) {
358 key.chat_id = Some(v);
359 }
360 if let Some(v) = nonempty(&row.chat_type) {
361 key.kind = Some(v);
362 }
363 if let Some(v) = nonempty(&row.thread_id) {
364 key.thread_id = Some(v);
365 }
366 if let Some(v) = nonempty(&row.user_id) {
367 key.participant_id = Some(v);
368 }
369 if key.platform.is_none() && trigger == Trigger::Channel {
370 key.platform = Some(source.clone());
371 }
372 if let Some(p) = nonempty(&row.profile_name) {
373 profile = Some(p);
374 }
375 let handoff = nonempty(&row.handoff_state).map(|state| Handoff {
376 to: nonempty(&row.handoff_platform),
377 state,
378 error: nonempty(&row.handoff_error),
379 });
380 let mut residue = Residue::default();
381 let end_reason = match nonempty(&row.end_reason) {
382 Some(word) => match EndReason::parse(&word) {
383 Some(r) => Some(r),
384 None => {
385 residue.keep("end_reason", serde_json::Value::String(word));
386 None
387 }
388 },
389 None => None,
390 };
391 Self {
392 key,
393 profile,
394 worker: Worker {
395 harness: HarnessId::new(HarnessId::HERMES),
396 session_id: Some(row.id.clone()),
397 locator: locator.map(str::to_string),
398 },
399 trigger,
400 recurrence,
401 handoff,
402 started_at: row.started_at.map(epoch_to_rfc3339),
403 last_activity_at: row.ended_at.or(row.started_at).map(epoch_to_rfc3339),
404 ended_at: row.ended_at.map(epoch_to_rfc3339),
405 end_reason,
406 residue,
407 }
408 }
409}
410
411pub fn hermes_source_for_binding(binding: &Binding) -> String {
415 match binding.trigger {
416 Trigger::Cron => "cron".into(),
417 Trigger::Webhook => "webhook".into(),
418 Trigger::Api => "api_server".into(),
419 Trigger::Task => "kanban".into(),
420 Trigger::Human => "cli".into(),
421 Trigger::Parent => "delegate".into(),
422 Trigger::Heartbeat => "heartbeat".into(),
423 Trigger::Channel | Trigger::Unknown => {
424 binding.key.platform.clone().unwrap_or_else(|| "cli".into())
425 }
426 }
427}
428
429pub fn render_openclaw_session_key(agent: &str, binding: &Binding) -> String {
435 let key = &binding.key;
436 if let Some(k) = &key.key {
437 return k.clone();
438 }
439 if let Some(r) = &binding.recurrence {
440 return format!("cron:{}", r.job_id);
441 }
442 if key.kind.as_deref() == Some("main") || key.platform.is_none() {
443 return format!("agent:{agent}:main");
444 }
445 let mut out = format!(
446 "agent:{agent}:{}:{}:{}",
447 key.platform.clone().unwrap_or_default(),
448 key.kind.clone().unwrap_or_else(|| "dm".into()),
449 key.chat_id.clone().unwrap_or_default()
450 );
451 if let Some(t) = &key.thread_id {
452 out.push_str(&format!(":thread:{t}"));
453 }
454 out
455}
456
457pub fn parse_openclaw_session_key(
462 key: &str,
463) -> Option<(Option<String>, SurfaceKey, Trigger, Option<Recurrence>)> {
464 let parts: Vec<&str> = key.split(':').collect();
465 match parts.first().copied() {
466 Some("agent") if parts.len() >= 3 => {
467 let agent = Some(parts[1].to_string());
468 if parts[2] == "main" {
469 let surface = SurfaceKey {
470 key: Some(key.to_string()),
471 kind: Some("main".to_string()),
472 ..SurfaceKey::default()
473 };
474 return Some((agent, surface, Trigger::Unknown, None));
475 }
476 if parts.len() < 5 {
477 return None;
478 }
479 let thread_id = match (parts.get(5), parts.get(6)) {
480 (Some(&"thread"), Some(t)) | (Some(&"topic"), Some(t)) => Some(t.to_string()),
481 _ => None,
482 };
483 let surface = SurfaceKey {
484 key: Some(key.to_string()),
485 platform: Some(parts[2].to_string()),
486 kind: Some(parts[3].to_string()),
487 chat_id: Some(parts[4].to_string()),
488 thread_id,
489 participant_id: None,
490 };
491 Some((agent, surface, Trigger::Channel, None))
492 }
493 Some("cron") if parts.len() >= 2 => Some((
494 None,
495 SurfaceKey {
496 key: Some(key.to_string()),
497 ..SurfaceKey::default()
498 },
499 Trigger::Cron,
500 Some(Recurrence {
501 job_id: parts[1..].join(":"),
502 kind: "cron".into(),
503 }),
504 )),
505 Some("hook") if parts.len() >= 2 => Some((
506 None,
507 SurfaceKey {
508 key: Some(key.to_string()),
509 ..SurfaceKey::default()
510 },
511 Trigger::Webhook,
512 None,
513 )),
514 Some("acp-bridge") => Some((
515 None,
516 SurfaceKey {
517 key: Some(key.to_string()),
518 platform: Some("acp".into()),
519 ..SurfaceKey::default()
520 },
521 Trigger::Api,
522 None,
523 )),
524 _ => None,
525 }
526}
527
528impl Binding {
529 pub fn from_openclaw_key(
533 key: &str,
534 agent_from_path: Option<&str>,
535 session_id: Option<&str>,
536 locator: Option<&str>,
537 ) -> Option<Self> {
538 let (agent, surface, trigger, recurrence) = parse_openclaw_session_key(key)?;
539 Some(Self {
540 key: surface,
541 profile: agent.or_else(|| agent_from_path.map(str::to_string)),
542 worker: Worker {
543 harness: HarnessId::new(HarnessId::OPENCLAW),
544 session_id: session_id.map(str::to_string),
545 locator: locator.map(str::to_string),
546 },
547 trigger,
548 recurrence,
549 ..Self::default()
550 })
551 }
552}
553
554#[derive(Debug, Clone, Default)]
558pub struct OrchestratorBindingRow {
559 pub platform: String,
561 pub chat_type: String,
563 pub chat_id: Option<String>,
565 pub thread_id: Option<String>,
567 pub participant_id: Option<String>,
569 pub worker_harness: String,
571 pub worker_session_id: Option<String>,
573 pub worker_locator: Option<String>,
575 pub started_at: Option<String>,
577 pub last_activity_at: Option<String>,
579 pub ended_at: Option<String>,
581 pub end_reason: Option<String>,
583 pub handoff_to: Option<String>,
585 pub handoff_state: Option<String>,
587 pub handoff_error: Option<String>,
589 pub recurrence_job_id: Option<String>,
591}
592
593impl Binding {
594 pub fn from_orchestrator_row(profile: &str, row: &OrchestratorBindingRow) -> Self {
598 let mut key = SurfaceKey {
599 key: None,
600 platform: Some(row.platform.clone()),
601 kind: Some(row.chat_type.clone()),
602 chat_id: row.chat_id.clone(),
603 thread_id: row.thread_id.clone(),
604 participant_id: row.participant_id.clone(),
605 };
606 key.key = Some(render_hermes_session_key(profile, &key));
607 let trigger = if row.recurrence_job_id.is_some() {
608 Trigger::Cron
609 } else if row.platform == "webhook" {
610 Trigger::Webhook
611 } else {
612 Trigger::Channel
613 };
614 let mut residue = Residue::default();
615 let end_reason = match row.end_reason.as_deref() {
616 Some(word) => match EndReason::parse(word) {
617 Some(r) => Some(r),
618 None => {
619 residue.keep("end_reason", serde_json::Value::String(word.to_string()));
620 None
621 }
622 },
623 None => None,
624 };
625 Self {
626 key,
627 profile: Some(profile.to_string()),
628 worker: Worker {
629 harness: HarnessId::new(&row.worker_harness),
630 session_id: row.worker_session_id.clone().filter(|s| !s.is_empty()),
631 locator: row.worker_locator.clone(),
632 },
633 trigger,
634 recurrence: row.recurrence_job_id.clone().map(|job_id| Recurrence {
635 job_id,
636 kind: "cron".into(),
637 }),
638 handoff: row.handoff_state.clone().map(|state| Handoff {
639 to: row.handoff_to.clone(),
640 state,
641 error: row.handoff_error.clone(),
642 }),
643 started_at: row.started_at.clone(),
644 last_activity_at: row.last_activity_at.clone(),
645 ended_at: row.ended_at.clone(),
646 end_reason,
647 residue,
648 }
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn hermes_row_columns_win_over_the_key_and_api_server_keeps_its_key() {
658 let row = HermesSessionRow {
659 id: "s1".into(),
660 source: Some("telegram".into()),
661 session_key: Some("agent:coder:telegram:group:-100777:55".into()),
662 chat_id: Some("-100999".into()),
663 profile_name: Some("coder".into()),
664 started_at: Some(1_788_000_000.5),
665 ..Default::default()
666 };
667 let b = Binding::from_hermes_row(&row, Some("state.db"));
668 assert_eq!(b.trigger, Trigger::Channel);
669 assert_eq!(b.key.chat_id.as_deref(), Some("-100999"));
670 assert_eq!(b.key.thread_id.as_deref(), Some("55"));
671 assert_eq!(b.profile.as_deref(), Some("coder"));
672 assert_eq!(b.started_at.as_deref(), Some("2026-08-29T10:40:00.500Z"));
673 let n = b.nouns();
674 assert_eq!(
675 n.surface
676 .as_ref()
677 .and_then(|s| s.platform.clone())
678 .as_deref(),
679 Some("telegram")
680 );
681
682 let api = HermesSessionRow {
683 id: "s2".into(),
684 source: Some("api_server".into()),
685 session_key: Some("agent:main:chat:dm:ada-dm".into()),
686 ..Default::default()
687 };
688 let b = Binding::from_hermes_row(&api, None);
689 assert_eq!(b.trigger, Trigger::Api);
690 assert_eq!(b.key.chat_id.as_deref(), Some("ada-dm"));
691 assert_eq!(b.profile, None);
692 }
693
694 #[test]
695 fn hermes_cron_and_delegate_and_terminal_rows() {
696 let cron = HermesSessionRow {
697 id: "cron_job42_20260902_120000".into(),
698 source: Some("cron".into()),
699 ..Default::default()
700 };
701 let b = Binding::from_hermes_row(&cron, None);
702 assert_eq!(b.trigger, Trigger::Cron);
703 assert_eq!(
704 b.recurrence.as_ref().map(|r| r.job_id.as_str()),
705 Some("job42")
706 );
707 let child = HermesSessionRow {
708 id: "c".into(),
709 source: Some("cli".into()),
710 lineage_kind: Some("delegate".into()),
711 ..Default::default()
712 };
713 assert_eq!(
714 Binding::from_hermes_row(&child, None).trigger,
715 Trigger::Parent
716 );
717 let terminal = HermesSessionRow {
718 id: "t".into(),
719 source: Some("cli".into()),
720 end_reason: Some("weird".into()),
721 ..Default::default()
722 };
723 let b = Binding::from_hermes_row(&terminal, None);
724 assert_eq!(b.surface(), None, "a terminal session has a degenerate key");
725 assert_eq!(b.nouns().surface, None);
726 assert_eq!(b.end_reason, None);
727 assert_eq!(
728 b.residue.0.get("end_reason").and_then(|v| v.as_str()),
729 Some("weird")
730 );
731 }
732
733 #[test]
734 fn openclaw_keys_and_orchestrator_rows() {
735 let b = Binding::from_openclaw_key(
736 "agent:ops:telegram:group:-1:thread:7",
737 None,
738 Some("u1"),
739 None,
740 )
741 .unwrap();
742 assert_eq!(b.profile.as_deref(), Some("ops"));
743 assert_eq!(b.key.thread_id.as_deref(), Some("7"));
744 assert_eq!(b.trigger, Trigger::Channel);
745 let c = Binding::from_openclaw_key("cron:abc:def", Some("ops"), None, None).unwrap();
746 assert_eq!(
747 c.recurrence.as_ref().map(|r| r.job_id.as_str()),
748 Some("abc:def")
749 );
750 assert_eq!(c.profile.as_deref(), Some("ops"));
751 assert!(Binding::from_openclaw_key("nonsense", None, None, None).is_none());
752
753 let row = OrchestratorBindingRow {
754 platform: "telegram".into(),
755 chat_type: "dm".into(),
756 chat_id: Some("123456".into()),
757 worker_harness: "codex".into(),
758 worker_session_id: Some("sess-1".into()),
759 end_reason: Some("idle".into()),
760 ended_at: Some("2026-09-04T10:00:00.000Z".into()),
761 ..Default::default()
762 };
763 let b = Binding::from_orchestrator_row("default", &row);
764 assert_eq!(
765 b.key.key.as_deref(),
766 Some("agent:default:telegram:dm:123456")
767 );
768 assert_eq!(b.trigger, Trigger::Channel);
769 assert_eq!(b.end_reason, Some(EndReason::Idle));
770 let fire = OrchestratorBindingRow {
771 platform: "cron".into(),
772 chat_type: "dm".into(),
773 chat_id: Some("job42".into()),
774 recurrence_job_id: Some("job42".into()),
775 worker_harness: "hermes".into(),
776 worker_session_id: Some("f".into()),
777 ..Default::default()
778 };
779 assert_eq!(
780 Binding::from_orchestrator_row("default", &fire)
781 .nouns()
782 .trigger,
783 Some(Trigger::Cron)
784 );
785 let hook = OrchestratorBindingRow {
786 platform: "webhook".into(),
787 chat_type: "dm".into(),
788 worker_harness: "hermes".into(),
789 worker_session_id: Some("w".into()),
790 ..Default::default()
791 };
792 assert_eq!(
793 Binding::from_orchestrator_row("default", &hook).trigger,
794 Trigger::Webhook
795 );
796 let (parsed, profile) = parse_hermes_session_key(b.key.key.as_deref().unwrap()).unwrap();
798 assert_eq!(parsed.chat_id, b.key.chat_id);
799 assert_eq!(profile, None);
800 }
801}