From 237ada6e21d3bb23fd4607990ffa5c652df536da Mon Sep 17 00:00:00 2001 From: altair823 Date: Thu, 30 Apr 2026 05:17:11 +0000 Subject: [PATCH] p0-1: kb-app facade stubs + tracing init helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the kb-app crate (§7) as the single facade between UI crates (kb-cli / kb-tui / kb-desktop) and the rest of the workspace. Public surface mirrors the task spec exactly: - init_workspace(force) — XDG dir creation + config.toml seed; idempotent unless force=true. Honors XDG envs and tilde-expands the workspace root to $HOME/KnowledgeBase. - doctor() — emits a doctor.v1 report with config_loaded + data_dir_writable checks; downstream checks land in later phases. - ingest / list_docs / inspect_doc / inspect_chunk / search / ask — bail!("not yet wired (P-)") so kb-cli surfaces exit code 2 cleanly per §10. - AskOpts + DoctorReport + DoctorCheck. - doctor_signal::{DoctorUnhealthy, RefusalSignal, NoHitSignal} — signal types the CLI downcasts on for §10 exit-code mapping. - logging::init() — daily-rolling file appender at $XDG_STATE_HOME/kb/logs/kb.log, plus stderr-fallback EnvFilter. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kb-app/Cargo.toml | 21 ++++ crates/kb-app/src/doctor_signal.rs | 39 +++++++ crates/kb-app/src/lib.rs | 173 +++++++++++++++++++++++++++++ crates/kb-app/src/logging.rs | 42 +++++++ 4 files changed, 275 insertions(+) create mode 100644 crates/kb-app/Cargo.toml create mode 100644 crates/kb-app/src/doctor_signal.rs create mode 100644 crates/kb-app/src/lib.rs create mode 100644 crates/kb-app/src/logging.rs diff --git a/crates/kb-app/Cargo.toml b/crates/kb-app/Cargo.toml new file mode 100644 index 0000000..f9857de --- /dev/null +++ b/crates/kb-app/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "kb-app" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +description = "Facade — orchestrates components for kb-cli/tui/desktop" + +[dependencies] +kb-core = { path = "../kb-core" } +kb-config = { path = "../kb-config" } +anyhow = { workspace = true } +thiserror = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +tracing-appender = "0.2" +toml = "0.8" +dirs = "5" diff --git a/crates/kb-app/src/doctor_signal.rs b/crates/kb-app/src/doctor_signal.rs new file mode 100644 index 0000000..d83c8e2 --- /dev/null +++ b/crates/kb-app/src/doctor_signal.rs @@ -0,0 +1,39 @@ +//! Signal types used by `kb-cli`'s `exit_code` mapping (§10). +//! +//! These are *not* errors per se: a doctor failure is normal output, just +//! signalled out-of-band so the CLI can exit with the right status. + +use std::fmt; + +#[derive(Debug)] +pub struct DoctorUnhealthy; + +impl fmt::Display for DoctorUnhealthy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("doctor unhealthy") + } +} + +impl std::error::Error for DoctorUnhealthy {} + +#[derive(Debug)] +pub struct RefusalSignal; + +impl fmt::Display for RefusalSignal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("refusal") + } +} + +impl std::error::Error for RefusalSignal {} + +#[derive(Debug)] +pub struct NoHitSignal; + +impl fmt::Display for NoHitSignal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("no hit") + } +} + +impl std::error::Error for NoHitSignal {} diff --git a/crates/kb-app/src/lib.rs b/crates/kb-app/src/lib.rs new file mode 100644 index 0000000..4c800ae --- /dev/null +++ b/crates/kb-app/src/lib.rs @@ -0,0 +1,173 @@ +//! `kb-app` — facade that downstream `kb-cli` / `kb-tui` / `kb-desktop` +//! depend on (§7, §8). +//! +//! P0 implementations stub out — the signatures are frozen so that later +//! phases swap in real bodies without breaking call sites. + +use std::path::PathBuf; + +use anyhow::bail; +use serde::{Deserialize, Serialize}; + +use kb_core::{ + Answer, CanonicalDocument, Chunk, ChunkId, DocFilter, DocSummary, DocumentId, + IngestReport, SearchHit, SearchMode, SearchQuery, SourceScope, +}; + +pub mod doctor_signal; +pub mod logging; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AskOpts { + pub k: usize, + pub explain: bool, + pub mode: SearchMode, + pub temperature: Option, + pub seed: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DoctorReport { + /// Wire schema version label (`"doctor.v1"`). + pub schema_version: String, + pub ok: bool, + pub checks: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DoctorCheck { + pub name: String, + pub ok: bool, + pub detail: String, + pub hint: Option, +} + +/// Create XDG dirs and write a starter `config.toml`. Idempotent unless +/// `force=true` (which overwrites an existing config). +pub fn init_workspace(force: bool) -> anyhow::Result<()> { + let cfg_path = kb_config::Config::xdg_config_path(); + let data_dir = kb_config::Config::xdg_data_dir(); + let cache_dir = kb_config::Config::xdg_cache_dir(); + let state_dir = kb_config::Config::xdg_state_dir(); + + for d in [ + cfg_path.parent().map(PathBuf::from).unwrap_or_default(), + data_dir.clone(), + cache_dir, + state_dir.clone(), + state_dir.join("logs"), + ] { + if !d.as_os_str().is_empty() { + std::fs::create_dir_all(&d)?; + } + } + + let workspace_root = expand_tilde(&kb_config::Config::defaults().workspace.root); + std::fs::create_dir_all(&workspace_root)?; + + if !cfg_path.exists() || force { + let cfg = kb_config::Config::defaults(); + let toml_text = toml::to_string_pretty(&cfg)?; + std::fs::write(&cfg_path, toml_text)?; + } + + Ok(()) +} + +fn expand_tilde(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + if s == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + PathBuf::from(s) +} + +pub fn ingest(_scope: SourceScope, _summary_only: bool) -> anyhow::Result { + bail!("not yet wired (P1-2)") +} + +pub fn list_docs(_filter: DocFilter) -> anyhow::Result> { + bail!("not yet wired (P1-5)") +} + +pub fn inspect_doc(_id: &DocumentId) -> anyhow::Result { + bail!("not yet wired (P1-5)") +} + +pub fn inspect_chunk(_id: &ChunkId) -> anyhow::Result { + bail!("not yet wired (P1-5)") +} + +pub fn search(_query: SearchQuery) -> anyhow::Result> { + bail!("not yet wired (P3-1/P4-1)") +} + +pub fn ask(_query: &str, _opts: AskOpts) -> anyhow::Result { + bail!("not yet wired (P5-1)") +} + +/// Run the doctor checks. P0 emits `config_loaded` + `data_dir_writable` +/// (downstream checks land in later phases). +pub fn doctor() -> anyhow::Result { + tracing::debug!("doctor() invoked"); + let mut checks = Vec::new(); + + // config_loaded — defaults always load; from-file is best-effort. + let cfg_path = kb_config::Config::xdg_config_path(); + let (config_ok, config_detail) = if cfg_path.exists() { + match kb_config::Config::from_file(&cfg_path) { + Ok(_) => (true, cfg_path.display().to_string()), + Err(e) => (false, format!("{} ({e})", cfg_path.display())), + } + } else { + // Defaults are always loadable; report the path that would be read. + (true, format!("{} (defaults)", cfg_path.display())) + }; + checks.push(DoctorCheck { + name: "config_loaded".to_string(), + ok: config_ok, + detail: config_detail, + hint: if config_ok { + None + } else { + Some("run `kb init` to seed config".to_string()) + }, + }); + + // data_dir_writable — try to create the dir and write a probe file. + let data_dir = kb_config::Config::xdg_data_dir(); + let writable = (|| -> anyhow::Result<()> { + std::fs::create_dir_all(&data_dir)?; + let probe = data_dir.join(".kb-doctor-probe"); + std::fs::write(&probe, b"ok")?; + std::fs::remove_file(&probe).ok(); + Ok(()) + })(); + let (data_ok, data_detail, data_hint) = match writable { + Ok(()) => (true, data_dir.display().to_string(), None), + Err(e) => ( + false, + format!("{} ({e})", data_dir.display()), + Some("ensure XDG_DATA_HOME is writable".to_string()), + ), + }; + checks.push(DoctorCheck { + name: "data_dir_writable".to_string(), + ok: data_ok, + detail: data_detail, + hint: data_hint, + }); + + let ok = checks.iter().all(|c| c.ok); + Ok(DoctorReport { + schema_version: "doctor.v1".to_string(), + ok, + checks, + }) +} diff --git a/crates/kb-app/src/logging.rs b/crates/kb-app/src/logging.rs new file mode 100644 index 0000000..4e5c755 --- /dev/null +++ b/crates/kb-app/src/logging.rs @@ -0,0 +1,42 @@ +//! Tracing initialization helper for `kb-cli`. +//! +//! Daily-rolling file appender at `~/.local/state/kb/logs/` per task spec. +//! Returns a `WorkerGuard` that the caller must keep alive until program +//! exit (so buffered log lines flush). + +use anyhow::Result; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LogLevel { + Default, + Verbose, + Debug, +} + +/// Initialize tracing. Returns a guard to keep alive until exit. Idempotent +/// — a second call is a no-op. +pub fn init(level: LogLevel) -> Result> { + let log_dir = kb_config::Config::xdg_state_dir().join("logs"); + std::fs::create_dir_all(&log_dir)?; + + let file_appender = tracing_appender::rolling::daily(&log_dir, "kb.log"); + let (nb, guard) = tracing_appender::non_blocking(file_appender); + + let env_filter = match level { + LogLevel::Default => EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")), + LogLevel::Verbose => EnvFilter::new("info"), + LogLevel::Debug => EnvFilter::new("debug"), + }; + + let registry = tracing_subscriber::registry() + .with(env_filter) + .with(fmt::layer().with_writer(nb).with_ansi(false)); + + // `try_init` rather than `init` so a second call (e.g. in tests) is a + // no-op. + let _ = registry.try_init(); + + Ok(Some(guard)) +}