✨ feat(kebab-cli): error_classify dispatcher + wire helpers (fb-27)
`error_classify::classify` maps anyhow::Error → ErrorV1 wire record by downcasting to known typed errors (LlmError + ConfigInvalid + NotIndexed re-exported from kebab_app::error_signal, plus std::io::Error chain). Generic fallback emits `code: "generic"` with the chain in `details` when verbose. wire.rs adds wire_schema (idempotent re-tag, mirrors wire_doctor pattern since SchemaV1 carries its own schema_version field) and wire_error_v1 (simple tag_object). Tests pin both wrappers + 7 classify code paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3558,6 +3558,8 @@ dependencies = [
|
|||||||
"kebab-core",
|
"kebab-core",
|
||||||
"kebab-eval",
|
"kebab-eval",
|
||||||
"kebab-tui",
|
"kebab-tui",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"time",
|
"time",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ kebab-eval = { path = "../kebab-eval" }
|
|||||||
# launches it.
|
# launches it.
|
||||||
kebab-tui = { path = "../kebab-tui" }
|
kebab-tui = { path = "../kebab-tui" }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
# p9-fb-02: ingest progress UI.
|
# p9-fb-02: ingest progress UI.
|
||||||
@@ -43,3 +44,6 @@ ctrlc = "3"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
# llm_unreachable_classifies_to_model_unreachable test needs a real
|
||||||
|
# reqwest::Error (private constructor) — built from a connect-refused call.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
|||||||
182
crates/kebab-cli/src/error_classify.rs
Normal file
182
crates/kebab-cli/src/error_classify.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
//! Map `anyhow::Error` (returned by `kebab-app` facade calls) to the
|
||||||
|
//! `error.v1` wire shape. The classifier downcasts to known typed errors
|
||||||
|
//! re-exported via `kebab_app::error_signal` (LlmError, ConfigInvalid,
|
||||||
|
//! NotIndexed) and falls back to `code: "generic"` for everything else.
|
||||||
|
//!
|
||||||
|
//! Refusal / no-hit / doctor-unhealthy are NOT routed here — they remain
|
||||||
|
//! exit-code-only signals (see main.rs `exit_code()`).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use kebab_app::error_signal::{ConfigInvalid, LlmError, NotIndexed};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorV1 {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
pub details: Value,
|
||||||
|
pub hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 {
|
||||||
|
if let Some(s) = err.downcast_ref::<ConfigInvalid>() {
|
||||||
|
return ErrorV1 {
|
||||||
|
code: "config_invalid".to_string(),
|
||||||
|
message: s.to_string(),
|
||||||
|
details: json!({
|
||||||
|
"path": s.path.to_string_lossy(),
|
||||||
|
"cause": s.cause,
|
||||||
|
}),
|
||||||
|
hint: Some("check `--config <path>` and TOML syntax".to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(s) = err.downcast_ref::<NotIndexed>() {
|
||||||
|
return ErrorV1 {
|
||||||
|
code: "not_indexed".to_string(),
|
||||||
|
message: s.to_string(),
|
||||||
|
details: json!({
|
||||||
|
"expected": s.expected,
|
||||||
|
"found": s.found,
|
||||||
|
}),
|
||||||
|
hint: Some("run `kebab init` then `kebab ingest`".to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(s) = err.downcast_ref::<LlmError>() {
|
||||||
|
return classify_llm(s);
|
||||||
|
}
|
||||||
|
if let Some(io) = err.downcast_ref::<std::io::Error>() {
|
||||||
|
return ErrorV1 {
|
||||||
|
code: "io_error".to_string(),
|
||||||
|
message: io.to_string(),
|
||||||
|
details: json!({"kind": format!("{:?}", io.kind())}),
|
||||||
|
hint: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut details = json!({});
|
||||||
|
if verbose {
|
||||||
|
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
|
||||||
|
details = json!({"chain": chain});
|
||||||
|
}
|
||||||
|
ErrorV1 {
|
||||||
|
code: "generic".to_string(),
|
||||||
|
message: err.to_string(),
|
||||||
|
details,
|
||||||
|
hint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_llm(s: &LlmError) -> ErrorV1 {
|
||||||
|
match s {
|
||||||
|
LlmError::Unreachable { endpoint, source } => ErrorV1 {
|
||||||
|
code: "model_unreachable".to_string(),
|
||||||
|
message: format!("ollama unreachable at {endpoint}"),
|
||||||
|
details: json!({
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"source": source.to_string(),
|
||||||
|
}),
|
||||||
|
hint: Some(format!("ensure `ollama serve` is reachable at {endpoint}")),
|
||||||
|
},
|
||||||
|
LlmError::ModelNotPulled(model) => ErrorV1 {
|
||||||
|
code: "model_not_pulled".to_string(),
|
||||||
|
message: format!("ollama model `{model}` is not pulled"),
|
||||||
|
details: json!({"model": model}),
|
||||||
|
hint: Some(format!("run `ollama pull {model}`")),
|
||||||
|
},
|
||||||
|
LlmError::Timeout(e) => ErrorV1 {
|
||||||
|
code: "timeout".to_string(),
|
||||||
|
message: format!("ollama timeout: {e}"),
|
||||||
|
details: json!({"source": e.to_string()}),
|
||||||
|
hint: Some("increase timeout or check Ollama load".to_string()),
|
||||||
|
},
|
||||||
|
LlmError::Stream(body) => ErrorV1 {
|
||||||
|
code: "generic".to_string(),
|
||||||
|
message: format!("ollama HTTP error: {body}"),
|
||||||
|
details: json!({"body": body}),
|
||||||
|
hint: None,
|
||||||
|
},
|
||||||
|
LlmError::Malformed(line) => ErrorV1 {
|
||||||
|
code: "generic".to_string(),
|
||||||
|
message: format!("malformed response line: {line}"),
|
||||||
|
details: json!({"line": line}),
|
||||||
|
hint: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_invalid_classifies_to_config_invalid_code() {
|
||||||
|
let err = anyhow::Error::new(ConfigInvalid {
|
||||||
|
path: std::path::PathBuf::from("/tmp/x.toml"),
|
||||||
|
cause: "missing".to_string(),
|
||||||
|
});
|
||||||
|
let v1 = classify(&err, false);
|
||||||
|
assert_eq!(v1.code, "config_invalid");
|
||||||
|
assert_eq!(v1.details.get("path").and_then(|p| p.as_str()), Some("/tmp/x.toml"));
|
||||||
|
assert!(v1.hint.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_indexed_classifies_correctly() {
|
||||||
|
let err = anyhow::Error::new(NotIndexed {
|
||||||
|
expected: "/data/k.sqlite".to_string(),
|
||||||
|
found: None,
|
||||||
|
});
|
||||||
|
let v1 = classify(&err, false);
|
||||||
|
assert_eq!(v1.code, "not_indexed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn llm_unreachable_classifies_to_model_unreachable() {
|
||||||
|
// We cannot construct a reqwest::Error from scratch (private constructor).
|
||||||
|
// Use a real network call with a guaranteed-unroutable endpoint:
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_millis(50))
|
||||||
|
.build().unwrap();
|
||||||
|
let err = client.get("http://127.0.0.1:1").send().unwrap_err();
|
||||||
|
let llm = LlmError::Unreachable {
|
||||||
|
endpoint: "http://127.0.0.1:1".to_string(),
|
||||||
|
source: err,
|
||||||
|
};
|
||||||
|
let anyhow_err = anyhow::Error::new(llm);
|
||||||
|
let v1 = classify(&anyhow_err, false);
|
||||||
|
assert_eq!(v1.code, "model_unreachable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_not_pulled_classifies_correctly() {
|
||||||
|
let llm = LlmError::ModelNotPulled("gemma4:e4b".to_string());
|
||||||
|
let v1 = classify(&anyhow::Error::new(llm), false);
|
||||||
|
assert_eq!(v1.code, "model_not_pulled");
|
||||||
|
assert_eq!(v1.details.get("model").and_then(|p| p.as_str()), Some("gemma4:e4b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_error_classifies_to_generic() {
|
||||||
|
let err = anyhow::anyhow!("something else");
|
||||||
|
let v1 = classify(&err, false);
|
||||||
|
assert_eq!(v1.code, "generic");
|
||||||
|
assert!(v1.hint.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_with_verbose_includes_chain() {
|
||||||
|
let err = anyhow::anyhow!("root").context("middle").context("leaf");
|
||||||
|
let v1 = classify(&err, true);
|
||||||
|
assert_eq!(v1.code, "generic");
|
||||||
|
let chain = v1.details.get("chain").and_then(|c| c.as_array()).unwrap();
|
||||||
|
assert_eq!(chain.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn io_error_classifies_correctly() {
|
||||||
|
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
|
||||||
|
let err = anyhow::Error::new(io);
|
||||||
|
let v1 = classify(&err, false);
|
||||||
|
assert_eq!(v1.code, "io_error");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use clap::{Parser, Subcommand};
|
|||||||
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
||||||
|
|
||||||
mod cancel;
|
mod cancel;
|
||||||
|
mod error_classify;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,35 @@ pub fn wire_ingest_progress(
|
|||||||
Ok(tag_object(v, "ingest_progress.v1"))
|
Ok(tag_object(v, "ingest_progress.v1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wrap a [`kebab_app::SchemaV1`] as `schema.v1`.
|
||||||
|
///
|
||||||
|
/// Uses the idempotent re-tag pattern (mirrors `wire_doctor`) because
|
||||||
|
/// `SchemaV1` already carries `schema_version: "schema.v1"` as a struct
|
||||||
|
/// field. The re-tag is a defensive no-op when the field is present; it
|
||||||
|
/// stamps the correct version if a future refactor ever drops the field.
|
||||||
|
pub fn wire_schema(s: &kebab_app::SchemaV1) -> Value {
|
||||||
|
let v = serde_json::to_value(s).expect("SchemaV1 serializes");
|
||||||
|
if let Value::Object(ref map) = v {
|
||||||
|
if matches!(
|
||||||
|
map.get("schema_version"),
|
||||||
|
Some(Value::String(s)) if s == "schema.v1"
|
||||||
|
) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_object(v, "schema.v1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an [`crate::error_classify::ErrorV1`] as `error.v1`.
|
||||||
|
///
|
||||||
|
/// Uses the simple `tag_object` pattern because `ErrorV1` is a
|
||||||
|
/// kebab-cli-local type that does NOT carry `schema_version` itself
|
||||||
|
/// (kebab-core convention).
|
||||||
|
pub fn wire_error_v1(e: &crate::error_classify::ErrorV1) -> Value {
|
||||||
|
let v = serde_json::to_value(e).expect("ErrorV1 serializes");
|
||||||
|
tag_object(v, "error.v1")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -200,6 +229,51 @@ mod tests {
|
|||||||
assert_eq!(schema_of(&tagged), Some("x.v1"));
|
assert_eq!(schema_of(&tagged), Some("x.v1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_wrapper_tags_schema_version() {
|
||||||
|
use kebab_app::{Capabilities, Models, SchemaV1, Stats, WireBlock};
|
||||||
|
let schema = SchemaV1 {
|
||||||
|
schema_version: "schema.v1".to_string(),
|
||||||
|
kebab_version: "0.2.1".to_string(),
|
||||||
|
wire: WireBlock { schemas: vec!["answer.v1".to_string()] },
|
||||||
|
capabilities: Capabilities {
|
||||||
|
json_mode: true, ingest_progress: true, ingest_cancellation: true,
|
||||||
|
rag_multi_turn: true, search_cache: true, incremental_ingest: true,
|
||||||
|
streaming_ask: false, http_daemon: false, mcp_server: false,
|
||||||
|
single_file_ingest: false,
|
||||||
|
},
|
||||||
|
models: Models {
|
||||||
|
parser_version: "x".to_string(),
|
||||||
|
chunker_version: "y".to_string(),
|
||||||
|
embedding_version: "z".to_string(),
|
||||||
|
prompt_template_version: "w".to_string(),
|
||||||
|
index_version: "v".to_string(),
|
||||||
|
corpus_revision: 7,
|
||||||
|
},
|
||||||
|
stats: Stats {
|
||||||
|
doc_count: 1, chunk_count: 2, asset_count: 1,
|
||||||
|
last_ingest_at: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let v = wire_schema(&schema);
|
||||||
|
assert_eq!(schema_of(&v), Some("schema.v1"));
|
||||||
|
assert_eq!(v.get("kebab_version").and_then(Value::as_str), Some("0.2.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_wrapper_tags_schema_version_and_emits_code() {
|
||||||
|
use crate::error_classify::ErrorV1;
|
||||||
|
let err = ErrorV1 {
|
||||||
|
code: "config_invalid".to_string(),
|
||||||
|
message: "bad config".to_string(),
|
||||||
|
details: serde_json::json!({"path": "/tmp/x"}),
|
||||||
|
hint: Some("check the path".to_string()),
|
||||||
|
};
|
||||||
|
let v = wire_error_v1(&err);
|
||||||
|
assert_eq!(schema_of(&v), Some("error.v1"));
|
||||||
|
assert_eq!(v.get("code").and_then(Value::as_str), Some("config_invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reset_wrapper_tags_schema_version_and_serializes_scope() {
|
fn reset_wrapper_tags_schema_version_and_serializes_scope() {
|
||||||
let r = kebab_app::ResetReport {
|
let r = kebab_app::ResetReport {
|
||||||
|
|||||||
Reference in New Issue
Block a user