use chrono::Utc;
use synaps_cli::events::registry::{
find_session_registration, list_active_sessions, SessionRegistration,
};
use synaps_cli::events::{Event, EventChannel, EventContent, EventSource, Severity};
use tokio::io::AsyncWriteExt;
#[cfg(unix)]
use tokio::net::UnixStream;
use uuid::Uuid;
#[allow(clippy::too_many_arguments)]
pub async fn run(
message: String,
source: String,
severity: String,
channel: Option<String>,
content_type: String,
session: Option<String>,
broadcast: bool,
inbox: bool,
) -> anyhow::Result<()> {
let event = build_event(message, source, severity, channel, content_type);
let json = serde_json::to_string(&event)?;
if broadcast {
let sessions = list_active_sessions();
if sessions.is_empty() {
eprintln!("No active sessions found for broadcast.");
}
let mut missed = Vec::new();
for reg in sessions {
match send_with_retry(reg.clone(), &json, None).await {
Ok(id) => eprintln!("Event sent to session {id} via socket."),
Err(e) => {
eprintln!("Not delivered to session {}: {e}", reg.session_id);
missed.push(reg.session_id);
}
}
}
if !missed.is_empty() {
anyhow::bail!("broadcast missed {} session(s): {}", missed.len(), missed.join(", "));
}
return Ok(());
}
if let Some(query) = session {
match find_session_registration(&query) {
Some(reg) => match send_with_retry(reg, &json, Some(&query)).await {
Ok(id) => eprintln!("Event sent to session {id} via socket."),
Err(e) if inbox => {
eprintln!("{e} — dropping to inbox (--inbox).");
write_inbox(&event)?;
eprintln!("{INBOX_NOTE}");
}
Err(e) => anyhow::bail!(
"{e}; nothing was delivered.\nAdd --inbox to leave it in the shared inbox instead."
),
},
None if inbox => {
eprintln!("No session matching {:?} found — dropping to inbox (--inbox).", query);
write_inbox(&event)?;
eprintln!("{INBOX_NOTE}");
}
None => anyhow::bail!("{}", no_match_message(&query, &list_active_sessions())),
}
return Ok(());
}
let sessions = list_active_sessions();
match sessions.len() {
1 => match send_with_retry(sessions[0].clone(), &json, None).await {
Ok(id) => eprintln!("Event sent to session {id} via socket."),
Err(e) => {
eprintln!("{e} — writing to inbox.");
write_inbox(&event)?;
eprintln!("{INBOX_NOTE}");
}
},
_ => {
if sessions.is_empty() {
eprintln!("No active sessions — writing to inbox.");
} else {
eprintln!(
"{} active sessions, ambiguous — writing to inbox.",
sessions.len()
);
}
write_inbox(&event)?;
eprintln!("{INBOX_NOTE}");
}
}
Ok(())
}
const INBOX_NOTE: &str = "Note: it went to the shared inbox; only a session with an attached \
input owner takes it (exactly once). Use --session <id> to reach a detached session.";
fn no_match_message(query: &str, live: &[SessionRegistration]) -> String {
let mut msg = format!("no running session matches {query:?}; nothing was delivered.\n");
if live.is_empty() {
msg.push_str("No sessions are running.\n");
} else {
msg.push_str("Running sessions (match by full id, name, or id prefix):\n");
for reg in live {
match reg.name.as_deref() {
Some(name) => msg.push_str(&format!(" {} ({name})\n", reg.session_id)),
None => msg.push_str(&format!(" {}\n", reg.session_id)),
}
}
}
msg.push_str("Add --inbox to leave it in the shared inbox instead.");
msg
}
fn build_event(
message: String,
source: String,
severity: String,
channel: Option<String>,
content_type: String,
) -> Event {
Event {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now(),
source: EventSource {
source_type: source.clone(),
name: source,
callback: None,
},
channel: channel.map(|c| EventChannel {
id: c.clone(),
name: c,
}),
sender: None,
content: EventContent {
text: message,
content_type,
severity: Some(Severity::from_str(&severity)),
data: None,
disclosure: None,
},
expects_response: false,
reply_to: None,
}
}
pub(crate) async fn send_event_to_session(query: &str, event: &Event) -> anyhow::Result<String> {
let reg = find_session_registration(query)
.ok_or_else(|| anyhow::anyhow!("no running session matches {:?}", query))?;
let json = serde_json::to_string(event)?;
send_with_retry(reg, &json, Some(query)).await
}
const SOCKET_ATTEMPTS: u32 = 3;
const SOCKET_RETRY_WAITS_MS: [u64; 2] = [150, 500];
const SOCKET_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
async fn send_with_retry(
mut reg: SessionRegistration,
json: &str,
requery: Option<&str>,
) -> anyhow::Result<String> {
let mut last = None;
for attempt in 0..SOCKET_ATTEMPTS {
if attempt > 0 {
let wait = SOCKET_RETRY_WAITS_MS[(attempt as usize - 1).min(SOCKET_RETRY_WAITS_MS.len() - 1)];
tokio::time::sleep(std::time::Duration::from_millis(wait)).await;
if let Some(found) = requery.and_then(find_session_registration) {
reg = found;
}
}
let sent = tokio::time::timeout(SOCKET_ATTEMPT_TIMEOUT, send_via_socket(®.socket_path, json)).await;
match sent {
Ok(Ok(())) => return Ok(reg.session_id),
Ok(Err(e)) => last = Some(e.to_string()),
Err(_) => last = Some(format!("timed out after {} s", SOCKET_ATTEMPT_TIMEOUT.as_secs())),
}
}
anyhow::bail!(
"socket send to session {} failed {} times (last: {})",
reg.session_id,
SOCKET_ATTEMPTS,
last.unwrap_or_default()
)
}
async fn send_via_socket(socket_path: &str, json: &str) -> anyhow::Result<()> {
let sock = std::path::Path::new(socket_path);
let run_dir = synaps_cli::events::registry::registry_dir();
if !sock.starts_with(&run_dir) {
anyhow::bail!(
"socket path {:?} is outside registry dir — refusing to connect",
socket_path
);
}
let mut stream = connect_session_transport(socket_path).await?;
stream.write_all(json.as_bytes()).await?;
stream.shutdown().await?;
Ok(())
}
#[cfg(unix)]
async fn connect_session_transport(socket_path: &str) -> anyhow::Result<UnixStream> {
Ok(UnixStream::connect(socket_path).await?)
}
#[cfg(windows)]
async fn connect_session_transport(
socket_path: &str,
) -> anyhow::Result<tokio::net::windows::named_pipe::NamedPipeClient> {
use tokio::net::windows::named_pipe::ClientOptions;
let pipe_name = synaps_cli::events::socket::pipe_name_for(socket_path);
Ok(ClientOptions::new().open(&pipe_name)?)
}
fn write_inbox(event: &Event) -> anyhow::Result<()> {
let inbox_dir = synaps_cli::config::base_dir().join("inbox");
std::fs::create_dir_all(&inbox_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&inbox_dir) {
let mut perms = meta.permissions();
perms.set_mode(0o700);
let _ = std::fs::set_permissions(&inbox_dir, perms);
}
}
let filename = format!(
"{}-{}.json",
Utc::now().timestamp_nanos_opt().unwrap_or(0),
Uuid::new_v4().simple()
);
let path = inbox_dir.join(&filename);
let tmp_path = inbox_dir.join(format!("{}.tmp", filename));
std::fs::write(&tmp_path, serde_json::to_string_pretty(event)?)?;
std::fs::rename(&tmp_path, &path)?;
eprintln!("Event written to inbox: {}", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn reg(id: &str, name: Option<&str>) -> SessionRegistration {
SessionRegistration {
kind: "session".into(),
session_id: id.into(),
name: name.map(str::to_string),
socket_path: format!("/tmp/{id}.sock"),
pid: 1,
started_at: Utc::now(),
}
}
#[test]
fn a_session_miss_names_the_running_sessions_and_the_inbox_opt_in() {
let msg = no_match_message(
"49f6",
&[reg("20261014-042151-49f6", Some("ion")), reg("20261014-183459-3f61", None)],
);
assert!(msg.contains("\"49f6\""), "{msg}");
assert!(msg.contains("20261014-042151-49f6 (ion)"), "{msg}");
assert!(msg.contains("20261014-183459-3f61"), "{msg}");
assert!(msg.contains("--inbox"), "{msg}");
}
#[test]
fn a_session_miss_with_nothing_running_says_so() {
let msg = no_match_message("x", &[]);
assert!(msg.contains("No sessions are running."), "{msg}");
}
}