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,
}
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)]
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))]
fn alive(_pid: u32) -> bool {
true
}
enum Work {
Roots(Vec<PathBuf>),
Rest(Vec<PathBuf>),
}
#[derive(Default, Clone, Copy)]
struct Tally {
indexed: u64,
unchanged: u64,
skipped: u64,
removed: u64,
chunks: u64,
images: u64,
symbols: u64,
}
impl Tally {
fn add(&mut self, report: &crate::IndexReport) {
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,
already: Vec<String>,
scanned: u64,
total: u64,
tally: Tally,
}
enum Job {
Index(Indexing),
Forget(PathBuf),
}
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,
Running,
Paused,
Stopping,
Done,
Stopped,
Failed,
}
impl RunStatus {
pub fn finished(self) -> bool {
matches!(self, Self::Done | Self::Stopped | Self::Failed)
}
}
pub struct RunState {
pub id: u64,
pub paths: Vec<PathBuf>,
pub status: RunStatus,
pub submitted: u64,
origin: std::time::Instant,
paused_at: Option<std::time::Instant>,
paused_total: Duration,
ended: Option<Duration>,
pub scanned: u64,
pub total: u64,
pub indexed: u64,
pub chunks: u64,
pub symbols: u64,
pub phase: Option<String>,
pub summary: Option<serde_json::Value>,
log: VecDeque<serde_json::Value>,
next_seq: u64,
}
impl RunState {
fn new(id: u64, paths: Vec<PathBuf>) -> Self {
Self {
id,
paths,
status: RunStatus::Queued,
submitted: now(),
origin: std::time::Instant::now(),
paused_at: None,
paused_total: Duration::ZERO,
ended: None,
scanned: 0,
total: 0,
indexed: 0,
chunks: 0,
symbols: 0,
phase: None,
summary: None,
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 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 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.unhold();
}
Some("file") => {
self.status = RunStatus::Running;
self.phase = 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.indexed = num("indexed").unwrap_or(self.indexed);
self.chunks = num("chunks").unwrap_or(self.chunks);
self.symbols = num("symbols").unwrap_or(self.symbols);
}
Some("paused") => {
self.status = RunStatus::Paused;
self.hold();
}
Some("resumed" | "slice") => {
self.status = RunStatus::Running;
self.unhold();
}
Some("done") => {
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.summary = Some(event.clone());
}
Some("error") => {
self.status = RunStatus::Failed;
self.unhold();
self.ended = Some(self.elapsed());
}
_ => {}
}
let mut line = event.clone();
if let Some(object) = line.as_object_mut() {
object.insert("seq".into(), serde_json::json!(self.next_seq));
}
self.next_seq += 1;
if self.log.len() == LOG_HISTORY {
self.log.pop_front();
}
self.log.push_back(line);
}
}
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,
}
impl Store {
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>) {
let mut runs = self.runs.lock().unwrap_or_else(|e| e.into_inner());
runs.push(RunState::new(id, paths));
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 runs_changed(&self) {
changes::bump(changes::Domain::Runs);
}
fn record(&self, id: u64, event: &serde_json::Value) {
if let Some(run) = self
.runs
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter_mut()
.find(|run| run.id == id)
{
run.absorb(event);
}
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);
}
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 {
serde_json::json!({
"id": run.id,
"store": self.name,
"paths": run.paths.iter()
.map(|p| crate::plain(&p.display().to_string()))
.collect::<Vec<_>>(),
"status": run.status,
"position": position,
"submitted": run.submitted,
"elapsed_ms": run.elapsed().as_millis() as u64,
"ticking": matches!(run.status, RunStatus::Running | RunStatus::Queued | RunStatus::Stopping),
"scanned": run.scanned,
"total": run.total,
"indexed": run.indexed,
"chunks": run.chunks,
"symbols": run.symbols,
"phase": run.phase,
"summary": run.summary,
"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_index(
&self,
run: u64,
paths: Vec<PathBuf>,
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: Work::Roots(paths),
already: Vec::new(),
scanned: 0,
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(..)) {
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>,
report: mpsc::Sender<serde_json::Value>,
}
pub struct Admission {
queue: Mutex<VecDeque<Pending>>,
running: 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()),
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) {
self.limit.store(limit.max(1), Ordering::Relaxed);
self.pump();
}
pub fn running(&self) -> usize {
self.running.lock().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn submit(
&self,
store: &Arc<Store>,
paths: Vec<PathBuf>,
) -> (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());
{
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,
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 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.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.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> {
self.queue
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.position(|p| p.store.name == store)
.map(|at| 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 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,
}
impl Limit {
fn new(derived: crate::system::Derivation, saved: Option<usize>, variable: &str) -> Self {
let from_env = std::env::var(variable)
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.filter(|n| *n > 0);
let (value, source) = match (from_env, saved.filter(|n| *n > 0)) {
(Some(n), _) => (n, Source::Environment),
(None, Some(n)) => (n, Source::Saved),
(None, None) => (derived.value, Source::Derived),
};
Self {
value,
source,
derived: derived.value,
reason: derived.reason,
}
}
}
#[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 runs_at_once = Limit::new(derived.runs_at_once, saved.runs_at_once, PARALLEL_ENV);
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,
),
index_memory_mb: Limit::new(
derived.index_memory_mb,
saved.index_memory_mb,
crate::index::INDEX_MEMORY_ENV,
),
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 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(),
)
}
}
pub struct State {
pub server: Arc<Server>,
pub fleet: Mutex<Option<Fleet>>,
stores: RwLock<Vec<Arc<Store>>>,
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>>,
pub proxies: Mutex<BTreeMap<u32, u64>>,
pub clients: Mutex<BTreeMap<String, Client>>,
pub mcp_fleet: Mutex<Option<Fleet>>,
pub ledger: bool,
}
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 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))
}
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 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);
changes::bump(changes::Domain::Stores);
crate::home::delete_store(name)
}
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> {
let clients = self.clients.lock().unwrap_or_else(|e| e.into_inner());
clients
.get(&format!("{transport}:{session}"))
.map(|client| client.name.clone())
.filter(|name| name != "unnamed client")
}
pub fn note_client(
&self,
session: &str,
transport: &str,
name: 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: name.unwrap_or("unnamed client").to_string(),
transport: transport.to_string(),
revision: revision.unwrap_or("—").to_string(),
queries: 0,
seen: now,
});
if let Some(name) = name {
entry.name = name.to_string();
}
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);
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 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 store = Arc::new(Store {
name: name.clone(),
dir: dir.clone(),
roots,
watched,
queue: Mutex::new(VecDeque::new()),
events: Mutex::new(VecDeque::new()),
runs: Mutex::new(Vec::new()),
watching: AtomicBool::new(true),
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(if expect_run {
now() as usize + EXPECTED_RUN_GRACE_SECS
} else {
0
}),
});
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);
std::thread::spawn(move || {
let _lock = lock;
if let Err(e) = tend(&watching, debounce, &watching.stop, &*report, &admission) {
report(&format!("{}: watcher stopped: {e}", watching.name));
watching.note(format!("watcher stopped: {e}"));
}
watching.watching.store(false, Ordering::Relaxed);
});
(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 refuse(&self, class: Refusal) {
*self
.refusals
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(class.as_str())
.or_insert(0) += 1;
}
}
fn discovery(port: u16, token: &str) -> Discovery {
Discovery {
pid: std::process::id(),
port,
token: token.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
}
}
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)
}
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();
for dir in dirs {
let lock = StoreLock::acquire(dir)
.with_context(|| format!("{} cannot be opened by the daemon", dir.display()))?;
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()));
report(if ledger {
"ledger: recording (local only; --no-ledger to stop)"
} else {
"ledger: off for this session"
});
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()
));
stores.push(Arc::new(Store {
name,
dir,
roots,
watched,
queue: Mutex::new(VecDeque::new()),
events: Mutex::new(VecDeque::new()),
runs: Mutex::new(Vec::new()),
watching: AtomicBool::new(false),
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(0),
}));
}
let fleet = if dirs.is_empty() {
None
} else {
let mut fleet = Fleet::open(dirs)?;
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());
let state = Arc::new(State {
server: Arc::clone(&server),
fleet: Mutex::new(fleet),
stores: RwLock::new(stores),
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()),
proxies: Mutex::new(BTreeMap::new()),
clients: Mutex::new(BTreeMap::new()),
mcp_fleet: Mutex::new(None),
ledger,
});
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;
if let Err(e) = tend(&store, debounce, &store.stop, &*report, &admission) {
report(&format!("{}: watcher stopped: {e}", store.name));
store.note(format!("watcher stopped: {e}"));
}
store.watching.store(false, Ordering::Relaxed);
}));
}
report_dropped_queue(&state.stores(), &*report_line);
{
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());
for store in state.stores() {
store.stop.store(true, Ordering::SeqCst);
}
for watcher in watchers {
let _ = watcher.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 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;
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();
watch::run_held(
&mut writer,
&roots,
debounce,
stop,
&|| {
store.queue_depth() > 0
|| admission.position_of(&store.name).is_some()
|| (now() as usize) < store.expecting_run_until.load(Ordering::Relaxed)
},
|progress| {
use watch::Progress;
match progress {
Progress::Ready {
catch_up,
files,
chunks,
} => {
let text = format!(
"watching — {files} files, {chunks} chunks \
({} indexed at startup, {} unchanged)",
catch_up.indexed, catch_up.unchanged
);
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| {
loop {
let Some(next) = store
.queue
.lock()
.unwrap_or_else(|e| e.into_inner())
.pop_front()
else {
return Ok(());
};
perform(store, writer, next, admission);
}
},
)
}
fn perform(store: &Arc<Store>, writer: &mut Semlith, queued: Queued, admission: &Arc<Admission>) {
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,
already,
scanned: scanned_before,
total: total_before,
mut tally,
}) => {
if let Work::Roots(roots) = &work {
let names: Vec<String> = roots.iter().map(|p| p.display().to_string()).collect();
say(serde_json::json!({ "event": "started", "paths": names }));
}
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 told = std::sync::atomic::AtomicBool::new(false);
move || {
if store.cancelled.load(Ordering::Relaxed) {
return crate::Flow::Stop;
}
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 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": "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 },
"indexed": indexed_before + progress.indexed as u64,
"chunks": chunks_before + progress.chunks as u64,
"symbols": symbols_before + progress.symbols as u64,
"elapsed_ms": store.run_elapsed_ms(run),
}));
};
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, &control, on_file),
};
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 mut undone = 0;
for key in &written {
if writer.forget_held(Path::new(key)).is_ok() {
undone += 1;
}
}
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;
}
tally.add(&done);
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)),
already: written,
scanned: scanned_before + done.scanned as u64,
total,
tally,
}),
report: back,
});
say(serde_json::json!({
"event": "slice",
"remaining": done.remaining,
"indexed": tally.indexed,
"chunks": tally.chunks,
}));
return;
}
store.note(format!("{} indexed from the portal", tally.indexed));
say(serde_json::json!({
"event": "done",
"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": done.refused.iter().map(|(path, why)| {
serde_json::json!({ "path": crate::plain(path), "why": why })
}).collect::<Vec<_>>(),
"failed": done.failed.iter().map(|(path, why)| {
serde_json::json!({ "path": crate::plain(path), "why": why })
}).collect::<Vec<_>>(),
"skipped_reasons": done.skipped_reasons,
"elapsed_ms": store.run_elapsed_ms(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);
}
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() })),
},
}
}
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}"));
}
}
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())
}
fn bare_store(name: &str) -> Arc<Store> {
Arc::new(Store {
name: name.to_string(),
dir: PathBuf::from("/nowhere").join(name),
roots: Vec::new(),
watched: Vec::new(),
queue: Mutex::new(VecDeque::new()),
events: Mutex::new(VecDeque::new()),
runs: Mutex::new(Vec::new()),
watching: AtomicBool::new(true),
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(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")]);
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());
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());
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)]);
}
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());
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());
let (_, waiting) = admission.submit(&b, Vec::new());
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", 2),
("runs", 1),
("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 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 {
name: name.to_string(),
dir: PathBuf::from("/nowhere").join(name),
roots: Vec::new(),
watched: Vec::new(),
queue: Mutex::new(VecDeque::new()),
events: Mutex::new(VecDeque::new()),
runs: Mutex::new(Vec::new()),
watching: AtomicBool::new(false),
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(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()),
proxies: Mutex::new(BTreeMap::new()),
clients: Mutex::new(BTreeMap::new()),
mcp_fleet: Mutex::new(None),
ledger: false,
};
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());
}
}