saya-cli 0.4.1

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
//! Stderr tracing for the post-turn extraction boundary.
//!
//! Without this there is no visibility into the only path that writes
//! memory — no log, no receipt, no counter — and two reviews were misled by the
//! rendered `memory learned ·` line as a result. This module is the durable
//! win: a one-line stderr trace at the `run_extraction` boundary, off by
//! default so production logs nothing. Two ways to turn it on: the
//! `SAYA_EXTRACTION_TRACE` env var, or `--verbose`, which seeds the same gate
//! at startup (see [`enable`]).
//!
//! What is logged — and what is not — is documented on [`trace_extraction`].

use std::sync::OnceLock;

/// Whether the boundary trace prints. Seeded either by [`enable`] (from
/// `--verbose`, before any turn runs) or lazily from the environment.
static ENABLED: OnceLock<bool> = OnceLock::new();

const MAX_TRACE_ERROR_BYTES: usize = 160;

/// Turns the trace on for this process, whatever the environment says.
///
/// Called once at startup when `--verbose` is passed. Seeding the gate is what
/// makes that flag reachable here: the alternative — threading a `verbose:
/// bool` through `run_prompt_with_sink` → `run_prompt_with_inputs`, every test
/// that calls it, and `RuntimeConfig` — is a lot of plumbing for one boolean,
/// and mutating the environment instead is `unsafe` under edition 2024. A
/// later `get_or_init` sees the value already set and does not consult the
/// environment, so the flag wins over an unset variable and agrees with a set
/// one.
pub(crate) fn enable() {
    let _ = ENABLED.set(true);
}

fn enabled() -> bool {
    *ENABLED
        .get_or_init(|| std::env::var_os("SAYA_EXTRACTION_TRACE").is_some_and(|v| !v.is_empty()))
}

/// Traces one post-turn extraction boundary event to stderr when
/// `SAYA_EXTRACTION_TRACE` is set (any non-empty value).
///
/// Logged:
/// - the outcome token (`ok` / `failed` / `timed_out` / `gate_declined`),
/// - the number of objects in the turn's object table,
/// - the count of proposals persisted (when extraction ran), and
/// - on error the `ExtractionRunnerError` display string (a category + short
///   message, not user data).
///
/// Never logged: the raw model response. It may contain user data (rows,
/// prompts), so it is excluded by construction — `run_extraction` does not
/// surface it here, and this trace does not print it even when enabled.
///
/// Two triggers, one gate. `SAYA_EXTRACTION_TRACE` matches the existing
/// debug-knob pattern in this crate (`SAYA_HISTORY`, `SAYA_STATE_DB`,
/// `SAYA_SESSION_DIR`) and is safe to leave set in a shell while debugging a
/// "memory didn't record" report. `--verbose` seeds the same gate at startup
/// via [`enable`], which is what a user reaches for first and what that flag
/// previously did not do — it was declared and read nowhere. Off by default,
/// so production logs nothing.
pub(crate) fn trace_extraction(
    outcome: &'static str,
    object_count: usize,
    proposal_count: Option<usize>,
    error: Option<&str>,
    elapsed: Option<std::time::Duration>,
) {
    if !enabled() {
        return;
    }
    eprintln!(
        "{}",
        format_trace_line(outcome, object_count, proposal_count, error, elapsed)
    );
}

fn format_trace_line(
    outcome: &'static str,
    object_count: usize,
    proposal_count: Option<usize>,
    error: Option<&str>,
    elapsed: Option<std::time::Duration>,
) -> String {
    let proposals = match proposal_count {
        Some(n) => format!(" proposals={n}"),
        None => String::new(),
    };
    let err = match error {
        Some(e) => format!(" error={}", redacted_trace_error(e)),
        None => String::new(),
    };
    // Duration is the extraction's real cost on a given gateway and model:
    // there is no wall-clock cap (owner decision), so this is how a slow
    // model's post-turn wait — and what the session breaker saw — is read.
    let ms = match elapsed {
        Some(d) => format!(" ms={}", d.as_millis()),
        None => String::new(),
    };
    format!("saya extraction: outcome={outcome} objects={object_count}{proposals}{err}{ms}")
}

fn redacted_trace_error(error: &str) -> String {
    let safe = saya_types::redact(error)
        .chars()
        .map(|ch| {
            if ch.is_control() && ch != '\t' {
                ' '
            } else {
                ch
            }
        })
        .collect::<String>();
    if safe.len() <= MAX_TRACE_ERROR_BYTES {
        return safe;
    }

    // Keep the cap byte-based while preserving UTF-8 boundaries. The ellipsis
    // makes truncation visible without retaining an unbounded provider detail.
    let marker = "…";
    let mut end = MAX_TRACE_ERROR_BYTES.saturating_sub(marker.len());
    while end > 0 && !safe.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}{}", &safe[..end], marker)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `--verbose` calls [`enable`] before any turn runs; the gate must then
    /// report on regardless of the environment. This is the whole point of the
    /// seeding design — the flag was previously declared and read nowhere, so
    /// a test that only exercised the env var would have passed against a dead
    /// flag.
    #[test]
    fn enable_seeds_the_gate_without_touching_the_environment() {
        enable();
        assert!(
            enabled(),
            "--verbose must turn the trace on even with SAYA_EXTRACTION_TRACE unset"
        );
        // Idempotent: a second call is a no-op, not a panic or a reset.
        enable();
        assert!(enabled());
    }

    #[test]
    fn trace_error_is_redacted_and_bounded() {
        let sentinel = "provider-json-secret-sentinel";
        let detail = format!(
            "extraction provider failed password={sentinel} {}\nterminal-noise",
            "x".repeat(MAX_TRACE_ERROR_BYTES * 2)
        );
        let line = format_trace_line("failed", 1, Some(0), Some(&detail), None);

        assert!(line.contains("password=[redacted]"));
        assert!(!line.contains(sentinel));
        assert!(!line.contains('\n'));
        assert!(
            line.len() < 320,
            "trace must remain bounded even for an oversized provider error"
        );
    }
}