1use std::io::Read;
16use std::net::TcpListener;
17use std::path::{Path, PathBuf};
18use std::process::{Child, Command, Stdio};
19use std::sync::{Arc, Mutex};
20use std::time::{Duration, Instant};
21
22use serde_json::{json, Value};
23
24use crate::formal_ai_client::{http_request, HttpError, API_PREFIX};
25use crate::formal_ai_policy::Bounds;
26
27pub const MIN_FORMAL_AI_VERSION: &str = "0.355.27";
29pub const SERVER_ENTRY: &str = "js/server/main.mjs";
30pub const INSTALL_HINT: &str = "Install Formal AI from https://github.com/link-assistant/formal-ai (git clone https://github.com/link-assistant/formal-ai.git) and set START_FORMAL_AI_ROOT to the checkout, or set START_FORMAL_AI_URL to a running server. See docs/formal-ai.md.";
31
32pub fn compare_versions(left: &str, right: &str) -> Option<std::cmp::Ordering> {
34 let parse = |v: &str| -> Option<Vec<u64>> {
35 if v.is_empty() {
36 return None;
37 }
38 v.split('.')
39 .map(|part| {
40 if !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()) {
41 part.parse::<u64>().ok()
42 } else {
43 None
44 }
45 })
46 .collect()
47 };
48 let a = parse(left)?;
49 let b = parse(right)?;
50 for i in 0..a.len().max(b.len()) {
51 let order = a.get(i).unwrap_or(&0).cmp(b.get(i).unwrap_or(&0));
52 if order != std::cmp::Ordering::Equal {
53 return Some(order);
54 }
55 }
56 Some(std::cmp::Ordering::Equal)
57}
58
59pub fn read_cargo_version(root: &Path) -> Option<String> {
61 let text = std::fs::read_to_string(root.join("rust").join("Cargo.toml")).ok()?;
62 let mut in_package = false;
63 for line in text.lines() {
64 if line.starts_with('[') {
65 in_package = line.starts_with("[package]");
66 continue;
67 }
68 if !in_package {
69 continue;
70 }
71 if let Some(rest) = line.strip_prefix("version") {
72 let rest = rest.trim_start();
73 if let Some(value) = rest.strip_prefix('=') {
74 let value = value.trim();
75 if let Some(inner) = value.strip_prefix('"') {
76 if let Some(end) = inner.find('"') {
77 if end > 0 {
78 return Some(inner[..end].to_string());
79 }
80 }
81 }
82 }
83 }
84 }
85 None
86}
87
88fn read_json_version(file: &Path) -> Option<String> {
89 let text = std::fs::read_to_string(file).ok()?;
90 let value: Value = serde_json::from_str(&text).ok()?;
91 value
92 .get("version")
93 .and_then(Value::as_str)
94 .filter(|v| !v.is_empty())
95 .map(str::to_string)
96}
97
98fn run_text(program: &str, args: &[&str]) -> Option<String> {
99 let output = Command::new(program)
100 .args(args)
101 .stdin(Stdio::null())
102 .output()
103 .ok()?;
104 if output.status.success() {
105 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
106 } else {
107 None
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Default)]
113pub struct RuntimeIdentity {
114 pub source: String,
116 pub url: Option<String>,
117 pub root: Option<String>,
118 pub entry: Option<String>,
119 pub version: Option<String>,
120 pub engine_version: Option<String>,
121 pub commit: Option<String>,
122 pub dirty: Option<bool>,
123 pub node: Option<String>,
124 pub node_version: Option<String>,
125 pub memory: Option<String>,
126 pub port: Option<u16>,
127}
128
129impl RuntimeIdentity {
130 pub fn to_json(&self) -> Value {
132 if self.source == "url" {
133 return json!({ "source": "url", "url": self.url });
134 }
135 let mut value = json!({
136 "source": self.source,
137 "root": self.root,
138 "entry": self.entry,
139 "version": self.version,
140 "engineVersion": self.engine_version,
141 "commit": self.commit,
142 "dirty": self.dirty,
143 "node": self.node,
144 "nodeVersion": self.node_version,
145 });
146 if let Some(memory) = &self.memory {
147 value["memory"] = json!(memory);
148 }
149 if let Some(port) = self.port {
150 value["port"] = json!(port);
151 }
152 value
153 }
154}
155
156pub fn checkout_identity(root: &Path, node: &str) -> RuntimeIdentity {
158 let root_text = root.to_string_lossy().to_string();
159 let commit = run_text("git", &["-C", &root_text, "rev-parse", "HEAD"]);
160 let status = commit
161 .as_ref()
162 .and_then(|_| run_text("git", &["-C", &root_text, "status", "--porcelain"]));
163 RuntimeIdentity {
164 source: "checkout".to_string(),
165 root: Some(root_text),
166 entry: Some(SERVER_ENTRY.to_string()),
167 version: read_cargo_version(root),
168 engine_version: read_json_version(
169 &root
170 .join("packages")
171 .join("formal-ai-engine")
172 .join("package.json"),
173 ),
174 commit,
175 dirty: status.map(|s| !s.is_empty()),
176 node: Some(node.to_string()),
177 node_version: run_text(node, &["--version"]),
178 ..RuntimeIdentity::default()
179 }
180}
181
182pub fn describe_identity(identity: &RuntimeIdentity) -> String {
184 if identity.source == "url" {
185 return format!(
186 "formal-ai at {} (version unverified)",
187 identity.url.as_deref().unwrap_or("")
188 );
189 }
190 let commit = match &identity.commit {
191 Some(commit) if !commit.is_empty() => format!(
192 "{}{}",
193 commit.chars().take(12).collect::<String>(),
194 if identity.dirty == Some(true) {
195 "+dirty"
196 } else {
197 ""
198 }
199 ),
200 _ => "no git commit".to_string(),
201 };
202 format!(
203 "formal-ai {} ({}, js server {}, node {})",
204 identity.version.as_deref().unwrap_or("unknown"),
205 commit,
206 identity.entry.as_deref().unwrap_or(SERVER_ENTRY),
207 identity.node_version.as_deref().unwrap_or("unknown")
208 )
209}
210
211pub fn qualify_identity(
214 identity: &RuntimeIdentity,
215 get: &dyn Fn(&str) -> Option<String>,
216) -> Result<(), String> {
217 let expected = get("START_FORMAL_AI_EXPECTED_REVISION")
218 .unwrap_or_default()
219 .trim()
220 .to_string();
221 if identity.source == "url" {
222 if get("START_FORMAL_AI_ALLOW_UNVERIFIED").as_deref() != Some("1") {
223 return Err(format!(
224 "START_FORMAL_AI_URL={} does not report its Formal AI version, so it cannot be qualified. Set START_FORMAL_AI_ALLOW_UNVERIFIED=1 to use it anyway, or use START_FORMAL_AI_ROOT.",
225 identity.url.as_deref().unwrap_or("")
226 ));
227 }
228 return Ok(());
229 }
230 if identity.node_version.is_none() {
231 return Err(format!(
232 "Cannot run \"{} --version\"; set START_FORMAL_AI_NODE to a Node.js executable.",
233 identity.node.as_deref().unwrap_or("node")
234 ));
235 }
236 let root = identity.root.as_deref().unwrap_or("");
237 let version = identity.version.as_deref().unwrap_or("");
238 let order = compare_versions(version, MIN_FORMAL_AI_VERSION);
239 if order.is_none_or(|o| o == std::cmp::Ordering::Less) {
240 return Err(format!(
241 "Formal AI at {} is version {}; --use-formal-ai needs {} or newer. Update the checkout (git pull).",
242 root,
243 if version.is_empty() { "unknown" } else { version },
244 MIN_FORMAL_AI_VERSION
245 ));
246 }
247 let pinned = identity
248 .commit
249 .as_deref()
250 .is_some_and(|commit| commit.starts_with(&expected.to_lowercase()));
251 if !expected.is_empty() && !pinned {
252 return Err(format!(
253 "Formal AI at {} is at commit {}, not START_FORMAL_AI_EXPECTED_REVISION={}.",
254 root,
255 identity.commit.as_deref().unwrap_or("unknown"),
256 expected
257 ));
258 }
259 Ok(())
260}
261
262pub struct Runtime {
264 pub base_url: String,
265 pub identity: RuntimeIdentity,
266 child: Option<Child>,
267 home: Option<PathBuf>,
268}
269
270impl Runtime {
271 pub fn stop(&mut self) {
272 if let Some(mut child) = self.child.take() {
273 if let Ok(None) = child.try_wait() {
274 let _ = child.kill();
275 }
276 let _ = child.wait();
277 }
278 if let Some(home) = self.home.take() {
279 let _ = std::fs::remove_dir_all(home);
280 }
281 }
282}
283
284impl Drop for Runtime {
285 fn drop(&mut self) {
286 self.stop();
287 }
288}
289
290fn free_port() -> Result<u16, String> {
291 let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
292 let port = listener.local_addr().map_err(|e| e.to_string())?.port();
293 Ok(port)
294}
295
296fn private_home() -> Result<PathBuf, String> {
297 let base = std::env::temp_dir();
298 for attempt in 0..100u32 {
299 let candidate = base.join(format!(
300 "start-formal-ai-{}-{}",
301 std::process::id(),
302 uuid::Uuid::new_v4()
303 .simple()
304 .to_string()
305 .get(..8)
306 .unwrap_or("x")
307 ));
308 match std::fs::create_dir(&candidate) {
309 Ok(()) => return Ok(candidate),
310 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && attempt < 99 => {}
311 Err(error) => return Err(error.to_string()),
312 }
313 }
314 Err("cannot create a private Formal AI home".to_string())
315}
316
317pub fn start_runtime(
319 get: &dyn Fn(&str) -> Option<String>,
320 bounds: &Bounds,
321 cancel: &dyn Fn() -> bool,
322) -> Result<Runtime, String> {
323 let url = get("START_FORMAL_AI_URL")
324 .unwrap_or_default()
325 .trim()
326 .to_string();
327 let root = get("START_FORMAL_AI_ROOT")
328 .unwrap_or_default()
329 .trim()
330 .to_string();
331 if !url.is_empty() && !root.is_empty() {
332 return Err("Set only one of START_FORMAL_AI_URL and START_FORMAL_AI_ROOT.".to_string());
333 }
334 if !url.is_empty() {
335 if !url.starts_with("http://") {
336 return Err(format!(
337 "START_FORMAL_AI_URL must be an http:// URL, got \"{}\"",
338 url
339 ));
340 }
341 let identity = RuntimeIdentity {
342 source: "url".to_string(),
343 url: Some(url.trim_end_matches('/').to_string()),
344 ..RuntimeIdentity::default()
345 };
346 qualify_identity(&identity, get)?;
347 return Ok(Runtime {
348 base_url: identity.url.clone().unwrap_or_default(),
349 identity,
350 child: None,
351 home: None,
352 });
353 }
354 if root.is_empty() {
355 return Err(format!(
356 "--use-formal-ai needs a Formal AI runtime and has no fallback. {}",
357 INSTALL_HINT
358 ));
359 }
360 let absolute_root = std::env::current_dir()
361 .map(|cwd| cwd.join(&root))
362 .unwrap_or_else(|_| PathBuf::from(&root));
363 if !absolute_root.join(SERVER_ENTRY).exists() {
364 return Err(format!(
365 "START_FORMAL_AI_ROOT={} has no {}. {}",
366 root, SERVER_ENTRY, INSTALL_HINT
367 ));
368 }
369 let node = get("START_FORMAL_AI_NODE")
370 .filter(|n| !n.trim().is_empty())
371 .unwrap_or_else(|| "node".to_string())
372 .trim()
373 .to_string();
374 let identity = checkout_identity(&absolute_root, &node);
375 qualify_identity(&identity, get)?;
376 spawn_server(&absolute_root, &node, identity, get, bounds, cancel)
377}
378
379fn spawn_server(
380 root: &Path,
381 node: &str,
382 mut identity: RuntimeIdentity,
383 get: &dyn Fn(&str) -> Option<String>,
384 bounds: &Bounds,
385 cancel: &dyn Fn() -> bool,
386) -> Result<Runtime, String> {
387 let port = free_port()?;
388 let home = private_home()?;
389 let memory = get("START_FORMAL_AI_MEMORY_PATH")
390 .unwrap_or_default()
391 .trim()
392 .to_string();
393 let mut command = Command::new(node);
394 command
395 .arg(format!("--max-old-space-size={}", bounds.max_memory_mb))
396 .arg(root.join(SERVER_ENTRY))
397 .args(["--agent-mode", "--host", "127.0.0.1", "--port"])
398 .arg(port.to_string())
399 .arg("--silent")
400 .current_dir(root)
401 .env_clear()
402 .env("PATH", get("PATH").unwrap_or_default())
403 .env("HOME", &home)
404 .env(
405 "FORMAL_AI_MEMORY_PATH",
406 if memory.is_empty() {
407 home.join("memory.lino")
408 } else {
409 PathBuf::from(&memory)
410 },
411 )
412 .stdin(Stdio::null())
413 .stdout(Stdio::null())
414 .stderr(Stdio::piped());
415 for name in ["LANG", "SYSTEMROOT"] {
416 if let Some(value) = get(name).filter(|v| !v.is_empty()) {
417 command.env(name, value);
418 }
419 }
420 identity.memory = Some(
421 if memory.is_empty() {
422 "private"
423 } else {
424 "START_FORMAL_AI_MEMORY_PATH"
425 }
426 .to_string(),
427 );
428 let mut runtime = Runtime {
429 base_url: format!("http://127.0.0.1:{}", port),
430 identity,
431 child: None,
432 home: Some(home),
433 };
434 let stderr = Arc::new(Mutex::new(String::new()));
435 let mut child = match command.spawn() {
436 Ok(child) => child,
437 Err(error) => {
438 return Err(format!(
439 "Formal AI server did not become ready in time ({})",
440 error
441 ))
442 }
443 };
444 if let Some(mut pipe) = child.stderr.take() {
445 let sink = Arc::clone(&stderr);
446 std::thread::spawn(move || {
447 let mut buf = [0u8; 4096];
448 while let Ok(n) = pipe.read(&mut buf) {
449 if n == 0 {
450 break;
451 }
452 if let Ok(mut text) = sink.lock() {
453 text.push_str(&String::from_utf8_lossy(&buf[..n]));
454 let excess = text.len().saturating_sub(4096);
455 if excess > 0 {
456 let cut = (excess..=text.len())
457 .find(|i| text.is_char_boundary(*i))
458 .unwrap_or(text.len());
459 text.drain(..cut);
460 }
461 }
462 }
463 });
464 }
465 runtime.child = Some(child);
466 let deadline = Instant::now() + Duration::from_millis(bounds.startup_timeout_ms);
467 if let Err(message) = wait_ready(&mut runtime, deadline, cancel) {
468 runtime.stop();
469 let tail = stderr
470 .lock()
471 .map(|t| t.trim().to_string())
472 .unwrap_or_default();
473 if tail.is_empty() {
474 return Err(message);
475 }
476 let start = tail.chars().count().saturating_sub(500);
477 let tail: String = tail.chars().skip(start).collect();
478 return Err(format!("{}: {}", message, tail));
479 }
480 runtime.identity.port = Some(port);
481 Ok(runtime)
482}
483
484fn wait_ready(
485 runtime: &mut Runtime,
486 deadline: Instant,
487 cancel: &dyn Fn() -> bool,
488) -> Result<(), String> {
489 let mut last_error = "no response".to_string();
490 while Instant::now() < deadline {
491 if cancel() {
492 return Err("Formal AI server startup was cancelled".to_string());
493 }
494 if let Some(child) = runtime.child.as_mut() {
495 if let Ok(Some(status)) = child.try_wait() {
496 let code = status
497 .code()
498 .map_or_else(|| "signal".to_string(), |c| c.to_string());
499 return Err(format!("Formal AI server exited during startup ({})", code));
500 }
501 }
502 let remaining = deadline.saturating_duration_since(Instant::now());
503 match http_request(
504 &runtime.base_url,
505 "GET",
506 &format!("{}/models", API_PREFIX),
507 None,
508 remaining.min(Duration::from_secs(2)),
509 cancel,
510 ) {
511 Ok((status, _)) if (200..300).contains(&status) => return Ok(()),
512 Ok((status, _)) => last_error = format!("HTTP {}", status),
513 Err(HttpError::Timeout) => last_error = "timeout".to_string(),
514 Err(HttpError::Cancelled) => {
515 return Err("Formal AI server startup was cancelled".to_string())
516 }
517 Err(HttpError::Io(message)) => last_error = message,
518 }
519 std::thread::sleep(Duration::from_millis(100));
520 }
521 Err(format!(
522 "Formal AI server did not become ready in time ({})",
523 last_error
524 ))
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use std::cmp::Ordering;
531 use std::collections::HashMap;
532
533 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
534 let map: HashMap<String, String> = pairs
535 .iter()
536 .map(|(k, v)| (k.to_string(), v.to_string()))
537 .collect();
538 move |name| map.get(name).cloned()
539 }
540
541 fn checkout(version: &str) -> RuntimeIdentity {
542 RuntimeIdentity {
543 source: "checkout".to_string(),
544 root: Some("/fa".to_string()),
545 entry: Some(SERVER_ENTRY.to_string()),
546 version: Some(version.to_string()),
547 commit: Some("0123456789abcdef".to_string()),
548 dirty: Some(false),
549 node: Some("node".to_string()),
550 node_version: Some("v24.0.0".to_string()),
551 ..RuntimeIdentity::default()
552 }
553 }
554
555 #[test]
556 fn compares_dotted_versions() {
557 assert_eq!(
558 compare_versions("0.355.27", "0.355.27"),
559 Some(Ordering::Equal)
560 );
561 assert_eq!(
562 compare_versions("0.355.28", "0.355.27"),
563 Some(Ordering::Greater)
564 );
565 assert_eq!(compare_versions("0.355", "0.355.1"), Some(Ordering::Less));
566 assert_eq!(
567 compare_versions("1.0", "0.999.999"),
568 Some(Ordering::Greater)
569 );
570 assert_eq!(compare_versions("1.0-beta", "1.0"), None);
571 assert_eq!(compare_versions("", "1.0"), None);
572 }
573
574 #[test]
575 fn reads_the_package_version_only() {
576 let dir = tempfile::tempdir().unwrap();
577 std::fs::create_dir(dir.path().join("rust")).unwrap();
578 std::fs::write(
579 dir.path().join("rust").join("Cargo.toml"),
580 "[workspace]\nversion = \"9.9.9\"\n\n[package]\nname = \"formal-ai\"\nversion = \"0.355.27\"\n\n[dependencies]\nversion = \"1\"\n",
581 )
582 .unwrap();
583 assert_eq!(read_cargo_version(dir.path()).as_deref(), Some("0.355.27"));
584 assert_eq!(read_cargo_version(&dir.path().join("missing")), None);
585 }
586
587 #[test]
588 fn qualifies_versions_revisions_and_urls() {
589 let none = env(&[]);
590 assert!(qualify_identity(&checkout("0.355.27"), &none).is_ok());
591 let old = qualify_identity(&checkout("0.355.26"), &none).unwrap_err();
592 assert!(old.contains("needs 0.355.27 or newer"), "{}", old);
593 let pin = env(&[("START_FORMAL_AI_EXPECTED_REVISION", "0123AB")]);
594 assert!(qualify_identity(
595 &checkout("0.355.27"),
596 &env(&[("START_FORMAL_AI_EXPECTED_REVISION", "0123")])
597 )
598 .is_ok());
599 let pinned = qualify_identity(&checkout("0.355.27"), &pin).unwrap_err();
600 assert!(pinned.contains("not START_FORMAL_AI_EXPECTED_REVISION=0123AB"));
601 let mut no_node = checkout("0.355.27");
602 no_node.node_version = None;
603 assert!(qualify_identity(&no_node, &none)
604 .unwrap_err()
605 .contains("set START_FORMAL_AI_NODE"));
606 let url = RuntimeIdentity {
607 source: "url".to_string(),
608 url: Some("http://127.0.0.1:9".to_string()),
609 ..RuntimeIdentity::default()
610 };
611 assert!(qualify_identity(&url, &none)
612 .unwrap_err()
613 .contains("cannot be qualified"));
614 assert!(qualify_identity(&url, &env(&[("START_FORMAL_AI_ALLOW_UNVERIFIED", "1")])).is_ok());
615 }
616
617 #[test]
618 fn describes_identities() {
619 let mut identity = checkout("0.355.27");
620 identity.dirty = Some(true);
621 assert_eq!(
622 describe_identity(&identity),
623 "formal-ai 0.355.27 (0123456789ab+dirty, js server js/server/main.mjs, node v24.0.0)"
624 );
625 identity.commit = None;
626 assert!(describe_identity(&identity).contains("(no git commit, "));
627 let url = RuntimeIdentity {
628 source: "url".to_string(),
629 url: Some("http://h:1".to_string()),
630 ..RuntimeIdentity::default()
631 };
632 assert_eq!(
633 describe_identity(&url),
634 "formal-ai at http://h:1 (version unverified)"
635 );
636 assert_eq!(url.to_json(), json!({"source": "url", "url": "http://h:1"}));
637 }
638
639 #[test]
640 fn resolution_never_falls_back() {
641 let bounds = Bounds::default();
642 let never = || false;
643 let cases: Vec<(Vec<(&str, &str)>, &str)> = vec![
644 (vec![], "needs a Formal AI runtime and has no fallback"),
645 (
646 vec![
647 ("START_FORMAL_AI_URL", "http://a"),
648 ("START_FORMAL_AI_ROOT", "/b"),
649 ],
650 "Set only one of",
651 ),
652 (
653 vec![("START_FORMAL_AI_URL", "ftp://a")],
654 "must be an http:// URL",
655 ),
656 (
657 vec![("START_FORMAL_AI_URL", "https://a")],
658 "must be an http:// URL",
659 ),
660 (
661 vec![("START_FORMAL_AI_URL", "http://127.0.0.1:9")],
662 "cannot be qualified",
663 ),
664 (
665 vec![("START_FORMAL_AI_ROOT", "/nonexistent/formal-ai")],
666 "has no js/server/main.mjs",
667 ),
668 ];
669 for (pairs, message) in cases {
670 let error = start_runtime(&env(&pairs), &bounds, &never)
671 .err()
672 .expect("must fail");
673 assert!(error.contains(message), "{} !~ {}", error, message);
674 }
675 let runtime = match start_runtime(
676 &env(&[
677 ("START_FORMAL_AI_URL", "http://127.0.0.1:9/"),
678 ("START_FORMAL_AI_ALLOW_UNVERIFIED", "1"),
679 ]),
680 &bounds,
681 &never,
682 ) {
683 Ok(runtime) => runtime,
684 Err(error) => panic!("unverified URL must be allowed: {}", error),
685 };
686 assert_eq!(runtime.base_url, "http://127.0.0.1:9");
687 }
688}