use crate::fleet::Fleet;
use crate::home::{self, Registry};
use crate::http::{self, Refusal, Server};
use crate::lock::StoreLock;
use crate::{Semlith, watch};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock, mpsc};
use std::time::{Duration, SystemTime};
pub const DISCOVERY_FILE: &str = "daemon.json";
const EVENT_HISTORY: usize = 20;
const SLICE: Duration = Duration::from_secs(45);
const LOG_HISTORY: usize = 500;
const RUN_HISTORY: usize = 24;
const NO_RUN: u64 = 0;
const EXPECTED_RUN_GRACE_SECS: usize = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Discovery {
pub pid: u32,
pub port: u16,
pub token: String,
pub version: String,
#[serde(default)]
pub started: u64,
}
impl Discovery {
pub fn read(store_dir: &Path) -> Option<Self> {
if !home::Registry::load().ok()?.trusts(store_dir) {
return None;
}
let path = store_dir.join(DISCOVERY_FILE);
if worth_warning_about(&path) {
eprintln!(
"semlith: ignoring {} — it is not a file this user wrote privately",
path.display()
);
return None;
}
if !owner_only(&path) {
return None;
}
let text = std::fs::read_to_string(&path).ok()?;
let found: Self = serde_json::from_str(&text).ok()?;
if found.token.len() != 64 || !found.token.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
if !alive(found.pid) {
return None;
}
Some(found)
}
fn write(&self, store_dir: &Path) -> Result<()> {
let path = store_dir.join(DISCOVERY_FILE);
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(home::FILE_MODE);
}
let mut file = options
.open(&path)
.with_context(|| format!("writing {}", path.display()))?;
use std::io::Write;
file.write_all((serde_json::to_string_pretty(self)? + "\n").as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
home::tighten_file(&path);
Ok(())
}
fn remove(store_dir: &Path) {
let _ = std::fs::remove_file(store_dir.join(DISCOVERY_FILE));
}
}
fn worth_warning_about(path: &Path) -> bool {
path.exists() && !owner_only(path)
}
#[cfg(unix)]
fn owner_only(path: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
let me = unsafe { libc::getuid() };
meta.uid() == me && meta.permissions().mode() & 0o177 == 0
}
#[cfg(not(unix))]
fn owner_only(path: &Path) -> bool {
path.exists()
}
#[cfg(unix)]
pub(crate) fn alive(pid: u32) -> bool {
unsafe {
libc::kill(pid as i32, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
#[cfg(not(unix))]
pub(crate) fn alive(_pid: u32) -> bool {
true
}
enum Work {
Roots(Vec<PathBuf>),
Rest(Vec<PathBuf>),
}
#[derive(Default, Clone)]
struct Tally {
indexed: u64,
unchanged: u64,
skipped: u64,
removed: u64,
chunks: u64,
images: u64,
symbols: u64,
refused: Vec<(String, String)>,
failed: Vec<(String, String)>,
skipped_reasons: std::collections::BTreeMap<String, usize>,
stages: Box<crate::pipeline::Stages>,
cache_lookups: u64,
cache_hits: u64,
rows: u64,
}
impl Tally {
fn add(&mut self, report: &crate::IndexReport) {
self.stages.add(&report.stages);
self.cache_lookups += report.cache_lookups as u64;
self.cache_hits += report.cache_hits as u64;
self.rows += report.rows as u64;
self.refused.extend(report.refused.iter().cloned());
self.failed.extend(report.failed.iter().cloned());
for (kind, n) in &report.skipped_reasons {
*self.skipped_reasons.entry(kind.clone()).or_insert(0) += n;
}
self.indexed += report.indexed as u64;
self.unchanged += report.unchanged as u64;
self.skipped += report.skipped as u64;
self.removed += report.removed as u64;
self.chunks += report.chunks as u64;
self.images += report.images as u64;
self.symbols += report.symbols as u64;
}
}
struct Indexing {
work: Work,
first: bool,
already: Vec<String>,
scanned: u64,
total: u64,
bytes: u64,
bytes_total: u64,
tally: Tally,
}
enum Job {
Index(Indexing),
Forget(PathBuf),
Accept {
path: PathBuf,
mode: String,
source: String,
},
Revoke(PathBuf),
Compact(crate::compact::CompactOptions),
}
struct Queued {
run: u64,
job: Job,
report: mpsc::Sender<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Event {
pub at: u64,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RunStatus {
Queued,
Review,
Running,
Pausing,
Paused,
Held,
Stopping,
Done,
Stopped,
Failed,
}
impl RunStatus {
pub fn finished(self) -> bool {
matches!(self, Self::Done | Self::Stopped | Self::Failed)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RunKind {
Run,
CatchUp,
Batch,
Compact,
Reindex,
Rebuild,
Files,
}
impl RunKind {
fn forced(self) -> bool {
matches!(self, Self::Rebuild | Self::Files)
}
fn label(self, paths: usize) -> String {
match self {
Self::Run => "Index".to_string(),
Self::Reindex | Self::Rebuild => "Re-index".to_string(),
Self::Files => format!("Re-index {paths} files"),
Self::CatchUp | Self::Batch => "Watcher catch-up".to_string(),
Self::Compact => "Compact".to_string(),
}
}
}
const RATE_WINDOW_MS: u64 = 10_000;
const ETA_SETTLE_MS: u64 = 5_000;
pub const BATCH_ADMIT_OVER: usize = 32;
pub struct RunState {
pub id: u64,
pub paths: Vec<PathBuf>,
pub kind: RunKind,
pub files_before: Option<u64>,
pub chunks_before: Option<u64>,
pub threads: u64,
samples: VecDeque<(u64, u64, u64)>,
lane_samples: VecDeque<(u64, BTreeMap<String, u64>)>,
first_sample: Option<(u64, u64, u64)>,
pub delete: Option<String>,
pub status: RunStatus,
pub submitted: u64,
pub started: Option<u64>,
pub finished: Option<u64>,
queued_ms: Option<u64>,
origin: std::time::Instant,
paused_at: Option<std::time::Instant>,
paused_total: Duration,
ended: Option<Duration>,
pub scanned: u64,
pub total: u64,
pub bytes: u64,
pub bytes_total: u64,
pub indexed: u64,
pub chunks: u64,
pub symbols: u64,
pub rows: u64,
pub phase: Option<String>,
pub plan: Option<serde_json::Value>,
plan_eta_ms: Option<u64>,
shown_eta: std::cell::Cell<Option<(u64, u64)>>,
pub summary: Option<serde_json::Value>,
stages: Option<serde_json::Value>,
cache: (u64, u64),
log: VecDeque<serde_json::Value>,
next_seq: u64,
}
impl RunState {
fn new(id: u64, paths: Vec<PathBuf>, kind: RunKind) -> Self {
Self {
id,
paths,
kind,
files_before: None,
chunks_before: None,
threads: 0,
samples: VecDeque::new(),
lane_samples: VecDeque::new(),
first_sample: None,
delete: None,
status: RunStatus::Queued,
submitted: now(),
started: None,
finished: None,
queued_ms: None,
origin: std::time::Instant::now(),
paused_at: None,
paused_total: Duration::ZERO,
ended: None,
scanned: 0,
total: 0,
bytes: 0,
bytes_total: 0,
indexed: 0,
chunks: 0,
symbols: 0,
rows: 0,
phase: None,
shown_eta: std::cell::Cell::new(None),
plan: None,
plan_eta_ms: None,
summary: None,
stages: None,
cache: (0, 0),
log: VecDeque::new(),
next_seq: 0,
}
}
fn elapsed(&self) -> Duration {
if let Some(ended) = self.ended {
return ended;
}
let now = std::time::Instant::now();
let held = self.paused_total
+ self
.paused_at
.map(|at| now.saturating_duration_since(at))
.unwrap_or_default();
now.saturating_duration_since(self.origin)
.saturating_sub(held)
}
fn embed_rate(&self) -> Option<f64> {
let (t0, _, b0) = self.first_sample?;
let &(at, _, bytes) = self.samples.back()?;
let ms = at.checked_sub(t0).filter(|ms| *ms > 0)?;
let moved = bytes.checked_sub(b0).filter(|b| *b > 0)?;
Some(moved as f64 * 1000.0 / ms as f64)
}
fn hold(&mut self) {
if self.paused_at.is_none() {
self.paused_at = Some(std::time::Instant::now());
}
}
fn unhold(&mut self) {
if let Some(at) = self.paused_at.take() {
self.paused_total += at.elapsed();
}
}
fn rates(&self) -> (Option<f64>, Option<f64>) {
let Some((t0, c0, _)) = self.first_sample else {
return (None, None);
};
if self.chunks == 0 {
return (None, None);
}
let now = self.elapsed().as_millis() as u64;
let per_second = |from: (u64, u64, u64)| {
let span = now.saturating_sub(from.0);
(span > 0).then(|| {
((self.chunks.saturating_sub(from.1)) as f64 * 1000.0 / span as f64 * 10.0).round()
/ 10.0
})
};
let start = now.saturating_sub(RATE_WINDOW_MS);
let base = self
.samples
.iter()
.rev()
.find(|(at, _, _)| *at <= start)
.copied()
.unwrap_or((t0, c0, 0));
(per_second(base), per_second((t0, c0, 0)))
}
fn eta_ms(&self) -> Option<u64> {
if !matches!(self.status, RunStatus::Running) || self.bytes_total == 0 {
return None;
}
let now = self.elapsed().as_millis() as u64;
let from_plan = self.plan_eta_ms.map(|eta| eta.saturating_sub(now));
let Some((t0, _, b0)) = self.first_sample else {
return from_plan;
};
if now.saturating_sub(t0) < ETA_SETTLE_MS {
return from_plan;
}
let start = now.saturating_sub(RATE_WINDOW_MS);
let (at, _, was) = self
.samples
.iter()
.rev()
.find(|(at, _, _)| *at <= start)
.copied()
.unwrap_or((t0, 0, b0));
let moved = self.bytes.saturating_sub(was);
let span = now.saturating_sub(at);
let shown = self.shown_eta.get();
let stalled = self.phase.is_some() || moved == 0;
if stalled && let Some((eta, _)) = shown {
return Some(eta);
}
if moved == 0 || span == 0 {
return None;
}
let left = self.bytes_total.saturating_sub(self.bytes);
let mut eta = (left as u128 * span as u128 / moved as u128) as u64;
if let Some((last, _)) = shown {
eta = eta.min(last.saturating_mul(2).max(1_000));
}
self.shown_eta.set(Some((eta, now)));
Some(eta)
}
fn lane_rates(&self) -> BTreeMap<String, f64> {
let (Some((_, latest)), Some(_)) = (self.lane_samples.back(), self.first_sample) else {
return BTreeMap::new();
};
let now = self.elapsed().as_millis() as u64;
let start = now.saturating_sub(RATE_WINDOW_MS);
let base = self
.lane_samples
.iter()
.rev()
.find(|(at, _)| *at <= start)
.or_else(|| self.lane_samples.front());
let Some((from, earlier)) = base else {
return BTreeMap::new();
};
let span = now.saturating_sub(*from).max(1) as f64 / 1000.0;
latest
.iter()
.map(|(lane, n)| {
let was = earlier.get(lane).copied().unwrap_or(0);
(
lane.clone(),
((n.saturating_sub(was)) as f64 / span * 10.0).round() / 10.0,
)
})
.collect()
}
fn sample_lanes(&mut self, lanes: BTreeMap<String, u64>) {
let now = self.elapsed().as_millis() as u64;
self.lane_samples.push_back((now, lanes));
let start = now.saturating_sub(RATE_WINDOW_MS);
while self.lane_samples.len() > 2
&& self.lane_samples.get(1).is_some_and(|(at, _)| *at <= start)
{
self.lane_samples.pop_front();
}
while self.lane_samples.len() > 1024 {
self.lane_samples.pop_front();
}
}
fn sample(&mut self) {
let now = self.elapsed().as_millis() as u64;
if self.first_sample.is_none() {
self.first_sample = Some((now, self.chunks, self.bytes));
}
self.samples.push_back((now, self.chunks, self.bytes));
let start = now.saturating_sub(RATE_WINDOW_MS);
while self.samples.len() > 2 && self.samples.get(1).is_some_and(|(at, _, _)| *at <= start) {
self.samples.pop_front();
}
while self.samples.len() > 1024 {
self.samples.pop_front();
}
}
fn absorb(&mut self, event: &serde_json::Value) {
let num = |key: &str| event.get(key).and_then(serde_json::Value::as_u64);
match event.get("event").and_then(serde_json::Value::as_str) {
Some("started") => {
self.status = RunStatus::Running;
self.started.get_or_insert_with(now);
let waited = self.elapsed().as_millis() as u64;
self.queued_ms.get_or_insert(waited);
self.files_before = num("files_before").or(self.files_before);
self.chunks_before = num("chunks_before").or(self.chunks_before);
self.unhold();
}
Some("held") => {
self.status = RunStatus::Held;
self.hold();
}
Some(kind @ ("file" | "progress")) => {
if !matches!(
self.status,
RunStatus::Pausing | RunStatus::Paused | RunStatus::Stopping
) {
self.status = RunStatus::Running;
}
self.phase = if kind == "progress" {
self.phase.take()
} else {
match event.get("outcome").and_then(serde_json::Value::as_str) {
Some("writing") => event
.get("why")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.or_else(|| Some("writing the index to disk".to_string())),
_ => None,
}
};
self.scanned = num("scanned").unwrap_or(self.scanned);
self.total = num("total").unwrap_or(self.total);
self.bytes = num("bytes").unwrap_or(self.bytes);
self.bytes_total = num("bytes_total").unwrap_or(self.bytes_total);
self.indexed = num("indexed").unwrap_or(self.indexed);
self.chunks = num("chunks").unwrap_or(self.chunks);
self.symbols = num("symbols").unwrap_or(self.symbols);
self.rows = num("rows").unwrap_or(self.rows);
self.threads = num("threads").filter(|n| *n > 0).unwrap_or(self.threads);
let embedding = self.first_sample.is_some()
|| self.chunks > 0
|| kind == "progress"
|| event.get("outcome").and_then(serde_json::Value::as_str) == Some("indexing");
if embedding {
self.sample();
if let Some(lanes) = event.get("lanes").and_then(serde_json::Value::as_object) {
let lanes: BTreeMap<String, u64> = lanes
.iter()
.filter_map(|(k, v)| v.as_u64().map(|n| (k.clone(), n)))
.collect();
if !lanes.is_empty() {
self.sample_lanes(lanes);
}
}
}
}
Some("paused") => {
self.status = RunStatus::Paused;
self.hold();
}
Some("resumed" | "slice") => {
if let Some(stages) = event.get("stages") {
self.stages = Some(stages.clone());
}
if let (Some(asked), Some(held)) = (num("cache_lookups"), num("cache_hits")) {
self.cache = (asked, held);
}
if self.status != RunStatus::Stopping {
self.status = RunStatus::Running;
}
self.unhold();
}
Some("deleted" | "delete-failed") => {
self.delete = event
.get("text")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
}
Some("done") => {
if let Some(stages) = event.get("stages") {
self.stages = Some(stages.clone());
}
if let (Some(asked), Some(held)) = (num("cache_lookups"), num("cache_hits")) {
self.cache = (asked, held);
}
let stopped = event
.get("stopped")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
self.status = if stopped {
RunStatus::Stopped
} else {
RunStatus::Done
};
self.phase = None;
self.indexed = num("indexed").unwrap_or(self.indexed);
self.chunks = num("chunks").unwrap_or(self.chunks);
self.unhold();
self.ended = Some(self.elapsed());
self.finished = Some(now());
self.summary = Some(event.clone());
}
Some("error") => {
self.status = RunStatus::Failed;
self.unhold();
self.ended = Some(self.elapsed());
self.finished = Some(now());
}
_ => {}
}
if event.get("event").and_then(serde_json::Value::as_str) == Some("progress") {
return;
}
let mut line = event.clone();
if let Some(object) = line.as_object_mut() {
object.insert("seq".into(), serde_json::json!(self.next_seq));
object.entry("at").or_insert(serde_json::json!(now()));
}
self.next_seq += 1;
if self.log.len() == LOG_HISTORY {
self.log.pop_front();
}
self.log.push_back(line);
}
}
const HISTORY_KEEP: usize = 50;
const HISTORY_LOG: usize = 200;
pub const HISTORY_FILE: &str = "runs.jsonl";
impl RunState {
fn history_row(&self, store: &str) -> serde_json::Value {
let result = match self.status {
RunStatus::Stopped => "stopped",
RunStatus::Failed => "failed",
_ => "done",
};
let mut stages = serde_json::Map::new();
if let Some(object) = self.stages.as_ref().and_then(serde_json::Value::as_object) {
for (key, value) in object {
if let (Some(stage), Some(ms)) = (key.strip_suffix("_ms"), value.as_u64())
&& key != "wall_ms"
{
stages.insert(stage.to_string(), serde_json::json!(ms));
}
}
let embed: u64 = object
.get("embed_wait_ms")
.and_then(serde_json::Value::as_object)
.map(|lanes| lanes.values().filter_map(serde_json::Value::as_u64).sum())
.unwrap_or(0);
stages.insert("embed".into(), serde_json::json!(embed));
}
let log: Vec<serde_json::Value> = self
.log
.iter()
.rev()
.take(HISTORY_LOG)
.rev()
.map(history_line)
.collect();
serde_json::json!({
"id": self.id,
"store": store,
"kind": self.kind.label(self.paths.len()),
"started": self.started.unwrap_or(self.submitted),
"finished": self.finished.unwrap_or_else(now),
"result": result,
"files": self.indexed,
"chunks": self.chunks,
"stages": stages,
"log": log,
})
}
}
fn history_line(event: &serde_json::Value) -> serde_json::Value {
let text = |key: &str| {
event
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string()
};
let name = text("event");
let (level, line) = match name.as_str() {
"file" => {
let outcome = text("outcome");
let level = match outcome.as_str() {
"failed" => "error",
"refused" | "skipped" => "warn",
_ => "info",
};
let why = text("why");
let path = text("path");
(
level,
if why.is_empty() {
format!("{outcome} {path}")
} else {
format!("{outcome} {path}: {why}")
},
)
}
"error" => ("error", format!("error: {}", text("error"))),
"done" => (
"info",
format!(
"done: {} indexed, {} chunks",
event
.get("indexed")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
event
.get("chunks")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
),
),
_ if !text("text").is_empty() => ("info", text("text")),
other => ("info", other.to_string()),
};
serde_json::json!({
"at": event.get("at").cloned().unwrap_or(serde_json::Value::Null),
"level": level,
"text": line,
})
}
fn remember_run(dir: &Path, row: &serde_json::Value) {
use std::io::Write;
let path = dir.join(HISTORY_FILE);
let appended = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut file| writeln!(file, "{row}"));
if appended.is_err() {
return;
}
crate::home::tighten_file(&path);
if let Ok(text) = std::fs::read_to_string(&path) {
let lines: Vec<&str> = text.lines().collect();
if lines.len() > HISTORY_KEEP * 2 {
let kept = lines[lines.len() - HISTORY_KEEP..].join("\n") + "\n";
let _ = crate::home::write_private(&path, kept.as_bytes());
}
}
}
pub fn run_history(dir: &Path) -> Vec<serde_json::Value> {
let Ok(text) = std::fs::read_to_string(dir.join(HISTORY_FILE)) else {
return Vec::new();
};
text.lines()
.rev()
.filter_map(|line| serde_json::from_str(line).ok())
.take(HISTORY_KEEP)
.collect()
}
pub struct Store {
pub name: String,
pub dir: PathBuf,
pub roots: Vec<PathBuf>,
pub watched: Vec<PathBuf>,
queue: Mutex<VecDeque<Queued>>,
events: Mutex<VecDeque<Event>>,
runs: Mutex<Vec<RunState>>,
pub watching: AtomicBool,
pub stop: AtomicBool,
pub paused: AtomicBool,
pub cancelled: AtomicBool,
pub last_write: AtomicUsize,
pub pruned: AtomicUsize,
pub expecting_run_until: AtomicUsize,
pub stopped_because: Mutex<Option<String>>,
pub watch_events: AtomicBool,
pub gitignore: AtomicBool,
}
impl Store {
fn new(
name: String,
dir: PathBuf,
roots: Vec<PathBuf>,
watched: Vec<PathBuf>,
watching: bool,
expecting_run_until: usize,
) -> Self {
Self {
name,
dir,
roots,
watched,
queue: Mutex::new(VecDeque::new()),
events: Mutex::new(VecDeque::new()),
runs: Mutex::new(Vec::new()),
watching: AtomicBool::new(watching),
stop: AtomicBool::new(false),
paused: AtomicBool::new(false),
cancelled: AtomicBool::new(false),
last_write: AtomicUsize::new(0),
pruned: AtomicUsize::new(0),
expecting_run_until: AtomicUsize::new(expecting_run_until),
stopped_because: Mutex::new(None),
watch_events: AtomicBool::new(true),
gitignore: AtomicBool::new(true),
}
}
pub fn apply_settings(&self, settings: &crate::home::StoreSettings) {
self.watch_events.store(settings.watch, Ordering::Relaxed);
self.gitignore.store(settings.gitignore, Ordering::Relaxed);
}
fn note(&self, text: String) {
let mut events = self.events.lock().unwrap_or_else(|e| e.into_inner());
if events.len() == EVENT_HISTORY {
events.pop_front();
}
events.push_back(Event { at: now(), text });
changes::bump(changes::Domain::Events);
}
fn begin_run(&self, id: u64, paths: Vec<PathBuf>, kind: RunKind) {
let mut runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
runs.push(RunState::new(id, paths, kind));
while runs.len() > RUN_HISTORY {
match runs.iter().position(|run| run.status.finished()) {
Some(at) => {
runs.remove(at);
}
None => break,
}
}
drop(runs);
self.runs_changed();
}
fn set_plan(&self, id: u64, plan: &crate::Plan, review: bool) {
let mut runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
let seen = runs
.iter()
.filter(|r| r.id != id)
.filter_map(RunState::embed_rate)
.last();
if let Some(run) = runs.iter_mut().find(|r| r.id == id) {
run.plan = serde_json::to_value(plan).ok();
run.plan_eta_ms = plan
.eta_ms
.or_else(|| seen.map(|rate| (plan.embed_bytes as f64 / rate * 1000.0) as u64));
if review {
run.status = RunStatus::Review;
}
}
drop(runs);
self.runs_changed();
}
fn set_before(&self, id: u64, files: u64, chunks: u64) {
let mut runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(run) = runs.iter_mut().find(|r| r.id == id) {
run.files_before = Some(files);
run.chunks_before = Some(chunks);
}
drop(runs);
self.runs_changed();
}
fn leave_review(&self, id: u64, to: RunStatus) -> bool {
let mut runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
let Some(run) = runs
.iter_mut()
.find(|r| r.id == id && r.status == RunStatus::Review)
else {
return false;
};
run.status = to;
drop(runs);
self.runs_changed();
true
}
fn runs_changed(&self) {
changes::bump(changes::Domain::Runs);
}
fn record(&self, id: u64, event: &serde_json::Value) {
let ended = self
.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter_mut()
.find(|run| run.id == id)
.and_then(|run| {
let was = run.status.finished();
run.absorb(event);
let quiet = run.kind == RunKind::Batch
&& run.indexed == 0
&& run
.summary
.as_ref()
.and_then(|s| s.get("removed"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
== 0;
(!was && run.status.finished() && !quiet).then(|| run.history_row(&self.name))
});
if let Some(row) = ended {
remember_run(&self.dir, &row);
}
self.runs_changed();
}
fn with_live<T>(&self, body: impl FnOnce(&mut RunState) -> T) -> Option<T> {
self.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter_mut()
.find(|run| !run.status.finished())
.map(body)
}
pub fn mark_stopping(&self) {
self.with_live(|run| run.status = RunStatus::Stopping);
self.runs_changed();
}
pub fn mark_pausing(&self) {
self.with_live(|run| {
if matches!(run.status, RunStatus::Running) {
run.status = RunStatus::Pausing;
}
});
self.runs_changed();
}
pub fn mark_resuming(&self) {
self.with_live(|run| {
if matches!(run.status, RunStatus::Paused | RunStatus::Pausing) {
run.status = RunStatus::Running;
}
});
self.runs_changed();
}
pub fn live_files_before(&self) -> Option<u64> {
self.with_live(|run| run.files_before).flatten()
}
pub fn note_run(&self, id: u64, event: &serde_json::Value) {
self.record(id, event);
}
fn run_kind(&self, id: u64) -> Option<RunKind> {
self.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find(|run| run.id == id)
.map(|run| run.kind)
}
fn run_paths(&self, id: u64) -> Vec<PathBuf> {
self.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find(|run| run.id == id)
.map(|run| run.paths.clone())
.unwrap_or_default()
}
pub fn last_run(&self) -> Option<u64> {
self.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.last()
.map(|run| run.id)
}
fn run_elapsed_ms(&self, id: u64) -> u64 {
self.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find(|run| run.id == id)
.map(|run| run.elapsed().as_millis() as u64)
.unwrap_or(0)
}
fn current_run(&self) -> Option<u64> {
self.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.find(|run| !run.status.finished())
.map(|run| run.id)
}
fn interrupted(&self) -> Option<(Vec<PathBuf>, u64)> {
self.with_live(|run| (run.paths.clone(), run.indexed))
}
pub fn run_live(&self) -> bool {
self.current_run().is_some()
}
pub fn remove_run(&self, id: u64) -> bool {
let mut runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
let Some(at) = runs
.iter()
.position(|run| run.id == id && run.status.finished())
else {
return false;
};
runs.remove(at);
drop(runs);
self.runs_changed();
true
}
pub fn clear_finished_runs(&self) -> usize {
let mut runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
let before = runs.len();
runs.retain(|run| !run.status.finished());
let gone = before - runs.len();
drop(runs);
if gone > 0 {
self.runs_changed();
}
gone
}
pub fn run_snapshots(&self, position: Option<usize>) -> Vec<serde_json::Value> {
let runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
runs.iter()
.map(|run| {
let position = (!run.status.finished()).then_some(position).flatten();
self.snapshot_of(run, position)
})
.collect()
}
fn snapshot_of(&self, run: &RunState, position: Option<usize>) -> serde_json::Value {
let (rate, average) = run.rates();
serde_json::json!({
"id": run.id,
"store": self.name,
"kind": run.kind,
"files_before": run.files_before,
"chunks_before": run.chunks_before,
"threads": (run.threads > 0).then_some(run.threads),
"rate": if run.status.finished() { None } else { rate },
"rate_average": average,
"lane_rates": if run.status.finished() { BTreeMap::new() } else { run.lane_rates() },
"delete": run.delete,
"paths": run.paths.iter()
.map(|p| crate::plain(&p.display().to_string()))
.collect::<Vec<_>>(),
"status": run.status,
"plan": run.plan,
"position": position,
"submitted": run.submitted,
"started_at": run.started,
"finished_at": run.finished,
"queued_ms": run.queued_ms,
"elapsed_ms": run.elapsed().as_millis() as u64,
"eta_ms": run.eta_ms(),
"bytes": run.bytes,
"bytes_total": run.bytes_total,
"ticking": matches!(run.status, RunStatus::Running | RunStatus::Queued | RunStatus::Stopping | RunStatus::Pausing),
"scanned": run.scanned,
"total": run.total,
"indexed": run.indexed,
"chunks": run.chunks,
"symbols": run.symbols,
"phase": (run.status == RunStatus::Running)
.then(crate::accel::waiting_for)
.flatten()
.or_else(|| run.phase.clone()),
"summary": run.summary,
"stages": run.stages,
"pending_share": (run.status == RunStatus::Running && run.rows > 0).then(|| {
let pending = run.rows.saturating_sub(run.chunks) as f64;
let whole = (run.chunks_before.unwrap_or(0) + run.rows).max(1) as f64;
(pending / whole * 1000.0).round() / 1000.0
}),
"cache_lookups": run.cache.0,
"cache_hits": run.cache.1,
"cache_hit_rate": (run.cache.0 > 0).then(|| {
(run.cache.1 as f64 * 1000.0 / run.cache.0 as f64).round() / 10.0
}),
"log_from": run.log.front()
.and_then(|line| line.get("seq"))
.and_then(serde_json::Value::as_u64)
.map(|first| first.saturating_sub(1)),
"log_to": run.next_seq.saturating_sub(1),
})
}
pub fn log_after(&self, id: Option<u64>, seq: Option<u64>) -> Vec<serde_json::Value> {
let runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
let run = match id {
Some(id) => runs.iter().find(|run| run.id == id),
None => runs
.iter()
.find(|run| !run.status.finished())
.or_else(|| runs.last()),
};
run.map(|run| {
run.log
.iter()
.filter(|line| match seq {
None => true,
Some(after) => line
.get("seq")
.and_then(serde_json::Value::as_u64)
.is_some_and(|n| n > after),
})
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn events(&self) -> Vec<Event> {
self.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.cloned()
.collect()
}
fn submit(
&self,
job: Job,
notice: Option<serde_json::Value>,
) -> mpsc::Receiver<serde_json::Value> {
let (report, progress) = mpsc::channel();
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
if let Some(mut notice) = notice {
if let Some(object) = notice.as_object_mut() {
object.insert("store".into(), serde_json::json!(self.name));
object.insert("ahead".into(), serde_json::json!(queue.len()));
}
let _ = report.send(notice);
}
queue.push_back(Queued {
run: NO_RUN,
job,
report,
});
progress
}
fn submit_run(&self, run: u64, job: Job) -> mpsc::Receiver<serde_json::Value> {
let (report, progress) = mpsc::channel();
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
queue.push_back(Queued { run, job, report });
progress
}
fn submit_front(&self, job: Job) -> mpsc::Receiver<serde_json::Value> {
let (report, progress) = mpsc::channel();
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
queue.push_front(Queued {
run: NO_RUN,
job,
report,
});
progress
}
fn submit_index(
&self,
run: u64,
paths: Vec<PathBuf>,
kind: RunKind,
report: mpsc::Sender<serde_json::Value>,
mut notice: serde_json::Value,
) {
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
if let Some(object) = notice.as_object_mut() {
object.insert("store".into(), serde_json::json!(self.name));
object.insert("ahead".into(), serde_json::json!(queue.len()));
}
self.record(run, ¬ice);
let _ = report.send(notice);
queue.push_back(Queued {
run,
job: Job::Index(Indexing {
work: match kind {
RunKind::Batch | RunKind::Files => Work::Rest(paths),
RunKind::Run
| RunKind::CatchUp
| RunKind::Compact
| RunKind::Reindex
| RunKind::Rebuild => Work::Roots(paths),
},
first: true,
already: Vec::new(),
scanned: 0,
total: 0,
bytes: 0,
bytes_total: 0,
tally: Tally::default(),
}),
report,
});
}
pub fn queue_depth(&self) -> usize {
self.queue.lock().unwrap_or_else(|e| e.into_inner()).len()
}
fn requeue(&self, queued: Queued) {
self.queue
.lock()
.unwrap_or_else(|e| e.into_inner())
.push_back(queued);
}
pub fn cancel_queued(&self) -> Vec<u64> {
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut dropped = Vec::new();
queue.retain(|queued| {
if !matches!(queued.job, Job::Index(..) | Job::Compact(..)) {
return true;
}
let answer = serde_json::json!({
"event": "done",
"indexed": 0,
"unchanged": 0,
"skipped": 0,
"removed": 0,
"chunks": 0,
"images": 0,
"remaining": 0,
"stopped": true,
});
let _ = queued.report.send(answer);
dropped.push(queued.run);
false
});
drop(queue);
for run in &dropped {
self.record(
*run,
&serde_json::json!({
"event": "done",
"indexed": 0,
"unchanged": 0,
"skipped": 0,
"removed": 0,
"chunks": 0,
"images": 0,
"remaining": 0,
"stopped": true,
}),
);
}
dropped
}
}
struct Pending {
run: u64,
store: Arc<Store>,
paths: Vec<PathBuf>,
kind: RunKind,
report: mpsc::Sender<serde_json::Value>,
}
pub struct Admission {
queue: Mutex<VecDeque<Pending>>,
running: Mutex<Vec<u64>>,
held: Mutex<Vec<u64>>,
limit: AtomicUsize,
next: AtomicU64,
}
impl Admission {
pub fn new(limit: usize) -> Self {
Self {
queue: Mutex::new(VecDeque::new()),
running: Mutex::new(Vec::new()),
held: Mutex::new(Vec::new()),
limit: AtomicUsize::new(limit.max(1)),
next: AtomicU64::new(1),
}
}
pub fn limit(&self) -> usize {
self.limit.load(Ordering::Relaxed)
}
pub fn set_limit(&self, limit: usize) {
let limit = limit.max(1);
self.limit.store(limit, Ordering::Relaxed);
{
let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
let mut held = self.held.lock().unwrap_or_else(|e| e.into_inner());
while running.len() > limit {
let Some(newest) = running.iter().copied().max() else {
break;
};
running.retain(|id| *id != newest);
held.push(newest);
}
held.sort_unstable();
}
self.pump();
}
pub fn is_held(&self, run: u64) -> bool {
self.held
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&run)
}
pub fn held(&self) -> Vec<u64> {
self.held.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn running(&self) -> usize {
self.running.lock().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn mint(&self) -> u64 {
self.next.fetch_add(1, Ordering::Relaxed)
}
pub fn enqueue(&self, run: u64, store: &Arc<Store>, paths: Vec<PathBuf>, kind: RunKind) {
let (report, _progress) = mpsc::channel();
{
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
store.record(
run,
&serde_json::json!({ "event": "submitted", "run": run, "ahead": queue.len() }),
);
queue.push_back(Pending {
run,
store: Arc::clone(store),
paths,
kind,
report,
});
}
self.pump();
}
pub fn submit(
&self,
store: &Arc<Store>,
paths: Vec<PathBuf>,
kind: RunKind,
) -> (u64, mpsc::Receiver<serde_json::Value>) {
let run = self.next.fetch_add(1, Ordering::Relaxed);
let (report, progress) = mpsc::channel();
store.begin_run(run, paths.clone(), kind);
{
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
store.record(
run,
&serde_json::json!({
"event": "submitted",
"run": run,
"ahead": queue.len(),
}),
);
queue.push_back(Pending {
run,
store: Arc::clone(store),
paths,
kind,
report,
});
}
self.pump();
(run, progress)
}
pub fn pump(&self) {
loop {
let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
if running.len() >= self.limit() {
return;
}
{
let mut held = self.held.lock().unwrap_or_else(|e| e.into_inner());
if !held.is_empty() {
let oldest = held.remove(0);
running.push(oldest);
continue;
}
}
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let Some(next) = queue.pop_front() else {
return;
};
running.push(next.run);
drop(queue);
drop(running);
next.store.cancelled.store(false, Ordering::Relaxed);
next.store.paused.store(false, Ordering::Relaxed);
next.store.submit_index(
next.run,
next.paths,
next.kind,
next.report,
serde_json::json!({ "event": "queued" }),
);
}
}
pub fn finish(&self, run: u64) {
self.running
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|id| *id != run);
self.held
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|id| *id != run);
self.pump();
}
pub fn waiting(&self) -> Vec<(u64, String, Vec<PathBuf>)> {
self.queue
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|p| (p.run, p.store.name.clone(), p.paths.clone()))
.collect()
}
pub fn position_of(&self, store: &str) -> Option<usize> {
let ahead = self.held.lock().unwrap_or_else(|e| e.into_inner()).len();
self.queue
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.position(|p| p.store.name == store)
.map(|at| ahead + at + 1)
}
pub fn dequeue(&self, store: &str) -> usize {
let mut queue = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let mut dropped = 0;
queue.retain(|pending| {
if pending.store.name != store {
return true;
}
let answer = serde_json::json!({
"event": "done",
"indexed": 0,
"unchanged": 0,
"skipped": 0,
"removed": 0,
"chunks": 0,
"images": 0,
"remaining": 0,
"stopped": true,
"dequeued": true,
});
pending.store.record(pending.run, &answer);
let _ = pending.report.send(answer);
dropped += 1;
false
});
dropped
}
}
pub mod changes {
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Domain {
Stores,
Runs,
Clients,
Ledger,
Events,
Privacy,
}
pub const DOMAINS: [Domain; 6] = [
Domain::Stores,
Domain::Runs,
Domain::Clients,
Domain::Ledger,
Domain::Events,
Domain::Privacy,
];
impl Domain {
pub fn as_str(self) -> &'static str {
match self {
Self::Stores => "stores",
Self::Runs => "runs",
Self::Clients => "clients",
Self::Ledger => "ledger",
Self::Events => "events",
Self::Privacy => "privacy",
}
}
fn cell(self) -> &'static AtomicU64 {
static STORES: AtomicU64 = AtomicU64::new(0);
static RUNS: AtomicU64 = AtomicU64::new(0);
static CLIENTS: AtomicU64 = AtomicU64::new(0);
static LEDGER: AtomicU64 = AtomicU64::new(0);
static EVENTS: AtomicU64 = AtomicU64::new(0);
static PRIVACY: AtomicU64 = AtomicU64::new(0);
match self {
Self::Stores => &STORES,
Self::Runs => &RUNS,
Self::Clients => &CLIENTS,
Self::Ledger => &LEDGER,
Self::Events => &EVENTS,
Self::Privacy => &PRIVACY,
}
}
}
pub fn bump(domain: Domain) {
domain.cell().fetch_add(1, Ordering::Relaxed);
}
pub fn notice_registry() {
use std::sync::Mutex;
use std::time::SystemTime;
static SEEN: Mutex<Option<SystemTime>> = Mutex::new(None);
let Ok(path) = crate::home::registry_path() else {
return;
};
let Ok(at) = std::fs::metadata(&path).and_then(|m| m.modified()) else {
return;
};
let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
match *seen {
None => *seen = Some(at),
Some(was) if was != at => {
*seen = Some(at);
bump(Domain::Stores);
}
Some(_) => {}
}
}
pub fn notice_roots<'a>(roots: impl Iterator<Item = &'a std::path::Path>) {
use std::sync::Mutex;
static SEEN: Mutex<Option<Vec<std::path::PathBuf>>> = Mutex::new(None);
let mut missing: Vec<std::path::PathBuf> = roots
.filter(|root| !root.exists())
.map(|root| root.to_path_buf())
.collect();
missing.sort();
let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
match seen.as_ref() {
None => *seen = Some(missing),
Some(was) if *was != missing => {
*seen = Some(missing);
bump(Domain::Stores);
}
Some(_) => {}
}
}
pub fn read(domain: Domain) -> u64 {
domain.cell().load(Ordering::Relaxed)
}
}
pub const PARALLEL_ENV: &str = "SEMLITH_INDEX_PARALLEL";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
Derived,
Saved,
Environment,
}
impl Source {
fn as_str(self) -> &'static str {
match self {
Self::Derived => "derived",
Self::Saved => "saved",
Self::Environment => "set by the environment",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Limit {
pub value: usize,
pub source: Source,
pub derived: usize,
pub reason: String,
pub ceiling: usize,
}
impl Limit {
fn new(
derived: crate::system::Derivation,
saved: Option<usize>,
variable: &str,
ceiling: usize,
) -> Self {
let from_env = std::env::var(variable)
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.filter(|n| *n > 0);
let ceiling = ceiling.max(derived.value);
let (value, source) = match (from_env, saved.filter(|n| *n > 0)) {
(Some(n), _) => (n, Source::Environment),
(None, Some(n)) => (n.min(ceiling), Source::Saved),
(None, None) => (derived.value, Source::Derived),
};
Self {
value,
source,
derived: derived.value,
reason: derived.reason,
ceiling,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Limits {
pub runs_at_once: Limit,
pub embed_threads: Limit,
pub index_memory_mb: Limit,
pub machine: serde_json::Value,
}
impl Limits {
pub fn in_force() -> Self {
let machine = crate::system::read();
let derived = crate::system::derive(&machine, crate::system::PER_RUN_PEAK_MB);
let saved = home::Settings::load();
let cores = machine.logical_cores.max(1);
let memory_ceiling = machine
.available_memory_mb
.saturating_sub(crate::system::RESERVE_MB) as usize;
let runs_at_once = Limit::new(
derived.runs_at_once,
saved.runs_at_once,
PARALLEL_ENV,
cores,
);
let threads_derived = crate::system::threads_for(&machine, runs_at_once.value);
Self {
runs_at_once,
embed_threads: Limit::new(
threads_derived,
saved.embed_threads,
crate::embed::THREADS_ENV,
cores,
),
index_memory_mb: Limit::new(
derived.index_memory_mb,
saved.index_memory_mb,
crate::index::INDEX_MEMORY_ENV,
memory_ceiling,
),
machine: serde_json::json!({
"logical_cores": machine.logical_cores,
"physical_cores": machine.physical_cores,
"total_memory_mb": machine.total_memory_mb,
"available_memory_mb": machine.available_memory_mb,
"reserve_mb": crate::system::RESERVE_MB,
"per_run_peak_mb": crate::system::PER_RUN_PEAK_MB,
}),
}
}
pub fn apply(&self) {
crate::embed::set_threads_in_force(match self.embed_threads.source {
Source::Derived => 0,
_ => self.embed_threads.value,
});
crate::index::set_budget_mb(self.index_memory_mb.value);
}
pub fn as_applied(mut self) -> Self {
self.index_memory_mb.value = crate::index::budget_mb();
self
}
pub fn line(&self) -> String {
format!(
"indexing: {} run(s) at once ({}), {} embedder thread(s) each ({}), {} MiB of vectors per store ({})",
self.runs_at_once.value,
self.runs_at_once.source.as_str(),
self.embed_threads.value,
self.embed_threads.source.as_str(),
self.index_memory_mb.value,
self.index_memory_mb.source.as_str(),
)
}
}
type Awaiting = (Arc<Store>, Vec<PathBuf>);
pub struct State {
pub server: Arc<Server>,
pub fleet: Mutex<Option<Fleet>>,
stores: RwLock<Vec<Arc<Store>>>,
late_writers: Mutex<Vec<std::thread::JoinHandle<()>>>,
pub admission: Arc<Admission>,
pub airgap: bool,
pub started: SystemTime,
debounce: Duration,
report: Arc<dyn Fn(&str) + Send + Sync>,
pub refusals: Mutex<BTreeMap<&'static str, u64>>,
awaiting: Mutex<BTreeMap<u64, Awaiting>>,
pub proxies: Mutex<BTreeMap<u32, u64>>,
pub clients: Mutex<BTreeMap<String, Client>>,
pub mcp_fleet: Mutex<Option<Fleet>>,
pub ledger: bool,
pub schedules: Arc<crate::schedule::Runner>,
pub gone: Mutex<Vec<serde_json::Value>>,
}
const PROXY_FRESH: u64 = 120;
fn named(stores: &[Arc<Store>]) -> Vec<(PathBuf, String)> {
stores
.iter()
.map(|s| (s.dir.clone(), s.name.clone()))
.collect()
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Client {
pub name: String,
pub version: String,
pub transport: String,
pub revision: String,
pub queries: u64,
pub seen: u64,
}
#[derive(Debug, Clone)]
pub struct Unopened {
pub name: String,
pub dir: PathBuf,
pub why: String,
pub missing: bool,
}
impl State {
pub fn stores(&self) -> Vec<Arc<Store>> {
self.stores.read().expect("the stores lock").clone()
}
pub fn store(self: &Arc<Self>, name: &str) -> Option<Arc<Store>> {
if let Some(open) = self.stores().into_iter().find(|s| s.name == name) {
return Some(open);
}
self.reconcile();
self.stores().into_iter().find(|s| s.name == name)
}
pub fn reconcile(self: &Arc<Self>) -> Vec<Unopened> {
let Ok(registry) = Registry::load() else {
return Vec::new();
};
let open: Vec<PathBuf> = self.stores().iter().map(|s| s.dir.clone()).collect();
let mut out = Vec::new();
for name in registry.stores.keys() {
let Ok(dir) = Registry::dir_of(name) else {
continue;
};
let dir = crate::canonical(&dir);
if open.contains(&dir) {
continue;
}
if !dir.exists() {
out.push(Unopened {
name: name.clone(),
dir,
why: "registered, but there is nothing at that path".to_string(),
missing: true,
});
continue;
}
if let Err(e) = self.open_store(&dir, false) {
out.push(Unopened {
name: name.clone(),
dir,
why: format!("{e:#}"),
missing: false,
});
}
}
out
}
pub fn writable(self: &Arc<Self>, name: Option<&str>) -> Result<Arc<Store>> {
if let Some(name) = name
&& let Some(open) = self.store(name)
{
return Ok(open);
}
if name.is_none() && self.stores().is_empty() {
self.reconcile();
}
let stores = self.stores();
match (name, stores.as_slice()) {
(Some(name), _) => self
.store(name)
.with_context(|| format!("no store called {name} is open")),
(None, [one]) => Ok(Arc::clone(one)),
(None, []) => bail!("no store is open"),
(None, many) => bail!(
"this daemon has {} stores open, so a write has to name one: {}",
many.len(),
many.iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
.join(", ")
),
}
}
pub fn index(
&self,
store: &Arc<Store>,
paths: Vec<PathBuf>,
) -> Result<(u64, mpsc::Receiver<serde_json::Value>)> {
Self::writer_alive(store)?;
Ok(self.admission.submit(store, paths, RunKind::Run))
}
pub fn reindex(&self, store: &Arc<Store>, paths: Vec<PathBuf>, kind: RunKind) -> Result<u64> {
Self::writer_alive(store)?;
Ok(self.admission.submit(store, paths, kind).0)
}
pub fn plan(&self, store: &Arc<Store>, paths: &[PathBuf]) -> Result<crate::Plan> {
let mut reader = crate::Semlith::open_existing(&store.dir)?;
reader.gitignore = store.gitignore.load(Ordering::Relaxed);
reader.plan(paths)
}
pub fn index_planned(
&self,
store: &Arc<Store>,
paths: Vec<PathBuf>,
review: bool,
hold: bool,
) -> Result<u64> {
Self::writer_alive(store)?;
if !review && !hold {
let (ready, pinned) = mpsc::channel::<()>();
let (give_run, run_id) = mpsc::channel::<u64>();
let for_plan = Arc::downgrade(store);
let dir = store.dir.clone();
let plan_paths = paths.clone();
let gitignore = store.gitignore.load(Ordering::Relaxed);
std::thread::Builder::new()
.name("semlith-plan".to_string())
.spawn(move || {
let reader = crate::Semlith::open_existing(&dir).and_then(|mut reader| {
reader.gitignore = gitignore;
reader.pin_snapshot()?;
Ok(reader)
});
let _ = ready.send(());
let Ok(mut reader) = reader else { return };
let Ok(run) = run_id.recv() else { return };
if let Ok(plan) = reader.plan(&plan_paths)
&& let Some(store) = for_plan.upgrade()
{
store.set_plan(run, &plan, false);
}
reader.release_snapshot();
})
.context("starting the plan beside the run")?;
let _ = pinned.recv_timeout(Duration::from_secs(10));
let (run, _progress) = self.admission.submit(store, paths, RunKind::Run);
let _ = give_run.send(run);
return Ok(run);
}
let plan = self.plan(store, &paths).ok();
if (review || hold)
&& let Some(plan) = plan.as_ref().filter(|p| hold || !p.review.is_empty())
{
let run = self.admission.mint();
store.begin_run(run, paths.clone(), RunKind::Run);
store.set_plan(run, plan, true);
if let Ok((files, chunks, _)) =
crate::Semlith::open_existing(&store.dir).and_then(|reader| reader.stats())
{
store.set_before(run, files.max(0) as u64, chunks.max(0) as u64);
}
self.awaiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(run, (Arc::clone(store), paths));
return Ok(run);
}
let (run, _progress) = self.admission.submit(store, paths, RunKind::Run);
if let Some(plan) = &plan {
store.set_plan(run, plan, false);
}
Ok(run)
}
pub fn start_reviewed(&self, run: u64) -> Result<()> {
let Some((store, paths)) = self
.awaiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&run)
else {
anyhow::bail!("run {run} is not waiting for review");
};
store.leave_review(run, RunStatus::Queued);
self.admission.enqueue(run, &store, paths, RunKind::Run);
Ok(())
}
pub fn drop_reviewed(&self, run: u64) -> bool {
let taken = self
.awaiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&run);
match taken {
Some((store, _)) => store.leave_review(run, RunStatus::Stopped),
None => false,
}
}
pub fn forget(
&self,
store: &Arc<Store>,
path: PathBuf,
) -> Result<mpsc::Receiver<serde_json::Value>> {
Self::writer_alive(store)?;
Ok(store.submit(Job::Forget(path), None))
}
pub fn compact(
&self,
store: &Arc<Store>,
options: crate::compact::CompactOptions,
) -> Result<(u64, mpsc::Receiver<serde_json::Value>)> {
Self::writer_alive(store)?;
let run = self.admission.next.fetch_add(1, Ordering::Relaxed);
store.begin_run(run, vec![store.dir.clone()], RunKind::Compact);
Ok((run, store.submit_run(run, Job::Compact(options))))
}
pub fn accept(
&self,
store: &Arc<Store>,
path: PathBuf,
mode: &str,
source: &str,
) -> Result<mpsc::Receiver<serde_json::Value>> {
Self::writer_alive(store)?;
Ok(store.submit_front(Job::Accept {
path,
mode: mode.to_string(),
source: source.to_string(),
}))
}
pub fn revoke(
&self,
store: &Arc<Store>,
path: PathBuf,
) -> Result<mpsc::Receiver<serde_json::Value>> {
Self::writer_alive(store)?;
Ok(store.submit_front(Job::Revoke(path)))
}
pub fn delete_store(self: &Arc<Self>, name: &str) -> Result<PathBuf> {
let Some(store) = self.store(name) else {
anyhow::bail!("this daemon is not serving a store called {name}");
};
store.stop.store(true, Ordering::SeqCst);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while store.watching.load(Ordering::Relaxed) {
if std::time::Instant::now() > deadline {
anyhow::bail!(
"{name}'s watcher did not stop, so its lock is still held; \
nothing was deleted"
);
}
std::thread::sleep(Duration::from_millis(50));
}
self.stores
.write()
.expect("the stores lock")
.retain(|s| s.name != name);
self.reopen_readers();
Discovery::remove(&store.dir);
let removed = crate::home::delete_store(name);
changes::bump(changes::Domain::Stores);
removed
}
pub fn create_store(self: &Arc<Self>, name: &str, kind: &str) -> Result<Arc<Store>> {
let dir = crate::home::create_store(name, kind)?;
self.open_store(&dir, false)
}
pub fn settings_changed(self: &Arc<Self>, store: &Arc<Store>) {
let settings = crate::home::store_settings(&store.dir);
let was_watching = store.watch_events.load(Ordering::Relaxed);
store.apply_settings(&settings);
if settings.watch && !was_watching && !store.watched.is_empty() {
let _ = self
.admission
.submit(store, store.watched.clone(), RunKind::CatchUp);
}
refresh_unrecorded();
self.reopen_readers();
}
pub fn rename_store(self: &Arc<Self>, old: &str, new: &str) -> Result<Arc<Store>> {
let Some(store) = self.store(old) else {
bail!("this daemon is not serving a store called {old}");
};
crate::home::check_store_name(new)?;
if Registry::load()?.stores.contains_key(new) {
bail!("“{new}” is already a store on this machine. Pick another name.");
}
if store.run_live() || self.admission.position_of(old).is_some() || store.queue_depth() > 0
{
bail!("{old} is indexing; stop or wait for its run, then rename it");
}
store.stop.store(true, Ordering::SeqCst);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while store.watching.load(Ordering::Relaxed) {
if std::time::Instant::now() > deadline {
bail!("{old}'s writer did not stop, so nothing was renamed");
}
std::thread::sleep(Duration::from_millis(50));
}
self.stores
.write()
.expect("the stores lock")
.retain(|s| s.name != old);
self.reopen_readers();
Discovery::remove(&store.dir);
let dir = match crate::home::rename_store(old, new) {
Ok(dir) => dir,
Err(e) => {
let _ = self.reopen_after_stop(&store.dir);
return Err(e);
}
};
drop(store);
self.reopen_after_stop(&dir)
}
fn reopen_after_stop(self: &Arc<Self>, dir: &Path) -> Result<Arc<Store>> {
let mut last = None;
for _ in 0..40 {
match self.open_store(dir, false) {
Ok(store) => return Ok(store),
Err(e) => last = Some(e),
}
std::thread::sleep(Duration::from_millis(50));
}
Err(last.unwrap_or_else(|| anyhow::anyhow!("could not reopen {}", dir.display())))
}
pub fn delete_after_stop(self: &Arc<Self>, store: Arc<Store>) {
let state = Arc::clone(self);
std::thread::spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(600);
while store.run_live() || state.admission.position_of(&store.name).is_some() {
if std::time::Instant::now() > deadline {
store.note(
"the stop did not finish in ten minutes; the store was not deleted".into(),
);
return;
}
std::thread::sleep(Duration::from_millis(100));
}
let run = store.last_run();
let outcome = state.delete_store(&store.name);
let (event, text) = match &outcome {
Ok(dir) => (
"deleted",
format!(
"the store was deleted ({})",
crate::plain(&dir.display().to_string())
),
),
Err(e) => ("delete-failed", format!("the store was not deleted: {e:#}")),
};
(state.report)(&format!("{}: {text}", store.name));
if let Some(run) = run {
store.note_run(run, &serde_json::json!({ "event": event, "text": text }));
let snapshots = store.run_snapshots(None);
if outcome.is_ok() {
state.gone_changed(|gone| {
gone.extend(snapshots.into_iter().filter(|s| {
s.get("id").and_then(serde_json::Value::as_u64) == Some(run)
}))
});
}
}
});
}
pub fn gone_changed<T>(&self, change: impl FnOnce(&mut Vec<serde_json::Value>) -> T) -> T {
let out = change(&mut self.gone.lock().unwrap_or_else(|e| e.into_inner()));
changes::bump(changes::Domain::Runs);
out
}
fn writer_alive(store: &Arc<Store>) -> Result<()> {
if store.watching.load(Ordering::Relaxed) {
return Ok(());
}
bail!(
"the writer for {} is not running, so it cannot be written to; \
the daemon's stderr says why, and restarting it is the fix",
store.name
)
}
pub fn rotate(&self) -> String {
let fresh = self.server.rotate();
for store in self.stores() {
let _ = discovery(self.server.port(), &fresh).write(&store.dir);
}
fresh
}
pub fn client_name(&self, session: &str, transport: &str) -> Option<(String, String)> {
let clients = self.clients.lock().unwrap_or_else(|e| e.into_inner());
clients
.get(&format!("{transport}:{session}"))
.map(|client| (client.name.clone(), client.version.clone()))
.filter(|(name, _)| name != "unnamed client")
}
#[allow(clippy::too_many_arguments)]
pub fn note_client(
&self,
session: &str,
transport: &str,
name: Option<&str>,
version: Option<&str>,
host: Option<&str>,
revision: Option<&str>,
query: bool,
) {
let now = now();
let mut clients = self.clients.lock().unwrap_or_else(|e| e.into_inner());
let entry = clients
.entry(format!("{transport}:{session}"))
.or_insert_with(|| Client {
name: "unnamed client".to_string(),
version: String::new(),
transport: transport.to_string(),
revision: revision.unwrap_or("—").to_string(),
queries: 0,
seen: now,
});
if name.is_some() || (host.is_some() && entry.name == "unnamed client") {
entry.name = crate::clientid::label(name, host);
}
if let Some(version) = version.filter(|_| !name.is_some_and(crate::clientid::generic)) {
entry.version = version.chars().take(64).collect();
}
if let Some(revision) = revision {
entry.revision = revision.to_string();
}
if query {
entry.queries += 1;
}
entry.seen = now;
clients.retain(|_, client| now.saturating_sub(client.seen) <= PROXY_FRESH);
drop(clients);
Self::clients_changed();
}
fn clients_changed() {
changes::bump(changes::Domain::Clients);
}
pub fn clients(&self) -> Vec<Client> {
let now = now();
self.clients
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.filter(|client| now.saturating_sub(client.seen) <= PROXY_FRESH)
.cloned()
.collect()
}
pub fn saw_proxy(&self, pid: u32) {
let mut proxies = self.proxies.lock().unwrap_or_else(|e| e.into_inner());
let now = now();
proxies.insert(pid, now);
proxies.retain(|_, seen| now.saturating_sub(*seen) <= PROXY_FRESH);
}
pub fn forget_client(&self, session: &str, transport: &str, proxy: Option<u32>) {
self.clients
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&format!("{transport}:{session}"));
if let Some(pid) = proxy {
self.proxies
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&pid);
}
Self::clients_changed();
}
pub fn proxy_count(&self) -> usize {
let now = now();
self.proxies
.lock()
.unwrap_or_else(|e| e.into_inner())
.values()
.filter(|seen| now.saturating_sub(**seen) <= PROXY_FRESH)
.count()
}
pub fn open_mcp_fleet(&self) -> Result<()> {
let mut fleet = self.mcp_fleet.lock().unwrap_or_else(|e| e.into_inner());
if fleet.is_some() {
return Ok(());
}
let open = self.stores();
let dirs: Vec<PathBuf> = open.iter().map(|s| s.dir.clone()).collect();
if dirs.is_empty() {
bail!("this daemon has no store open");
}
let mut opened = Fleet::open(&dirs)?;
opened.name_from(&named(&open));
opened.quiet = true;
*fleet = Some(opened);
Ok(())
}
pub fn open_store(self: &Arc<Self>, dir: &Path, expect_run: bool) -> Result<Arc<Store>> {
let dir = crate::canonical(dir);
if let Some(open) = self.stores().into_iter().find(|s| s.dir == dir) {
return Ok(open);
}
let lock = StoreLock::acquire(&dir)
.with_context(|| format!("{} cannot be opened by the daemon", dir.display()))?;
let registry = Registry::load()?;
let (name, roots) = roots_for(&dir, ®istry);
let watched: Vec<PathBuf> = roots.iter().filter(|r| r.exists()).cloned().collect();
let expecting = if expect_run {
now() as usize + EXPECTED_RUN_GRACE_SECS
} else {
0
};
let store = Arc::new(Store::new(
name.clone(),
dir.clone(),
roots,
watched,
true,
expecting,
));
store.apply_settings(&crate::home::store_settings(&dir));
discovery(self.server.port(), &self.server.token()).write(&store.dir)?;
self.stores
.write()
.expect("the stores lock")
.push(Arc::clone(&store));
let watching = Arc::clone(&store);
let debounce = self.debounce;
let report = Arc::clone(&self.report);
let admission = Arc::clone(&self.admission);
let writer = std::thread::spawn(move || {
let _lock = lock;
keep_writing(&watching, debounce, &*report, &admission);
});
let mut late = self.late_writers.lock().unwrap_or_else(|e| e.into_inner());
late.retain(|handle| !handle.is_finished());
late.push(writer);
(self.report)(&format!(
"opened {name} at {} — now serving it",
dir.display()
));
self.reopen_readers();
changes::bump(changes::Domain::Stores);
Ok(store)
}
fn reopen_readers(&self) {
*self.fleet.lock().unwrap_or_else(|e| e.into_inner()) = None;
*self.mcp_fleet.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
pub fn open_fleet(&self) -> Result<()> {
let mut fleet = self.fleet.lock().unwrap_or_else(|e| e.into_inner());
if fleet.is_some() {
return Ok(());
}
let open = self.stores();
let open: Vec<Arc<Store>> = open
.into_iter()
.filter(|s| s.dir.join("store.db").exists())
.collect();
let dirs: Vec<PathBuf> = open.iter().map(|s| s.dir.clone()).collect();
if dirs.is_empty() {
return Ok(());
}
let mut opened = Fleet::open(&dirs)?;
opened.name_from(&named(&open));
opened.quiet = true;
*fleet = Some(opened);
Ok(())
}
pub fn say(&self, text: &str) {
(self.report)(text);
}
pub fn refuse(&self, class: Refusal) {
*self
.refusals
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(class.as_str())
.or_insert(0) += 1;
}
}
pub fn refresh_unrecorded() {
let registry = Registry::load().unwrap_or_default();
crate::ledger::set_unrecorded(
registry
.stores
.iter()
.filter(|(_, entry)| !entry.settings.record)
.filter_map(|(name, _)| Registry::dir_of(name).ok())
.collect(),
);
}
fn discovery(port: u16, token: &str) -> Discovery {
Discovery {
pid: std::process::id(),
port,
token: token.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
started: started_at(),
}
}
fn started_at() -> u64 {
static STARTED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*STARTED.get_or_init(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
})
}
pub fn stores_to_open(flags: &[PathBuf], paths: &[PathBuf], cwd: &Path) -> Result<Vec<PathBuf>> {
if !flags.is_empty() {
return Ok(flags.to_vec());
}
if paths.is_empty() {
return home::all_dirs(&[], cwd);
}
let mut out = Vec::new();
for path in paths {
let dir = home::resolve(&[], path, None)?.one()?;
if !dir.join("store.db").exists() {
bail!(
"{} has no store yet — run `semlith index {}` first",
path.display(),
path.display()
);
}
if !out.contains(&dir) {
out.push(dir);
}
}
Ok(out)
}
pub fn held_by_daemon(dirs: &[PathBuf]) -> (Vec<(PathBuf, Discovery)>, Vec<PathBuf>) {
let mut held = Vec::new();
let mut free = Vec::new();
for dir in dirs {
match StoreLock::acquire(dir) {
Ok(_) => free.push(dir.clone()),
Err(_) => match Discovery::read(dir) {
Some(found) => held.push((dir.clone(), found)),
None => free.push(dir.clone()),
},
}
}
(held, free)
}
fn roots_for(dir: &Path, registry: &Registry) -> (String, Vec<PathBuf>) {
if let Some(name) = registry.name_of(dir) {
let roots = registry.stores[name].roots.clone();
return (name.to_string(), roots);
}
let name = dir
.parent()
.and_then(Path::file_name)
.or_else(|| dir.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "store".to_string());
let root = dir
.parent()
.map(crate::canonical)
.unwrap_or_else(|| dir.to_path_buf());
(name, vec![root])
}
#[allow(clippy::too_many_arguments)]
pub fn run(
dirs: &[PathBuf],
port: u16,
debounce: Duration,
airgap: bool,
ledger: bool,
mcp_http: bool,
report: impl Fn(&str) + Send + Sync + 'static,
) -> Result<Arc<State>> {
let registry = Registry::load()?;
for name in registry.stores.keys() {
let Ok(dir) = Registry::dir_of(name) else {
continue;
};
if dir.join("store.db").exists() {
continue;
}
report(&format!(
"skipped {name}: registered, but there is no store at {}",
dir.display()
));
}
let mut locks = Vec::new();
let mut opening = Vec::new();
let mut waiting = Vec::new();
for dir in dirs {
let Some(lock) = StoreLock::try_acquire(dir)
.with_context(|| format!("{} cannot be opened by the daemon", dir.display()))?
else {
let (name, _) = roots_for(dir, ®istry);
report(&format!(
"{name}: being indexed by another process; opened when that run ends"
));
waiting.push(dir.clone());
continue;
};
let (name, roots) = roots_for(dir, ®istry);
opening.push((name, crate::canonical(dir), roots));
locks.push(lock);
}
let server = Arc::new(Server::bind(port)?);
report(&format!("listening on 127.0.0.1:{}", server.port()));
crate::ledger::set_session_off(!ledger);
let settings = crate::home::Settings::load();
crate::add::set_runtime_airgap(settings.airgap.unwrap_or(false));
crate::add::start_counting();
if settings.airgap == Some(true) && !airgap {
report("airgap: on from the Privacy page; nothing will be fetched");
}
let paused = settings.ledger_paused.unwrap_or(false);
crate::ledger::set_paused(paused);
report(if !ledger {
"ledger: off for this session"
} else if paused {
"ledger: paused from the portal (Resume recording on the Ledger page)"
} else {
"ledger: recording (local only; --no-ledger to stop)"
});
let key = crate::home::agent_key()?;
server.set_agent_key(&key);
server.set_mcp_open(mcp_http);
if mcp_http {
report(&format!(
"MCP over HTTP at http://127.0.0.1:{}{}",
server.port(),
crate::http::MCP_PATH
));
} else {
report("MCP over HTTP is closed (--no-mcp-http); `semlith mcp` over stdio is unaffected");
}
let mut stores = Vec::new();
for (name, dir, roots) in opening {
let watched: Vec<PathBuf> = roots.iter().filter(|r| r.exists()).cloned().collect();
for missing in roots.iter().filter(|r| !r.exists()) {
report(&format!(
"{name}: root {} is not there; not watching it",
missing.display()
));
}
report(&format!(
"opened {name} at {} — watching {} root(s)",
dir.display(),
watched.len()
));
let store = Store::new(name, dir, roots, watched, false, 0);
store.apply_settings(&crate::home::store_settings(&store.dir));
stores.push(Arc::new(store));
}
refresh_unrecorded();
let fleet = if stores.is_empty() {
None
} else {
let canonical: Vec<PathBuf> = stores.iter().map(|s| s.dir.clone()).collect();
let mut fleet = Fleet::open(&canonical)?;
fleet.name_from(&named(&stores));
fleet.quiet = true;
Some(fleet)
};
let token = server.token();
for store in &stores {
discovery(server.port(), &token).write(&store.dir)?;
}
let report_line: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(report);
let limits = Limits::in_force();
report_line(&limits.line());
limits.apply();
crate::accel::manage();
if crate::accel::enabled().ane
&& crate::accel::unavailable_here("ane").is_none()
&& crate::model_cache_dir()
.ok()
.is_some_and(|cache| crate::packs::installed(&cache, &crate::packs::coreml()).is_some())
&& let Some(lane) = crate::accel::lane("ane")
{
lane.wake();
}
{
let log = Arc::clone(&report_line);
crate::priority::manage(move |line| log(line));
}
let state = Arc::new(State {
server: Arc::clone(&server),
fleet: Mutex::new(fleet),
stores: RwLock::new(stores),
late_writers: Mutex::new(Vec::new()),
admission: Arc::new(Admission::new(limits.runs_at_once.value)),
airgap,
started: SystemTime::now(),
debounce,
report: Arc::clone(&report_line),
refusals: Mutex::new(BTreeMap::new()),
awaiting: Mutex::new(BTreeMap::new()),
proxies: Mutex::new(BTreeMap::new()),
clients: Mutex::new(BTreeMap::new()),
mcp_fleet: Mutex::new(None),
ledger,
schedules: crate::schedule::Runner::new(),
gone: Mutex::new(Vec::new()),
});
match crate::schedule::Schedules::load() {
Ok(file) if !file.schedules.is_empty() => {
report_line(&format!("schedules: {} registered", file.schedules.len()))
}
Ok(_) => {}
Err(e) => report_line(&format!("schedules: {e:#}")),
}
crate::schedule::Runner::spawn(Arc::clone(&state));
watch::stop_on_signal();
let report = Arc::clone(&report_line);
let mut watchers = Vec::new();
for (store, lock) in state.stores().into_iter().zip(locks) {
let report = Arc::clone(&report);
store.watching.store(true, Ordering::Relaxed);
let admission = Arc::clone(&state.admission);
watchers.push(std::thread::spawn(move || {
let _lock = lock;
keep_writing(&store, debounce, &*report, &admission);
}));
}
report_dropped_queue(&state.stores(), &*report_line);
if !waiting.is_empty() {
let opener = Arc::clone(&state);
std::thread::spawn(move || {
while !waiting.is_empty() && !watch::STOP.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_secs(2));
waiting.retain(|dir| match StoreLock::try_acquire(dir) {
Ok(Some(free)) => {
drop(free);
opener.open_store(dir, false).is_err()
}
Ok(None) => true,
Err(e) => {
(opener.report)(&format!("{}: {e:#}", dir.display()));
false
}
});
}
});
}
{
let warming = Arc::clone(&state);
let report = report_line.clone();
std::thread::spawn(move || {
let mut fleet = warming.fleet.lock().unwrap_or_else(|e| e.into_inner());
if let Some(fleet) = fleet.as_mut()
&& let Err(e) = fleet.warm()
{
report(&format!("could not load the embedding model: {e}"));
}
});
}
println!("{}", server.url());
report("open the URL on stdout to reach the portal; Ctrl-C stops the daemon");
Ok(state.clone()).and_then(|state| {
let handler = crate::routes::handler(Arc::clone(&state));
let for_refusals = Arc::clone(&state);
let result = server.serve(handler, &watch::STOP, move |class| {
for_refusals.refuse(class);
});
watch::STOP.store(true, Ordering::SeqCst);
remember_dropped_queue(&state.stores(), &state.admission.waiting());
state.schedules.stop();
for store in state.stores() {
store.stop.store(true, Ordering::SeqCst);
}
for watcher in watchers {
let _ = watcher.join();
}
let late =
std::mem::take(&mut *state.late_writers.lock().unwrap_or_else(|e| e.into_inner()));
for writer in late {
let _ = writer.join();
}
for store in state.stores() {
Discovery::remove(&store.dir);
}
let refusals = state
.refusals
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if !refusals.is_empty() {
let counts: Vec<String> = refusals
.iter()
.map(|(class, n)| format!("{class} {n}"))
.collect();
report(&format!("refused requests: {}", counts.join(", ")));
}
report("stopped");
result.map(|()| state)
})
}
fn keep_writing(
store: &Arc<Store>,
debounce: Duration,
report: &(dyn Fn(&str) + Send + Sync),
admission: &Arc<Admission>,
) {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
tend(store, debounce, &store.stop, report, admission)
}));
let why = match outcome {
Ok(Ok(())) => None,
Ok(Err(e)) => Some(format!("{e:#}")),
Err(panic) => Some(format!(
"the writer panicked: {}",
panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "no message".to_string())
)),
};
store.watching.store(false, Ordering::Relaxed);
if let Some(why) = why {
report(&format!("{}: watcher stopped: {why}", store.name));
store.note(format!("watcher stopped: {why}"));
*store
.stopped_because
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(why);
}
changes::bump(changes::Domain::Stores);
}
const SESSION_IDLE: Duration = Duration::from_secs(60);
const SESSION_IDLE_ENV: &str = "SEMLITH_SESSION_IDLE_SECS";
fn session_idle() -> Duration {
std::env::var(SESSION_IDLE_ENV)
.ok()
.and_then(|v| v.parse().ok())
.map(Duration::from_secs)
.unwrap_or(SESSION_IDLE)
}
fn tend(
store: &Arc<Store>,
debounce: Duration,
stop: &AtomicBool,
report: &(dyn Fn(&str) + Send + Sync),
admission: &Arc<Admission>,
) -> Result<()> {
let mut writer = Semlith::open(&store.dir, None)?;
writer.quiet = true;
writer.gitignore = store.gitignore.load(Ordering::Relaxed);
let mut next_compact_check = std::time::Instant::now() + COMPACT_FIRST_CHECK;
match writer.prune_out_of_root(&store.roots) {
Ok(0) => {}
Ok(dropped) => {
store.pruned.store(dropped, Ordering::Relaxed);
let text =
format!("reconciled — {dropped} file(s) dropped, held outside this store's roots");
report(&format!("{}: {text}", store.name));
store.note(text);
}
Err(e) => {
report(&format!("{}: could not reconcile: {e}", store.name));
}
}
let roots = store.watched.clone();
let expecting = (now() as usize) < store.expecting_run_until.load(Ordering::Relaxed);
let deferred = !roots.is_empty() && !expecting && store.watch_events.load(Ordering::Relaxed);
if deferred {
let _ = admission.submit(store, roots.clone(), RunKind::CatchUp);
}
watch::run_held(
&mut writer,
&roots,
debounce,
stop,
watch::Held {
catch_up: false,
waiting: &|| false,
defer: &|paths| {
if !store.watch_events.load(Ordering::Relaxed) {
return true;
}
if paths.len() <= BATCH_ADMIT_OVER {
return false;
}
let _ = admission.submit(store, paths.to_vec(), RunKind::Batch);
true
},
},
|progress| {
use watch::Progress;
match progress {
Progress::Ready {
catch_up,
files,
chunks,
} => {
let _ = &catch_up;
let text = if deferred {
format!(
"watching — {files} files, {chunks} chunks \
(catch-up deferred: {files} files queued)"
)
} else {
format!("watching — {files} files, {chunks} chunks")
};
report(&format!("{}: {text}", store.name));
store.note(text);
}
Progress::Batch(batch, elapsed) => {
let text = format!(
"{} re-embedded, {} removed, {} chunks in {:.1}s",
batch.indexed,
batch.removed,
batch.chunks,
elapsed.as_secs_f32()
);
report(&format!("{}: {text}", store.name));
store.note(text);
store.last_write.store(now() as usize, Ordering::Relaxed);
}
Progress::Error(e) => {
report(&format!("{}: watch error: {e}", store.name));
store.note(format!("error: {e}"));
}
Progress::Failed { path, why } => {
let text = format!(
"could not index {}: {why}",
crate::plain(&path.display().to_string())
);
report(&format!("{}: {text}", store.name));
store.note(text);
}
Progress::File(_) => {}
}
},
|writer| {
writer.follow_budget();
writer.release_if_idle(session_idle());
writer.gitignore = store.gitignore.load(Ordering::Relaxed);
if std::time::Instant::now() >= next_compact_check {
next_compact_check = std::time::Instant::now() + COMPACT_CHECK;
auto_compact(store, writer, admission);
}
let mut stopped = Vec::new();
loop {
if store.stop.load(Ordering::Relaxed) {
return Ok(stopped);
}
let Some(next) = store
.queue
.lock()
.unwrap_or_else(|e| e.into_inner())
.pop_front()
else {
return Ok(stopped);
};
if let Some(roots) = perform(store, writer, next, admission) {
stopped.extend(roots);
}
}
},
)
}
const COMPACT_CHECK: std::time::Duration = std::time::Duration::from_secs(600);
const COMPACT_FIRST_CHECK: std::time::Duration = std::time::Duration::from_secs(60);
fn compaction_due(threshold: u64, idle: bool, disk: &crate::compact::Footprint) -> bool {
threshold > 0
&& idle
&& disk.dead_percent() > threshold
&& disk.reclaimable >= crate::compact::AUTO_FLOOR_BYTES
}
fn auto_compact(store: &Arc<Store>, writer: &Semlith, admission: &Arc<Admission>) {
let idle = !store.run_live()
&& store.queue_depth() == 0
&& !store.paused.load(Ordering::Relaxed)
&& admission.position_of(&store.name).is_none()
&& (now() as usize) >= store.expecting_run_until.load(Ordering::Relaxed);
let threshold = crate::compact::threshold_in_force();
let retention = crate::compact::retention_in_force();
let Ok(disk) = writer.footprint(retention) else {
return;
};
if !compaction_due(threshold, idle, &disk) {
return;
}
let run = admission.next.fetch_add(1, Ordering::Relaxed);
store.begin_run(run, vec![store.dir.clone()], RunKind::Compact);
store.note(format!(
"{} % of this store is reclaimable, past the {threshold} % threshold: compacting",
disk.dead_percent()
));
let _ = store.submit_run(
run,
Job::Compact(crate::compact::CompactOptions {
retention_days: retention,
dry_run: false,
}),
);
}
fn perform(
store: &Arc<Store>,
writer: &mut Semlith,
queued: Queued,
admission: &Arc<Admission>,
) -> Option<Vec<PathBuf>> {
let Queued { run, job, report } = queued;
store.expecting_run_until.store(0, Ordering::Relaxed);
let back = report.clone();
let say = |value: serde_json::Value| {
store.record(run, &value);
let _ = report.send(value);
};
match job {
Job::Index(Indexing {
work,
first,
already,
scanned: scanned_before,
total: total_before,
bytes: bytes_before,
bytes_total: bytes_total_before,
mut tally,
}) => {
if first {
let names: Vec<String> = match &work {
Work::Roots(roots) => roots.iter().map(|p| p.display().to_string()).collect(),
Work::Rest(files) => files.iter().map(|p| p.display().to_string()).collect(),
};
let (files, chunks, _) = writer.stats().unwrap_or((0, 0, 0));
say(serde_json::json!({
"event": "started",
"paths": names,
"files_before": files.max(0),
"chunks_before": chunks.max(0),
}));
}
writer.follow_budget();
writer.boundary = crate::Boundary::within(crate::home::index_roots(&store.dir));
store.paused.store(false, Ordering::Relaxed);
let control = {
let store = Arc::clone(store);
let admission = Arc::clone(admission);
let told = std::sync::atomic::AtomicBool::new(false);
let held = std::sync::atomic::AtomicBool::new(false);
move || {
if store.cancelled.load(Ordering::Relaxed) {
return crate::Flow::Stop;
}
if store.stop.load(Ordering::Relaxed) {
return crate::Flow::Yield;
}
if admission.is_held(run) {
if !held.swap(true, Ordering::Relaxed) {
say(serde_json::json!({ "event": "held" }));
}
return crate::Flow::Pause;
}
if held.swap(false, Ordering::Relaxed) {
say(serde_json::json!({ "event": "resumed" }));
}
if store.paused.load(Ordering::Relaxed) {
if !told.swap(true, Ordering::Relaxed) {
say(serde_json::json!({ "event": "paused" }));
}
return crate::Flow::Pause;
}
if told.swap(false, Ordering::Relaxed) {
say(serde_json::json!({ "event": "resumed" }));
}
crate::Flow::Run
}
};
let indexed_before = tally.indexed;
let rows_before = tally.rows;
let chunks_before = tally.chunks;
let symbols_before = tally.symbols;
let on_file = |path: &Path, progress: crate::IndexProgress| {
let scanned = scanned_before + progress.scanned as u64;
say(serde_json::json!({
"event": if progress.outcome == crate::FileOutcome::Progress { "progress" } else { "file" },
"path": crate::plain(&path.display().to_string()),
"outcome": progress.outcome.as_str(),
"why": progress.why,
"scanned": scanned,
"total": if total_before > 0 { total_before } else { progress.total as u64 },
"bytes": bytes_before + progress.bytes,
"bytes_total": if bytes_total_before > 0 { bytes_total_before } else { progress.bytes_total },
"indexed": indexed_before + progress.indexed as u64,
"chunks": chunks_before + progress.chunks as u64,
"rows": rows_before + progress.rows as u64,
"symbols": symbols_before + progress.symbols as u64,
"threads": progress.threads,
"lanes": progress.lanes,
"elapsed_ms": store.run_elapsed_ms(run),
}));
};
writer.force = store.run_kind(run).is_some_and(RunKind::forced);
let outcome = match work {
Work::Roots(ref roots) => {
writer.index_within_held_under(roots, SLICE, &control, on_file)
}
Work::Rest(rest) => writer.index_rest_held_under(
rest,
SLICE,
(bytes_total_before > 0).then_some(bytes_total_before),
&control,
on_file,
),
};
writer.force = false;
match outcome {
Ok(mut done) => {
store.last_write.store(now() as usize, Ordering::Relaxed);
let mut written = already;
written.append(&mut done.written);
if done.stopped {
let undone = writer.undo_held(&written).unwrap_or_else(|e| {
store.note(format!("the undo failed: {e:#}"));
0
});
store.note(format!(
"an index run was stopped; {undone} file(s) it had embedded were undone"
));
say(serde_json::json!({
"event": "done",
"indexed": 0,
"unchanged": 0,
"skipped": 0,
"removed": undone,
"chunks": 0,
"images": 0,
"remaining": 0,
"stopped": true,
}));
store.paused.store(false, Ordering::Relaxed);
store.cancelled.store(false, Ordering::Relaxed);
release(run, admission);
return Some(store.run_paths(run));
}
tally.add(&done);
let (slice_indexed, slice_chunks) = (tally.indexed, tally.chunks);
let slice_stages = tally.stages.clone();
let slice_cache = (tally.cache_lookups, tally.cache_hits);
if done.remaining > 0 {
let total = if total_before > 0 {
total_before
} else {
(scanned_before + done.scanned as u64) + done.remaining as u64
};
store.requeue(Queued {
run,
job: Job::Index(Indexing {
work: Work::Rest(std::mem::take(&mut done.pending)),
first: false,
already: written,
scanned: scanned_before + done.scanned as u64,
total,
bytes: bytes_before + done.bytes,
bytes_total: if bytes_total_before > 0 {
bytes_total_before
} else {
done.bytes_total
},
tally,
}),
report: back,
});
say(serde_json::json!({
"event": "slice",
"remaining": done.remaining,
"indexed": slice_indexed,
"chunks": slice_chunks,
"stages": slice_stages,
"cache_lookups": slice_cache.0,
"cache_hits": slice_cache.1,
}));
return None;
}
let quiet_batch = store.run_kind(run) == Some(RunKind::Batch)
&& tally.indexed == 0
&& tally.removed == 0;
if store.run_kind(run) == Some(RunKind::CatchUp) {
let text = format!(
"catch-up done: {} indexed, {} unchanged, {} removed",
tally.indexed, tally.unchanged, tally.removed
);
eprintln!("semlith: {}: {text}", store.name);
store.note(text);
} else {
store.note(format!("{} indexed from the portal", tally.indexed));
}
if tally.indexed > 0 {
eprintln!("semlith: {}: {}", store.name, tally.stages.line());
}
say(serde_json::json!({
"event": "done",
"stages": tally.stages,
"cache_lookups": tally.cache_lookups,
"cache_hits": tally.cache_hits,
"indexed": tally.indexed,
"unchanged": tally.unchanged,
"skipped": tally.skipped,
"removed": tally.removed,
"chunks": tally.chunks,
"images": tally.images,
"remaining": done.remaining,
"stopped": done.stopped,
"refused": tally.refused.iter().map(|(path, why)| {
serde_json::json!({ "path": crate::plain(path), "why": why })
}).collect::<Vec<_>>(),
"failed": tally.failed.iter().map(|(path, why)| {
serde_json::json!({ "path": crate::plain(path), "why": why })
}).collect::<Vec<_>>(),
"skipped_reasons": tally.skipped_reasons,
"review": crate::store::refusals(writer.db())
.map(|rows| rows.iter().filter(|r| r.reviewable && r.accepted.is_none()).count())
.unwrap_or(0),
"elapsed_ms": store.run_elapsed_ms(run),
}));
if quiet_batch {
store.remove_run(run);
}
}
Err(e) => say(serde_json::json!({ "event": "error", "error": e.to_string() })),
}
store.paused.store(false, Ordering::Relaxed);
store.cancelled.store(false, Ordering::Relaxed);
release(run, admission);
None
}
Job::Accept { path, mode, source } => {
let key = path.to_string_lossy().into_owned();
match writer.accept(&key, &mode, &source) {
Ok(accepted) => {
let indexed = if mode == "refused" {
Ok(0)
} else {
writer
.index_changed(vec![PathBuf::from(&accepted.path)], |_, _| {})
.map(|r| r.indexed)
};
store.last_write.store(now() as usize, Ordering::Relaxed);
store.note(format!(
"{} {} ({})",
if mode == "refused" {
"refused"
} else {
"accepted"
},
path.display(),
mode
));
match indexed {
Ok(n) => say(serde_json::json!({
"event": "done",
"accepted": accepted,
"indexed": n,
})),
Err(e) => {
say(serde_json::json!({ "event": "error", "error": format!("{e:#}") }))
}
}
}
Err(e) => say(serde_json::json!({ "event": "error", "error": format!("{e:#}") })),
}
None
}
Job::Revoke(path) => {
let key = path.to_string_lossy().into_owned();
match writer.revoke(&key) {
Ok(revoked) => {
if revoked {
let _ = writer.index_changed(vec![path.clone()], |_, _| {});
}
store.note(format!("revoked {}", path.display()));
say(serde_json::json!({ "event": "done", "revoked": revoked }))
}
Err(e) => say(serde_json::json!({ "event": "error", "error": format!("{e:#}") })),
}
None
}
Job::Compact(options) => {
store.cancelled.store(false, Ordering::Relaxed);
say(
serde_json::json!({ "event": "started", "paths": [store.dir.display().to_string()] }),
);
let stop = || store.cancelled.load(Ordering::Relaxed);
match writer.compact_held(&options, &stop) {
Ok(report) => {
store.cancelled.store(false, Ordering::Relaxed);
let text = if report.stopped {
"a compaction was stopped before its swap; nothing changed".to_string()
} else {
store.last_write.store(now() as usize, Ordering::Relaxed);
format!(
"compacted: {} to {} on disk ({} vectors and {} retired definitions dropped)",
crate::human_bytes(report.before.total() as i64),
crate::human_bytes(report.after.total() as i64),
report.vectors_dropped,
report.history_dropped,
)
};
eprintln!(
"semlith: {}: {text} [database {} -> {}, exact {} -> {}, vectors {} -> {}]",
store.name,
report.before.database,
report.after.database,
report.before.exact,
report.after.exact,
report.before.vectors,
report.after.vectors,
);
store.note(text);
say(serde_json::json!({
"event": "done",
"stopped": report.stopped,
"compact": report,
}));
}
Err(e) => {
store.cancelled.store(false, Ordering::Relaxed);
say(serde_json::json!({ "event": "error", "error": format!("{e:#}") }))
}
}
None
}
Job::Forget(path) => {
match writer.forget_held(&path) {
Ok((chunks, images)) => {
store.last_write.store(now() as usize, Ordering::Relaxed);
store.note(format!("forgot {}", path.display()));
say(serde_json::json!({
"event": "done",
"forgot": chunks,
"images": images,
"path": path.display().to_string(),
}));
}
Err(e) => say(serde_json::json!({ "event": "error", "error": e.to_string() })),
}
None
}
}
}
fn dropped_queue_path() -> Option<PathBuf> {
home::home_or_error().ok().map(|h| h.join("queued.json"))
}
fn remember_dropped_queue(stores: &[Arc<Store>], waiting: &[(u64, String, Vec<PathBuf>)]) {
let Some(path) = dropped_queue_path() else {
return;
};
let mut rows: Vec<serde_json::Value> = waiting
.iter()
.map(|(_, store, paths)| {
serde_json::json!({
"store": store,
"paths": paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
"started": false,
})
})
.collect();
for store in stores {
if let Some((paths, kept)) = store.interrupted() {
rows.push(serde_json::json!({
"store": store.name,
"paths": paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
"started": true,
"kept": kept,
}));
}
}
if rows.is_empty() {
let _ = std::fs::remove_file(&path);
return;
}
if let Ok(body) = serde_json::to_string_pretty(&rows) {
let _ = home::write_private(&path, (body + "\n").as_bytes());
}
}
fn report_dropped_queue(stores: &[Arc<Store>], report: &(dyn Fn(&str) + Send + Sync)) {
let Some(path) = dropped_queue_path() else {
return;
};
let Ok(text) = std::fs::read_to_string(&path) else {
return;
};
let _ = std::fs::remove_file(&path);
let Ok(rows) = serde_json::from_str::<Vec<serde_json::Value>>(&text) else {
return;
};
for row in rows {
let Some(name) = row.get("store").and_then(serde_json::Value::as_str) else {
continue;
};
let paths: Vec<String> = row
.get("paths")
.and_then(serde_json::Value::as_array)
.map(|list| {
list.iter()
.filter_map(serde_json::Value::as_str)
.map(crate::plain)
.collect()
})
.unwrap_or_default();
let text = if row.get("started").and_then(serde_json::Value::as_bool) == Some(true) {
let kept = row
.get("kept")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
format!(
"stopped during a run of {}; {kept} file(s) were kept, \
and indexing it again reports those as unchanged",
paths.join(", ")
)
} else {
format!(
"queued when the daemon stopped and never started: {}",
paths.join(", ")
)
};
report(&format!("{name}: {text}"));
if let Some(store) = stores.iter().find(|s| s.name == name) {
store.note(text);
}
}
}
fn release(run: u64, admission: &Arc<Admission>) {
admission.finish(run);
}
pub struct Writer(pub Arc<State>);
impl crate::mcp::Writer for Writer {
fn index(&self, store: Option<&str>, paths: &[PathBuf]) -> Result<String, String> {
let store = self.0.writable(store).map_err(|e| e.to_string())?.clone();
let (_run, progress) = self
.0
.index(&store, paths.to_vec())
.map_err(|e| e.to_string())?;
let mut last = None;
for event in progress {
match event["event"].as_str() {
Some("error") => {
return Err(event["error"]
.as_str()
.unwrap_or("indexing failed")
.to_string());
}
Some("done") => last = Some(event),
_ => {}
}
}
let done = last.ok_or_else(|| "the writer stopped before answering".to_string())?;
let count = |key: &str| done[key].as_u64().unwrap_or(0);
let by_reason = {
let mut parts: Vec<(u64, String)> = done["skipped_reasons"]
.as_object()
.into_iter()
.flatten()
.map(|(kind, n)| (n.as_u64().unwrap_or(0), kind.clone()))
.collect();
parts.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
if parts.is_empty() {
String::new()
} else {
format!(
" ({})",
parts
.iter()
.map(|(n, kind)| format!("{n} {kind}"))
.collect::<Vec<_>>()
.join(", ")
)
}
};
let mut text = format!(
"{} indexed, {} unchanged, {} skipped{by_reason}, {} removed ({} chunks)",
count("indexed"),
count("unchanged"),
count("skipped"),
count("removed"),
count("chunks"),
);
if count("remaining") > 0 {
text.push_str(&format!(
"\nStopped at the time limit with {} paths remaining. \
Call semlith_index again with the same arguments to continue; \
nothing already indexed is redone.",
count("remaining")
));
}
for (kind, lines) in [("refused", &done["refused"]), ("failed", &done["failed"])] {
for entry in lines.as_array().into_iter().flatten() {
let (Some(path), Some(why)) = (entry["path"].as_str(), entry["why"].as_str())
else {
continue;
};
text.push_str(&format!("\n{kind}: {path} — {why}"));
}
}
let waiting = count("review");
if waiting > 0 {
text.push_str(&format!(
"\n{waiting} file{} await{} the owner's review (semlith refused, or the portal's Files ▸ Not indexed).",
if waiting == 1 { "" } else { "s" },
if waiting == 1 { "s" } else { "" }
));
}
Ok(text)
}
fn add(&self, store: Option<&str>, url: &str) -> Result<String, String> {
let store = self.0.writable(store).map_err(|e| e.to_string())?.clone();
let fetched = crate::add::fetch(url, &store.dir).map_err(|e| format!("{e:#}"))?;
let indexed = self.index(Some(&store.name), std::slice::from_ref(&fetched.path))?;
Ok(format!(
"Fetched {} into {}. {indexed}",
fetched.url,
fetched.path.display()
))
}
fn forget(&self, store: Option<&str>, path: &str) -> Result<String, String> {
let store = self.0.writable(store).map_err(|e| e.to_string())?.clone();
let progress = self
.0
.forget(&store, PathBuf::from(path))
.map_err(|e| e.to_string())?;
let done = progress
.recv()
.map_err(|_| "the writer stopped before answering".to_string())?;
if let Some(error) = done["error"].as_str() {
return Err(error.to_string());
}
let chunks = done["forgot"].as_u64().unwrap_or(0);
let images = done["images"].as_u64().unwrap_or(0);
Ok(match (chunks, images) {
(0, 0) => format!("{path} was not indexed; nothing removed."),
(0, images) => format!("Removed {images} image vector(s) for {path}."),
(chunks, 0) => format!("Removed {chunks} chunks for {path}."),
(chunks, images) => {
format!("Removed {chunks} chunks and {images} image vector(s) for {path}.")
}
})
}
}
pub fn url(state: &State) -> String {
state.server.url()
}
pub fn uptime(state: &State) -> u64 {
state
.started
.elapsed()
.map(|d| d.as_secs())
.unwrap_or_default()
}
pub fn now() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn port_of(flag: Option<u16>) -> u16 {
flag.or_else(|| {
std::env::var(http::PORT_ENV)
.ok()
.and_then(|v| v.parse().ok())
})
.unwrap_or(http::DEFAULT_PORT)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_discovery_file_that_is_not_there_is_not_a_privacy_rejection() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join(DISCOVERY_FILE);
assert!(
!missing.exists(),
"the fixture wrote a file it should not have"
);
assert!(!owner_only(&missing));
assert!(
!worth_warning_about(&missing),
"a missing {DISCOVERY_FILE} was reported as a privacy rejection",
);
let present = dir.path().join("present.json");
std::fs::write(&present, "{}").expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&present, std::fs::Permissions::from_mode(0o644))
.expect("chmod");
assert!(
worth_warning_about(&present),
"a world-readable discovery file stopped being worth a line",
);
}
}
fn counters() -> std::sync::MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn a_finished_run_outlives_the_daemon_in_its_store_history() {
let _held = counters();
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(Store::new(
"notes".into(),
dir.path().to_path_buf(),
Vec::new(),
Vec::new(),
true,
0,
));
store.begin_run(
1,
vec![PathBuf::from("/a"), PathBuf::from("/b")],
RunKind::Files,
);
store.record(1, &serde_json::json!({ "event": "started" }));
store.record(
1,
&serde_json::json!({ "event": "file", "outcome": "failed", "path": "/a", "why": "unreadable" }),
);
store.record(
1,
&serde_json::json!({ "event": "done", "indexed": 1, "chunks": 4,
"stages": { "wall_ms": 9, "read_ms": 2, "write_ms": 3,
"embed_wait_ms": { "cpu": 4 }, "prepare_cpu_ms": {} } }),
);
store.record(1, &serde_json::json!({ "event": "deleted", "text": "x" }));
store.begin_run(2, Vec::new(), RunKind::Batch);
store.record(
2,
&serde_json::json!({ "event": "done", "indexed": 0, "removed": 0 }),
);
let history = run_history(dir.path());
assert_eq!(history.len(), 1, "{history:?}");
let row = &history[0];
assert_eq!(row["kind"], "Re-index 2 files");
assert_eq!(row["result"], "done");
assert_eq!(row["files"], 1);
assert_eq!(row["chunks"], 4);
assert_eq!(
row["stages"],
serde_json::json!({ "read": 2, "write": 3, "embed": 4 })
);
let log = row["log"].as_array().unwrap();
assert!(log.iter().any(|l| l["level"] == "error"), "{log:?}");
assert!(log.iter().all(|l| l["at"].is_u64()));
for id in 10..10 + (HISTORY_KEEP as u64 * 2 + 5) {
store.begin_run(id, Vec::new(), RunKind::Run);
store.record(id, &serde_json::json!({ "event": "error", "error": "no" }));
}
let history = run_history(dir.path());
assert_eq!(history.len(), HISTORY_KEEP);
assert_eq!(history[0]["result"], "failed");
let lines = std::fs::read_to_string(dir.path().join(HISTORY_FILE)).unwrap();
assert!(
lines.lines().count() <= HISTORY_KEEP * 2,
"the file was never trimmed"
);
}
fn bare_store(name: &str) -> Arc<Store> {
Arc::new(Store::new(
name.to_string(),
PathBuf::from("/nowhere").join(name),
Vec::new(),
Vec::new(),
true,
0,
))
}
#[test]
fn a_runs_clock_spans_its_slices_and_never_goes_backwards() {
let _held = counters();
let store = bare_store("api");
store.begin_run(1, vec![PathBuf::from("/work/api")], RunKind::Run);
store.record(
1,
&serde_json::json!({ "event": "started", "paths": ["/work/api"] }),
);
std::thread::sleep(Duration::from_millis(30));
let first = store.run_elapsed_ms(1);
store.record(1, &serde_json::json!({ "event": "slice", "remaining": 12 }));
std::thread::sleep(Duration::from_millis(30));
let second = store.run_elapsed_ms(1);
assert!(second >= first, "{second} went backwards from {first}");
assert!(second >= 50, "the clock did not span the slice: {second}ms");
}
#[test]
fn a_held_run_stops_counting_and_a_finished_one_freezes() {
let _held = counters();
let store = bare_store("api");
store.begin_run(1, Vec::new(), RunKind::Run);
store.record(1, &serde_json::json!({ "event": "started", "paths": [] }));
store.record(1, &serde_json::json!({ "event": "paused" }));
let held = store.run_elapsed_ms(1);
std::thread::sleep(Duration::from_millis(40));
assert_eq!(
store.run_elapsed_ms(1),
held,
"the clock counted while the run was held"
);
store.record(1, &serde_json::json!({ "event": "resumed" }));
std::thread::sleep(Duration::from_millis(20));
store.record(
1,
&serde_json::json!({ "event": "done", "indexed": 3, "stopped": false }),
);
let total = store.run_elapsed_ms(1);
std::thread::sleep(Duration::from_millis(30));
assert_eq!(
store.run_elapsed_ms(1),
total,
"a finished run's clock kept going"
);
}
#[test]
fn two_cursors_over_one_log_each_see_every_line_once() {
let _held = counters();
let store = bare_store("api");
store.begin_run(1, Vec::new(), RunKind::Run);
for scanned in 0..5 {
store.record(
1,
&serde_json::json!({ "event": "file", "scanned": scanned }),
);
}
let seqs = |lines: &[serde_json::Value]| {
lines
.iter()
.map(|l| l["seq"].as_u64().unwrap())
.collect::<Vec<_>>()
};
let first = store.log_after(None, None);
assert_eq!(seqs(&first), vec![0, 1, 2, 3, 4]);
store.record(1, &serde_json::json!({ "event": "file", "scanned": 5 }));
assert_eq!(seqs(&store.log_after(None, Some(4))), vec![5]);
assert_eq!(seqs(&store.log_after(None, None)), vec![0, 1, 2, 3, 4, 5]);
assert!(store.log_after(None, Some(5)).is_empty());
}
#[test]
fn the_queue_admits_in_submission_order_and_only_up_to_the_limit() {
let _held = counters();
let admission = Admission::new(2);
let stores: Vec<Arc<Store>> = ["a", "b", "c", "d"].into_iter().map(bare_store).collect();
for store in &stores {
admission.submit(
store,
vec![PathBuf::from("/work").join(&store.name)],
RunKind::Run,
);
}
assert_eq!(admission.running(), 2, "the limit admitted more than two");
let waiting: Vec<String> = admission.waiting().into_iter().map(|(_, s, _)| s).collect();
assert_eq!(waiting, vec!["c", "d"]);
assert_eq!(admission.position_of("c"), Some(1));
assert_eq!(admission.position_of("d"), Some(2));
assert_eq!(
admission.position_of("a"),
None,
"a running run is not waiting"
);
let first = stores[0].current_run().expect("a is running");
admission.finish(first);
assert_eq!(admission.position_of("d"), Some(1));
assert_eq!(admission.running(), 2);
admission.set_limit(4);
assert!(admission.waiting().is_empty());
assert_eq!(admission.running(), 3);
}
#[test]
fn every_run_that_ends_gives_its_place_back() {
let _held = counters();
let admission = Arc::new(Admission::new(2));
let stores: Vec<Arc<Store>> = ["a", "b", "c"].into_iter().map(bare_store).collect();
let mut ids = Vec::new();
for store in &stores {
let (id, _) = admission.submit(store, Vec::new(), RunKind::Run);
ids.push(id);
}
assert_eq!(admission.running(), 2);
for (store, id) in stores.iter().zip(&ids) {
store.record(
*id,
&serde_json::json!({ "event": "done", "indexed": 0, "stopped": false }),
);
assert_eq!(store.current_run(), None);
release(*id, &admission);
}
assert_eq!(
admission.running(),
0,
"a finished run kept its place, so the daemon can admit nothing more"
);
assert!(admission.waiting().is_empty(), "the queue never drained");
}
#[test]
fn a_dequeued_run_is_answered_at_once_and_undoes_nothing() {
let _held = counters();
let admission = Admission::new(1);
let (a, b) = (bare_store("a"), bare_store("b"));
admission.submit(&a, Vec::new(), RunKind::Run);
let (_, waiting) = admission.submit(&b, Vec::new(), RunKind::Run);
assert_eq!(admission.position_of("b"), Some(1));
assert_eq!(admission.dequeue("b"), 1);
assert_eq!(admission.position_of("b"), None);
assert_eq!(admission.running(), 1, "dequeuing b disturbed a");
let answer = waiting.recv().expect("the dequeued run was answered");
assert_eq!(answer["event"], "done");
assert_eq!(answer["stopped"], true);
assert_eq!(answer["dequeued"], true);
assert_eq!(answer["removed"], 0, "a dequeued run undid something");
assert_eq!(admission.dequeue("b"), 0);
}
#[test]
fn a_counter_moves_for_its_own_domain_and_no_other() {
let _held = counters();
for domain in changes::DOMAINS {
let before: Vec<u64> = changes::DOMAINS.iter().map(|d| changes::read(*d)).collect();
changes::bump(domain);
for (other, was) in changes::DOMAINS.iter().zip(&before) {
let now = changes::read(*other);
if *other == domain {
assert_eq!(now, was + 1, "{} did not move", domain.as_str());
} else {
assert_eq!(
now,
*was,
"writing {} moved {}",
domain.as_str(),
other.as_str()
);
}
}
}
}
const BUMP_SITES: &[(&str, usize)] = &[
("stores", 3),
("runs", 2),
("clients", 1),
("ledger", 1),
("events", 1),
("privacy", 1),
];
#[test]
fn one_bump_site_per_domain() {
const SOURCES: &[(&str, &str)] = &[
("daemon.rs", include_str!("daemon.rs")),
("routes.rs", include_str!("routes.rs")),
("store.rs", include_str!("store.rs")),
("doctor.rs", include_str!("doctor.rs")),
("ledger.rs", include_str!("ledger.rs")),
("lib.rs", include_str!("lib.rs")),
("watch.rs", include_str!("watch.rs")),
("mcp.rs", include_str!("mcp.rs")),
("http.rs", include_str!("http.rs")),
("home.rs", include_str!("home.rs")),
];
for domain in changes::DOMAINS {
let needle = format!("Domain::{}", title_case(domain.as_str()));
let sites: usize = SOURCES
.iter()
.map(|(_, text)| {
text.lines()
.filter(|line| {
line.contains("changes::bump") && line.contains(needle.as_str())
})
.count()
})
.sum();
let allowed = BUMP_SITES
.iter()
.find(|(name, _)| *name == domain.as_str())
.map(|(_, n)| *n)
.expect("every domain declares how many places may write it");
assert_eq!(
sites,
allowed,
"{} is bumped at {sites} site(s) and {allowed} is what it may have; a counter \
is bumped where its domain is written, and a site beyond that list is a \
second source of truth",
domain.as_str()
);
}
}
fn title_case(word: &str) -> String {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[test]
fn an_unregistered_store_is_named_after_the_directory_holding_it() {
let registry = Registry::default();
let (name, roots) = roots_for(Path::new("/work/api/.semlith"), ®istry);
assert_eq!(name, "api");
assert_eq!(roots.len(), 1);
assert!(roots[0].ends_with("api") || roots[0] == Path::new("/work/api"));
}
#[test]
fn a_lowered_limit_holds_the_newest_runs_and_releases_them_in_order() {
let _held = counters();
let admission = Admission::new(3);
let stores: Vec<Arc<Store>> = ["a", "b", "c", "d"].into_iter().map(bare_store).collect();
let ids: Vec<u64> = stores
.iter()
.map(|store| admission.submit(store, Vec::new(), RunKind::Run).0)
.collect();
assert_eq!(admission.running(), 3);
admission.set_limit(1);
assert_eq!(admission.running(), 1, "one left running");
assert_eq!(admission.held(), vec![ids[1], ids[2]], "held oldest first");
assert!(!admission.is_held(ids[0]), "the oldest keeps going");
assert_eq!(
admission.position_of("d"),
Some(3),
"a queued run waits behind the held ones"
);
admission.finish(ids[0]);
assert_eq!(admission.held(), vec![ids[2]], "the older held run went on");
assert!(!admission.is_held(ids[1]));
admission.finish(ids[1]);
assert!(admission.held().is_empty());
admission.finish(ids[2]);
assert_eq!(admission.running(), 1, "then the queue moves");
}
#[test]
fn the_rate_is_rolling_and_blank_before_the_first_batch() {
let mut run = RunState::new(1, Vec::new(), RunKind::Run);
assert_eq!(run.rates(), (None, None), "nothing embedded yet");
run.first_sample = Some((0, 0, 0));
run.samples = VecDeque::from(vec![(0, 0, 0), (20_000, 400, 0), (25_000, 500, 0)]);
run.chunks = 600;
run.ended = Some(Duration::from_secs(30));
let (rate, average) = run.rates();
assert_eq!(rate, Some(20.0));
assert_eq!(average, Some(20.0));
}
#[test]
fn the_estimate_is_bytes_left_over_the_rolling_byte_rate() {
let mut run = RunState::new(1, Vec::new(), RunKind::Run);
run.status = RunStatus::Running;
run.bytes_total = 10_000_000;
run.first_sample = Some((0, 0, 0));
run.samples = VecDeque::from(vec![(0, 0, 0), (2_000, 40, 200_000)]);
run.bytes = 300_000;
run.ended = Some(Duration::from_secs(3));
assert_eq!(
run.eta_ms(),
None,
"three seconds in, the rate has not settled"
);
run.samples = VecDeque::from(vec![
(0, 0, 0),
(10_000, 200, 1_000_000),
(15_000, 300, 2_500_000),
]);
run.bytes = 4_000_000;
run.ended = Some(Duration::from_secs(20));
assert_eq!(run.eta_ms(), Some(20_000));
run.status = RunStatus::Paused;
assert_eq!(
run.eta_ms(),
None,
"a paused run has no time left to count down"
);
}
#[test]
fn the_first_estimate_comes_with_the_first_batch() {
let mut done = RunState::new(1, Vec::new(), RunKind::Run);
done.first_sample = Some((1_000, 0, 0));
done.samples = VecDeque::from(vec![(1_000, 0, 0), (5_000, 90, 2_000_000)]);
assert_eq!(done.embed_rate(), Some(500_000.0));
let mut run = RunState::new(2, Vec::new(), RunKind::Run);
run.status = RunStatus::Running;
run.bytes_total = 3_000_000;
run.plan_eta_ms = Some(6_000);
run.first_sample = Some((500, 0, 0));
run.samples = VecDeque::from(vec![(500, 0, 0)]);
run.bytes = 50_000;
run.ended = Some(Duration::from_millis(600));
assert_eq!(
run.eta_ms(),
Some(5_400),
"no estimate with the first batch"
);
}
#[test]
fn a_stalled_phase_holds_the_estimate_and_it_never_more_than_doubles() {
let mut run = RunState::new(1, Vec::new(), RunKind::Run);
run.status = RunStatus::Running;
run.bytes_total = 10_000_000;
run.first_sample = Some((0, 0, 0));
run.samples = VecDeque::from(vec![
(0, 0, 0),
(10_000, 200, 1_000_000),
(15_000, 300, 2_500_000),
]);
run.bytes = 4_000_000;
run.ended = Some(Duration::from_secs(20));
let before = run.eta_ms().expect("settled");
run.phase = Some("writing the index".to_string());
for t in 21..=28 {
run.samples.push_back((t * 1_000, 300, 4_000_000));
run.ended = Some(Duration::from_secs(t));
let eta = run.eta_ms().expect("held, not dropped");
assert!(eta <= before * 2, "{eta} ms after {before} ms at {t} s");
}
run.phase = None;
run.samples.push_back((29_000, 310, 4_200_000));
run.bytes = 4_200_000;
run.ended = Some(Duration::from_secs(30));
let after = run.eta_ms().expect("moving again");
assert!(after <= before * 2, "{after} ms after {before} ms");
}
#[test]
fn a_long_runs_estimate_follows_its_rate() {
let mut run = RunState::new(1, Vec::new(), RunKind::Run);
run.status = RunStatus::Running;
run.bytes_total = 1_000_000_000;
run.first_sample = Some((0, 0, 0));
run.samples = VecDeque::from(vec![(0, 0, 0)]);
let mut shown = Vec::new();
for t in 1..=40u64 {
run.bytes = if t <= 20 {
t * 100_000
} else {
2_000_000 + (t - 20) * 200_000
};
run.samples.push_back((t * 1_000, t, run.bytes));
run.ended = Some(Duration::from_secs(t));
if let Some(eta) = run.eta_ms() {
shown.push((t, eta));
}
}
let at = |t: u64| {
shown
.iter()
.find(|(at, _)| *at == t)
.expect("an estimate")
.1
};
assert_eq!(at(20) / 1_000, 9_980);
assert_eq!(at(40) / 1_000, 4_970);
}
#[test]
fn a_saved_figure_does_not_follow_the_free_memory_below_the_derived_one() {
let derived = |value| crate::system::Derivation {
value,
reason: String::new(),
};
let unset = "SEMLITH_TEST_UNSET_LIMIT";
for free_less_reserve in [0, 311, 400] {
let limit = Limit::new(derived(512), Some(512), unset, free_less_reserve);
assert_eq!(limit.value, 512, "at {free_less_reserve} MiB of room");
assert_eq!(limit.ceiling, 512);
}
assert_eq!(
Limit::new(derived(512), Some(4096), unset, 1024).value,
1024
);
}
#[test]
fn an_unnamed_write_among_several_stores_is_refused() {
let state = State {
server: Arc::new(Server::bind(0).expect("an ephemeral port")),
fleet: Mutex::new(None),
stores: RwLock::new(
["api", "cli"]
.into_iter()
.map(|name| {
Arc::new(Store::new(
name.to_string(),
PathBuf::from("/nowhere").join(name),
Vec::new(),
Vec::new(),
false,
0,
))
})
.collect(),
),
admission: Arc::new(Admission::new(1)),
airgap: false,
started: SystemTime::now(),
debounce: Duration::from_millis(500),
report: Arc::new(|_| {}),
refusals: Mutex::new(BTreeMap::new()),
late_writers: Mutex::new(Vec::new()),
awaiting: Mutex::new(BTreeMap::new()),
proxies: Mutex::new(BTreeMap::new()),
clients: Mutex::new(BTreeMap::new()),
mcp_fleet: Mutex::new(None),
ledger: false,
schedules: crate::schedule::Runner::new(),
gone: Mutex::new(Vec::new()),
};
let state = Arc::new(state);
let err = match state.writable(None) {
Err(e) => e.to_string(),
Ok(store) => panic!("an unnamed write picked {}", store.name),
};
assert!(err.contains("api, cli"), "{err}");
assert_eq!(
state.writable(Some("cli")).map(|s| s.name.clone()).ok(),
Some("cli".to_string())
);
assert!(state.writable(Some("nope")).is_err());
}
#[test]
fn a_store_is_compacted_only_when_idle_and_past_the_threshold_and_floor() {
use crate::compact::{AUTO_FLOOR_BYTES, Footprint};
let mb = 1024 * 1024;
let dead = |total: u64, reclaimable: u64| Footprint {
database: total,
exact: 0,
vectors: 0,
reclaimable,
};
assert!(compaction_due(25, true, &dead(100 * mb, 40 * mb)));
assert!(!compaction_due(25, false, &dead(100 * mb, 40 * mb)));
assert!(!compaction_due(0, true, &dead(100 * mb, 40 * mb)));
assert!(!compaction_due(25, true, &dead(100 * mb, 20 * mb)));
assert!(!compaction_due(
25,
true,
&dead(2 * mb, AUTO_FLOOR_BYTES - 1)
));
}
}