mahbot 0.7.3

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
//! Onboarding flow: the Phase-1 scripted provider-setup scenario (GUI Home
//! chat) and the Phase-2 Assistant kickoff ("hi mah bot" auto-send).
//!
//! Phase 1 runs in the GUI Home chat while no LLM provider is configured
//! (`provider_configured() == false`): a hard-coded scripted scenario guides
//! the admin to enter an OpenRouter key or a custom endpoint+key, persisting
//! it via the public config path so the live in-memory CONFIG updates. The
//! scripted exchange is transient (never persisted to chat_history).
//!
//! Phase 2 fires once a provider is configured and the onboarding state is
//! `Init` for an admin: the kickoff persists `OnboardingState::Finished`
//! (closing the flow) and routes a single real "hi mah bot" user message
//! through the normal pipeline. The Assistant drives onboarding now (the
//! Support role is gone); the onboarding guide reaches the greeting session
//! via the pending-guide bridge, not the onboarding state.

use crate::ChannelMessage;
use crate::config::{
    CONFIG_KEY_PROVIDER_ENDPOINT, CONFIG_KEY_PROVIDER_ENDPOINT_KEY, CONFIG_KEY_PROVIDER_KEY,
};
use std::sync::atomic::{AtomicBool, Ordering};

/// Process-local CAS guard ensuring only one `kickoff_onboarding` attempt is
/// in flight per process; it is reset on a persist failure so a later kickoff
/// attempt can retry. The persisted `Finished` state is the durable guard
/// across restarts.
static KICKOFF_FIRED: AtomicBool = AtomicBool::new(false);

/// Bridge over the greeting race: `kickoff_onboarding` persists `Finished`
/// BEFORE the async pipeline builds the session that handles the greeting
/// (the greeting crosses many channel hops; the persist is a single fast
/// config_kv upsert), so `Session::build_context_messages` cannot rely on
/// `onboarding_stage() != Finished` alone to inject the onboarding guide.
/// The kickoff arms this flag; the first Assistant session built for the admin
/// afterwards consumes it and gets the guide — in practice the greeting
/// session, since nothing else builds one during the kickoff window. If the
/// greeting send fails, the next admin Assistant session receives the guide
/// instead (a reasonable fallback).
static GREETING_GUIDE_PENDING: AtomicBool = AtomicBool::new(false);

/// Arm the pending-guide bridge (see [`GREETING_GUIDE_PENDING`]).
pub fn mark_greeting_guide_pending() {
    GREETING_GUIDE_PENDING.store(true, Ordering::SeqCst);
}

/// Consume the pending-guide bridge; returns true exactly once per kickoff.
pub fn take_greeting_guide_pending() -> bool {
    GREETING_GUIDE_PENDING.swap(false, Ordering::SeqCst)
}

/// Parsed Phase-1 provider input.
#[derive(Debug)]
pub enum ProviderInput {
    /// An OpenRouter API key (a token containing `sk-or-v1-`).
    OpenRouterKey(String),
    /// A custom endpoint URL, with an optional per-endpoint key.
    CustomEndpoint { url: String, key: Option<String> },
    /// Input that is neither a recognized key nor a URL.
    Invalid,
}

/// Parse the user's Phase-1 provider entry.
///
/// Rules:
/// - OpenRouter key = a token containing `sk-or-v1-…`.
/// - Custom endpoint = URL on line 1, optional key on line 2.
/// - A bare URL alone (keyless custom endpoint) counts as configured.
#[must_use]
pub fn parse_provider_input(raw: &str) -> ProviderInput {
    let trimmed = raw.trim();
    let lines: Vec<&str> = trimmed
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty())
        .collect();
    if lines.len() >= 2 && is_http_url(lines[0]) {
        let key = lines[1].to_string();
        // A default OpenRouter endpoint + a `sk-or-v1-` key is an OpenRouter key:
        // the user meant "use OpenRouter", not a custom endpoint.
        if crate::config::is_default_endpoint(lines[0]) && key.contains("sk-or-v1-") {
            return ProviderInput::OpenRouterKey(key);
        }
        return custom_endpoint(lines[0], Some(key));
    }
    if lines.len() == 1 && is_http_url(lines[0]) {
        return custom_endpoint(lines[0], None);
    }
    // A line carrying a `sk-or-v1-` token is an OpenRouter key. Extract the key
    // line rather than the whole input, so a reversed entry (key line, then URL)
    // never persists a multi-line blob as the provider key.
    if let Some(key_line) = lines.iter().find(|l| l.contains("sk-or-v1-")) {
        return ProviderInput::OpenRouterKey(key_line.to_string());
    }
    ProviderInput::Invalid
}

