1use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use serde::{Deserialize, Serialize};
22
23pub const ADOPTED_FILE: &str = "adopted.json";
24const SEND_SOCK_NAME: &str = "web-client.sock";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Adoption {
30 pub provider: String,
31 pub session: String,
32 #[serde(default)]
33 pub cwd: Option<String>,
34 pub nick: String,
35 pub account_ref: String,
36 #[serde(default)]
37 pub store_root: Option<String>,
38}
39
40pub fn load_adoptions(base: &Path) -> Vec<Adoption> {
41 std::fs::read(base.join(ADOPTED_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
42}
43
44pub fn save_adoption(base: &Path, a: &Adoption) -> Result<(), String> {
46 let mut all = load_adoptions(base);
47 all.retain(|x| !(x.provider == a.provider && x.session == a.session));
48 all.push(a.clone());
49 let tmp = base.join(format!("{ADOPTED_FILE}.tmp"));
50 std::fs::write(&tmp, serde_json::to_vec_pretty(&all).map_err(|e| e.to_string())?).map_err(|e| format!("adopted: {}", e.kind()))?;
51 std::fs::rename(&tmp, base.join(ADOPTED_FILE)).map_err(|e| format!("adopted: {}", e.kind()))
52}
53
54pub fn sole_adoption(base: &Path, provider: &str) -> Option<String> {
57 let mut it = load_adoptions(base).into_iter().filter(|a| a.provider == provider);
58 let first = it.next()?;
59 it.next().is_none().then_some(first.session)
60}
61
62pub fn grok_session_cwd(grok_home: &Path, session: &str) -> Option<String> {
65 let rows: serde_json::Value = std::fs::read(grok_home.join("active_sessions.json")).ok().and_then(|b| serde_json::from_slice(&b).ok())?;
66 let rows = rows.as_array().cloned().or_else(|| rows.get("sessions").and_then(|s| s.as_array().cloned()))?;
67 rows.iter()
68 .find(|r| r.get("session_id").and_then(|v| v.as_str()) == Some(session))
69 .and_then(|r| r.get("cwd").and_then(|v| v.as_str()))
70 .filter(|c| !c.is_empty())
71 .map(str::to_owned)
72}
73
74pub fn leader_for(provider: &str) -> Option<PathBuf> {
76 match provider {
77 "grok" => {
78 let home = gate4agent_wake::grok_leader::grok_home()?;
79 Some(gate4agent_wake::grok_leader::leader_socket(&home, std::env::var_os("GROK_LEADER_SOCKET").as_deref()))
80 }
81 _ => None,
82 }
83}
84
85static GENERATIONS: Mutex<BTreeMap<String, u64>> = Mutex::new(BTreeMap::new());
86
87pub fn watch(accounts: &crate::m4a_accounts::M4aSessionAccounts, a: Adoption) -> bool {
89 let _ = accounts;
90 let Some(leader) = leader_for(&a.provider) else {
91 tracing::warn!(provider = %a.provider, nick = %a.nick, "letter listener not started: no leader for this provider (grok home unknown or provider not built in); letters stay in the inbox");
92 return false;
93 };
94 if a.cwd.is_none() || a.store_root.is_none() {
95 tracing::warn!(nick = %a.nick, "letter listener not started: adoption has no cwd or store root (adopt again with --cwd)");
96 return false;
97 }
98 let gen = {
99 let mut g = GENERATIONS.lock().unwrap_or_else(|p| p.into_inner());
100 let e = g.entry(a.account_ref.clone()).or_insert(0);
101 *e += 1;
102 *e
103 };
104 let nick = a.nick.clone();
105 match std::thread::Builder::new().name(format!("m4a-wake-{}", a.nick)).spawn(move || listen(a, leader, gen)) {
106 Ok(_) => {
107 tracing::info!(%nick, "letter listener starting");
108 true
109 }
110 Err(e) => {
111 tracing::warn!(%nick, error = %e, "letter listener thread did not start");
112 false
113 }
114 }
115}
116
117pub fn restore(accounts: &crate::m4a_accounts::M4aSessionAccounts) -> usize {
119 let path = accounts.base().join(ADOPTED_FILE);
120 let adoptions = match std::fs::read(&path) {
121 Ok(b) => match serde_json::from_slice::<Vec<Adoption>>(&b) {
122 Ok(v) => v,
123 Err(e) => {
124 tracing::warn!(file = %path.display(), error = %e, "adopted sessions file is not readable json; no letter listeners restored");
125 return 0;
126 }
127 },
128 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
129 tracing::info!(file = %path.display(), "no adopted sessions to restore");
130 return 0;
131 }
132 Err(e) => {
133 tracing::warn!(file = %path.display(), error = %e.kind(), "adopted sessions file not readable; no letter listeners restored");
134 return 0;
135 }
136 };
137 let mut started = 0;
138 for a in adoptions {
139 if watch(accounts, a) {
140 started += 1;
141 }
142 }
143 tracing::info!(started, "letter listeners restored from adopted sessions");
144 started
145}
146
147pub fn restore_from_env() -> usize {
149 match crate::m4a_accounts::M4aSessionAccounts::from_env() {
150 Some(accounts) => restore(&accounts),
151 None => {
152 tracing::info!("m4a accounts not configured (agent bin / accounts root unset); no letter listeners restored");
153 0
154 }
155 }
156}
157
158pub fn is_watched(account_ref: &str) -> bool {
160 GENERATIONS.lock().unwrap_or_else(|p| p.into_inner()).contains_key(account_ref)
161}
162
163fn current(a: &Adoption, gen: u64) -> bool {
164 GENERATIONS.lock().unwrap_or_else(|p| p.into_inner()).get(&a.account_ref) == Some(&gen)
165}
166
167fn now_ms() -> i64 {
168 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
169}
170
171fn listen(a: Adoption, leader: PathBuf, gen: u64) {
174 use mail4agent_messenger_shell::{HostSession, MachineClient, ProviderKind, ProviderSession, SessionKind, WakeChain};
175 let mut backoff = Duration::from_secs(5);
176 let pusher = LeaderPush::start(a.clone(), leader);
177 while current(&a, gen) {
178 let product = crate::m4a_accounts::M4aSessionAccounts::resolve_product_url(
179 std::env::var_os("HATCHERY_ENROLL_STATE").map(std::path::PathBuf::from).as_deref(),
180 );
181 let (Some(url), Some(root), Some(cwd)) = (product, a.store_root.clone(), a.cwd.clone()) else {
182 tracing::warn!(nick = %a.nick, "m4a letter listener not started: messenger url, store root or cwd unknown");
183 return;
184 };
185 let opened = MachineClient::open(&url, Path::new(&root), vec![HostSession::new(a.nick.clone(), a.account_ref.clone())]);
186 let mut client = match opened {
187 Ok(c) => c,
188 Err(e) => {
189 tracing::warn!(nick = %a.nick, error = %e, "m4a letter listener: store did not open; retrying");
190 std::thread::sleep(backoff);
191 backoff = (backoff * 2).min(Duration::from_secs(300));
192 continue;
193 }
194 };
195 backoff = Duration::from_secs(5);
196 if let Err(e) = client.listen_for_sends(&Path::new(&root).join(SEND_SOCK_NAME)) {
198 tracing::warn!(nick = %a.nick, error = %e, "m4a send socket not opened");
199 }
200 let session = ProviderSession { kind: SessionKind::local(ProviderKind::Grok), session_id: a.session.clone(), nick: a.nick.clone(), cwd: Some(PathBuf::from(&cwd)), headless: false };
201 let mut chain = WakeChain::new();
202 chain.push("node-leader", Box::new(pusher.adapter()));
203 match client.session_mut(&a.nick) {
204 Ok(store) => store.set_wake_chain(session, chain),
205 Err(e) => {
206 tracing::warn!(nick = %a.nick, error = %e, "m4a letter listener: session not in the opened store");
207 std::thread::sleep(Duration::from_secs(60));
208 continue;
209 }
210 }
211 tracing::info!(nick = %a.nick, provider = %a.provider, "m4a letter listener up");
212 while current(&a, gen) {
213 let report = client.tick(now_ms(), 15);
214 for (_, err) in &report.errors {
215 tracing::debug!(nick = %a.nick, error = %err, "m4a listener drive error");
216 }
217 if client.error_streak() > 40 {
218 tracing::warn!(nick = %a.nick, "m4a listener: homeserver keeps failing; reopening");
219 break;
220 }
221 std::thread::sleep(Duration::from_millis(500));
222 }
223 }
224 tracing::info!(nick = %a.nick, "m4a letter listener replaced or stopped");
225}
226
227struct LeaderPush {
229 tx: std::sync::mpsc::Sender<String>,
230}
231
232impl LeaderPush {
233 fn start(a: Adoption, leader: PathBuf) -> Self {
234 let (tx, rx) = std::sync::mpsc::channel::<String>();
235 let _ = std::thread::Builder::new().name(format!("m4a-push-{}", a.nick)).spawn(move || {
236 let Ok(rt) = tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build() else { return };
237 let cwd = a.cwd.clone().unwrap_or_default();
238 for prompt in rx {
239 match rt.block_on(gate4agent_wake::grok_leader::push_into_session(&leader, &a.session, &cwd, &prompt)) {
240 Ok(()) => tracing::info!(nick = %a.nick, "letter pushed into the live session"),
241 Err(e) => tracing::warn!(nick = %a.nick, error = %e, "letter push into the live session failed (letter stays in the mailbox history)"),
242 }
243 }
244 });
245 Self { tx }
246 }
247
248 fn adapter(&self) -> PushAdapter {
249 PushAdapter { tx: self.tx.clone() }
250 }
251}
252
253struct PushAdapter {
254 tx: std::sync::mpsc::Sender<String>,
255}
256
257impl mail4agent_messenger_shell::WakeAdapter for PushAdapter {
258 fn kind(&self) -> mail4agent_messenger_shell::SessionKind {
259 mail4agent_messenger_shell::SessionKind::local(mail4agent_messenger_shell::ProviderKind::Grok)
260 }
261 fn probe(&self, _s: &mail4agent_messenger_shell::ProviderSession) -> Result<(), mail4agent_messenger_shell::WakeError> {
262 Ok(())
263 }
264 fn wake(&mut self, s: &mail4agent_messenger_shell::ProviderSession, l: &mail4agent_messenger_shell::WakeLetter<'_>) -> Result<mail4agent_messenger_shell::WakeOutcome, mail4agent_messenger_shell::WakeError> {
265 let v = serde_json::json!({"from": l.from_nick, "event_id": l.event_id, "room": l.room, "body": l.body, "kind": if l.reaction.is_some() {"reaction"} else {"message"}, "reply_needed": l.reaction.is_none()});
266 self.tx.send(letter_prompt(&s.nick, &v)).map_err(|_| mail4agent_messenger_shell::WakeError::Unavailable("push worker gone".into()))?;
267 Ok(mail4agent_messenger_shell::WakeOutcome::Delivered)
268 }
269}
270
271pub fn letter_prompt(nick: &str, letter: &serde_json::Value) -> String {
273 let s = |k: &str| letter.get(k).and_then(|v| v.as_str()).unwrap_or("").to_owned();
274 let mut head = format!("[m4a letter]\nkind: {}\nfrom: {}\nto: {nick}\n", if s("kind").is_empty() { "message".into() } else { s("kind") }, s("from"));
275 if !s("room").is_empty() {
276 head.push_str(&format!("room: {}\n", s("room")));
277 }
278 head.push_str(&format!("event: {}\n", s("event_id")));
279 if letter.get("reply_needed").and_then(|v| v.as_bool()).unwrap_or(true) {
280 head.push_str(&format!("reply: your m4a MCP tool `send` with to = \"{}\"\n", s("from")));
281 }
282 format!("{head}---\n{}", s("body"))
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn adoption(session: &str) -> Adoption {
290 Adoption { provider: "grok".into(), session: session.into(), cwd: Some("w".into()), nick: "grok-1".into(), account_ref: "grok-ab".into(), store_root: Some("s".into()) }
291 }
292
293 #[test]
294 fn adoptions_persist_replace_and_resolve_a_sole_session() {
295 let dir = std::env::temp_dir().join(format!("m4a-wake-{}", std::process::id()));
296 let _ = std::fs::remove_dir_all(&dir);
297 std::fs::create_dir_all(&dir).unwrap();
298 assert_eq!(sole_adoption(&dir, "grok"), None);
299 save_adoption(&dir, &adoption("s-1")).unwrap();
300 save_adoption(&dir, &adoption("s-1")).unwrap();
301 assert_eq!(load_adoptions(&dir).len(), 1);
302 assert_eq!(sole_adoption(&dir, "grok").as_deref(), Some("s-1"));
303 assert_eq!(sole_adoption(&dir, "kimi"), None);
304 save_adoption(&dir, &adoption("s-2")).unwrap();
305 assert_eq!(sole_adoption(&dir, "grok"), None, "two adoptions are ambiguous");
306 std::fs::remove_dir_all(&dir).unwrap();
307 }
308
309 #[test]
310 fn restart_with_saved_adoptions_starts_their_listeners() {
311 let dir = std::env::temp_dir().join(format!("m4a-wake-restore-{}", std::process::id()));
312 let _ = std::fs::remove_dir_all(&dir);
313 std::fs::create_dir_all(&dir).unwrap();
314 let accounts = crate::m4a_accounts::M4aSessionAccounts::new("m4a-agent", &dir);
315 assert_eq!(restore(&accounts), 0, "nothing saved");
316 let mut a = adoption("s-restore");
317 a.account_ref = "ref-restore".into();
318 save_adoption(&dir, &a).unwrap();
319 let mut b = adoption("s-nocwd");
320 b.account_ref = "ref-nocwd".into();
321 b.cwd = None;
322 save_adoption(&dir, &b).unwrap();
323 std::env::set_var("GROK_HOME", dir.join("grok"));
324 assert_eq!(restore(&accounts), 1);
325 assert!(is_watched("ref-restore"));
326 assert!(!is_watched("ref-nocwd"));
327 std::fs::write(dir.join(ADOPTED_FILE), b"not json").unwrap();
328 assert_eq!(restore(&accounts), 0);
329 GENERATIONS.lock().unwrap().remove("ref-restore");
330 std::fs::remove_dir_all(&dir).unwrap();
331 }
332
333 #[test]
334 fn prompt_names_sender_and_reply_tool() {
335 let p = letter_prompt("grok-1", &serde_json::json!({"from":"web","event_id":"$e","room":"!r","body":"hi"}));
336 assert!(p.contains("from: web") && p.contains("to: grok-1") && p.contains("to = \"web\"") && p.ends_with("---\nhi"));
337 }
338
339 #[test]
340 fn grok_cwd_comes_from_the_index() {
341 let dir = std::env::temp_dir().join(format!("m4a-wake-idx-{}", std::process::id()));
342 std::fs::create_dir_all(&dir).unwrap();
343 std::fs::write(dir.join("active_sessions.json"), r#"[{"session_id":"a","cwd":"w"},{"session_id":"b","cwd":""}]"#).unwrap();
344 assert_eq!(grok_session_cwd(&dir, "a").as_deref(), Some("w"));
345 assert_eq!(grok_session_cwd(&dir, "b"), None);
346 std::fs::remove_dir_all(&dir).unwrap();
347 }
348}