yah-qed 0.8.47

CI scheduler: pipelines, step DAGs, triggers, and pass/fail gating over task execution
//! Stable-path worktree slots for `workspace = "isolated"` pipelines (R833-T17).
//!
//! # Why a stable path at all
//!
//! sccache 0.17 hashes the absolute cwd and `CARGO_MANIFEST_DIR` into every
//! Rust cache key and applies `basedirs` on the C/C++ path only (R833-S1,
//! measured: 1 hit / 727 misses). A per-run `qed-worktree-<run_id>` therefore
//! shares nothing with the previous run of the same pipeline, and neither does
//! cargo's own incremental state, since that `target/` is deleted with the
//! worktree. A slot's path never changes, and a successful run leaves its
//! ignored files (`target/`, `node_modules/`) in place, so run two of a day
//! starts warm on both counts.
//!
//! # The shape, and how it relates to the venue pool
//!
//! Same design as yah's dispatch venue pool (`app/yah/cli/src/venue.rs`,
//! R842-F5): a bounded set of numbered slots at stable paths, allocated under
//! an exclusive file lock over a JSON registry, with a slot that must not be
//! reset skipped rather than reset. Two differences, both because a qed run
//! holds its slot in-process for its whole life:
//!
//! - **The holder signal is git's own worktree lock**, which qed already
//!   stamps on every isolated worktree with the run id and pid (R874-B2) and
//!   which `yah qed prune` already reads. The venue's time-boxed reservation
//!   exists to cover a launch gap between allocate and bind; qed has no such
//!   gap, because the lock is taken inside the same critical section.
//! - **"Must not be reset" means retained for a resume (R766)**, recorded in
//!   the registry by run id, rather than "has uncommitted files". A slot a
//!   SUCCESSFUL run left dirty is build state, not someone's work, and is
//!   repositioned with `checkout --force` + `clean -fd`, which keeps ignored
//!   files — strictly less destructive than the `worktree remove --force` the
//!   per-run path does today.
//!
//! When every slot is busy or retained, [`acquire`] returns `None` and the
//! runner falls back to a fresh per-run worktree. A run never waits on a slot.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// Basename prefix of every slot directory. Still under the `qed-worktree-`
/// prefix so `yah qed prune`'s `$TMPDIR` scan and the desktop storage panel
/// see slots; `yah qed prune` reclaims idle ones like any untracked tree.
pub const SLOT_PREFIX: &str = "qed-worktree-slot-";

/// One slot's registry entry. Absent = never retained.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
struct SlotRecord {
    /// Run id of a FAILED run that kept this slot for `--from-step` resume.
    /// A retained slot is never handed to another run; the resume, or a prune
    /// that removes the directory, frees it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    retained_by: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Registry {
    /// Slot path (as a string) → record.
    #[serde(default)]
    slots: BTreeMap<String, SlotRecord>,
}

/// `$TMPDIR/qed-worktree-slots/` — registry and lock, beside the slots.
fn registry_dir() -> PathBuf {
    std::env::temp_dir().join("qed-worktree-slots")
}

/// `/Users/leif/ss/yah` → `yah-<8 hex>`, so two camps with the same directory
/// name never share a slot.
fn camp_slug(camp_root: &Path) -> String {
    let canon = std::fs::canonicalize(camp_root).unwrap_or_else(|_| camp_root.to_path_buf());
    let name = canon
        .file_name()
        .map(|n| sanitize(&n.to_string_lossy()))
        .unwrap_or_else(|| "camp".to_string());
    let hash = blake3::hash(canon.to_string_lossy().as_bytes()).to_hex();
    format!("{name}-{}", &hash[..8])
}

fn sanitize(s: &str) -> String {
    s.chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '-' })
        .collect()
}

/// The stable path of slot `n` of `pipeline` in `camp_root`.
pub fn slot_path(camp_root: &Path, pipeline: &str, n: u32) -> PathBuf {
    std::env::temp_dir().join(format!(
        "{SLOT_PREFIX}{}-{}-{n}",
        camp_slug(camp_root),
        sanitize(pipeline)
    ))
}

/// Whether `path` is a slot (as opposed to a per-run worktree).
pub fn is_slot_path(path: &Path) -> bool {
    path.file_name()
        .and_then(|n| n.to_str())
        .is_some_and(|n| n.starts_with(SLOT_PREFIX))
}

