qqq-cli 0.12.0

Local-first task queue for coding agent sessions
#[path = "common/next_output.rs"]
mod next_output;

use rusqlite::Connection;
use serde_json::Value;
use std::{
    path::Path,
    process::{Command, Output},
};
use tempfile::TempDir;

fn command(path: &Path) -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_qqq"));
    command
        .current_dir(path)
        .arg("--json")
        .env("HOME", path)
        .env_remove("QQQ_SESSION")
        .env_remove("CODEX_THREAD_ID")
        .env_remove("CODEX_SESSION_ID")
        .env_remove("CLAUDE_CODE_SESSION_ID")
        .env_remove("HERDR_ENV")
        .env_remove("HERDR_PANE_ID")
        .env("EDITOR", "nonexistent-qqq-editor");
    command
}
fn run(path: &Path, args: &[&str]) -> Output {
    command(path).args(args).output().unwrap()
}
fn parsed(output: Output) -> Value {
    assert!(
        output.status.success(),
        "{}",
        String::from_utf8_lossy(&output.stderr)
    );
    serde_json::from_slice(&output.stdout).unwrap()
}
fn ok(path: &Path, args: &[&str]) -> Value {
    if args.first() == Some(&"next") {
        return next_output::json(run(path, args));
    }
    parsed(run(path, args))
}
fn project() -> TempDir {
    let dir = TempDir::new().unwrap();
    ok(dir.path(), &["init"]);
    dir
}
fn claimed() -> TempDir {
    let dir = project();
    ok(dir.path(), &["add", "Task\n\nDetails"]);
    ok(
        dir.path(),
        &[
            "next",
            "--local",
            "--session",
            "owner",
            "--harness-name",
            "codex",
            "--harness-session",
            "display-owner",
            "--orchestrator-name",
            "herdr",
            "--orchestrator-session",
            "server-1",
        ],
    );
    dir
}
fn assert_released(task: &Value) {
    assert_eq!(task["status"], "new");
    for key in [
        "harness_name",
        "harness_session",
        "orchestrator_name",
        "orchestrator_session",
    ] {
        assert_eq!(task.get(key), Some(&Value::Null), "{key} was not cleared");
    }
}

#[test]
fn force_without_session_or_herdr_preserves_details_and_releases_claim() {
    let d = project();
    let p = d.path();
    ok(p, &["add", "Parent"]);
    ok(p, &["add", "Task\n\nDetails", "--parent", "1"]);
    ok(p, &["next", "--local", "--session", "parent"]);
    ok(p, &["edit", "--complete", "1", "--session", "parent"]);
    ok(p, &["message", "2", "Existing note"]);
    std::fs::write(p.join("x.png"), b"\x89PNG\r\n\x1a\nfixture").unwrap();
    ok(p, &["edit", "2", "--image", "x.png"]);
    ok(p, &["next", "--local", "--session", "owner"]);
    let conn = Connection::open(p.join(".qqq/qqq.db")).unwrap();
    let link = r#"{"server":null,"identity":{"agent":"codex","kind":"id","value":"owner"},"pane":{"pane_id":"p1","workspace_id":"w1","tab_id":"t1"}}"#;
    conn.execute("INSERT INTO herdr_links VALUES (2, ?)", [link])
        .unwrap();
    let before = ok(p, &["show", "2"]);
    let released = parsed(
        command(p)
            .env("PATH", p.join("missing-bin"))
            .env("HERDR_ENV", "1")
            .env("HERDR_PANE_ID", "missing-pane")
            .args(["edit", "-1", "--set-status", "new", "--force"])
            .output()
            .unwrap(),
    );
    assert_released(&released);
    assert!(
        conn.query_row(
            "SELECT claim_key IS NULL FROM tasks WHERE id=2",
            [],
            |row| row.get::<_, bool>(0)
        )
        .unwrap()
    );
    for key in ["id", "description", "parent_id", "created_at"] {
        assert_eq!(released[key], before["task"][key]);
    }
    let detail = ok(p, &["show", "2"]);
    for key in ["messages", "images", "herdr"] {
        assert_eq!(detail[key], before[key]);
    }
    assert_eq!(detail["events"][0], before["events"][0]);
    assert_eq!(detail["events"].as_array().unwrap().len(), 2);
    assert_eq!(detail["events"][1]["action"], "release");
    assert_eq!(detail["events"][1]["session"], "manual");
    assert_eq!(ok(p, &["next", "--local", "--session", "next"])["id"], 2);
}