/// Shared validation tail for both URL branches: reject the default endpoint
/// (a bare default URL, or a default URL with a non-OpenRouter key, can't
/// configure the provider — persisting it would never become configured) and
/// malformed URL lines; otherwise build the custom endpoint.
fn custom_endpoint(url_line: &str, key: Option<String>) -> ProviderInput {
    if crate::config::is_default_endpoint(url_line) {
        return ProviderInput::Invalid;
    }
    if !is_clean_url_line(url_line) {
        return ProviderInput::Invalid;
    }
    ProviderInput::CustomEndpoint {
        url: url_line.to_string(),
        key,
    }
}

// Deliberately case-insensitive, unlike the prefix-only `util::is_http_url`
// (whose case-sensitive contract config validation relies on) — onboarding
// accepts pasted endpoint URLs leniently regardless of scheme casing.
fn is_http_url(s: &str) -> bool {
    let lower = s.to_ascii_lowercase();
    lower.starts_with("http://") || lower.starts_with("https://")
}

/// A URL cannot contain a literal space, so a candidate URL line must be a
/// single whitespace-delimited token. A line with trailing junk (e.g. a key
/// pasted on the same line as the URL) is malformed.
fn is_clean_url_line(s: &str) -> bool {
    s.split_whitespace().count() == 1
}

/// Persist a parsed provider input via the public config path (updates the
/// live in-memory CONFIG and the `config_kv` row). Returns an error for
/// [`ProviderInput::Invalid`].
pub async fn persist_provider_input(input: &ProviderInput) -> anyhow::Result<()> {
    match input {
        ProviderInput::OpenRouterKey(key) => {
            crate::config::persist_settled_string_field(CONFIG_KEY_PROVIDER_KEY, key).await?;
        }
        ProviderInput::CustomEndpoint { url, key } => {
            if let Some(k) = key {
                crate::config::persist_settled_string_field(CONFIG_KEY_PROVIDER_ENDPOINT_KEY, k)
                    .await?;
            }
            // Persist the endpoint LAST — it is the gating field for
            // `provider_configured()`, so a failure here leaves the provider
            // unconfigured and the script re-prompts instead of proceeding with a
            // keyless endpoint the user did not intend.
            crate::config::persist_settled_string_field(CONFIG_KEY_PROVIDER_ENDPOINT, url).await?;
        }
        ProviderInput::Invalid => anyhow::bail!("invalid provider input"),
    }
    Ok(())
}

/// Phase-1 scripted intro messages (shown in the GUI Home chat).
#[must_use]
pub fn intro_messages() -> [&'static str; 2] {
    [
        "Welcome to MahBot! Before we start, I need an LLM provider to power your agents.",
        "Enter your OpenRouter API key (it starts with `sk-or-v1-`), or paste a custom endpoint. For a custom endpoint, put the URL on the FIRST line and your API key on the SECOND line (the key is optional).",
    ]
}

/// Phase-1 success message after the provider is configured.
#[must_use]
pub fn success_message() -> &'static str {
    "Provider configured! Setting up your admin Assistant — one moment."
}

/// Phase-1 re-prompt for unparseable input.
#[must_use]
pub fn invalid_message() -> &'static str {
    "That doesn't look right. Enter an OpenRouter key (starts with `sk-or-v1-`) or a custom endpoint URL (first line) + optional API key (second line)."
}

