loopflow 0.12.0

Run steps and flows with coding agents
Documentation
use std::path::Path;
use std::process::{Command, Output};

use loopflow::id::WaveId;
use loopflow::interactive_handoff::{
    InteractiveHandoff, InteractiveHandoffAttach, InteractiveHandoffStatus,
};
use loopflow::store::sqlite::SqliteStore;
use loopflow::wave::Wave;
use loopflow_test_support::TestRepo;

fn run_lf(repo: &Path, home: &Path, arguments: &[&str]) -> Output {
    let output = Command::new(env!("CARGO_BIN_EXE_lf"))
        .args(arguments)
        .current_dir(repo)
        .env("LF_HOME", home)
        .env("LF_DB_PATH", home.join("loopflow.db"))
        .env_remove("LF_CONTROL_HOME")
        .env_remove("LF_CONTROL_DB_PATH")
        .env_remove("LF_RUN_ID")
        .env_remove("LF_PROCESS_ID")
        .env_remove("LF_WAVE_ID")
        .env_remove("LF_PROJECT_SESSION_ID")
        .env_remove("LF_TASK_SESSION_ID")
        .output()
        .expect("run lf handoff");
    assert!(
        output.status.success(),
        "lf {:?} failed: {}",
        arguments,
        String::from_utf8_lossy(&output.stderr)
    );
    output
}

#[test]
fn handoff_cli_opens_attaches_and_hands_the_same_session_back() {
    let repo = TestRepo::new();
    let home = tempfile::tempdir().unwrap();
    let store = SqliteStore::new(&home.path().join("loopflow.db")).unwrap();
    let wave = Wave::new(
        WaveId::new(),
        "product".to_string(),
        repo.path().display().to_string(),
    );
    store.create_wave(&wave).unwrap();
    drop(store);

    let parent = format!("wave:{}", wave.id());
    let cwd = repo.path().display().to_string();
    let opened = run_lf(
        repo.path(),
        home.path(),
        &[
            "handoff",
            "open",
            "--parent",
            &parent,
            "--home",
            "jack@local",
            "--cwd",
            &cwd,
            "--provider",
            "codex",
            "--generation",
            "1",
            "--reason",
            "OAuth login requires a human",
            "--env",
            "LF_HOME=/tmp/lf",
            "--json",
            "--",
            "tmux",
            "attach-session",
            "-t",
            "lf-auth-interactive",
        ],
    );
    let opened: serde_json::Value = serde_json::from_slice(&opened.stdout).unwrap();
    assert_eq!(opened["created"], true);
    let session_id = opened["handoff"]["id"].as_str().unwrap();

    let attached = run_lf(
        repo.path(),
        home.path(),
        &["handoff", "attach", session_id, "--json"],
    );
    let attached: InteractiveHandoffAttach = serde_json::from_slice(&attached.stdout).unwrap();
    assert_eq!(attached.status, InteractiveHandoffStatus::Attached);
    assert_eq!(attached.cwd, repo.path());
    assert_eq!(
        attached.argv,
        ["tmux", "attach-session", "-t", "lf-auth-interactive"]
    );

    let handed_back = run_lf(
        repo.path(),
        home.path(),
        &[
            "handoff",
            "back",
            session_id,
            "--summary",
            "finish the review fixes headlessly",
            "--json",
        ],
    );
    let handed_back: InteractiveHandoff = serde_json::from_slice(&handed_back.stdout).unwrap();
    assert_eq!(handed_back.id.as_str(), session_id);
    assert_eq!(handed_back.status, InteractiveHandoffStatus::HandedBack);
}

#[test]
fn handoff_present_execs_into_the_attach_argv() {
    let repo = TestRepo::new();
    let home = tempfile::tempdir().unwrap();
    let store = SqliteStore::new(&home.path().join("loopflow.db")).unwrap();
    let wave = Wave::new(
        WaveId::new(),
        "product".to_string(),
        repo.path().display().to_string(),
    );
    store.create_wave(&wave).unwrap();
    drop(store);

    let parent = format!("wave:{}", wave.id());
    let cwd = repo.path().display().to_string();
    let opened = run_lf(
        repo.path(),
        home.path(),
        &[
            "handoff",
            "open",
            "--parent",
            &parent,
            "--home",
            "jack@local",
            "--cwd",
            &cwd,
            "--provider",
            "codex",
            "--generation",
            "1",
            "--reason",
            "OAuth login requires a human",
            "--json",
            "--",
            "true",
        ],
    );
    let opened: serde_json::Value = serde_json::from_slice(&opened.stdout).unwrap();
    let session_id = opened["handoff"]["id"].as_str().unwrap();

    // present attaches and execs `true`; the process exits 0 when `true` finishes.
    let output = Command::new(env!("CARGO_BIN_EXE_lf"))
        .args(["handoff", "present", session_id])
        .current_dir(repo.path())
        .env("LF_HOME", home.path())
        .env("LF_DB_PATH", home.path().join("loopflow.db"))
        .env_remove("LF_CONTROL_HOME")
        .env_remove("LF_CONTROL_DB_PATH")
        .env_remove("LF_RUN_ID")
        .env_remove("LF_PROCESS_ID")
        .env_remove("LF_WAVE_ID")
        .env_remove("LF_PROJECT_SESSION_ID")
        .env_remove("LF_TASK_SESSION_ID")
        .output()
        .expect("run lf handoff present");
    assert!(
        output.status.success(),
        "lf handoff present failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    // After present, the handoff should be attached (first-attach evidence recorded).
    let status = run_lf(
        repo.path(),
        home.path(),
        &["handoff", "status", session_id, "--json"],
    );
    let handoff: InteractiveHandoff = serde_json::from_slice(&status.stdout).unwrap();
    assert_eq!(handoff.status, InteractiveHandoffStatus::Attached);
}