use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{FocrError, FocrResult};
pub const DOCTOR_SCHEMA_VERSION: u32 = 1;
pub const EXIT_HEALTHY: i32 = 0;
pub const EXIT_FINDINGS: i32 = 1;
pub const EXIT_PARTIAL: i32 = 2;
pub const EXIT_FAILED_ROLLED_BACK: i32 = 3;
pub const EXIT_REFUSED_UNSAFE: i32 = 4;
pub const EXIT_CONCURRENCY_LOST: i32 = 5;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum Fixability {
Auto { op: String },
RefusedUnsafe { recommended_command: String },
AdviceOnly { hint: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Finding {
pub detector: &'static str,
pub severity: &'static str,
pub path: Option<String>,
pub message: String,
pub fixability: Fixability,
}
pub struct DoctorRoot {
pub cache_root: PathBuf,
}
impl DoctorRoot {
pub fn resolve() -> FocrResult<Self> {
let cache_root = crate::dist::cache_root().ok_or_else(|| {
FocrError::Other(anyhow::anyhow!(
"cannot resolve a user cache directory (set HOME)"
))
})?;
Ok(Self { cache_root })
}
pub fn models_dir(&self) -> PathBuf {
self.cache_root.join("models")
}
pub fn doctor_dir(&self) -> PathBuf {
self.cache_root.join(".doctor")
}
pub fn runs_dir(&self) -> PathBuf {
self.doctor_dir().join("runs")
}
pub fn lock_path(&self) -> PathBuf {
self.doctor_dir().join("lock")
}
}
#[must_use]
pub fn detect(root: &DoctorRoot) -> Vec<Finding> {
let mut findings = Vec::new();
detect_model_not_resolvable(root, &mut findings);
detect_stale_focrq_format(root, &mut findings);
detect_unreadable_cache_entries(root, &mut findings);
detect_orphaned_partials(root, &mut findings);
findings
}
fn detect_model_not_resolvable(_root: &DoctorRoot, out: &mut Vec<Finding>) {
let spec = crate::OcrEngine::model_path();
if !crate::native_engine::native_model_available(&spec) {
let dirs: Vec<String> = crate::native_engine::model_resolution_search_dirs()
.into_iter()
.map(|p| p.display().to_string())
.collect();
out.push(Finding {
detector: "model_not_resolvable",
severity: "warn",
path: None,
message: format!(
"no default model artifact resolvable (searched: {})",
dirs.join(", ")
),
fixability: Fixability::AdviceOnly {
hint: "run `focr pull` to download the int8 weights, or set FOCR_MODEL_PATH / FOCR_MODEL_DIR".into(),
},
});
}
}
fn detect_stale_focrq_format(root: &DoctorRoot, out: &mut Vec<Finding>) {
let Ok(entries) = std::fs::read_dir(root.models_dir()) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || !name.ends_with(".focrq") {
continue;
}
let Ok(prefix) = prefix_reader::read_prefix(&path, 16) else {
continue;
};
fn version_of(prefix: &[u8]) -> Option<u32> {
if prefix.len() < 10 || &prefix[..6] != crate::native_engine::weights::FOCRQ_MAGIC {
return None;
}
Some(u32::from_le_bytes([
prefix[6], prefix[7], prefix[8], prefix[9],
]))
}
if let Some(version) = version_of(prefix.as_bytes()) {
let current = crate::native_engine::weights::FOCRQ_FORMAT_VERSION;
if version != current {
out.push(Finding {
detector: "stale_focrq_format",
severity: "error",
path: Some(path.display().to_string()),
message: format!(
"{name}: format version {version} != current {current}"
),
fixability: Fixability::RefusedUnsafe {
recommended_command: format!(
"focr pull # re-fetch a current artifact (re-quantizing locally: `focr convert <safetensors> -o {name}`)"
),
},
});
}
}
}
}
#[cfg(unix)]
fn detect_unreadable_cache_entries(root: &DoctorRoot, out: &mut Vec<Finding>) {
use std::os::unix::fs::PermissionsExt;
let Ok(entries) = std::fs::read_dir(root.models_dir()) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || !path.is_file() {
continue;
}
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.permissions().mode() & 0o400 == 0 {
out.push(Finding {
detector: "unreadable_cache_entry",
severity: "error",
path: Some(path.display().to_string()),
message: format!(
"{name}: owner has no read permission (mode {:o})",
meta.permissions().mode() & 0o7777
),
fixability: Fixability::Auto {
op: "chmod_u_rw".into(),
},
});
}
}
}
#[cfg(not(unix))]
fn detect_unreadable_cache_entries(_root: &DoctorRoot, _out: &mut Vec<Finding>) {}
fn staging_download_is_active(path: &Path) -> bool {
let Some(lock_path) = crate::dist::pull_lock_path_for_staging(path) else {
return false;
};
let metadata = match std::fs::symlink_metadata(&lock_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
Err(_) => return true,
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return true;
}
let file = match std::fs::File::options()
.read(true)
.write(true)
.open(&lock_path)
{
Ok(file) => file,
Err(_) => return true,
};
match file.try_lock() {
Ok(()) => false,
Err(std::fs::TryLockError::WouldBlock | std::fs::TryLockError::Error(_)) => true,
}
}
fn detect_orphaned_partials_in_dir(directory: &Path, out: &mut Vec<Finding>) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with("._")
&& (name.ends_with(".tmp") || name.ends_with(".partial"))
&& !staging_download_is_active(&path)
{
out.push(Finding {
detector: "orphaned_partial_download",
severity: "warn",
path: Some(path.display().to_string()),
message: format!("{name}: leftover partial/temp file in the model cache"),
fixability: Fixability::Auto {
op: "quarantine".into(),
},
});
}
}
}
fn detect_orphaned_partials(root: &DoctorRoot, out: &mut Vec<Finding>) {
let models = root.models_dir();
detect_orphaned_partials_in_dir(&models, out);
let Ok(entries) = std::fs::read_dir(&models) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if metadata.is_dir() && !metadata.file_type().is_symlink() {
detect_orphaned_partials_in_dir(&path, out);
}
}
}
pub(crate) mod prefix_reader {
use std::io::Read;
use std::path::Path;
pub struct PrefixBytes(Vec<u8>);
impl PrefixBytes {
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
pub fn read_prefix(path: &Path, n: usize) -> std::io::Result<PrefixBytes> {
let mut f = std::fs::File::open(path)?;
let mut buf = vec![0u8; n];
let mut filled = 0;
while filled < n {
let k = f.read(&mut buf[filled..])?;
if k == 0 {
break;
}
filled += k;
}
buf.truncate(filled);
Ok(PrefixBytes(buf))
}
}
pub mod mutation {
use super::{DOCTOR_SCHEMA_VERSION, DoctorRoot, EXIT_CONCURRENCY_LOST};
use crate::{FocrError, FocrResult};
use serde::{Deserialize, Serialize};
use std::io::Write as _;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Action {
pub schema_version: u32,
pub op: String,
pub path: String,
pub before_hash: String,
pub after_hash: String,
pub before_mode: Option<u32>,
pub after_mode: Option<u32>,
pub backup: Option<String>,
}
pub struct DoctorRun {
pub id: String,
root_cache: PathBuf,
dir: PathBuf,
lock: PathBuf,
pub actions: Vec<Action>,
}
pub fn sha256_file(path: &Path) -> String {
use sha2::{Digest, Sha256};
match std::fs::read(path) {
Ok(bytes) => {
let mut h = Sha256::new();
h.update(&bytes);
format!("{:x}", h.finalize())
}
Err(_) => "unreadable".into(),
}
}
impl DoctorRun {
pub fn begin(root: &DoctorRoot, id: &str) -> FocrResult<Self> {
std::fs::create_dir_all(root.runs_dir())
.map_err(|e| FocrError::Other(anyhow::anyhow!("create runs dir: {e}")))?;
let lock = root.lock_path();
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock)
{
Ok(mut f) => {
let _ = writeln!(f, "{}", std::process::id());
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(FocrError::Other(anyhow::anyhow!(
"doctor lock held ({}) — another doctor run is live (exit {})",
lock.display(),
EXIT_CONCURRENCY_LOST
)));
}
Err(e) => {
return Err(FocrError::Other(anyhow::anyhow!(
"acquire doctor lock: {e}"
)));
}
}
let dir = root.runs_dir().join(id);
std::fs::create_dir_all(dir.join("backups"))
.map_err(|e| FocrError::Other(anyhow::anyhow!("create run dir: {e}")))?;
Ok(Self {
id: id.to_string(),
root_cache: root.cache_root.clone(),
dir,
lock,
actions: Vec::new(),
})
}
fn backups_dir(&self) -> PathBuf {
self.dir.join("backups")
}
fn backup_name(&self, path: &Path) -> String {
format!(
"{:03}_{}",
self.actions.len(),
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
)
}
fn guard_blast_radius(&self, path: &Path) -> FocrResult<()> {
if !path.starts_with(&self.root_cache) {
return Err(FocrError::Other(anyhow::anyhow!(
"REFUSED: {} is outside the doctor's blast radius ({})",
path.display(),
self.root_cache.display()
)));
}
Ok(())
}
fn record(&mut self, action: Action) -> FocrResult<()> {
let line = serde_json::to_string(&action)
.map_err(|e| FocrError::Other(anyhow::anyhow!("serialize action: {e}")))?;
let log = self.dir.join("actions.jsonl");
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log)
.map_err(|e| FocrError::Other(anyhow::anyhow!("open {}: {e}", log.display())))?;
writeln!(f, "{line}")
.map_err(|e| FocrError::Other(anyhow::anyhow!("append action: {e}")))?;
f.sync_all().ok();
self.actions.push(action);
Ok(())
}
#[cfg(unix)]
pub fn chmod_readable(&mut self, path: &Path) -> FocrResult<()> {
use std::os::unix::fs::PermissionsExt;
self.guard_blast_radius(path)?;
let before_mode = std::fs::metadata(path)
.map_err(|e| FocrError::Other(anyhow::anyhow!("stat {}: {e}", path.display())))?
.permissions()
.mode()
& 0o7777;
let before_hash = sha256_file(path);
let new_mode = before_mode | 0o600;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(new_mode))
.map_err(|e| FocrError::Other(anyhow::anyhow!("chmod {}: {e}", path.display())))?;
let backup = self.backup_name(path);
std::fs::copy(path, self.backups_dir().join(&backup))
.map_err(|e| FocrError::Other(anyhow::anyhow!("backup {}: {e}", path.display())))?;
let after_hash = sha256_file(path);
self.record(Action {
schema_version: DOCTOR_SCHEMA_VERSION,
op: "chmod_u_rw".into(),
path: path.display().to_string(),
before_hash,
after_hash,
before_mode: Some(before_mode),
after_mode: Some(new_mode),
backup: Some(backup),
})
}
pub fn quarantine(&mut self, path: &Path) -> FocrResult<()> {
self.guard_blast_radius(path)?;
let before_hash = sha256_file(path);
let backup = self.backup_name(path);
let dest = self.backups_dir().join(&backup);
std::fs::rename(path, &dest).map_err(|e| {
FocrError::Other(anyhow::anyhow!("quarantine {}: {e}", path.display()))
})?;
self.record(Action {
schema_version: DOCTOR_SCHEMA_VERSION,
op: "quarantine".into(),
path: path.display().to_string(),
before_hash: before_hash.clone(),
after_hash: "absent".into(),
before_mode: None,
after_mode: None,
backup: Some(backup),
})
}
pub fn rollback(&mut self, root: &DoctorRoot) -> FocrResult<usize> {
let actions = std::mem::take(&mut self.actions);
let n = actions.len();
undo_actions(root, &self.dir, actions.into_iter().rev())?;
Ok(n)
}
}
impl Drop for DoctorRun {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.lock);
}
}
pub fn undo_actions<I>(root: &DoctorRoot, run_dir: &Path, actions: I) -> FocrResult<()>
where
I: Iterator<Item = Action>,
{
for a in actions {
let path = PathBuf::from(&a.path);
if !path.starts_with(&root.cache_root) {
return Err(FocrError::Other(anyhow::anyhow!(
"undo refused: {} outside blast radius",
a.path
)));
}
match a.op.as_str() {
"quarantine" => {
let backup = a.backup.as_deref().ok_or_else(|| {
FocrError::Other(anyhow::anyhow!(
"undo failed closed: quarantine of {} has no backup",
a.path
))
})?;
let src = run_dir.join("backups").join(backup);
if !src.exists() {
return Err(FocrError::Other(anyhow::anyhow!(
"undo failed closed: backup {} missing",
src.display()
)));
}
std::fs::rename(&src, &path).map_err(|e| {
FocrError::Other(anyhow::anyhow!("restore {}: {e}", a.path))
})?;
let restored = sha256_file(&path);
if restored != a.before_hash {
return Err(FocrError::Other(anyhow::anyhow!(
"undo verification failed: {} restored hash {} != recorded {}",
a.path,
restored,
a.before_hash
)));
}
}
"chmod_u_rw" => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = a.before_mode.ok_or_else(|| {
FocrError::Other(anyhow::anyhow!(
"undo failed closed: chmod of {} has no before_mode",
a.path
))
})?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))
.map_err(|e| {
FocrError::Other(anyhow::anyhow!("restore mode {}: {e}", a.path))
})?;
}
}
other => {
return Err(FocrError::Other(anyhow::anyhow!(
"undo failed closed: unknown op {other:?} in actions.jsonl"
)));
}
}
}
Ok(())
}
pub fn load_actions(root: &DoctorRoot, run_id: &str) -> FocrResult<(PathBuf, Vec<Action>)> {
let dir = root.runs_dir().join(run_id);
let log = dir.join("actions.jsonl");
let text = std::fs::read_to_string(&log)
.map_err(|e| FocrError::Other(anyhow::anyhow!("no run {run_id}: {e}")))?;
let mut actions = Vec::new();
for (i, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let a: Action = serde_json::from_str(line).map_err(|e| {
FocrError::Other(anyhow::anyhow!("actions.jsonl line {}: {e}", i + 1))
})?;
actions.push(a);
}
Ok((dir, actions))
}
}
#[derive(Debug, Serialize)]
pub struct FixReport {
pub run_id: String,
pub fixed: usize,
pub refused: usize,
pub advice_only: usize,
pub failed_rolled_back: bool,
pub exit_code: i32,
}
pub fn fix(root: &DoctorRoot, findings: &[Finding], run_id: &str) -> FocrResult<FixReport> {
let mut run = mutation::DoctorRun::begin(root, run_id)?;
let mut fixed = 0usize;
let mut refused = 0usize;
let mut advice = 0usize;
for f in findings {
match &f.fixability {
Fixability::Auto { op } => {
let path = f.path.as_deref().map(PathBuf::from).ok_or_else(|| {
FocrError::Other(anyhow::anyhow!("auto finding without a path"))
})?;
let applied = match op.as_str() {
#[cfg(unix)]
"chmod_u_rw" => run.chmod_readable(&path),
"quarantine" => run.quarantine(&path),
other => Err(FocrError::Other(anyhow::anyhow!("unknown auto op {other}"))),
};
match applied {
Ok(()) => fixed += 1,
Err(e) => {
let undone = run.rollback(root)?;
eprintln!("focr doctor: fix failed ({e}); rolled back {undone} action(s)");
return Ok(FixReport {
run_id: run.id.clone(),
fixed: 0,
refused,
advice_only: advice,
failed_rolled_back: true,
exit_code: EXIT_FAILED_ROLLED_BACK,
});
}
}
}
Fixability::RefusedUnsafe { .. } => refused += 1,
Fixability::AdviceOnly { .. } => advice += 1,
}
}
let exit_code = if fixed > 0 && refused == 0 && advice == 0 {
EXIT_HEALTHY
} else if fixed > 0 {
EXIT_PARTIAL
} else if refused > 0 {
EXIT_REFUSED_UNSAFE
} else if advice > 0 {
EXIT_PARTIAL
} else {
EXIT_HEALTHY
};
Ok(FixReport {
run_id: run.id.clone(),
fixed,
refused,
advice_only: advice,
failed_rolled_back: false,
exit_code,
})
}
pub fn undo(root: &DoctorRoot, run_id: &str) -> FocrResult<usize> {
let (dir, actions) = mutation::load_actions(root, run_id)?;
let n = actions.len();
mutation::undo_actions(root, &dir, actions.into_iter().rev())?;
Ok(n)
}
#[must_use]
pub fn capabilities() -> serde_json::Value {
serde_json::json!({
"schema_version": DOCTOR_SCHEMA_VERSION,
"command": "doctor.capabilities",
"contract_version": 1,
"detectors": [
{"name": "model_not_resolvable", "fix": "advice_only"},
{"name": "stale_focrq_format", "fix": "refused_unsafe (re-quantization is irreversible; exact command recommended)"},
{"name": "unreadable_cache_entry", "fix": "auto (chmod_u_rw, mode+bytes backed up)", "platform": "unix"},
{"name": "orphaned_partial_download", "fix": "auto (quarantine into the run's backups; reversible)"},
],
"fixers": ["chmod_u_rw", "quarantine"],
"exit_codes": {
"0": "healthy / all fixed",
"1": "findings present (detect-only)",
"2": "partial fix",
"3": "fix failed and was rolled back",
"4": "refused unsafe (nothing auto-fixable)",
"5": "concurrency lost (doctor lock held)",
},
"mutation_contract": "single chokepoint: backup-first into .doctor/runs/<run-id>/backups/, before/after SHA-256 + mode in actions.jsonl, one lock per run; blast radius = the user cache root only",
"undo": "focr doctor undo <run-id> # byte-for-byte restore, hash-verified, fails closed",
"env": ["HOME (cache root)", "FOCR_MODEL_PATH", "FOCR_MODEL_DIR"],
"run_artifact_schema_version": DOCTOR_SCHEMA_VERSION,
})
}
#[must_use]
pub fn robot_docs() -> String {
let mut s = String::new();
let _ = writeln!(
s,
"# focr doctor — agent handbook (contract v{DOCTOR_SCHEMA_VERSION})"
);
let _ = writeln!(
s,
"\nfocr doctor # detect-only. exit 0 healthy, 1 findings"
);
let _ = writeln!(
s,
"focr doctor --json # same, one JSON object on stdout"
);
let _ = writeln!(
s,
"focr doctor --dry-run # worst-case blast radius, NO mutation"
);
let _ = writeln!(
s,
"focr doctor --fix # safe repairs only. exit 0/2/3/4/5 (see capabilities)"
);
let _ = writeln!(
s,
"focr doctor undo <run-id> # byte-for-byte restore, hash-verified"
);
let _ = writeln!(
s,
"focr doctor capabilities --json # the full contract, from the tool"
);
let _ = writeln!(
s,
"\nRules: detect-then-fix; every write is backed up first under"
);
let _ = writeln!(
s,
".doctor/runs/<run-id>/backups/ with SHA-256s in actions.jsonl;"
);
let _ = writeln!(
s,
"irreversible repairs are REFUSED with the exact command to run instead;"
);
let _ = writeln!(s, "blast radius is the user cache root only.");
s
}
#[cfg(test)]
mod pull_coordination_tests {
use super::*;
#[test]
fn live_pull_staging_is_not_reported_as_orphaned() {
let root =
std::env::temp_dir().join(format!("focr_doctor_pull_lock_{}", std::process::id()));
std::fs::create_dir_all(&root).expect("create test root");
let key = "a".repeat(32);
let lock_path = root.join(format!(".focr-pull-{key}.lock"));
let stage_path = root.join(format!(
".focr-stage-{key}-{}-0.partial",
std::process::id()
));
let holder = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.expect("open lock");
holder.try_lock().expect("hold pull lock");
std::fs::write(&stage_path, b"in progress").expect("write staging file");
assert!(staging_download_is_active(&stage_path));
drop(holder);
assert!(
!staging_download_is_active(&stage_path),
"descriptor release must make crash-left staging discoverable"
);
std::fs::remove_file(stage_path).expect("remove stage");
std::fs::remove_file(lock_path).expect("remove lock");
std::fs::remove_dir(root).expect("remove test root");
}
#[test]
fn nested_model_staging_is_detected_after_pull_crash() {
let root =
std::env::temp_dir().join(format!("focr_doctor_nested_stage_{}", std::process::id()));
let model = root.join("got-ocr2");
std::fs::create_dir_all(&model).expect("create nested model directory");
let stage = model.join(format!(
".focr-stage-{}-{}-0.partial",
"b".repeat(32),
std::process::id()
));
std::fs::write(&stage, b"crash-left bytes").expect("write nested stage");
let mut findings = Vec::new();
detect_orphaned_partials_in_dir(&model, &mut findings);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].detector, "orphaned_partial_download");
assert_eq!(findings[0].path.as_deref(), stage.to_str());
std::fs::remove_file(stage).expect("remove nested stage");
std::fs::remove_dir(model).expect("remove nested model directory");
std::fs::remove_dir(root).expect("remove nested test root");
}
}