/// Exclusive advisory lock over the registry (`File::lock` = `flock(2)`).
/// The kernel drops it with the fd, so a crashed allocator never wedges it.
struct RegistryLock(std::fs::File);

impl RegistryLock {
    fn acquire(camp_root: &Path) -> Result<Self, String> {
        let dir = registry_dir();
        std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
        let path = dir.join(format!("{}.lock", camp_slug(camp_root)));
        let file = std::fs::OpenOptions::new()
            .create(true)
            .read(true)
            .write(true)
            .truncate(false)
            .open(&path)
            .map_err(|e| format!("open {}: {e}", path.display()))?;
        file.lock().map_err(|e| format!("lock {}: {e}", path.display()))?;
        Ok(Self(file))
    }
}

impl Drop for RegistryLock {
    fn drop(&mut self) {
        let _ = self.0.unlock();
    }
}

fn registry_path(camp_root: &Path) -> PathBuf {
    registry_dir().join(format!("{}.json", camp_slug(camp_root)))
}

fn load(camp_root: &Path) -> Registry {
    std::fs::read_to_string(registry_path(camp_root))
        .ok()
        .and_then(|raw| serde_json::from_str(&raw).ok())
        .unwrap_or_default()
}

fn save(camp_root: &Path, reg: &Registry) -> Result<(), String> {
    let path = registry_path(camp_root);
    let body = serde_json::to_string_pretty(reg).map_err(|e| e.to_string())? + "\n";
    std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
}

fn key(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}

/// `git worktree list --porcelain` → canonical path → lock reason (`Some("")`
/// for a reasonless lock, `None` when unlocked).
fn registered(camp_root: &Path) -> BTreeMap<PathBuf, Option<String>> {
    let mut out = BTreeMap::new();
    let Ok(o) = std::process::Command::new("git")
        .current_dir(camp_root)
        .args(["worktree", "list", "--porcelain"])
        .output()
    else {
        return out;
    };
    let text = String::from_utf8_lossy(&o.stdout);
    let mut current: Option<PathBuf> = None;
    for line in text.lines() {
        if let Some(p) = line.strip_prefix("worktree ") {
            let p = PathBuf::from(p);
            let canon = std::fs::canonicalize(&p).unwrap_or(p);
            out.insert(canon.clone(), None);
            current = Some(canon);
        } else if line == "locked" || line.starts_with("locked ") {
            if let Some(c) = &current {
                let reason = line.strip_prefix("locked").unwrap_or("").trim().to_string();
                out.insert(c.clone(), Some(reason));
            }
        }
    }
    out
}

