1use std::fs::File;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use crate::ipc::{SendListener, SendStream};
15use crate::machine::lock_store;
16use m4a_agent::engine::PushLink;
17use crate::send::{send_sock_path_named, SendReply, SendRequest};
18use crate::{
19 nonempty_var, OpenedStore, SessionConfig, SessionWake, ShellError,
20 LEADER_SOCK_ENV, STORE_ROOT_ENV,
21};
22
23pub const NODE_DEFAULT_SOCK_NAME: &str = "node-client.sock";
25
26const SEND_JOIN_WAIT_SECS: u64 = 120;
28
29pub struct NodeClient {
32 store: OpenedStore,
33 push: PushLink,
34 send_listener: Option<SendListener>,
35 send_sock: Option<PathBuf>,
36 send_queue: Vec<PendingSend>,
37 last_full_drive: Instant,
38 store_root: PathBuf,
39 _lock: File,
40}
41
42struct PendingSend {
43 stream: SendStream,
44 request: SendRequest,
45 started: Instant,
46 room: Option<String>,
47 peer: Option<String>,
48}
49
50impl Drop for NodeClient {
51 fn drop(&mut self) {
52 if let Some(path) = self.send_sock.take() {
53 let _ = std::fs::remove_file(path);
54 }
55 }
56}
57
58#[derive(Debug, Default)]
61pub struct NodeTickReport {
62 pub pushed: Vec<String>,
64 pub joined: Vec<String>,
66 pub errors: Vec<String>,
68 pub sent: Vec<(String, String, SendReply)>,
70 pub wake_note: Option<String>,
72}
73
74impl NodeClient {
75 pub fn from_env() -> Result<Self, ShellError> {
81 let wake = SessionWake::node_cli()?;
82 let sock = wake.leader_sock.as_ref().ok_or_else(|| {
83 ShellError::SessionList(format!(
84 "node client requires {LEADER_SOCK_ENV} (ACP leader.sock)"
85 ))
86 })?;
87 if !mail4agent_grok::leader_is_listening(sock) {
88 return Err(ShellError::SessionList(
89 "the leader is not listening; start grok with [cli] use_leader = true".to_string(),
90 ));
91 }
92
93 let config = SessionConfig::from_env()?;
94 let lock = lock_store(&config.store_dir())?;
95 let store = OpenedStore::connect_with_wake(&config, wake)?;
96
97 let push = PushLink::open(config.homeserver_url(), store.keep_prefix(), vec![store.device_bearer().to_string()], true)?;
98 let store_root = PathBuf::from(
99 nonempty_var(STORE_ROOT_ENV).ok_or(ShellError::StoreRoot)?,
100 );
101
102 Ok(Self {
103 store,
104 push,
105 send_listener: None,
106 send_sock: None,
107 send_queue: Vec::new(),
108 last_full_drive: Instant::now(),
109 store_root,
110 _lock: lock,
111 })
112 }
113
114 pub fn nick(&self) -> Option<&str> {
116 self.store.nick()
117 }
118
119 pub fn session_id(&self) -> &str {
121 self.store.session_id()
122 }
123
124 pub fn user_id(&self) -> &str {
126 self.store.user_id()
127 }
128
129 pub fn store_root(&self) -> &Path {
131 &self.store_root
132 }
133
134 pub fn store(&self) -> &OpenedStore {
136 &self.store
137 }
138
139 pub fn store_mut(&mut self) -> &mut OpenedStore {
141 &mut self.store
142 }
143
144 pub fn wake_log(&self) -> &[crate::WakeAttempt] {
147 self.store.wake_log()
148 }
149
150 pub fn wake_note(&self) -> Option<&str> {
152 self.store.wake_note()
153 }
154
155 pub fn listen_for_sends(&mut self, path: &Path) -> Result<(), ShellError> {
160 let listener = SendListener::bind(path).map_err(|err| {
161 if err.kind() == std::io::ErrorKind::AlreadyExists {
162 ShellError::SessionList(
163 "another client already listens on the send socket".to_string(),
164 )
165 } else {
166 ShellError::Io(err)
167 }
168 })?;
169 listener.set_nonblocking(true)?;
170 self.send_listener = Some(listener);
171 self.send_sock = Some(path.to_path_buf());
172 Ok(())
173 }
174
175 pub fn listen_for_sends_from_env(&mut self) -> Result<PathBuf, ShellError> {
178 let path = send_sock_path_named(
179 |key| std::env::var(key).ok().filter(|value| !value.is_empty()),
180 &self.store_root,
181 NODE_DEFAULT_SOCK_NAME,
182 );
183 self.listen_for_sends(&path)?;
184 Ok(path)
185 }
186
187 pub fn tick(&mut self, now_ms: i64, full_drive_secs: u64) -> NodeTickReport {
194 let mut report = NodeTickReport::default();
195 let me = self.store.user_id().to_string();
196 let mut pushed = false;
197 for (recipient, event) in self.push.drain() {
198 if recipient != me {
199 continue;
200 }
201 report.pushed.push(event.event_id.clone());
202 self.store.record_push(event);
203 pushed = true;
204 }
205
206 let full = self.last_full_drive.elapsed().as_secs() >= full_drive_secs;
207 if full {
208 self.last_full_drive = Instant::now();
209 }
210
211 if pushed || full {
212 if let Err(err) = self.store.drive(now_ms, pushed) {
213 report.errors.push(err.to_string());
214 } else {
215 match self.store.accept_direct_invites(now_ms) {
216 Ok(joined) => report.joined.extend(joined),
217 Err(err) => report.errors.push(err.to_string()),
218 }
219 }
220 }
221
222 self.serve_sends(now_ms, &mut report);
223 report.wake_note = self.store.wake_note().map(str::to_string);
224 report
225 }
226
227 pub fn send_blocking(
231 &mut self,
232 to: &str,
233 text: &str,
234 wait: Duration,
235 ) -> SendReply {
236 let as_nick = self
237 .store
238 .nick()
239 .unwrap_or("")
240 .to_string();
241 let started = Instant::now();
242 let mut room = None;
243 let mut peer = None;
244 loop {
245 let now = now_ms();
246 match self.try_send(&as_nick, to, text, now, &mut room, &mut peer) {
247 Some(reply) => return reply,
248 None if started.elapsed() >= wait => {
249 return SendReply {
250 room,
251 ..SendReply::failed(format!("{to} has not joined the DM yet"))
252 }
253 }
254 None => {
255 let _ = self.store.drive(now, false);
256 std::thread::sleep(Duration::from_millis(500));
257 }
258 }
259 }
260 }
261
262 fn try_send(
263 &mut self,
264 as_nick: &str,
265 to: &str,
266 text: &str,
267 now_ms: i64,
268 room: &mut Option<String>,
269 peer: &mut Option<String>,
270 ) -> Option<SendReply> {
271 let own = self.store.nick().unwrap_or("");
272 if !own.eq_ignore_ascii_case(as_nick) {
273 return Some(SendReply::failed(format!(
274 "{as_nick} is not this node client session"
275 )));
276 }
277 if peer.is_none() {
278 let mut last_err = None;
279 for attempt in 0..3 {
280 if attempt > 0 {
281 let _ = self.store.drive(now_ms, false);
282 }
283 match self.store.find_nick(to, now_ms) {
284 Ok(found) => {
285 *peer = Some(found.user_id);
286 last_err = None;
287 break;
288 }
289 Err(err) => last_err = Some(err),
290 }
291 }
292 if let Some(err) = last_err {
293 return Some(SendReply::failed(format!("find {to}: {err}")));
294 }
295 }
296 if room.is_none() {
297 match self.store.ensure_dm(to, now_ms) {
298 Ok(room_id) => *room = Some(room_id),
299 Err(err) => return Some(SendReply::failed(format!("open DM: {err}"))),
300 }
301 }
302 let (room_id, peer_id) = (room.clone()?, peer.clone()?);
303 if !self.store.member_joined(&room_id, &peer_id) {
304 return None;
305 }
306 match self.store.write_to_nick(to, text, now_ms) {
307 Ok(room_id) => {
308 let event_id = self
309 .store
310 .texts()
311 .into_iter()
312 .rev()
313 .find(|row| row.room_id == room_id && row.body == text)
314 .and_then(|row| row.event_id);
315 Some(SendReply {
316 ok: true,
317 room: Some(room_id),
318 event_id,
319 error: None,
320 })
321 }
322 Err(err) => Some(SendReply {
323 room: Some(room_id),
324 ..SendReply::failed(format!("send: {err}"))
325 }),
326 }
327 }
328
329 fn serve_sends(&mut self, now_ms: i64, report: &mut NodeTickReport) {
330 if let Some(listener) = &self.send_listener {
331 loop {
332 match listener.accept() {
333 Ok(mut stream) => match crate::send::read_request(&mut stream) {
334 Ok(request) => self.send_queue.push(PendingSend {
335 stream,
336 request,
337 started: Instant::now(),
338 room: None,
339 peer: None,
340 }),
341 Err(err) => {
342 crate::send::write_reply(&mut stream, &SendReply::failed(err))
343 }
344 },
345 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
346 Err(_) => break,
347 }
348 }
349 }
350 let queue = std::mem::take(&mut self.send_queue);
351 for mut pending in queue {
352 let (as_nick, to, text) = (
353 pending.request.as_nick.clone(),
354 pending.request.to.clone(),
355 pending.request.text.clone(),
356 );
357 let outcome = self.try_send(
358 &as_nick,
359 &to,
360 &text,
361 now_ms,
362 &mut pending.room,
363 &mut pending.peer,
364 );
365 let reply = match outcome {
366 Some(reply) => reply,
367 None if pending.started.elapsed().as_secs() >= SEND_JOIN_WAIT_SECS => {
368 SendReply {
369 room: pending.room.clone(),
370 ..SendReply::failed(format!("{to} has not joined the DM yet"))
371 }
372 }
373 None => {
374 self.send_queue.push(pending);
375 continue;
376 }
377 };
378 crate::send::write_reply(&mut pending.stream, &reply);
379 report.sent.push((as_nick, to, reply));
380 }
381 }
382}
383
384fn now_ms() -> i64 {
385 std::time::SystemTime::now()
386 .duration_since(std::time::UNIX_EPOCH)
387 .map(|elapsed| elapsed.as_millis() as i64)
388 .unwrap_or(0)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::{ROUTINE_BEARER_ENV, ROUTINE_URL_ENV, LEADER_CWD_ENV};
395
396 #[test]
397 fn node_wake_refuses_routine_env_and_keeps_leader_only() {
398 match SessionWake::node_from_lookup(|key| match key {
399 ROUTINE_URL_ENV => Some("http://127.0.0.1/hook".into()),
400 LEADER_SOCK_ENV => Some("/tmp/leader.sock".into()),
401 _ => None,
402 }) {
403 Ok(_) => panic!("routine url accepted"),
404 Err(err) => {
405 assert!(matches!(err, ShellError::NodeRoutine));
406 let text = err.to_string();
407 assert!(!text.contains("127.0.0.1"));
408 assert!(!text.contains("hook"));
409 }
410 }
411
412 match SessionWake::node_from_lookup(|key| match key {
413 ROUTINE_BEARER_ENV => Some("secret-bearer".into()),
414 _ => None,
415 }) {
416 Ok(_) => panic!("routine bearer accepted"),
417 Err(err) => {
418 assert!(matches!(err, ShellError::NodeRoutine));
419 assert!(!err.to_string().contains("secret-bearer"));
420 }
421 }
422
423 let wake = match SessionWake::node_from_lookup(|key| match key {
424 LEADER_SOCK_ENV => Some("/tmp/node-leader.sock".into()),
425 LEADER_CWD_ENV => Some("/tmp/work".into()),
426 _ => None,
427 }) {
428 Ok(wake) => wake,
429 Err(err) => panic!("leader only refused: {err}"),
430 };
431 assert!(wake.routine_url.is_none());
432 assert!(wake.routine_bearer.is_none());
433 assert_eq!(
434 wake.leader_sock.as_deref(),
435 Some(Path::new("/tmp/node-leader.sock"))
436 );
437 assert_eq!(wake.leader_cwd.as_deref(), Some("/tmp/work"));
438 }
439
440 #[test]
441 fn node_default_sock_name_differs_from_web() {
442 assert_ne!(NODE_DEFAULT_SOCK_NAME, crate::DEFAULT_SOCK_NAME);
443 assert_eq!(NODE_DEFAULT_SOCK_NAME, "node-client.sock");
444 }
445
446 #[cfg(unix)]
447 #[test]
448 fn fake_acp_peer_answers_session_prompt_for_wake_framing() {
449 use std::os::unix::net::UnixListener;
450 use std::sync::{Arc, Mutex};
451 use std::thread;
452
453 use serde_json::{json, Value};
454 use tokio::io::{AsyncReadExt, AsyncWriteExt};
455 use tokio::net::UnixStream;
456
457 fn encode(bytes: &[u8]) -> Vec<u8> {
458 mail4agent_grok::encode_frame(bytes).expect("frame")
459 }
460
461 let dir = std::env::temp_dir().join(format!(
462 "m4a-node-fake-acp-{}-{}",
463 std::process::id(),
464 std::time::SystemTime::now()
465 .duration_since(std::time::UNIX_EPOCH)
466 .expect("clock")
467 .as_nanos()
468 ));
469 std::fs::create_dir_all(&dir).expect("dir");
470 let path = dir.join("leader.sock");
471 let listener = UnixListener::bind(&path).expect("bind");
472 let prompts = Arc::new(Mutex::new(Vec::<String>::new()));
473 let recorded = Arc::clone(&prompts);
474 let server = thread::spawn(move || {
475 let (sock, _) = listener.accept().expect("accept");
476 let runtime = tokio::runtime::Builder::new_current_thread()
477 .enable_all()
478 .build()
479 .expect("rt");
480 runtime.block_on(async move {
481 sock.set_nonblocking(true).expect("nb");
483 let mut stream = UnixStream::from_std(sock).expect("tokio");
484 async fn read_value(stream: &mut UnixStream) -> Value {
485 let mut len_buf = [0u8; 4];
486 stream.read_exact(&mut len_buf).await.expect("len");
487 let len = u32::from_be_bytes(len_buf) as usize;
488 let mut buf = vec![0u8; len];
489 stream.read_exact(&mut buf).await.expect("body");
490 serde_json::from_slice(&buf).expect("json")
491 }
492 async fn write_value(stream: &mut UnixStream, value: &Value) {
493 let bytes = serde_json::to_vec(value).expect("json");
494 let frame = encode(&bytes);
495 stream.write_all(&frame).await.expect("write");
496 stream.flush().await.expect("flush");
497 }
498 let register = read_value(&mut stream).await;
499 assert_eq!(register["type"], "register");
500 write_value(&mut stream, &json!({"type": "registered", "ready": true})).await;
501 loop {
502 let value = read_value(&mut stream).await;
503 if value.get("type").and_then(Value::as_str) == Some("disconnect") {
504 break;
505 }
506 if value.get("type").and_then(Value::as_str) != Some("acp") {
507 continue;
508 }
509 let payload = value["payload"].as_str().expect("payload");
510 let inner: Value = serde_json::from_str(payload).expect("inner");
511 if inner["method"].as_str() == Some("session/prompt") {
512 let text = inner["params"]["prompt"][0]["text"]
513 .as_str()
514 .unwrap_or("")
515 .to_string();
516 recorded.lock().expect("p").push(text);
517 }
518 let id = inner["id"].clone();
519 let body = json!({"jsonrpc":"2.0","id": id, "result": {}}).to_string();
520 write_value(&mut stream, &json!({"type":"acp","payload": body})).await;
521 }
522 });
523 });
524
525 mail4agent_grok::wake_decrypted_room_blocking(
526 &path,
527 "local-session-id",
528 "/tmp",
529 "hello-from-node-test",
530 )
531 .expect("fake peer answered");
532 server.join().expect("server");
533 let got = prompts.lock().expect("prompts");
534 assert_eq!(got.as_slice(), ["hello-from-node-test"]);
535 let _ = std::fs::remove_dir_all(&dir);
536 }
537}