loopflow 0.12.13

Run steps and flows with coding agents
Documentation
mod support;

use std::process::Command;

use loopflow::store::{
    open_store, CredentialState, ProviderAccount, ProviderAccountId, RoutingState, StorageConfig,
};
use support::EnvGuard;

fn account(account_id: &str, home: std::path::PathBuf) -> ProviderAccount {
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    ProviderAccount {
        provider: "codex".to_string(),
        account_id: ProviderAccountId::parse(account_id).expect("account id"),
        home: Some(home),
        login_email: None,
        credential_state: CredentialState::Connected,
        routing_state: RoutingState::Automatic,
        plan: None,
        paid_through: None,
        utilization_percent: None,
        cooldown_until: None,
        cooldown_reason: None,
        last_selected_at: None,
        created_at: now,
        updated_at: now,
    }
}

#[test]
fn accounts_verify_separates_cached_state_from_live_provider_state() {
    let home = tempfile::TempDir::new().expect("lf home");
    let active_home = home.path().join("accounts/codex/active");
    let revoked_home = home.path().join("accounts/codex/revoked");
    std::fs::create_dir_all(&active_home).expect("active home");
    std::fs::create_dir_all(&revoked_home).expect("revoked home");
    let codex = r#"#!/bin/sh
read -r initialize
echo '{"jsonrpc":"2.0","id":1,"result":{}}'
read -r limits
case "$CODEX_HOME" in
  */active)
    echo '{"jsonrpc":"2.0","id":2,"result":{"rateLimits":{"planType":"team","primary":{"usedPercent":12,"windowDurationMins":300,"resetsAt":1900000000}}}}';;
  */revoked)
    echo '{"jsonrpc":"2.0","id":2,"error":{"code":401,"message":"token_invalidated"}}';;
  *) exit 9;;
esac
"#;
    let _env = EnvGuard::with_lf_home(&[("codex", codex)], home.path());
    let runtime = tokio::runtime::Runtime::new().expect("store runtime");
    let store = runtime
        .block_on(open_store(&StorageConfig::sqlite(
            home.path().join("loopflow.db"),
        )))
        .expect("account store");
    for seeded in [
        account("active", active_home),
        account("revoked", revoked_home),
    ] {
        runtime
            .block_on(store.upsert_provider_account(&seeded))
            .expect("seed account");
    }

    let cached_output = Command::new(env!("CARGO_BIN_EXE_lf"))
        .args(["auth", "accounts", "codex"])
        .output()
        .expect("read cached auth accounts");
    assert!(cached_output.status.success());
    assert!(String::from_utf8_lossy(&cached_output.stdout)
        .contains("auth: cached connected · live not checked (use --verify)"));

    let output = Command::new(env!("CARGO_BIN_EXE_lf"))
        .args(["auth", "accounts", "codex", "--verify"])
        .output()
        .expect("run auth verify");

    assert!(
        output.status.success(),
        "lf auth accounts --verify failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("auth: cached connected · live active"));
    assert!(stdout.contains("auth: cached connected · live needs re-login"));
    assert!(stdout.contains("recover: lf auth connect codex revoked"));
    let revoked_id = ProviderAccountId::parse("revoked").expect("revoked id");
    let revoked = runtime
        .block_on(store.get_provider_account("codex", &revoked_id))
        .expect("read revoked account")
        .expect("revoked account");
    assert_eq!(revoked.credential_state, CredentialState::Missing);
}