#[test]
fn force_accepts_mismatched_explicit_and_environment_sessions_as_authors() {
    for explicit in [false, true] {
        let d = claimed();
        let p = d.path();
        let mut cmd = command(p);
        cmd.env("PATH", p.join("missing-bin"))
            .env("QQQ_SESSION", "wrong-env")
            .args(["edit", "1", "--set-status", "new", "--force"]);
        if explicit {
            cmd.args(["--session", "wrong-explicit"]);
        }
        let released = parsed(cmd.output().unwrap());
        assert_released(&released);
        assert_eq!(
            ok(p, &["show", "1"])["events"][1]["session"],
            if explicit {
                "wrong-explicit"
            } else {
                "wrong-env"
            }
        );
    }
}

#[test]
fn force_skips_ambiguous_harness_owner_matching_and_records_session_author() {
    let d = project();
    let p = d.path();
    for (description, owner, harness) in [("One", "first", "codex"), ("Two", "second", "claude")] {
        ok(p, &["add", description]);
        ok(
            p,
            &[
                "next",
                "--local",
                "--session",
                owner,
                "--harness-name",
                harness,
                "--harness-session",
                "shared",
            ],
        );
    }
    let released = parsed(
        command(p)
            .env("PATH", p.join("missing-bin"))
            .args([
                "edit",
                "1",
                "--set-status",
                "new",
                "--force",
                "--harness-session",
                "shared",
            ])
            .output()
            .unwrap(),
    );
    assert_released(&released);
    assert_eq!(ok(p, &["show", "1"])["events"][1]["session"], "shared");
    assert_eq!(ok(p, &["show", "2"])["task"]["status"], "in_progress");
}

#[test]
fn default_release_still_rejects_wrong_owner_atomically() {
    let d = claimed();
    let p = d.path();
    ok(p, &["add", "Parent"]);
    let before = ok(p, &["show", "1"]);
    for explicit in [false, true] {
        let mut cmd = command(p);
        cmd.args([
            "edit",
            "1",
            "--set-status",
            "new",
            "-d",
            "Changed",
            "--set-parent",
            "2",
        ]);
        if explicit {
            cmd.args(["--session", "wrong"]);
        } else {
            cmd.env("QQQ_SESSION", "wrong");
        }
        let output = cmd.output().unwrap();
        assert!(!output.status.success());
        let failure: Value = serde_json::from_slice(&output.stderr).unwrap();
        assert_eq!(failure["code"], "OWNERSHIP_MISMATCH");
        assert_eq!(failure["details"]["task_id"], 1);
        assert!(!failure.to_string().contains("wrong"));
        assert_eq!(ok(p, &["show", "1"]), before);
    }
}

#[cfg(unix)]
#[test]
fn force_skips_herdr_lookup_while_default_release_requires_it() {
    use std::os::unix::fs::PermissionsExt;
    let d = claimed();
    let p = d.path();
    let bin = p.join("bin");
    std::fs::create_dir(&bin).unwrap();
    let herdr = bin.join("herdr");
    std::fs::write(
        &herdr,
        "#!/bin/sh\nprintf called > \"$QQQ_TEST_HERDR_LOG\"\nexit 1\n",
    )
    .unwrap();
    std::fs::set_permissions(&herdr, std::fs::Permissions::from_mode(0o755)).unwrap();
    let log = p.join("herdr.log");
    let before = ok(p, &["show", "1"]);
    let output = command(p)
        .env("PATH", &bin)
        .env("QQQ_TEST_HERDR_LOG", &log)
        .args(["edit", "1", "--set-status", "new"])
        .output()
        .unwrap();
    assert!(!output.status.success());
    assert!(log.exists(), "Default release did not attempt Herdr lookup");
    assert_eq!(ok(p, &["show", "1"]), before);
    std::fs::remove_file(&log).unwrap();
    let released = parsed(
        command(p)
            .env("PATH", &bin)
            .env("QQQ_TEST_HERDR_LOG", &log)
            .args(["edit", "1", "--set-status", "new", "--force"])
            .output()
            .unwrap(),
    );
    assert_eq!(released["status"], "new");
    assert!(!log.exists(), "Forced release attempted Herdr lookup");
}

