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 document_runs_dir(&self) -> PathBuf {
self.cache_root.join("document-runs")
}
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_sidecar_tokenizers(root, &mut findings);
detect_unreadable_cache_entries(root, &mut findings);
detect_unloadable_installed_models(root, &mut findings);
detect_orphaned_partials(root, &mut findings);
detect_package_transactions(root, &mut findings);
detect_document_runs(root, &mut findings);
findings
}
fn detect_document_runs(root: &DoctorRoot, out: &mut Vec<Finding>) {
use crate::native_engine::checkpoint::{KEYED_CHECKPOINT, KEYED_JOURNAL, journal_summary};
let Ok(entries) = std::fs::read_dir(root.document_runs_dir()) else {
return;
};
let mut runs: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
runs.sort();
for run in runs {
let (journal, checkpoint) = (run.join(KEYED_JOURNAL), run.join(KEYED_CHECKPOINT));
if journal.exists() {
let (message, fixability) = match journal_summary(&journal) {
Ok(summary) => (
format!(
"an interrupted document run (or one still running): {} pages in {} \
windows, {}",
summary.pages,
summary.windows,
if checkpoint.exists() {
"with its checkpoint"
} else {
"no checkpoint, so only `--resume=recompute` can continue it"
}
),
match summary.command {
Some(command) => Fixability::RefusedUnsafe {
recommended_command: command,
},
None => Fixability::AdviceOnly {
hint: "re-run the command that started it".into(),
},
},
),
Err(e) => (
format!("an interrupted document run whose journal does not read: {e}"),
Fixability::AdviceOnly {
hint: "re-run the command with `--resume=recompute`, or remove the \
directory to start over"
.into(),
},
),
};
out.push(Finding {
detector: "interrupted_document_run",
severity: "warn",
path: Some(journal.display().to_string()),
message,
fixability,
});
} else if checkpoint.exists() {
out.push(Finding {
detector: "kept_document_checkpoint",
severity: "info",
path: Some(checkpoint.display().to_string()),
message: "a finished document run's checkpoint (`--keep-checkpoint`)".into(),
fixability: Fixability::AdviceOnly {
hint: "nothing reads it; remove the directory when you are done with it".into(),
},
});
}
}
}
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 model_cache_dirs(root: &DoctorRoot) -> Vec<PathBuf> {
let models = root.models_dir();
let packages = std::fs::read_dir(&models)
.into_iter()
.flatten()
.flatten()
.filter(|entry| !entry.file_name().as_encoded_bytes().starts_with(b"."))
.map(|entry| entry.path())
.filter(|path| {
std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
});
let mut dirs: Vec<PathBuf> = packages.collect();
dirs.sort();
dirs.insert(0, models);
dirs
}
fn detect_stale_focrq_format(root: &DoctorRoot, out: &mut Vec<Finding>) {
let entries = model_cache_dirs(root)
.into_iter()
.filter_map(|dir| std::fs::read_dir(dir).ok())
.flatten();
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}`)"
),
},
});
}
}
}
}
fn detect_sidecar_tokenizers(root: &DoctorRoot, out: &mut Vec<Finding>) {
let entries = model_cache_dirs(root)
.into_iter()
.filter_map(|dir| std::fs::read_dir(dir).ok())
.flatten();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || !name.ends_with(".focrq") {
continue;
}
let Some(fault) = crate::native_engine::sidecar_tokenizer_fault(&entry.path()) else {
continue;
};
let model = fault.model_id;
let (detector, message) = match (&fault.refusal, fault.owner) {
(None, _) => (
"missing_tokenizer",
format!("{name} ({model}) has no tokenizer.json beside it"),
),
(Some(refusal), Some(owner)) => (
"tokenizer_mismatch",
format!("{name} ({model}) sits beside {owner}'s tokenizer.json: {refusal}"),
),
(Some(refusal), None) => ("tokenizer_mismatch", format!("{name} ({model}): {refusal}")),
};
out.push(Finding {
detector,
severity: "error",
path: Some(fault.tokenizer_path.display().to_string()),
message,
fixability: Fixability::RefusedUnsafe {
recommended_command: fault.recommended_command,
},
});
}
}
fn detect_unloadable_installed_models(root: &DoctorRoot, out: &mut Vec<Finding>) {
let models = root.models_dir();
let manifest = crate::dist::builtin_manifest().ok();
let published = |id: &str| {
manifest
.as_ref()
.is_some_and(|manifest| manifest.model == id || manifest.models.contains_key(id))
};
for package in model_cache_dirs(root)
.into_iter()
.filter(|dir| *dir != models)
{
let Some(id) = package.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(fault) = crate::native_engine::installed_model_fault(&models, id) else {
continue;
};
let artifact = fault
.artifact
.map(|artifact| artifact.display().to_string());
if artifact.is_some() && out.iter().any(|finding| finding.path == artifact) {
continue;
}
out.push(Finding {
detector: "installed_model_not_loadable",
severity: "error",
path: Some(artifact.unwrap_or_else(|| package.display().to_string())),
message: format!(
"{id} is installed but `focr ocr --model {id}` would not load it: {}",
fault.refusal
),
fixability: if published(id) {
Fixability::RefusedUnsafe {
recommended_command: format!("focr pull {id}"),
}
} else {
Fixability::AdviceOnly {
hint: format!("`focr pull` does not publish {id}; re-install its artifact"),
}
},
});
}
}
#[cfg(unix)]
fn detect_unreadable_cache_entries(root: &DoctorRoot, out: &mut Vec<Finding>) {
use std::os::unix::fs::PermissionsExt;
let entries = model_cache_dirs(root)
.into_iter()
.filter_map(|dir| std::fs::read_dir(dir).ok())
.flatten();
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,
};
crate::dist::try_lock_settled(&file).is_err()
}
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);
}
}
}
fn package_model_for_key(models: &Path, key: &str) -> Option<String> {
let installed = std::fs::read_dir(models)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
.filter(|name| !name.starts_with('.'));
crate::model_arch::registry()
.iter()
.map(|arch| arch.id().to_owned())
.chain(installed)
.find(|id| crate::dist::package_key(id) == key)
}
fn detect_package_transactions(root: &DoctorRoot, out: &mut Vec<Finding>) {
use crate::dist::PullState;
let models = root.models_dir();
let Ok(entries) = std::fs::read_dir(&models) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let Some(state) = crate::dist::parse_pull_state(&name) else {
continue;
};
if !std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir())
|| staging_download_is_active(&path)
{
continue;
}
let model = package_model_for_key(&models, state.key());
let command = format!("focr pull {}", model.as_deref().unwrap_or("<model>"));
let model = model.unwrap_or_else(|| "a model".to_owned());
let (detector, severity, message) = match state {
PullState::PackageStage { .. } => (
"abandoned_package_stage",
"warn",
format!(
"{name}: the {model} package an interrupted pull was assembling; `{command}` \
removes it"
),
),
PullState::PackageAside { .. } if models.join(&model).is_dir() => (
"superseded_package_left",
"warn",
format!(
"{name}: the previous {model} package, left by an interrupted pull after \
the new one was installed; `{command}` removes it"
),
),
PullState::PackageAside { .. } => (
"interrupted_package_install",
"error",
format!(
"{name}: an interrupted pull moved the installed {model} package aside \
and did not finish, so {model} is not installed; `{command}` restores it"
),
),
};
out.push(Finding {
detector,
severity,
path: Some(path.display().to_string()),
message,
fixability: Fixability::RefusedUnsafe {
recommended_command: command,
},
});
}
}
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": "missing_tokenizer", "fix": "refused_unsafe (`focr pull <model>` re-installs the package with its pinned tokenizer)"},
{"name": "tokenizer_mismatch", "fix": "refused_unsafe (`focr pull <model>` re-installs the package with its pinned tokenizer)"},
{"name": "unreadable_cache_entry", "fix": "auto (chmod_u_rw, mode+bytes backed up)", "platform": "unix"},
{"name": "installed_model_not_loadable", "fix": "refused_unsafe (`focr pull <model>` re-installs a published model; advice_only otherwise)"},
{"name": "orphaned_partial_download", "fix": "auto (quarantine into the run's backups; reversible)"},
{"name": "abandoned_package_stage", "fix": "refused_unsafe (`focr pull <model>` removes it under the package lock)"},
{"name": "superseded_package_left", "fix": "refused_unsafe (`focr pull <model>` removes it under the package lock)"},
{"name": "interrupted_package_install", "fix": "refused_unsafe (`focr pull <model>` restores the previous package under the package lock)"},
{"name": "interrupted_document_run", "fix": "refused_unsafe (the journal and checkpoint are resumable state; its command continues the run)"},
{"name": "kept_document_checkpoint", "fix": "advice_only"},
],
"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 document_run_tests {
use super::*;
use crate::native_engine::checkpoint::{Journal, KEYED_CHECKPOINT, KEYED_JOURNAL};
fn root(test: &str) -> DoctorRoot {
let cache_root =
std::env::temp_dir().join(format!("focr_doctor_{test}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_root);
std::fs::create_dir_all(&cache_root).expect("create test root");
DoctorRoot { cache_root }
}
fn write_journal(dir: &Path, command: &str) -> PathBuf {
std::fs::create_dir_all(dir).expect("run directory");
let path = dir.join(KEYED_JOURNAL);
let mut journal = Journal::create(&path).expect("journal");
journal
.append(&[serde_json::json!({"kind": "key", "key": {}, "command": command})])
.expect("key line");
path
}
#[test]
fn a_stale_run_is_reported_with_its_command_and_left_in_place() {
let root = root("stale");
let run = root.document_runs_dir().join("abc");
let journal = write_journal(&run, "focr ocr book.pdf --multi-page");
let checkpoint = run.join(KEYED_CHECKPOINT);
std::fs::write(&checkpoint, b"state").expect("checkpoint");
let findings: Vec<Finding> = detect(&root)
.into_iter()
.filter(|f| f.detector == "interrupted_document_run")
.collect();
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(
findings[0].fixability,
Fixability::RefusedUnsafe {
recommended_command: "focr ocr book.pdf --multi-page".into()
}
);
assert!(findings[0].message.contains("with its checkpoint"));
let report = fix(&root, &findings, "doctor-test").expect("the fix pass");
assert_eq!((report.fixed, report.refused), (0, 1));
assert_eq!(std::fs::read(&checkpoint).expect("kept"), b"state");
assert!(journal.exists());
}
#[test]
fn a_checkpoint_without_a_journal_is_a_finished_runs_and_a_damaged_journal_says_so() {
let root = root("kept");
let kept = root.document_runs_dir().join("kept");
std::fs::create_dir_all(&kept).expect("run directory");
std::fs::write(kept.join(KEYED_CHECKPOINT), b"state").expect("checkpoint");
let damaged = root.document_runs_dir().join("damaged");
std::fs::create_dir_all(&damaged).expect("run directory");
std::fs::write(damaged.join(KEYED_JOURNAL), b"not json\n").expect("journal");
let detected = detect(&root);
let names: Vec<&str> = detected.iter().map(|f| f.detector).collect();
assert!(names.contains(&"kept_document_checkpoint"), "{names:?}");
let run = detected
.iter()
.find(|f| f.detector == "interrupted_document_run")
.expect("the damaged run");
assert!(run.message.contains("does not read"), "{}", run.message);
assert!(matches!(run.fixability, Fixability::AdviceOnly { .. }));
}
}
#[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.pkg", 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::create_dir(&stage_path).expect("create stage");
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_dir(stage_path).expect("remove stage");
std::fs::remove_file(lock_path).expect("remove lock");
std::fs::remove_dir(root).expect("remove test root");
}
#[cfg(unix)]
#[test]
fn a_model_package_is_scanned_like_the_cache_root() {
use std::os::unix::fs::{PermissionsExt as _, symlink};
let cache_root =
std::env::temp_dir().join(format!("focr_doctor_packages_{}", std::process::id()));
let root = DoctorRoot {
cache_root: cache_root.clone(),
};
let models = root.models_dir();
let package = models.join("unlimited-ocr");
let pull_state = models.join(format!(".focr-prev-{}.pkg", "d".repeat(32)));
std::fs::create_dir_all(&package).expect("create package");
std::fs::create_dir_all(&pull_state).expect("create pull state");
symlink(&package, models.join("linked")).expect("link a package");
assert_eq!(
model_cache_dirs(&root),
[models.clone(), package.clone()],
"hidden directories and symlinks are not packages"
);
let mut stale = crate::native_engine::weights::FOCRQ_MAGIC.to_vec();
stale.extend_from_slice(
&(crate::native_engine::weights::FOCRQ_FORMAT_VERSION + 1).to_le_bytes(),
);
stale.extend_from_slice(&[0; 8]);
let artifact = package.join("unlimited-ocr.int8.focrq");
std::fs::write(&artifact, &stale).expect("write a stale artifact");
std::fs::write(pull_state.join("old.focrq"), &stale).expect("write into pull state");
let mut findings = Vec::new();
detect_stale_focrq_format(&root, &mut findings);
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].detector, "stale_focrq_format");
assert_eq!(findings[0].path.as_deref(), artifact.to_str());
let locked = package.join("tokenizer.json");
std::fs::write(&locked, b"{}").expect("write a tokenizer");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("drop read permission");
let mut findings = Vec::new();
detect_unreadable_cache_entries(&root, &mut findings);
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].detector, "unreadable_cache_entry");
assert_eq!(findings[0].path.as_deref(), locked.to_str());
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600))
.expect("restore read permission");
std::fs::remove_dir_all(cache_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");
}
}