qqq-cli 0.10.0

Local-first task queue for coding agent sessions
use anyhow::{Context, Result, ensure};
use serde::Deserialize;
use std::{
    io,
    path::PathBuf,
    process::{Command, Stdio},
    time::{Duration, Instant},
};

pub(super) fn project_root(db: &crate::db::Db) -> Result<PathBuf> {
    let path = db.conn.path().context("Database has no project path")?;
    std::path::Path::new(path)
        .parent()
        .and_then(|directory| directory.parent())
        .context("Database path has no project directory")?
        .canonicalize()
        .context("Cannot resolve queue project directory")
}

fn server_running() -> Result<bool> {
    #[derive(Deserialize)]
    struct Status {
        status: String,
    }
    let output = Command::new("herdr")
        .args(["status", "server", "--json"])
        .output()
        .context("Cannot check Herdr server")?;
    ensure!(
        output.status.success(),
        "Cannot check Herdr server: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    );
    let status: Status =
        serde_json::from_slice(&output.stdout).context("Invalid Herdr server status")?;
    ensure!(
        matches!(status.status.as_str(), "running" | "not_running"),
        "Unknown Herdr server status: {}",
        status.status
    );
    Ok(status.status == "running")
}

fn ensure_server() -> Result<()> {
    if server_running()? {
        return Ok(());
    }
    let mut server = Command::new("herdr")
        .arg("server")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .context("Cannot start Herdr server")?;
    let deadline = Instant::now() + Duration::from_secs(10);
    loop {
        if server_running()? {
            return Ok(());
        }
        if let Some(status) = server.try_wait()? {
            ensure!(status.success(), "Herdr server startup failed: {status}");
        }
        ensure!(
            Instant::now() < deadline,
            "Herdr server did not become ready"
        );
        std::thread::sleep(Duration::from_millis(100));
    }
}

pub(super) fn create(db: &crate::db::Db, prompt: &str) -> Result<String> {
    #[derive(Deserialize)]
    struct Panes {
        panes: Vec<crate::herdr::Pane>,
    }
    #[derive(Deserialize)]
    struct Created {
        root_pane: crate::herdr::Pane,
    }
    #[derive(Deserialize)]
    struct Agent {
        agent: crate::herdr::Pane,
    }
    crate::db::nonempty(prompt, "Prompt")?;
    let root = project_root(db)?;
    let cwd = root.to_str().context("Project directory is not UTF-8")?;
    ensure_server()?;
    let panes: Panes = crate::herdr::call(None, &["pane", "list"])?;
    let workspace = panes.panes.iter().find(|pane| {
        pane.cwd
            .as_deref()
            .is_some_and(|cwd| cwd.is_absolute() && cwd.canonicalize().is_ok_and(|cwd| cwd == root))
    });
    let created: Created = match workspace {
        Some(pane) => crate::herdr::call(
            None,
            &[
                "tab",
                "create",
                "--workspace",
                &pane.workspace_id,
                "--cwd",
                cwd,
                "--no-focus",
            ],
        )?,
        None => crate::herdr::call(
            None,
            &[
                "workspace",
                "create",
                "--cwd",
                cwd,
                "--label",
                root.file_name()
                    .and_then(|name| name.to_str())
                    .unwrap_or(cwd),
                "--no-focus",
            ],
        )?,
    };
    let pane = created.root_pane;
    crate::db::nonempty(&pane.pane_id, "Created pane ID")?;
    let prepare = || -> Result<()> {
        if let Some(workspace) = workspace {
            ensure!(
                pane.workspace_id == workspace.workspace_id,
                "Created tab is in a different workspace"
            );
        }
        let name = format!("qqq-worker-{}", pane.pane_id.replace(':', "-"));
        let started: Result<serde_json::Value> = crate::herdr::call(
            None,
            &[
                "agent",
                "start",
                &name,
                "--kind",
                "codex",
                "--pane",
                &pane.pane_id,
            ],
        );
        if let Err(error) = started {
            // Some interactive shell profiles start Codex automatically.
            let current: Result<Agent> = crate::herdr::call(None, &["agent", "get", &pane.pane_id]);
            let already_started = current.is_ok_and(|current| {
                current.agent.pane_id == pane.pane_id
                    && (current.agent.agent.as_deref() == Some("codex")
                        || current
                            .agent
                            .agent_session
                            .as_ref()
                            .is_some_and(|identity| identity.agent == "codex"))
            });
            if !already_started {
                return Err(error);
            }
            let _: serde_json::Value = crate::herdr::call(
                None,
                &[
                    "agent",
                    "wait",
                    &pane.pane_id,
                    "--until",
                    "idle",
                    "--timeout",
                    "30000",
                ],
            )?;
        }
        let _: serde_json::Value =
            crate::herdr::call(None, &["agent", "prompt", &pane.pane_id, prompt])
                .context("Prompt may have been delivered; inspect the agent before trying again")?;
        let _: serde_json::Value = crate::herdr::call(None, &["agent", "focus", &pane.pane_id])?;
        Ok(())
    };
    prepare().with_context(|| {
        format!(
            "Created Herdr pane {}; inspect it before retrying",
            pane.pane_id
        )
    })?;
    Ok(pane.pane_id)
}

pub(super) fn focus(db: &crate::db::Db, id: i64) -> Result<Option<String>> {
    let link = db
        .link(id)?
        .context("Task has no Herdr link; run qqq herdr link")?;
    let pane = crate::herdr::find(&link.identity, link.server.as_deref())?;
    let _: serde_json::Value =
        crate::herdr::call(link.server.as_deref(), &["agent", "focus", &pane.pane_id])?;
    Ok(link.server)
}

pub(super) fn attach(server: Option<&str>) -> Result<()> {
    let mut command = Command::new("herdr");
    if let Some(server) = server {
        crate::db::nonempty(server, "Herdr server")?;
        command.args(["--session", server]);
    }
    let status = command
        .stdout(io::stderr())
        .status()
        .context("Cannot open Herdr client")?;
    ensure!(status.success(), "Herdr client failed: {status}");
    Ok(())
}