1use std::fs;
2use std::fs::OpenOptions;
3use std::hash::{Hash, Hasher};
4use std::io::Write;
5#[cfg(unix)]
6use std::os::fd::AsRawFd;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde::{Deserialize, Serialize};
11
12use crate::DaemonWorkspaceConfig;
13
14#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub struct DaemonRegistryEntry {
17 pub workspace_root: String,
18 pub workspace_roots: Vec<String>,
19 pub project: Option<String>,
20 pub cache_dir: Option<String>,
21 pub live_refresh: Option<String>,
22 pub endpoint: String,
23 pub token: String,
24 pub pid: u32,
25 #[serde(default)]
26 pub heartbeat_unix_ms: u64,
27 #[serde(default)]
28 pub state: DaemonRegistryState,
29}
30
31pub const DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS: u64 = 15_000;
32
33pub fn registry_heartbeat_unix_ms() -> u64 {
34 SystemTime::now()
35 .duration_since(UNIX_EPOCH)
36 .unwrap_or_default()
37 .as_millis()
38 .try_into()
39 .unwrap_or(u64::MAX)
40}
41
42pub fn daemon_registry_heartbeat_expired(entry: &DaemonRegistryEntry) -> bool {
43 entry.heartbeat_unix_ms == 0
44 || registry_heartbeat_unix_ms().saturating_sub(entry.heartbeat_unix_ms)
45 > DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS
46}
47
48#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[serde(rename_all = "snake_case")]
51pub enum DaemonRegistryState {
52 Indexing,
53 #[default]
54 Ready,
55}
56
57pub fn registry_dir() -> PathBuf {
58 std::env::temp_dir().join("code-moniker-daemons")
59}
60
61pub fn canonical_workspace_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
62 let root = root.as_ref();
63 root.canonicalize()
64 .map_err(|err| anyhow::anyhow!("cannot canonicalize {}: {err}", root.display()))
65}
66
67pub fn canonical_workspace_roots<I, P>(roots: I) -> anyhow::Result<Vec<PathBuf>>
68where
69 I: IntoIterator<Item = P>,
70 P: AsRef<Path>,
71{
72 let mut canonical = Vec::new();
73 for root in roots {
74 let root = canonical_workspace_root(root)?;
75 if !canonical.contains(&root) {
76 canonical.push(root);
77 }
78 }
79 if canonical.is_empty() {
80 canonical.push(canonical_workspace_root(".")?);
81 }
82 Ok(canonical)
83}
84
85pub fn daemon_workspace_config<I, P>(
86 roots: I,
87 project: Option<String>,
88 cache_dir: Option<PathBuf>,
89 live_refresh: Option<String>,
90) -> anyhow::Result<DaemonWorkspaceConfig>
91where
92 I: IntoIterator<Item = P>,
93 P: AsRef<Path>,
94{
95 let roots = canonical_workspace_roots(roots)?;
96 Ok(DaemonWorkspaceConfig {
97 roots: roots
98 .into_iter()
99 .map(|root| root.display().to_string())
100 .collect(),
101 project,
102 cache_dir: cache_dir
103 .map(normalize_path)
104 .transpose()?
105 .map(|path| path.display().to_string()),
106 live_refresh,
107 })
108}
109
110pub fn validate_daemon_start_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<()> {
111 let roots = config_roots(config);
112 if let Some(root) = roots.iter().find(|root| root.parent().is_none()) {
113 anyhow::bail!(
114 "refusing to start a code-moniker daemon at filesystem root `{}`; pass an explicit project directory (MCP configurations should use an absolute project path)",
115 root.display()
116 );
117 }
118 Ok(())
119}
120
121pub fn canonical_workspace_config(
122 config: DaemonWorkspaceConfig,
123) -> anyhow::Result<DaemonWorkspaceConfig> {
124 daemon_workspace_config(
125 config.roots.iter().map(PathBuf::from),
126 config.project,
127 config.cache_dir.map(PathBuf::from),
128 config.live_refresh,
129 )
130}
131
132pub fn config_from_roots<I, P>(roots: I) -> anyhow::Result<DaemonWorkspaceConfig>
133where
134 I: IntoIterator<Item = P>,
135 P: AsRef<Path>,
136{
137 daemon_workspace_config(roots, None, None, Some("on-demand".to_string()))
138}
139
140pub fn registry_path_for_root(root: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
141 registry_path_for_roots([root])
142}
143
144pub fn registry_path_for_roots<I, P>(roots: I) -> anyhow::Result<PathBuf>
145where
146 I: IntoIterator<Item = P>,
147 P: AsRef<Path>,
148{
149 registry_path_for_config(&config_from_roots(roots)?)
150}
151
152pub fn registry_path_for_config(config: &DaemonWorkspaceConfig) -> anyhow::Result<PathBuf> {
153 let config = canonical_workspace_config(config.clone())?;
154 Ok(registry_dir().join(format!("{}.json", stable_config_hash(&config))))
155}
156
157pub fn read_registry_entry(
158 config: &DaemonWorkspaceConfig,
159) -> anyhow::Result<Option<DaemonRegistryEntry>> {
160 let path = registry_path_for_config(config)?;
161 match fs::read_to_string(&path) {
162 Ok(text) => Ok(serde_json::from_str(&text).ok()),
163 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
164 Err(err) => Err(err.into()),
165 }
166}
167
168pub fn write_registry_entry(
169 config: &DaemonWorkspaceConfig,
170 entry: &DaemonRegistryEntry,
171) -> anyhow::Result<()> {
172 fs::create_dir_all(registry_dir())?;
173 atomic_write_registry_entry(®istry_path_for_config(config)?, entry)?;
174 Ok(())
175}
176
177pub fn claim_registry_entry(
178 config: &DaemonWorkspaceConfig,
179 entry: &DaemonRegistryEntry,
180) -> anyhow::Result<bool> {
181 fs::create_dir_all(registry_dir())?;
182 let path = registry_path_for_config(config)?;
183 let text = serde_json::to_vec_pretty(entry)?;
184 match OpenOptions::new().write(true).create_new(true).open(path) {
185 Ok(mut file) => {
186 file.write_all(&text)?;
187 file.sync_all()?;
188 Ok(true)
189 }
190 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
191 Err(error) => Err(error.into()),
192 }
193}
194
195pub fn update_registry_entry_if_own(
196 config: &DaemonWorkspaceConfig,
197 entry: &DaemonRegistryEntry,
198) -> anyhow::Result<bool> {
199 let path = registry_path_for_config(config)?;
200 with_registry_lock(&path, || {
201 let current = fs::read_to_string(&path)
202 .ok()
203 .and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
204 let owned = current
205 .map(|current| current.token == entry.token && current.pid == entry.pid)
206 .unwrap_or(false);
207 if owned {
208 atomic_write_registry_entry(&path, entry)?;
209 }
210 Ok(owned)
211 })
212}
213
214fn atomic_write_registry_entry(path: &Path, entry: &DaemonRegistryEntry) -> anyhow::Result<()> {
215 let temp = path.with_extension(format!("{}.tmp", entry.token));
216 let text = serde_json::to_vec_pretty(entry)?;
217 {
218 let mut file = OpenOptions::new()
219 .write(true)
220 .create_new(true)
221 .open(&temp)?;
222 file.write_all(&text)?;
223 file.sync_all()?;
224 }
225 fs::rename(temp, path)?;
226 Ok(())
227}
228
229pub fn remove_registry_entry_if_own(path: &Path, own: &DaemonRegistryEntry) {
233 let _ = with_registry_lock(path, || {
234 let current = fs::read_to_string(path)
235 .ok()
236 .and_then(|text| serde_json::from_str::<DaemonRegistryEntry>(&text).ok());
237 let owned = current
238 .map(|entry| entry.token == own.token && entry.pid == own.pid)
239 .unwrap_or(false);
240 if owned {
241 let _ = fs::remove_file(path);
242 }
243 Ok(())
244 });
245}
246
247#[cfg(unix)]
248fn with_registry_lock<T>(
249 path: &Path,
250 action: impl FnOnce() -> anyhow::Result<T>,
251) -> anyhow::Result<T> {
252 let lock_path = path.with_extension("lock");
253 let lock = OpenOptions::new()
254 .read(true)
255 .write(true)
256 .create(true)
257 .truncate(false)
258 .open(lock_path)?;
259 if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == -1 {
262 return Err(std::io::Error::last_os_error().into());
263 }
264 action()
265}
266
267#[cfg(not(unix))]
268fn with_registry_lock<T>(
269 _path: &Path,
270 action: impl FnOnce() -> anyhow::Result<T>,
271) -> anyhow::Result<T> {
272 action()
273}
274
275pub fn list_registry_files() -> anyhow::Result<Vec<(PathBuf, DaemonRegistryEntry)>> {
276 let dir = registry_dir();
277 if !dir.exists() {
278 return Ok(Vec::new());
279 }
280 let mut entries = Vec::new();
281 for entry in fs::read_dir(&dir)? {
282 let entry = entry?;
283 if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
284 continue;
285 }
286 let text = fs::read_to_string(entry.path())?;
287 if let Ok(registry) = serde_json::from_str::<DaemonRegistryEntry>(&text) {
288 entries.push((entry.path(), registry));
289 }
290 }
291 entries.sort_by(|(_, a), (_, b)| a.workspace_root.cmp(&b.workspace_root));
292 Ok(entries)
293}
294
295pub fn pid_is_alive(pid: u32) -> bool {
296 #[cfg(unix)]
297 {
298 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
301 let errno = (result != 0)
302 .then(|| std::io::Error::last_os_error().raw_os_error())
303 .flatten();
304 kill_result_means_alive(result, errno)
305 }
306 #[cfg(not(unix))]
307 {
308 let _ = pid;
309 true
310 }
311}
312
313#[cfg(unix)]
314fn kill_result_means_alive(result: i32, errno: Option<i32>) -> bool {
315 result == 0 || errno != Some(libc::ESRCH)
316}
317
318pub fn list_registry_entries() -> anyhow::Result<Vec<DaemonRegistryEntry>> {
319 let mut entries = Vec::new();
320 for (path, entry) in list_registry_files()? {
321 if pid_is_alive(entry.pid) {
322 entries.push(entry);
323 } else {
324 remove_registry_entry_if_own(&path, &entry);
325 }
326 }
327 entries.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
328 Ok(entries)
329}
330
331pub fn config_roots(config: &DaemonWorkspaceConfig) -> Vec<PathBuf> {
332 config.roots.iter().map(PathBuf::from).collect()
333}
334
335pub fn workspace_label(roots: &[PathBuf]) -> String {
336 if roots.len() == 1 {
337 roots[0].display().to_string()
338 } else {
339 roots
340 .iter()
341 .map(|root| root.display().to_string())
342 .collect::<Vec<_>>()
343 .join(";")
344 }
345}
346
347fn normalize_path(path: PathBuf) -> anyhow::Result<PathBuf> {
348 if path.is_absolute() {
349 Ok(path)
350 } else {
351 Ok(std::env::current_dir()?.join(path))
352 }
353}
354
355fn stable_config_hash(config: &DaemonWorkspaceConfig) -> String {
360 let mut hasher = StableHasher::default();
361 for root in &config.roots {
362 root.hash(&mut hasher);
363 0xff_u8.hash(&mut hasher);
364 }
365 config.project.hash(&mut hasher);
366 0xfe_u8.hash(&mut hasher);
367 config.cache_dir.hash(&mut hasher);
368 format!("{:016x}", hasher.finish())
369}
370
371#[derive(Default)]
372struct StableHasher(u64);
373
374impl Hasher for StableHasher {
375 fn finish(&self) -> u64 {
376 self.0
377 }
378
379 fn write(&mut self, bytes: &[u8]) {
380 let mut hash = if self.0 == 0 {
381 0xcbf29ce484222325
382 } else {
383 self.0
384 };
385 for byte in bytes {
386 hash ^= u64::from(*byte);
387 hash = hash.wrapping_mul(0x100000001b3);
388 }
389 self.0 = hash;
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 fn entry(token: &str, pid: u32) -> DaemonRegistryEntry {
398 DaemonRegistryEntry {
399 workspace_root: "/tmp/ws".to_string(),
400 workspace_roots: vec!["/tmp/ws".to_string()],
401 project: None,
402 cache_dir: None,
403 live_refresh: None,
404 endpoint: "127.0.0.1:1".to_string(),
405 token: token.to_string(),
406 pid,
407 heartbeat_unix_ms: registry_heartbeat_unix_ms(),
408 state: DaemonRegistryState::Ready,
409 }
410 }
411
412 #[test]
413 fn registry_identity_ignores_the_refresh_mode() {
414 let base = DaemonWorkspaceConfig {
415 roots: vec!["/tmp/ws".to_string()],
416 project: None,
417 cache_dir: None,
418 live_refresh: Some("auto".to_string()),
419 };
420 let mut on_demand = base.clone();
421 on_demand.live_refresh = Some("on-demand".to_string());
422 assert_eq!(
423 stable_config_hash(&base),
424 stable_config_hash(&on_demand),
425 "one workspace must map to one registry slot, whatever the refresh mode"
426 );
427
428 let mut other_project = base.clone();
429 other_project.project = Some("api".to_string());
430 assert_ne!(
431 stable_config_hash(&base),
432 stable_config_hash(&other_project),
433 "what gets indexed still separates registry slots"
434 );
435 }
436
437 #[cfg(unix)]
438 #[test]
439 fn daemon_workspace_rejects_the_filesystem_root() {
440 let config =
441 daemon_workspace_config([Path::new("/")], None, None, Some("auto".to_string()))
442 .expect("filesystem root identity remains available for status and cleanup");
443 let error = validate_daemon_start_config(&config)
444 .expect_err("filesystem root must fail before daemon startup");
445 let message = error.to_string();
446 assert!(message.contains("refusing to start"), "{message}");
447 assert!(message.contains("absolute project path"), "{message}");
448 }
449
450 #[test]
451 fn shutdown_removal_spares_a_successor_entry() {
452 let dir = tempfile::tempdir().expect("tempdir");
453 let path = dir.path().join("ws.json");
454 let old = entry("old-token", 111);
455 let new = entry("new-token", 222);
456
457 fs::write(&path, serde_json::to_string(&new).expect("json")).expect("write");
458 remove_registry_entry_if_own(&path, &old);
459 assert!(path.exists(), "the successor's entry must survive");
460
461 remove_registry_entry_if_own(&path, &new);
462 assert!(!path.exists(), "the owner removes its own entry");
463
464 remove_registry_entry_if_own(&path, &new);
465 }
466
467 #[test]
468 fn legacy_registry_entries_default_to_ready() {
469 let mut value = serde_json::to_value(entry("legacy", 111)).expect("json");
470 value.as_object_mut().expect("object").remove("state");
471 let decoded: DaemonRegistryEntry = serde_json::from_value(value).expect("legacy entry");
472 assert_eq!(decoded.state, DaemonRegistryState::Ready);
473 }
474
475 #[test]
476 fn missing_or_old_heartbeat_expires_but_a_fresh_claim_does_not() {
477 let mut registry = entry("heartbeat", 111);
478 registry.heartbeat_unix_ms = 0;
479 assert!(daemon_registry_heartbeat_expired(®istry));
480 registry.heartbeat_unix_ms =
481 registry_heartbeat_unix_ms() - DAEMON_REGISTRY_HEARTBEAT_TIMEOUT_MS - 1;
482 assert!(daemon_registry_heartbeat_expired(®istry));
483 registry.heartbeat_unix_ms = registry_heartbeat_unix_ms();
484 assert!(!daemon_registry_heartbeat_expired(®istry));
485 }
486
487 #[test]
488 fn atomic_registry_update_replaces_a_complete_entry() {
489 let dir = tempfile::tempdir().expect("tempdir");
490 let path = dir.path().join("workspace.json");
491 let indexing = DaemonRegistryEntry {
492 state: DaemonRegistryState::Indexing,
493 ..entry("same-daemon", 111)
494 };
495 atomic_write_registry_entry(&path, &indexing).expect("write indexing entry");
496 let ready = DaemonRegistryEntry {
497 state: DaemonRegistryState::Ready,
498 ..indexing.clone()
499 };
500 atomic_write_registry_entry(&path, &ready).expect("write ready entry");
501 let read: DaemonRegistryEntry =
502 serde_json::from_str(&fs::read_to_string(path).expect("read entry")).expect("json");
503 assert_eq!(read, ready);
504 }
505
506 #[cfg(unix)]
507 #[test]
508 fn permission_denied_pid_is_alive_but_missing_pid_is_dead() {
509 assert!(kill_result_means_alive(-1, Some(libc::EPERM)));
510 assert!(!kill_result_means_alive(-1, Some(libc::ESRCH)));
511 assert!(kill_result_means_alive(0, None));
512 }
513}