#[test]
fn force_combined_edits_and_release_history_roll_back_on_image_failure() {
    let d = claimed();
    let p = d.path();
    ok(p, &["add", "New parent"]);
    std::fs::write(p.join("x.png"), b"\x89PNG\r\n\x1a\nfixture").unwrap();
    let conn = Connection::open(p.join(".qqq/qqq.db")).unwrap();
    conn.execute_batch("CREATE TRIGGER reject_image BEFORE INSERT ON images BEGIN SELECT RAISE(ABORT,'image insertion blocked'); END").unwrap();
    let before = ok(p, &["show", "1"]);
    let args = [
        "edit",
        "1",
        "--set-status",
        "new",
        "--force",
        "-d",
        "Changed",
        "--set-parent",
        "2",
        "--image",
        "x.png",
    ];
    let output = run(p, &args);
    assert!(!output.status.success());
    let failure: Value = serde_json::from_slice(&output.stderr).unwrap();
    assert_eq!(failure["code"], "DATABASE_ERROR");
    assert_eq!(failure["details"]["sqlite_extended_code"], 1811);
    assert_eq!(ok(p, &["show", "1"]), before);
    conn.execute_batch("DROP TRIGGER reject_image").unwrap();
    let released = ok(p, &args);
    assert_released(&released);
    assert_eq!(released["description"], "Changed");
    assert_eq!(released["parent_id"], 2);
    let detail = ok(p, &["show", "1"]);
    assert_eq!(detail["images"].as_array().unwrap().len(), 1);
    assert_eq!(detail["events"].as_array().unwrap().len(), 2);
    assert_eq!(detail["events"][1]["action"], "release");
    assert_eq!(ok(p, &["next", "--local", "--session", "next"])["id"], 2);
    assert!(ok(p, &["next", "--local", "--session", "owner"]).is_null());
    ok(p, &["edit", "--complete", "2", "--session", "next"]);
    assert_eq!(ok(p, &["next", "--local", "--session", "owner"])["id"], 1);
}

#[test]
fn force_requires_literal_new_status_and_rejects_new_or_completed_tasks() {
    let d = claimed();
    let p = d.path();
    let before = ok(p, &["show", "1"]);
    for args in [
        vec!["edit", "1", "--force"],
        vec!["edit", "1", "--force", "-d", "Changed"],
        vec!["edit", "1", "--set-pending", "--force"],
        vec!["edit", "1", "--set-status", "new", "--force", "--edit"],
        vec![
            "edit",
            "1",
            "--set-status",
            "error",
            "--force",
            "--reason",
            "Failure",
        ],
    ] {
        assert!(!run(p, &args).status.success(), "{args:?}");
        assert_eq!(ok(p, &["show", "1"]), before);
    }
    ok(p, &["edit", "--complete", "1", "--session", "owner"]);
    ok(p, &["add", "New task"]);
    for id in ["1", "2"] {
        let before = ok(p, &["show", id]);
        assert!(
            !run(
                p,
                &[
                    "edit",
                    id,
                    "--set-status",
                    "new",
                    "--force",
                    "-d",
                    "Changed"
                ]
            )
            .status
            .success()
        );
        assert_eq!(ok(p, &["show", id]), before);
    }
}

#[test]
fn error_retries_remain_session_free_with_or_without_force() {
    for force in [false, true] {
        let d = claimed();
        let p = d.path();
        ok(
            p,
            &[
                "edit",
                "1",
                "--set-status",
                "error",
                "--reason",
                "Failure",
                "--session",
                "owner",
            ],
        );
        let before = ok(p, &["show", "1"]);
        let mut cmd = command(p);
        cmd.env("PATH", p.join("missing-bin"))
            .args(["edit", "1", "--set-status", "new"]);
        if force {
            cmd.arg("--force");
        }
        assert_eq!(parsed(cmd.output().unwrap())["status"], "new");
        let detail = ok(p, &["show", "1"]);
        assert_eq!(detail["messages"], before["messages"]);
        assert_eq!(detail["events"][2]["action"], "release");
        assert_eq!(detail["events"][2]["session"], "manual");
    }
}