teamctl 0.5.1

Declarative CLI for running persistent AI agent teams.
//! `teamctl pending / approve / deny` — decide pending HITL approvals.

use std::path::Path;

use anyhow::{bail, Result};
use rusqlite::{params, Connection};

pub fn open_db(root: &Path) -> Result<Connection> {
    let compose = super::load(root)?;
    let db = compose.root.join(&compose.global.broker.path);
    if let Some(parent) = db.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let conn = Connection::open(&db)?;
    conn.busy_timeout(std::time::Duration::from_secs(5))?;
    conn.pragma_update(None, "journal_mode", "WAL")?;
    team_core::mailbox::ensure(&conn)?;
    Ok(conn)
}

pub fn pending(root: &Path) -> Result<()> {
    let conn = open_db(root)?;
    let mut stmt = conn.prepare(
        "SELECT id, project_id, agent_id, action, summary
         FROM approvals WHERE status='pending' ORDER BY id ASC",
    )?;
    let mut rows = stmt.query([])?;
    let mut found = false;
    println!(
        "{:<5} {:<14} {:<22} {:<14} SUMMARY",
        "ID", "PROJECT", "AGENT", "ACTION"
    );
    while let Some(r) = rows.next()? {
        found = true;
        let id: i64 = r.get(0)?;
        let project: String = r.get(1)?;
        let agent: String = r.get(2)?;
        let action: String = r.get(3)?;
        let summary: String = r.get(4)?;
        println!("{id:<5} {project:<14} {agent:<22} {action:<14} {summary}");
    }
    if !found {
        println!("(no pending approvals)");
    }
    Ok(())
}

pub fn decide(root: &Path, id: i64, approved: bool, note: Option<&str>) -> Result<()> {
    let conn = open_db(root)?;
    let status = if approved { "approved" } else { "denied" };
    let now_ts = now();
    // Order matters: status pin first, delivered_at flip second and
    // *only* when the status pin succeeded. The reverse order — flip
    // delivered_at unconditionally, then try the status pin — would
    // break the invariant `undeliverable ↔ delivered_at IS NULL` on
    // late CLI taps against rows that gc already moved to terminal.
    let n = conn.execute(
        "UPDATE approvals SET status=?1, decided_at=?2, decided_by='cli', decision_note=?3
         WHERE id=?4 AND status='pending'",
        params![status, now_ts, note, id],
    )?;
    if n == 0 {
        bail!("no pending approval with id {id}");
    }
    // A CLI decision is itself a delivery acknowledgement: the operator
    // saw the prompt on some surface (otherwise they couldn't decide).
    // Flip delivered_at when null so the row's lifecycle stays truthful.
    conn.execute(
        "UPDATE approvals SET delivered_at=?1
         WHERE id=?2 AND delivered_at IS NULL",
        params![now_ts, id],
    )?;
    println!("approval {id} {status}");
    Ok(())
}

fn now() -> f64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs_f64())
        .unwrap_or(0.0)
}