hematite/agent/
searx_lifecycle.rs1use crate::agent::config::HematiteConfig;
2use std::path::Path;
3use std::path::PathBuf;
4use std::process::Command;
5use tokio::time::{timeout, Duration};
6
7const SEARX_ROOT_ENV: &str = "HEMATITE_SEARX_ROOT";
8const DEFAULT_SEARX_URL: &str = "http://localhost:8080";
9
10#[derive(Clone, Debug, Default)]
11pub struct SearxRuntimeSession {
12 pub root: PathBuf,
13 pub owned_by_session: bool,
14 pub auto_stop_on_exit: bool,
15 pub startup_summary: Option<String>,
16}
17
18enum DockerState {
19 Ready,
20 MissingCli,
21 DaemonUnavailable(String),
22}
23
24pub fn resolve_searx_root() -> PathBuf {
25 if let Some(explicit) = std::env::var_os(SEARX_ROOT_ENV) {
26 let candidate = PathBuf::from(explicit);
27 if !candidate.as_os_str().is_empty() {
28 return candidate;
29 }
30 }
31
32 let home = std::env::var_os("USERPROFILE")
33 .or_else(|| std::env::var_os("HOME"))
34 .map(PathBuf::from)
35 .unwrap_or_else(|| PathBuf::from("."));
36
37 home.join(".hematite").join("searxng-local")
38}
39
40fn find_setup_script() -> Option<PathBuf> {
41 let mut candidates = Vec::new();
42
43 if let Ok(cwd) = std::env::current_dir() {
44 candidates.push(cwd.join("scripts").join("setup-searxng.ps1"));
45 candidates.push(cwd.join("setup-searxng.ps1"));
46 }
47
48 if let Ok(exe) = std::env::current_exe() {
49 if let Some(exe_dir) = exe.parent() {
50 candidates.push(exe_dir.join("setup-searxng.ps1"));
51 candidates.push(exe_dir.join("scripts").join("setup-searxng.ps1"));
52 }
53 }
54
55 candidates.into_iter().find(|path| Path::new(path).exists())
56}
57
58fn looks_like_local_searx_url(url: &str) -> bool {
59 let lower = url.to_ascii_lowercase();
60 lower.contains("localhost")
61 || lower.contains("127.0.0.1")
62 || lower.contains("[::1]")
63 || !lower.contains("://")
64}
65
66fn docker_state() -> DockerState {
67 match Command::new("docker")
68 .args(["info", "--format", "{{.ServerVersion}}"])
69 .output()
70 {
71 Ok(output) if output.status.success() => DockerState::Ready,
72 Ok(output) => {
73 let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
74 DockerState::DaemonUnavailable(if detail.is_empty() {
75 "Docker is installed but the daemon is not responding.".to_string()
76 } else {
77 detail
78 })
79 }
80 Err(err) if err.kind() == std::io::ErrorKind::NotFound => DockerState::MissingCli,
81 Err(err) => DockerState::DaemonUnavailable(err.to_string()),
82 }
83}
84
85fn ensure_scaffolded(root: &Path) -> Result<(), String> {
86 let compose_path = root.join("docker-compose.yaml");
87 let start_script = root.join("start_searx.bat");
88 if compose_path.exists() && start_script.exists() {
89 return Ok(());
90 }
91
92 let Some(script_path) = find_setup_script() else {
93 return Err(
94 "Local search bootstrap is unavailable: setup-searxng.ps1 could not be found."
95 .to_string(),
96 );
97 };
98
99 let output = Command::new("powershell")
100 .arg("-ExecutionPolicy")
101 .arg("Bypass")
102 .arg("-File")
103 .arg(script_path)
104 .arg("-TargetRoot")
105 .arg(root)
106 .output()
107 .map_err(|e| format!("Failed to scaffold local search: {}", e))?;
108
109 if output.status.success() {
110 Ok(())
111 } else {
112 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
113 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
114 let detail = if !stderr.is_empty() { stderr } else { stdout };
115 Err(format!("Failed to scaffold local search: {}", detail))
116 }
117}
118
119fn docker_compose_up(root: &Path) -> Result<(), String> {
120 let output = Command::new("docker")
121 .args(["compose", "up", "-d"])
122 .current_dir(root)
123 .output()
124 .map_err(|e| format!("Failed to start local search: {}", e))?;
125
126 if output.status.success() {
127 Ok(())
128 } else {
129 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
130 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
131 let detail = if !stderr.is_empty() { stderr } else { stdout };
132 Err(format!("Local search start failed: {}", detail))
133 }
134}
135
136fn docker_compose_down(root: &Path) -> Result<(), String> {
137 let output = Command::new("docker")
138 .args(["compose", "down"])
139 .current_dir(root)
140 .output()
141 .map_err(|e| format!("Failed to stop local search: {}", e))?;
142
143 if output.status.success() {
144 Ok(())
145 } else {
146 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
147 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
148 let detail = if !stderr.is_empty() { stderr } else { stdout };
149 Err(format!("Local search stop failed: {}", detail))
150 }
151}
152
153async fn wait_for_searx(url: &str) -> bool {
154 for _ in 0..20 {
155 if is_searx_responding(url).await {
156 return true;
157 }
158 tokio::time::sleep(Duration::from_millis(500)).await;
159 }
160 false
161}
162
163pub async fn is_searx_responding(url: &str) -> bool {
165 let client = reqwest::Client::builder()
166 .timeout(Duration::from_millis(500))
167 .build()
168 .unwrap_or_default();
169
170 match timeout(Duration::from_millis(600), client.get(url).send()).await {
171 Ok(Ok(resp)) => resp.status().is_success() || resp.status().as_u16() == 403, _ => false,
173 }
174}
175
176pub async fn boot_searx_if_needed(config: &HematiteConfig) -> SearxRuntimeSession {
178 let url = config.searx_url.as_deref().unwrap_or(DEFAULT_SEARX_URL);
179 let root = resolve_searx_root();
180 let mut session = SearxRuntimeSession {
181 root: root.clone(),
182 owned_by_session: false,
183 auto_stop_on_exit: config.auto_stop_searx,
184 startup_summary: None,
185 };
186
187 if !config.auto_start_searx {
188 return session;
189 }
190
191 if !looks_like_local_searx_url(url) {
192 return session;
193 }
194
195 if is_searx_responding(url).await {
197 return session;
198 }
199
200 if let Err(err) = ensure_scaffolded(&root) {
201 session.startup_summary = Some(err);
202 return session;
203 }
204
205 match docker_state() {
206 DockerState::MissingCli => {
207 session.startup_summary = Some(
208 "Local search is unavailable: Docker Desktop is not installed. Install it from https://www.docker.com/products/docker-desktop or set `auto_start_searx` to false in `.hematite/settings.json`.".to_string(),
209 );
210 return session;
211 }
212 DockerState::DaemonUnavailable(detail) => {
213 session.startup_summary = Some(format!(
214 "Local search is unavailable: Docker is installed but not running. Start Docker Desktop, then relaunch Hematite or start SearXNG manually from `{}`. Detail: {}",
215 root.display(),
216 detail
217 ));
218 return session;
219 }
220 DockerState::Ready => {}
221 }
222
223 if let Err(err) = docker_compose_up(&root) {
224 session.startup_summary = Some(err);
225 return session;
226 }
227
228 if wait_for_searx(url).await {
229 session.owned_by_session = true;
230 session.startup_summary = Some(format!(
231 "Local search auto-started: SearXNG is now live at {} (root: {}). Hematite started this stack in the current session{}.",
232 url,
233 root.display(),
234 if config.auto_stop_searx {
235 " and will stop it on exit"
236 } else {
237 ""
238 }
239 ));
240 } else {
241 session.startup_summary = Some(format!(
242 "Local search was started from `{}`, but {} never became reachable. Check `docker compose logs` in that folder.",
243 root.display(),
244 url
245 ));
246 }
247
248 session
249}
250
251pub async fn shutdown_searx_if_owned(session: &SearxRuntimeSession) -> Option<String> {
252 if !session.owned_by_session || !session.auto_stop_on_exit {
253 return None;
254 }
255
256 match docker_compose_down(&session.root) {
257 Ok(()) => Some(format!(
258 "Stopped session-owned local search stack at {}.",
259 session.root.display()
260 )),
261 Err(err) => Some(err),
262 }
263}