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 pub docker_wake_pending: bool,
19}
20
21pub(crate) enum DockerState {
22 Ready,
23 MissingCli,
24 DaemonUnavailable(String),
25}
26
27pub fn resolve_searx_root() -> PathBuf {
28 if let Some(explicit) = std::env::var_os(SEARX_ROOT_ENV) {
29 let candidate = PathBuf::from(explicit);
30 if !candidate.as_os_str().is_empty() {
31 return candidate;
32 }
33 }
34
35 let home = std::env::var_os("USERPROFILE")
36 .or_else(|| std::env::var_os("HOME"))
37 .map(PathBuf::from)
38 .unwrap_or_else(|| PathBuf::from("."));
39
40 home.join(".hematite").join("searxng-local")
41}
42
43fn find_setup_script() -> Option<PathBuf> {
44 let mut candidates = Vec::new();
45
46 if let Ok(cwd) = std::env::current_dir() {
47 candidates.push(cwd.join("scripts").join("setup-searxng.ps1"));
48 candidates.push(cwd.join("setup-searxng.ps1"));
49 }
50
51 if let Ok(exe) = std::env::current_exe() {
52 if let Some(exe_dir) = exe.parent() {
53 candidates.push(exe_dir.join("setup-searxng.ps1"));
54 candidates.push(exe_dir.join("scripts").join("setup-searxng.ps1"));
55 }
56 }
57
58 candidates.into_iter().find(|path| Path::new(path).exists())
59}
60
61fn looks_like_local_searx_url(url: &str) -> bool {
62 let lower = url.to_ascii_lowercase();
63 lower.contains("localhost")
64 || lower.contains("127.0.0.1")
65 || lower.contains("[::1]")
66 || !lower.contains("://")
67}
68
69#[cfg(target_os = "windows")]
72fn find_docker_desktop_exe() -> Option<PathBuf> {
73 let mut candidates = vec![
74 PathBuf::from(r"C:\Program Files\Docker\Docker\Docker Desktop.exe"),
75 PathBuf::from(r"C:\Program Files (x86)\Docker\Docker\Docker Desktop.exe"),
76 ];
77 if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
78 candidates.push(
79 local
80 .join("Programs")
81 .join("Docker")
82 .join("Docker")
83 .join("Docker Desktop.exe"),
84 );
85 }
86 candidates.into_iter().find(|p| p.exists())
87}
88
89pub(crate) fn docker_state() -> DockerState {
90 match Command::new("docker")
91 .args(["info", "--format", "{{.ServerVersion}}"])
92 .output()
93 {
94 Ok(output) if output.status.success() => DockerState::Ready,
95 Ok(output) => {
96 let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
97 DockerState::DaemonUnavailable(if detail.is_empty() {
98 "Docker is installed but the daemon is not responding.".to_string()
99 } else {
100 detail
101 })
102 }
103 Err(err) if err.kind() == std::io::ErrorKind::NotFound => DockerState::MissingCli,
104 Err(err) => DockerState::DaemonUnavailable(err.to_string()),
105 }
106}
107
108fn ensure_scaffolded(root: &Path) -> Result<(), String> {
109 let compose_path = root.join("docker-compose.yaml");
110 let start_script = root.join("start_searx.bat");
111 if compose_path.exists() && start_script.exists() {
112 return Ok(());
113 }
114
115 let Some(script_path) = find_setup_script() else {
116 return Err(
117 "Local search bootstrap is unavailable: setup-searxng.ps1 could not be found."
118 .to_string(),
119 );
120 };
121
122 let output = Command::new("powershell")
123 .arg("-ExecutionPolicy")
124 .arg("Bypass")
125 .arg("-File")
126 .arg(script_path)
127 .arg("-TargetRoot")
128 .arg(root)
129 .output()
130 .map_err(|e| format!("Failed to scaffold local search: {}", e))?;
131
132 if output.status.success() {
133 Ok(())
134 } else {
135 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
136 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
137 let detail = if !stderr.is_empty() { stderr } else { stdout };
138 Err(format!("Failed to scaffold local search: {}", detail))
139 }
140}
141
142pub(crate) fn docker_compose_up(root: &Path) -> Result<(), String> {
143 let output = Command::new("docker")
144 .args(["compose", "up", "-d"])
145 .current_dir(root)
146 .output()
147 .map_err(|e| format!("Failed to start local search: {}", e))?;
148
149 if output.status.success() {
150 Ok(())
151 } else {
152 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
153 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
154 let detail = if !stderr.is_empty() { stderr } else { stdout };
155 Err(format!("Local search start failed: {}", detail))
156 }
157}
158
159fn docker_compose_down(root: &Path) -> Result<(), String> {
160 let output = Command::new("docker")
161 .args(["compose", "down"])
162 .current_dir(root)
163 .output()
164 .map_err(|e| format!("Failed to stop local search: {}", e))?;
165
166 if output.status.success() {
167 Ok(())
168 } else {
169 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
170 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
171 let detail = if !stderr.is_empty() { stderr } else { stdout };
172 Err(format!("Local search stop failed: {}", detail))
173 }
174}
175
176pub(crate) async fn wait_for_searx(url: &str) -> bool {
177 for _ in 0..20 {
178 if is_searx_responding(url).await {
179 return true;
180 }
181 tokio::time::sleep(Duration::from_millis(500)).await;
182 }
183 false
184}
185
186pub async fn is_searx_responding(url: &str) -> bool {
188 let client = reqwest::Client::builder()
189 .timeout(Duration::from_millis(500))
190 .build()
191 .unwrap_or_default();
192
193 match timeout(Duration::from_millis(600), client.get(url).send()).await {
194 Ok(Ok(resp)) => resp.status().is_success() || resp.status().as_u16() == 403, _ => false,
196 }
197}
198
199pub async fn boot_searx_if_needed(config: &HematiteConfig) -> SearxRuntimeSession {
201 let url = config.searx_url.as_deref().unwrap_or(DEFAULT_SEARX_URL);
202 let root = resolve_searx_root();
203 let mut session = SearxRuntimeSession {
204 root: root.clone(),
205 owned_by_session: false,
206 auto_stop_on_exit: config.auto_stop_searx,
207 startup_summary: None,
208 docker_wake_pending: false,
209 };
210
211 if !config.auto_start_searx {
212 return session;
213 }
214
215 if !looks_like_local_searx_url(url) {
216 return session;
217 }
218
219 if is_searx_responding(url).await {
221 return session;
222 }
223
224 if let Err(err) = ensure_scaffolded(&root) {
225 session.startup_summary = Some(err);
226 return session;
227 }
228
229 match docker_state() {
230 DockerState::MissingCli => {
231 session.startup_summary = Some(
232 "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(),
233 );
234 return session;
235 }
236 DockerState::DaemonUnavailable(_detail) => {
237 #[cfg(target_os = "windows")]
238 if let Some(exe) = find_docker_desktop_exe() {
239 let launched = std::process::Command::new(&exe).spawn().is_ok();
240 if launched {
241 session.docker_wake_pending = true;
242 session.startup_summary = Some(
243 "Local search: Docker Desktop wasn't running — launching it now. \
244 SearXNG will auto-start once Docker is ready (~30–60s). \
245 Falling back to Jina until then."
246 .to_string(),
247 );
248 return session;
249 }
250 }
251 session.startup_summary = Some(format!(
252 "Local search is unavailable: Docker is installed but not running. \
253 Start Docker Desktop, then relaunch Hematite or run `docker compose up -d` in `{}`.",
254 root.display()
255 ));
256 return session;
257 }
258 DockerState::Ready => {}
259 }
260
261 if let Err(err) = docker_compose_up(&root) {
262 session.startup_summary = Some(err);
263 return session;
264 }
265
266 if wait_for_searx(url).await {
267 session.owned_by_session = true;
268 session.startup_summary = Some(format!(
269 "Local search auto-started: SearXNG is now live at {} (root: {}). Hematite started this stack in the current session{}.",
270 url,
271 root.display(),
272 if config.auto_stop_searx {
273 " and will stop it on exit"
274 } else {
275 ""
276 }
277 ));
278 } else {
279 session.startup_summary = Some(format!(
280 "Local search was started from `{}`, but {} never became reachable. Check `docker compose logs` in that folder.",
281 root.display(),
282 url
283 ));
284 }
285
286 session
287}
288
289pub async fn shutdown_searx_if_owned(session: &SearxRuntimeSession) -> Option<String> {
290 if !session.owned_by_session || !session.auto_stop_on_exit {
291 return None;
292 }
293
294 match docker_compose_down(&session.root) {
295 Ok(()) => Some(format!(
296 "Stopped session-owned local search stack at {}.",
297 session.root.display()
298 )),
299 Err(err) => Some(err),
300 }
301}