use schemars::JsonSchema;
use serde::Serialize;
use crate::envelope::{Response, RuntimeFacts, RuntimeRestartEvent};
use crate::error::{Result, ServerError};
pub(crate) enum IncompleteCause {
MissingImports(Vec<String>),
MissingOlean(String),
}
pub(crate) struct CompetingDecl {
pub name: String,
pub namespace: Option<String>,
}
pub(crate) fn missing_olean_failure(err: &ServerError) -> bool {
let text = err.to_string().to_lowercase();
(text.contains(".olean")
&& (text.contains("does not exist") || text.contains("no such file") || text.contains("object file")))
|| text.contains("unknown module")
|| text.contains("unknown import")
|| text.contains("module not found")
}
pub(crate) const NEEDS_BUILD_STATUS: &str = "needs_build";
pub(crate) enum CallOutcome<T> {
Ready(T),
NeedsBuild(ServerError),
}
pub(crate) fn classify_missing_olean<T>(outcome: Result<T>) -> Result<CallOutcome<T>> {
match outcome {
Ok(value) => Ok(CallOutcome::Ready(value)),
Err(err) if missing_olean_failure(&err) => Ok(CallOutcome::NeedsBuild(err)),
Err(err) => Err(err),
}
}
pub(crate) fn needs_build_text(project_root: &str, cause: &IncompleteCause) -> (String, String) {
let detail = match cause {
IncompleteCause::MissingImports(missing) if !missing.is_empty() => {
format!(" (missing imports: {})", missing.join(", "))
}
IncompleteCause::MissingImports(_) => String::new(),
IncompleteCause::MissingOlean(text) => format!(" (blocked: {})", first_line(text)),
};
let warning = format!(
"the project may not be fully built, so this query ran against an incomplete environment. \
Run `lake build` in {project_root} and resolve errors, then retry.{detail}"
);
let next_action = match cause {
IncompleteCause::MissingImports(missing) if !missing.is_empty() => {
format!("lake build {} # then retry", missing.join(" "))
}
IncompleteCause::MissingImports(_) | IncompleteCause::MissingOlean(_) => {
"lake build # complete the project environment, then retry".to_owned()
}
};
(warning, next_action)
}
#[must_use]
pub(crate) fn warn_needs_build<T>(resp: Response<T>, cause: &IncompleteCause) -> Response<T>
where
T: Serialize + JsonSchema,
{
let (warning, next_action) = needs_build_text(&resp.freshness.project_root, cause);
resp.warn(warning).hint(next_action)
}
pub(crate) fn ambiguous_text(candidates: &[CompetingDecl]) -> (String, String) {
let listed = candidates
.iter()
.map(|c| match &c.namespace {
Some(ns) if !ns.is_empty() => format!("{} (namespace {ns})", c.name),
_ => c.name.clone(),
})
.collect::<Vec<_>>()
.join(", ");
(
format!("name is genuinely ambiguous; competing declarations: {listed}"),
"fully-qualify the name to one of the competing declarations".to_owned(),
)
}
#[must_use]
pub(crate) fn warn_ambiguous<T>(resp: Response<T>, candidates: &[CompetingDecl]) -> Response<T>
where
T: Serialize + JsonSchema,
{
if candidates.is_empty() {
return resp;
}
let (warning, next_action) = ambiguous_text(candidates);
resp.warn(warning).hint(next_action)
}
pub(crate) const WORKER_RECYCLED_STATUS: &str = "worker_recycled";
pub(crate) fn execution_taint(runtime: &RuntimeFacts) -> Option<&RuntimeRestartEvent> {
let event = runtime.call_restart.as_ref()?;
matches!(
event.cause.as_str(),
"rss_post_job"
| "rss_hard_limit_exceeded"
| "child_abort"
| "child_exit"
| "session_missing"
| "worker_internal"
| "timeout"
| "cancelled"
)
.then_some(event)
}
pub(crate) fn execution_taint_text(event: &RuntimeRestartEvent) -> (String, String) {
let rss = match (event.rss_kib, event.limit_kib) {
(Some(rss), Some(limit)) => format!(" (rss {rss} KiB vs limit {limit} KiB)"),
(Some(rss), None) => format!(" (rss {rss} KiB)"),
_ => String::new(),
};
let warning = format!(
"the worker was recycled or restarted during this call ({cause}){rss}; any non-positive outcome here — a \
rejection, `not_found`, a failed tactic, or empty goals — may be a casualty of the recycle rather than a real \
result, and is not trustworthy. Retry; if it persists, the module is too heavy for the worker's memory budget \
(raise LEAN_HOST_MCP_WORKER_RSS_POST_JOB_RESTART_KIB or verify with `lake build` / `lake env lean`).",
cause = event.cause,
);
let next_action = "retry; if it persists, verify with `lake build <module>` or `lake env lean <file>`".to_owned();
(warning, next_action)
}
#[must_use]
pub(crate) fn warn_execution_taint<T>(resp: Response<T>, event: &RuntimeRestartEvent) -> Response<T>
where
T: Serialize + JsonSchema,
{
let (warning, next_action) = execution_taint_text(event);
resp.warn(warning).hint(next_action)
}
fn first_line(text: &str) -> &str {
text.lines().next().unwrap_or(text).trim()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn missing_olean_failure_recognizes_build_artifacts_not_domain_errors() {
assert!(missing_olean_failure(&ServerError::Lean(
"object file '/tmp/Missing/Import.olean' does not exist".to_owned()
)));
assert!(missing_olean_failure(&ServerError::Lean(
"unknown module 'Definitely.Missing'".to_owned()
)));
assert!(!missing_olean_failure(&ServerError::Lean(
"unknown declaration Foo.bar".to_owned()
)));
}
#[test]
fn needs_build_text_names_lake_build_and_avoids_the_word_ambiguous() {
let (warning, next_action) = needs_build_text("/work/proj", &IncompleteCause::MissingImports(Vec::new()));
assert!(warning.contains("lake build"));
assert!(warning.contains("/work/proj"));
assert!(!warning.to_lowercase().contains("ambiguous"));
assert!(next_action.contains("lake build"));
}
#[test]
fn needs_build_text_lists_missing_imports_in_warning_and_action() {
let cause = IncompleteCause::MissingImports(vec!["Foo.Bar".to_owned(), "Baz".to_owned()]);
let (warning, next_action) = needs_build_text("/work/proj", &cause);
assert!(warning.contains("Foo.Bar"));
assert!(warning.contains("missing imports"));
assert!(next_action.contains("lake build Foo.Bar Baz"));
}
#[test]
fn needs_build_text_includes_the_blocking_olean_for_missing_olean() {
let cause = IncompleteCause::MissingOlean("object file '/p/Foo.olean' does not exist\nsecond line".to_owned());
let (warning, _) = needs_build_text("/work/proj", &cause);
assert!(warning.contains("Foo.olean"));
assert!(!warning.contains("second line"));
}
#[test]
fn classify_missing_olean_routes_only_build_artifacts_to_needs_build() {
let missing: Result<()> = Err(ServerError::Lean(
"object file '/p/Dep.olean' of module Dep does not exist".to_owned(),
));
assert!(matches!(
classify_missing_olean(missing),
Ok(CallOutcome::NeedsBuild(_))
));
let other: Result<()> = Err(ServerError::Lean("worker thread gone".to_owned()));
assert!(classify_missing_olean(other).is_err());
assert!(matches!(classify_missing_olean(Ok(7)), Ok(CallOutcome::Ready(7))));
}
fn facts_with_restart(cause: &str) -> RuntimeFacts {
RuntimeFacts {
call_restart: Some(RuntimeRestartEvent {
cause: cause.to_owned(),
reason: format!("{cause} current_kib=7000000 limit_kib=5000000"),
worker_generation: 9,
planned: false,
rss_kib: Some(7_000_000),
limit_kib: Some(5_000_000),
}),
..RuntimeFacts::default()
}
}
#[test]
fn execution_taint_flags_job_disrupting_causes_only() {
for cause in [
"rss_post_job",
"rss_hard_limit_exceeded",
"child_abort",
"child_exit",
"session_missing",
"worker_internal",
"timeout",
"cancelled",
] {
assert!(
execution_taint(&facts_with_restart(cause)).is_some(),
"{cause} should taint the verdict"
);
}
for cause in [
"rss_import_switch",
"import_profile_switch",
"max_requests",
"max_imports",
"idle",
"explicit",
] {
assert!(
execution_taint(&facts_with_restart(cause)).is_none(),
"{cause} should not taint the verdict"
);
}
assert!(execution_taint(&RuntimeFacts::default()).is_none());
}
#[test]
fn execution_taint_text_names_cause_and_rss_and_offers_a_retry() {
let (warning, next_action) = execution_taint_text(&RuntimeRestartEvent {
cause: "rss_post_job".to_owned(),
reason: String::new(),
worker_generation: 1,
planned: false,
rss_kib: Some(7_600_000),
limit_kib: Some(5_242_880),
});
assert!(warning.contains("rss_post_job"));
assert!(warning.contains("7600000"));
assert!(warning.contains("5242880"));
assert!(warning.contains("not trustworthy"));
assert!(next_action.contains("retry"));
}
#[test]
fn ambiguous_text_names_each_competitor_with_its_namespace() {
let candidates = vec![
CompetingDecl {
name: "boundary.isPushout".to_owned(),
namespace: Some("SSet".to_owned()),
},
CompetingDecl {
name: "boundary.isPushout".to_owned(),
namespace: None,
},
];
let (warning, next_action) = ambiguous_text(&candidates);
assert!(warning.contains("ambiguous"));
assert!(warning.contains("namespace SSet"));
assert!(next_action.contains("fully-qualify"));
}
}