/// Phase-2 kickoff: if onboarding state is `Init` and a provider is
/// configured, persist `OnboardingState::Finished` (closing the flow) and
/// auto-send a single real "hi mah bot" user message through the normal GUI
/// pipeline. No-op when the state is not `Init`, when no provider is
/// configured, or when the user is not an admin.
///
/// Onboarding is strictly one-shot: the `== Init` gate plus the `KICKOFF_FIRED`
/// CAS make re-trigger impossible, even after a provider reconnection or a
/// second workspace — the state can only move `Init` → `Finished` and only this
/// route moves it. A guest NEVER triggers onboarding (none is planned
/// for them): the `is_admin` gate fails closed.
pub async fn kickoff_onboarding(user_name: &str) -> anyhow::Result<()> {
    use crate::config::OnboardingState;
    if crate::config::CONFIG.onboarding_stage() != OnboardingState::Init {
        return Ok(());
    }
    if !crate::config::provider_configured() {
        return Ok(());
    }
    // The onboarding flow is admin-only: bail before touching the
    // global state when the triggering user is not an admin (fails closed).
    // This keeps a guest (created via the Settings bypass pre-provider)
    // from consuming the onboarding state or emitting the auto-message.
    if !crate::users::is_admin(user_name).await {
        return Ok(());
    }
    // Compare-and-set: only one kickoff attempt is in flight per process;
    // the CAS is reset below on a persist failure so a later attempt retries.
    if KICKOFF_FIRED.swap(true, Ordering::SeqCst) {
        return Ok(());
    }
    // Persist `Finished` FIRST: if it fails, nothing has been sent and the
    // CAS is reset, so the next kickoff attempt (the Home path re-fires it on
    // a later HistoryLoaded render) retries cleanly — no duplicate greeting
    // is possible. On success the durable one-shot guard is in place before
    // any side effect.
    if let Err(e) = crate::config::persist_settled_string_field(
        crate::config::CONFIG_KEY_ONBOARDING_STATE,
        OnboardingState::Finished.as_str(),
    )
    .await
    {
        KICKOFF_FIRED.store(false, Ordering::SeqCst);
        return Err(e);
    }
    // The greeting session is built only after several async hops, by which
    // time the `Finished` write above is long since visible — arm the pending
    // bridge so `Session::build_context_messages` still injects the onboarding
    // guide into exactly that session.
    mark_greeting_guide_pending();
    let msg = ChannelMessage {
        user_name: user_name.to_string(),
        reply_target: user_name.to_string(),
        content: "hi mah bot".to_string(),
        channel: "gui".to_string(),
        workspace: crate::users::personal_workspace_name(user_name),
        ..Default::default()
    };
    if let Some(tx) = crate::GUI_MESSAGE_TX.get()
        && let Err(e) = tx.send(msg)
    {
        // Best-effort: the onboarding state is already `Finished`, so a later
        // kickoff cannot re-send; the next admin Assistant session simply
        // receives the guide instead.
        tracing::error!("kickoff: failed to send 'hi mah bot' via GUI_MESSAGE_TX: {e}");
    }
    Ok(())
}

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

    #[test]
    fn parses_openrouter_key() {
        // A token containing sk-or-v1- is an OpenRouter key.
        match parse_provider_input(" sk-or-v1-abc123def456 ") {
            ProviderInput::OpenRouterKey(k) => assert_eq!(k, "sk-or-v1-abc123def456"),
            other => panic!("expected OpenRouterKey, got {other:?}"),
        }
    }

    #[test]
    fn parses_keyless_custom_endpoint_url() {
        match parse_provider_input("https://ollama.local:11434/v1") {
            ProviderInput::CustomEndpoint { url, key } => {
                assert_eq!(url, "https://ollama.local:11434/v1");
                assert_eq!(key, None);
            }
            other => panic!("expected keyless CustomEndpoint, got {other:?}"),
        }
    }

    #[test]
    fn parses_two_line_custom_endpoint_with_key() {
        match parse_provider_input("http://localhost:8080/v1\nsk-local-123") {
            ProviderInput::CustomEndpoint { url, key } => {
                assert_eq!(url, "http://localhost:8080/v1");
                assert_eq!(key.as_deref(), Some("sk-local-123"));
            }
            other => panic!("expected CustomEndpoint with key, got {other:?}"),
        }
    }

    #[test]
    fn parses_default_openrouter_url_with_key_as_openrouter() {
        // The default OpenRouter endpoint on line 1 + `sk-or-v1-` key on line 2
        // is an OpenRouter key, not a custom endpoint (a default endpoint never
        // makes `provider_configured()` true, which would re-prompt forever).
        match parse_provider_input("https://openrouter.ai/api/v1\nsk-or-v1-abc123def456") {
            ProviderInput::OpenRouterKey(k) => assert_eq!(k, "sk-or-v1-abc123def456"),
            other => panic!("expected OpenRouterKey, got {other:?}"),
        }
    }

    #[test]
    fn parses_reversed_key_then_url_as_openrouter_key() {
        // Key on line 1, URL on line 2 (reversed from the documented order) — the
        // `sk-or-v1-` line is extracted instead of persisting the whole blob as
        // the provider key (which would falsely report `provider_configured()`).
        match parse_provider_input("sk-or-v1-abc123def456\nhttps://custom.example/v1") {
            ProviderInput::OpenRouterKey(k) => assert_eq!(k, "sk-or-v1-abc123def456"),
            other => panic!("expected OpenRouterKey, got {other:?}"),
        }
    }

    #[test]
    fn rejects_bare_default_openrouter_url() {
        // The default endpoint alone is not a custom endpoint: it never makes
        // `provider_configured()` true, so accept no key-less default URL.
        assert!(matches!(
            parse_provider_input("https://openrouter.ai/api/v1"),
            ProviderInput::Invalid
        ));
    }

    #[test]
    fn rejects_single_line_url_with_embedded_junk() {
        // A URL can't contain a literal space — a key pasted on the same line as
        // the URL is malformed (outside the documented two-line grammar) and must
        // not be persisted as a broken endpoint that reports as configured.
        assert!(matches!(
            parse_provider_input("https://custom.example/v1 sk-local-123"),
            ProviderInput::Invalid
        ));
        assert!(matches!(
            parse_provider_input("https://custom.example/v1 extra"),
            ProviderInput::Invalid
        ));
    }

    #[test]
    fn rejects_default_openrouter_url_with_unknown_key() {
        // Default endpoint + a non-`sk-or-v1-` key is not a usable provider either.
        assert!(matches!(
            parse_provider_input("https://openrouter.ai/api/v1\nmy-random-key"),
            ProviderInput::Invalid
        ));
    }

    #[test]
    fn rejects_unknown_input() {
        assert!(matches!(
            parse_provider_input("just some chat text"),
            ProviderInput::Invalid
        ));
    }
}