diff options
Diffstat (limited to 'src/agent_resume.rs')
| -rw-r--r-- | src/agent_resume.rs | 302 |
1 files changed, 302 insertions, 0 deletions
diff --git a/src/agent_resume.rs b/src/agent_resume.rs new file mode 100644 index 00000000..8f450088 --- /dev/null +++ b/src/agent_resume.rs @@ -0,0 +1,302 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +const MAX_SESSION_ID_LEN: usize = 512; +const MAX_SESSION_PATH_LEN: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentSessionRef { + pub kind: AgentSessionRefKind, + pub value: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSessionRefKind { + Id, + Path, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentResumePlan { + pub agent: String, + pub argv: Vec<String>, + pub dedupe_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedAgentSession { + pub source: String, + pub agent: String, + pub session_ref: AgentSessionRef, +} + +impl AgentSessionRef { + pub fn id(value: impl Into<String>) -> Option<Self> { + let value = value.into(); + valid_session_id(&value).then_some(Self { + kind: AgentSessionRefKind::Id, + value, + }) + } + + pub fn path(value: impl Into<String>) -> Option<Self> { + let value = value.into(); + valid_session_path(&value).then_some(Self { + kind: AgentSessionRefKind::Path, + value, + }) + } +} + +pub fn session_ref_from_report( + source: &str, + agent: &str, + agent_session_id: Option<String>, + _agent_session_path: Option<String>, +) -> Option<AgentSessionRef> { + if !is_official_agent_source(source, agent) { + return None; + } + + if agent == "pi" { + return _agent_session_path + .and_then(AgentSessionRef::path) + .or_else(|| agent_session_id.and_then(AgentSessionRef::id)); + } + + agent_session_id.and_then(AgentSessionRef::id) +} + +pub fn session_ref_from_snapshot( + source: &str, + agent: &str, + kind: AgentSessionRefKind, + value: &str, +) -> Option<PersistedAgentSession> { + if !is_official_agent_source(source, agent) { + return None; + } + let session_ref = match (agent, kind) { + ("pi", AgentSessionRefKind::Path) => AgentSessionRef::path(value)?, + (_, AgentSessionRefKind::Id) => AgentSessionRef::id(value)?, + _ => return None, + }; + Some(PersistedAgentSession { + source: source.to_string(), + agent: agent.to_string(), + session_ref, + }) +} + +pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option<AgentResumePlan> { + if !is_official_agent_source(source, agent) { + return None; + } + + let argv = match (source, agent, session_ref.kind) { + ("herdr:claude", "claude", AgentSessionRefKind::Id) => { + vec![ + "claude".into(), + "--resume".into(), + session_ref.value.clone(), + ] + } + ("herdr:codex", "codex", AgentSessionRefKind::Id) => { + vec!["codex".into(), "resume".into(), session_ref.value.clone()] + } + ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { + vec!["pi".into(), "--session".into(), session_ref.value.clone()] + } + ("herdr:hermes", "hermes", AgentSessionRefKind::Id) => { + vec![ + "hermes".into(), + "--resume".into(), + session_ref.value.clone(), + ] + } + ("herdr:opencode", "opencode", AgentSessionRefKind::Id) => { + vec![ + "opencode".into(), + "--session".into(), + session_ref.value.clone(), + ] + } + _ => return None, + }; + + Some(AgentResumePlan { + agent: agent.to_string(), + argv, + dedupe_key: dedupe_key(source, agent, session_ref), + }) +} + +pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> String { + format!( + "{source}\u{0}{agent}\u{0}{:?}\u{0}{}", + session_ref.kind, session_ref.value + ) +} + +fn is_official_agent_source(source: &str, agent: &str) -> bool { + matches!( + (source, agent), + ("herdr:claude", "claude") + | ("herdr:codex", "codex") + | ("herdr:pi", "pi") + | ("herdr:hermes", "hermes") + | ("herdr:opencode", "opencode") + ) +} + +fn valid_session_id(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_SESSION_ID_LEN && !value.chars().any(char::is_control) +} + +fn valid_session_path(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_SESSION_PATH_LEN + && !value.chars().any(char::is_control) + && Path::new(value).is_absolute() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn planner_allows_supported_agents() { + assert_eq!( + plan( + "herdr:claude", + "claude", + &AgentSessionRef::id("claude-session").unwrap() + ) + .unwrap() + .argv, + vec!["claude", "--resume", "claude-session"] + ); + assert_eq!( + plan( + "herdr:codex", + "codex", + &AgentSessionRef::id("codex-session").unwrap() + ) + .unwrap() + .argv, + vec!["codex", "resume", "codex-session"] + ); + assert_eq!( + plan( + "herdr:pi", + "pi", + &AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap() + ) + .unwrap() + .argv, + vec!["pi", "--session", "/tmp/pi-session.jsonl"] + ); + assert_eq!( + plan( + "herdr:hermes", + "hermes", + &AgentSessionRef::id("hermes-session").unwrap() + ) + .unwrap() + .argv, + vec!["hermes", "--resume", "hermes-session"] + ); + assert_eq!( + plan( + "herdr:opencode", + "opencode", + &AgentSessionRef::id("opencode-session").unwrap() + ) + .unwrap() + .argv, + vec!["opencode", "--session", "opencode-session"] + ); + } + + #[test] + fn planner_rejects_custom_and_unsupported_path_refs() { + assert!(plan( + "custom:claude", + "claude", + &AgentSessionRef::id("session").unwrap() + ) + .is_none()); + assert!(plan( + "herdr:claude", + "claude", + &AgentSessionRef::path("/tmp/claude-session").unwrap() + ) + .is_none()); + } + + #[test] + fn report_ref_prefers_pi_path_and_validates_values() { + let session_ref = session_ref_from_report( + "herdr:pi", + "pi", + Some("pi-id".into()), + Some("/tmp/pi-session.jsonl".into()), + ) + .unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Path); + assert_eq!(session_ref.value, "/tmp/pi-session.jsonl"); + + assert!(session_ref_from_report("herdr:pi", "pi", Some("bad\nid".into()), None).is_none()); + assert!( + session_ref_from_report("herdr:pi", "pi", None, Some("relative.jsonl".into())) + .is_none() + ); + assert!(session_ref_from_report("custom:pi", "pi", Some("pi-id".into()), None).is_none()); + assert!(session_ref_from_report( + "herdr:claude", + "claude", + None, + Some("/tmp/claude-session".into()) + ) + .is_none()); + } + + #[test] + fn ids_are_data_not_shell_text() { + let id = "abc; rm -rf /"; + let plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap(); + assert_eq!(plan.argv, vec!["codex", "resume", id]); + } + + #[test] + fn planner_rejects_path_refs_for_id_only_agents() { + assert!(plan( + "herdr:hermes", + "hermes", + &AgentSessionRef::path("/tmp/hermes-session").unwrap() + ) + .is_none()); + assert!(plan( + "herdr:opencode", + "opencode", + &AgentSessionRef::path("/tmp/opencode-session").unwrap() + ) + .is_none()); + assert!(session_ref_from_snapshot( + "herdr:hermes", + "hermes", + AgentSessionRefKind::Id, + "hermes-session" + ) + .is_some()); + assert!(session_ref_from_snapshot( + "herdr:opencode", + "opencode", + AgentSessionRefKind::Id, + "opencode-session" + ) + .is_some()); + } +} |