fn canonical(path: &Path) -> PathBuf {
    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

/// A lock held by a live qed run. Anything else — a lock whose pid is gone,
/// or one qed did not write — is not a holder.
fn lock_is_live(reason: &str) -> bool {
    match crate::runner::parse_worktree_lock_reason(reason) {
        Some((_, pid)) => pid_alive(pid),
        // An operator's bare `git worktree lock`: theirs, never ours to take.
        None => true,
    }
}

fn pid_alive(pid: u32) -> bool {
    if pid == std::process::id() {
        return true;
    }
    #[cfg(unix)]
    {
        std::process::Command::new("kill")
            .args(["-0", &pid.to_string()])
            .stderr(std::process::Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(true)
    }
    #[cfg(not(unix))]
    {
        true
    }
}

fn git(dir: &Path, args: &[&str]) -> Result<(), String> {
    let out = std::process::Command::new("git")
        .current_dir(dir)
        .args(args)
        .output()
        .map_err(|e| format!("spawn git: {e}"))?;
    if out.status.success() {
        Ok(())
    } else {
        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
    }
}

/// A slot handed to a run: positioned at the run's ref and locked to it.
#[derive(Debug)]
pub struct SlotLease {
    pub path: PathBuf,
    /// True when the slot already existed, i.e. it may carry warm `target/`.
    pub reused: bool,
}

/// Take the first free slot of `pipeline`, position it at `git_ref`, and lock
/// it to `run_id`. `Ok(None)` when all `slots` are busy or retained — the
/// caller builds in a fresh per-run worktree instead. `Err` only for a
/// failure on a slot already chosen (the caller also falls back on that).
pub fn acquire(
    camp_root: &Path,
    pipeline: &str,
    slots: u32,
    git_ref: &str,
    run_id: &str,
) -> Result<Option<SlotLease>, String> {
    if slots == 0 {
        return Ok(None);
    }
    let _guard = RegistryLock::acquire(camp_root)?;
    let mut reg = load(camp_root);
    // Forget registrations whose directory vanished (a prune, a reboot's
    // $TMPDIR wipe), so `worktree add` onto that slot path does not refuse.
    let _ = git(camp_root, &["worktree", "prune"]);
    let worktrees = registered(camp_root);
    let lock_reason = crate::runner::worktree_lock_reason(run_id, std::process::id());

    for n in 0..slots {
        let path = slot_path(camp_root, pipeline, n);
        let k = key(&path);
        if !path.exists() {
            // A retained record outliving its directory is stale.
            reg.slots.remove(&k);
        }
        if reg.slots.get(&k).is_some_and(|r| r.retained_by.is_some()) {
            continue;
        }
        match worktrees.get(&canonical(&path)) {
            Some(Some(reason)) if lock_is_live(reason) => continue,
            Some(lock) => {
                if lock.is_some() {
                    let _ = git(camp_root, &["worktree", "unlock", &path.to_string_lossy()]);
                }
                git(&path, &["checkout", "--force", "--detach", git_ref])
                    .map_err(|e| format!("reposition slot {} at {git_ref}: {e}", path.display()))?;
                // -fd, not -fdx: ignored files are the warm build state this
                // slot exists to keep.
                git(&path, &["clean", "-fd"])
                    .map_err(|e| format!("clean slot {}: {e}", path.display()))?;
                git(camp_root, &["worktree", "lock", "--reason", &lock_reason, &path.to_string_lossy()])
                    .map_err(|e| format!("lock slot {}: {e}", path.display()))?;
                save(camp_root, &reg)?;
                return Ok(Some(SlotLease { path, reused: true }));
            }
            None if path.exists() => {
                // A directory git does not know: not ours to delete. Skip it
                // and let `yah qed prune` (or the operator) deal with it.
                tracing::warn!(
                    path = %path.display(),
                    "qed: worktree slot path exists but is not a registered worktree; skipping it"
                );
                continue;
            }
            None => {
                git(
                    camp_root,
                    &["worktree", "add", "--force", "--detach", &path.to_string_lossy(), git_ref],
                )
                .map_err(|e| format!("git worktree add slot {} at {git_ref}: {e}", path.display()))?;
                git(camp_root, &["worktree", "lock", "--reason", &lock_reason, &path.to_string_lossy()])
                    .map_err(|e| format!("lock slot {}: {e}", path.display()))?;
                save(camp_root, &reg)?;
                return Ok(Some(SlotLease { path, reused: false }));
            }
        }
    }
    save(camp_root, &reg)?;
    Ok(None)
}

/// Record (`Some(run_id)`) or clear (`None`) a slot's R766 retention. Called by
/// the runner's worktree guard as the run ends.
pub fn set_retained(camp_root: &Path, path: &Path, run_id: Option<&str>) -> Result<(), String> {
    let _guard = RegistryLock::acquire(camp_root)?;
    let mut reg = load(camp_root);
    match run_id {
        Some(id) => {
            reg.slots.insert(key(path), SlotRecord { retained_by: Some(id.to_string()) });
        }
        None => {
            reg.slots.remove(&key(path));
        }
    }
    save(camp_root, &reg)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slot_paths_are_stable_and_distinct_per_pipeline_and_index() {
        let camp = tempfile::TempDir::new().unwrap();
        let a0 = slot_path(camp.path(), "yah-cli-release", 0);
        assert_eq!(a0, slot_path(camp.path(), "yah-cli-release", 0));
        assert_ne!(a0, slot_path(camp.path(), "yah-cli-release", 1));
        assert_ne!(a0, slot_path(camp.path(), "other", 0));
        assert!(is_slot_path(&a0));
        assert!(!is_slot_path(Path::new("/tmp/qed-worktree-abc")));
        let other = tempfile::TempDir::new().unwrap();
        assert_ne!(a0, slot_path(other.path(), "yah-cli-release", 0), "camps never share");
    }
}