convergio-night-agents 0.1.5

Night agents — scheduled autonomous agents running during off-peak hours
Documentation
//! Claude CLI agent spawner for night runs.

use std::process::Command;

use convergio_db::pool::ConnPool;
use rusqlite::params;
use tracing::error;

/// Spawn a Claude CLI agent for a night run.
pub fn spawn_claude_agent(pool: &ConnPool, run_id: i64, model: &str, prompt: &str) {
    let claude_bin = resolve_claude_bin();
    let Some(bin) = claude_bin else {
        mark_run_failed(pool, run_id, "claude binary not found");
        return;
    };
    let result = Command::new(&bin)
        .args(["--dangerously-skip-permissions"])
        .args(["--model", model])
        .args(["--max-turns", "30"])
        .args(["-p", prompt])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output();
    match result {
        Ok(output) if output.status.success() => {
            let outcome = String::from_utf8_lossy(&output.stdout);
            let summary = truncate_safe(&outcome, 2000);
            mark_run_completed(pool, run_id, &summary);
        }
        Ok(output) => {
            let err = String::from_utf8_lossy(&output.stderr);
            mark_run_failed(pool, run_id, &err);
        }
        Err(e) => mark_run_failed(pool, run_id, &e.to_string()),
    }
}

fn resolve_claude_bin() -> Option<String> {
    if let Ok(bin) = std::env::var("CONVERGIO_CLAUDE_BIN") {
        return Some(bin);
    }
    let candidates = [
        dirs::home_dir().map(|d| d.join(".local/bin/claude").to_string_lossy().to_string()),
        dirs::home_dir().map(|d| d.join(".claude/bin/claude").to_string_lossy().to_string()),
        Some("/usr/local/bin/claude".into()),
        Some("/opt/homebrew/bin/claude".into()),
    ];
    candidates
        .into_iter()
        .flatten()
        .find(|c| std::path::Path::new(c).exists())
}

pub fn mark_run_completed(pool: &ConnPool, run_id: i64, outcome: &str) {
    if let Ok(conn) = pool.get() {
        let _ = conn.execute(
            "UPDATE night_runs SET status = 'completed', \
             completed_at = datetime('now'), outcome = ?1 WHERE id = ?2",
            params![outcome, run_id],
        );
    }
}

pub fn mark_run_failed(pool: &ConnPool, run_id: i64, error_msg: &str) {
    error!(run_id, error_msg, "night run failed");
    if let Ok(conn) = pool.get() {
        let _ = conn.execute(
            "UPDATE night_runs SET status = 'failed', \
             completed_at = datetime('now'), error_message = ?1 WHERE id = ?2",
            params![error_msg, run_id],
        );
    }
}

/// Truncate a string at a safe UTF-8 char boundary.
pub fn truncate_safe(s: &str, max_len: usize) -> String {
    if s.len() <= max_len {
        return s.to_string();
    }
    let mut end = max_len;
    while !s.is_char_boundary(end) && end > 0 {
        end -= 1;
    }
    format!("{}...", &s[..end])
}