Add run record skeleton and ci.fnl gating
When quire serve receives a push event, it checks each updated ref
for .quire/ci.fnl via git show. If present, a run record is created
under runs/<repo>/pending/<run-id>/ with meta.yml and state.yml,
then immediately transitioned to complete (no eval yet). If absent,
no run is created and mirror push proceeds as before.

On startup, scan_orphans walks pending/ and active/ directories
across all repos. Pending orphans are completed; active orphans are
marked failed with a timestamp.

Layout: runs/<repo>/<state>/<run-id>/{meta.yml, state.yml}.
State transitions are atomic directory renames. YAML files use
atomic write (temp file + rename).

New types: Runs (per-repo run factory), Run (single run on disk),
RunState, RunMeta, RunStateFile. Repo gains name(), runs(), and
has_ci_fnl() methods.

Assisted-by: GLM-5.1
change xrkumorwtqqwmmtwmunrpzssvkxtlwnp
commit 9e7cdf79bd181428614ca735428e2d5b609ae50d
author Alpha Chen <alpha@kejadlen.dev>
date
parent ztpzzvrx
diff --git a/Cargo.lock b/Cargo.lock
index 7b307a7..5c27fd3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1155,6 +1155,47 @@ version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
 
+[[package]]
+name = "jiff"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
+dependencies = [
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
 [[package]]
 name = "jni"
 version = "0.22.4"
@@ -1719,6 +1760,21 @@ version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
 
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
 [[package]]
 name = "potential_utf"
 version = "0.1.5"
@@ -1858,6 +1914,7 @@ dependencies = [
  "clap",
  "clap_complete",
  "fs-err",
+ "jiff",
  "miette",
  "mlua",
  "predicates",
@@ -1866,12 +1923,15 @@ dependencies = [
  "sentry-tracing",
  "serde",
  "serde_json",
+ "serde_yaml_ng",
  "shell-words",
  "tempfile",
  "thiserror",
  "tokio",
  "tracing",
  "tracing-subscriber",
+ "uuid",
+ "walkdir",
 ]
 
 [[package]]
@@ -2396,6 +2456,19 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "serde_yaml_ng"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -2885,6 +2958,12 @@ version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
 
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
 [[package]]
 name = "untrusted"
 version = "0.9.0"
@@ -2956,6 +3035,7 @@ version = "1.23.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
 dependencies = [
+ "getrandom 0.4.2",
  "js-sys",
  "serde_core",
  "wasm-bindgen",
diff --git a/Cargo.toml b/Cargo.toml
index bba5b4e..643b239 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ base64 = "*"
 clap = { version = "*", features = ["derive", "env"] }
 clap_complete = "*"
 fs-err = "*"
+jiff = "*"
 miette = { version = "*", features = ["fancy"] }
 mlua = { version = "*", features = ["lua54", "serde", "vendored"] }
 regex = "*"
@@ -20,11 +21,14 @@ sentry = { version = "*", features = ["backtrace", "contexts", "debug-images", "
 sentry-tracing = "*"
 serde = { version = "*", features = ["derive"] }
 serde_json = "*"
+serde_yaml_ng = "*"
 shell-words = "*"
 thiserror = "*"
 tokio = { version = "*", features = ["full"] }
 tracing = "*"
 tracing-subscriber = { version = "*", features = ["env-filter"] }
+uuid = { version = "*", features = ["v7"] }
+walkdir = "*"
 
 [dev-dependencies]
 assert_cmd = "*"
diff --git a/src/bin/quire/commands/repo.rs b/src/bin/quire/commands/repo.rs
index eacece8..9b98f22 100644
--- a/src/bin/quire/commands/repo.rs
+++ b/src/bin/quire/commands/repo.rs
@@ -28,8 +28,8 @@ pub async fn new(quire: &Quire, name: &str) -> Result<()> {
 }
 
 pub async fn list(quire: &Quire) -> Result<()> {
-    for name in quire.repos()? {
-        println!("{name}");
+    for repo in quire.repos()? {
+        println!("{}", repo.name());
     }
     Ok(())
 }
diff --git a/src/bin/quire/commands/serve.rs b/src/bin/quire/commands/serve.rs
index 1867c6d..62bb685 100644
--- a/src/bin/quire/commands/serve.rs
+++ b/src/bin/quire/commands/serve.rs
@@ -5,6 +5,7 @@ use axum::Router;
 use axum::routing::get;
 use miette::{Context, IntoDiagnostic, Result};
 use quire::Quire;
+use quire::run;
 
 async fn health() -> &'static str {
     "ok"
@@ -36,6 +37,11 @@ pub async fn run(quire: &Quire) -> Result<()> {
 
     tracing::info!(path = %socket_path.display(), "listening on event socket");
 
+    // Scan for orphaned runs from a previous server instance.
+    for repo in quire.repos().context("failed to list repos")? {
+        repo.runs().reconcile_orphans();
+    }
+
     let quire_handle = quire.clone();
     let event_handle = tokio::spawn(event_listener(listener, quire_handle));
 
@@ -118,6 +124,51 @@ async fn dispatch_push(quire: &Quire, event: &quire::event::PushEvent) {
         }
     };
 
+    // CI gating: check each updated ref for .quire/ci.fnl.
+    for push_ref in &event.refs {
+        // Skip deletions (all-zero new sha).
+        if push_ref.new_sha == "0000000000000000000000000000000000000000" {
+            continue;
+        }
+
+        if repo.has_ci_fnl(&push_ref.new_sha) {
+            let meta = run::RunMeta {
+                sha: push_ref.new_sha.clone(),
+                r#ref: push_ref.r#ref.clone(),
+                pushed_at: event.pushed_at.clone(),
+            };
+
+            let runs = repo.runs();
+            match runs.create(&meta) {
+                Ok(mut run) => {
+                    tracing::info!(
+                        run_id = %run.id(),
+                        sha = %push_ref.new_sha,
+                        r#ref = %push_ref.r#ref,
+                        "created CI run"
+                    );
+
+                    // No eval yet — immediately complete.
+                    if let Err(e) = run.transition(run::RunState::Complete) {
+                        tracing::error!(
+                            run_id = %run.id(),
+                            %e,
+                            "failed to transition run to complete"
+                        );
+                    }
+                }
+                Err(e) => {
+                    tracing::error!(
+                        repo = %event.repo,
+                        %e,
+                        "failed to create CI run"
+                    );
+                }
+            }
+        }
+    }
+
+    // Mirror push — proceeds regardless of CI.
     let config = match repo.config() {
         Ok(c) => c,
         Err(e) => {
diff --git a/src/lib.rs b/src/lib.rs
index ac38b72..04a406f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,6 +2,7 @@ mod error;
 pub mod event;
 pub mod fennel;
 pub mod quire;
+pub mod run;
 pub mod secret;
 
 pub use error::Error;
diff --git a/src/quire.rs b/src/quire.rs
index 86e9438..ddc46b9 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -63,7 +63,8 @@ where
 ///
 /// Created by `Quire::repo` after validating the name.
 pub struct Repo {
-    path: PathBuf,
+    base_dir: PathBuf,
+    name: String,
 }
 
 impl Repo {
@@ -75,7 +76,8 @@ impl Repo {
     pub fn new(base: &Path, name: &str) -> Result<Self> {
         Self::validate_name(name)?;
         Ok(Self {
-            path: base.join(name),
+            base_dir: base.to_path_buf(),
+            name: name.to_string(),
         })
     }
 
@@ -91,7 +93,8 @@ impl Repo {
         let name = relative.to_string_lossy();
         Self::validate_name(&name)?;
         Ok(Self {
-            path: path.to_path_buf(),
+            base_dir: base.to_path_buf(),
+            name: name.to_string(),
         })
     }
 
@@ -121,12 +124,17 @@ impl Repo {
         Ok(())
     }
 
-    pub fn path(&self) -> &Path {
-        &self.path
+    pub fn path(&self) -> PathBuf {
+        self.base_dir.join(&self.name)
+    }
+
+    /// The repo name relative to the repos directory (e.g. `foo.git`).
+    pub fn name(&self) -> &str {
+        &self.name
     }
 
     pub fn exists(&self) -> bool {
-        self.path.is_dir()
+        self.path().is_dir()
     }
 
     /// Start a git command rooted in this bare repo.
@@ -135,10 +143,27 @@ impl Repo {
     /// `.status()`, `.output()`, or anything else.
     pub fn git(&self, args: &[&str]) -> std::process::Command {
         let mut cmd = std::process::Command::new("git");
-        cmd.args(args).current_dir(&self.path);
+        cmd.args(args).current_dir(self.path());
         cmd
     }
 
+    /// Access CI runs for this repo.
+    pub fn runs(&self) -> crate::run::Runs {
+        crate::run::Runs::new(self.base_dir.join("runs").join(&self.name))
+    }
+
+    /// Check whether this bare repo has `.quire/ci.fnl` at a given commit SHA.
+    ///
+    /// Returns true if the file exists (git show exit code 0), false otherwise.
+    pub fn has_ci_fnl(&self, sha: &str) -> bool {
+        self.git(&["show", &format!("{sha}:.quire/ci.fnl")])
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null())
+            .status()
+            .map(|s| s.success())
+            .unwrap_or(false)
+    }
+
     /// Push `main` to the configured mirror, injecting the GitHub token via
     /// `http.extraHeader` so it never appears in the URL or git's error output.
     ///
@@ -244,13 +269,16 @@ pub struct Quire {
 
 impl Default for Quire {
     fn default() -> Self {
-        Self {
-            base_dir: PathBuf::from("/var/quire"),
-        }
+        Self::new(PathBuf::from("/var/quire"))
     }
 }
 
 impl Quire {
+    /// Create a `Quire` rooted at the given base directory.
+    pub fn new(base_dir: PathBuf) -> Self {
+        Self { base_dir }
+    }
+
     pub fn base_dir(&self) -> &Path {
         &self.base_dir
     }
@@ -296,47 +324,31 @@ impl Quire {
         Repo::from_path(&self.repos_dir(), path)
     }
 
-    /// List all repository names under the repos directory.
-    pub fn repos(&self) -> Result<impl Iterator<Item = String> + '_> {
+    /// List all repositories under the repos directory.
+    ///
+    /// Walks at most two levels deep, collecting directories ending in `.git`.
+    /// This enforces the "at most one level of grouping" rule structurally.
+    pub fn repos(&self) -> Result<impl Iterator<Item = Repo>> {
         let repos_dir = self.repos_dir();
-        let entries = fs_err::read_dir(&repos_dir).into_diagnostic()?;
-
-        let mut repos: Vec<String> = Vec::new();
-        for entry in entries {
-            let entry = entry.into_diagnostic()?;
-            let path = entry.path();
-
-            if !path.is_dir() {
-                continue;
-            }
-
-            let Ok(relative) = path.strip_prefix(&repos_dir) else {
-                continue;
-            };
-            let name = relative.to_string_lossy();
 
-            // Top-level .git directory.
-            if name.ends_with(".git") {
-                repos.push(name.to_string());
-                continue;
-            }
-
-            // Group directory — collect .git children.
-            let Ok(children) = fs_err::read_dir(&path) else {
-                continue;
-            };
-            for child in children {
-                let child = child.into_diagnostic()?;
-                let child_name = child.file_name();
-                let child_name = child_name.to_string_lossy();
-                if child_name.ends_with(".git") && child.path().is_dir() {
-                    let full = format!("{}/{}", name, child_name);
-                    repos.push(full);
+        let mut repos: Vec<Repo> = walkdir::WalkDir::new(&repos_dir)
+            .max_depth(2)
+            .into_iter()
+            .filter_map(|entry| entry.ok())
+            .filter(|entry| entry.file_type().is_dir())
+            .filter_map(|entry| {
+                let name = entry.path().strip_prefix(&repos_dir).ok()?;
+                let name = name.to_string_lossy();
+                if name.ends_with(".git") {
+                    Some(name.to_string())
+                } else {
+                    None
                 }
-            }
-        }
+            })
+            .map(|name| Repo::new(&repos_dir, &name))
+            .collect::<Result<Vec<_>>>()?;
 
-        repos.sort();
+        repos.sort_by(|a, b| a.name().cmp(b.name()));
         Ok(repos.into_iter())
     }
 }
@@ -425,7 +437,10 @@ mod tests {
 
         git(&["init", "--bare", "-b", "main"]);
 
-        let repo = Repo { path: bare };
+        let repo = Repo {
+            name: "test.git".to_string(),
+            base_dir: bare.parent().unwrap().to_path_buf(),
+        };
 
         (dir, repo)
     }
@@ -472,7 +487,10 @@ mod tests {
             &work,
         );
 
-        let repo = Repo { path: bare };
+        let repo = Repo {
+            name: "test.git".to_string(),
+            base_dir: bare.parent().unwrap().to_path_buf(),
+        };
         (dir, repo)
     }
 
@@ -577,7 +595,10 @@ mod tests {
     fn repo_config_loads_mirror_url() {
         let dir = bare_repo_with_config(r#"{:mirror {:url "https://github.com/owner/repo.git"}}"#);
         let bare = dir.path().join("repos").join("test.git");
-        let repo = Repo { path: bare };
+        let repo = Repo {
+            name: "test.git".to_string(),
+            base_dir: bare.parent().unwrap().to_path_buf(),
+        };
 
         let config = repo.config().expect("config should load");
         assert_eq!(
@@ -606,7 +627,10 @@ mod tests {
     fn repo_config_returns_no_mirror_when_key_absent() {
         let dir = bare_repo_with_config("{}");
         let bare = dir.path().join("repos").join("test.git");
-        let repo = Repo { path: bare };
+        let repo = Repo {
+            name: "test.git".to_string(),
+            base_dir: bare.parent().unwrap().to_path_buf(),
+        };
 
         let config = repo.config().expect("should return default config");
         assert_eq!(config.mirror, None);
@@ -616,7 +640,10 @@ mod tests {
     fn repo_config_errors_on_malformed_fennel() {
         let dir = bare_repo_with_config("{:bad {:}");
         let bare = dir.path().join("repos").join("test.git");
-        let repo = Repo { path: bare };
+        let repo = Repo {
+            name: "test.git".to_string(),
+            base_dir: bare.parent().unwrap().to_path_buf(),
+        };
 
         let err = repo.config().unwrap_err();
         // The error message should reference the config path.
@@ -766,7 +793,8 @@ mod tests {
         git_in(&target, &["init", "--bare", "-b", "main"]);
 
         let repo = Repo {
-            path: source.clone(),
+            base_dir: source.parent().unwrap().to_path_buf(),
+            name: "source.git".to_string(),
         };
         let mirror = MirrorConfig {
             url: format!("file://{}", target.display()),
@@ -785,7 +813,10 @@ mod tests {
 
         make_bare_with_main(&work, &source);
 
-        let repo = Repo { path: source };
+        let repo = Repo {
+            base_dir: source.parent().unwrap().to_path_buf(),
+            name: "source.git".to_string(),
+        };
         let mirror = MirrorConfig {
             url: "file:///nonexistent/quire-test/target.git".to_string(),
         };
@@ -802,7 +833,10 @@ mod tests {
             r#"{:mirror {:url "https://x:token@github.com/owner/repo.git"}}"#,
         );
         let bare = dir.path().join("repos").join("test.git");
-        let repo = Repo { path: bare };
+        let repo = Repo {
+            name: "test.git".to_string(),
+            base_dir: bare.parent().unwrap().to_path_buf(),
+        };
 
         let err = repo.config().unwrap_err();
         let msg = err.to_string();
diff --git a/src/run.rs b/src/run.rs
new file mode 100644
index 0000000..ac7f032
--- /dev/null
+++ b/src/run.rs
@@ -0,0 +1,484 @@
+use std::path::{Path, PathBuf};
+
+use crate::Result;
+
+/// The state of a CI run.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RunState {
+    Pending,
+    Active,
+    Complete,
+    Failed,
+}
+
+impl RunState {
+    /// The directory name used for this state in the run storage layout.
+    pub fn dir_name(&self) -> &'static str {
+        match self {
+            RunState::Pending => "pending",
+            RunState::Active => "active",
+            RunState::Complete => "complete",
+            RunState::Failed => "failed",
+        }
+    }
+}
+
+/// Immutable metadata for a CI run. Written once and never modified.
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct RunMeta {
+    /// The commit SHA that triggered this run.
+    pub sha: String,
+    /// The full ref name (e.g. `refs/heads/main`).
+    pub r#ref: String,
+    /// ISO 8601 timestamp of when the push occurred.
+    pub pushed_at: String,
+}
+
+/// Mutable state for a CI run. Updated throughout the run lifecycle.
+#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct RunStateFile {
+    /// Current status of the run.
+    pub status: RunState,
+    /// ISO 8601 timestamp of when the run was picked up (moved to active).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub started_at: Option<String>,
+    /// ISO 8601 timestamp of when the run finished (moved to complete/failed).
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub finished_at: Option<String>,
+}
+
+/// Access to CI runs for a single repo.
+///
+/// Owns the base path (`runs/<repo>/`) and provides run creation.
+/// Obtain one via `Repo::runs()`.
+#[derive(Debug)]
+pub struct Runs {
+    base: PathBuf,
+}
+
+impl Runs {
+    pub fn new(base: PathBuf) -> Self {
+        Self { base }
+    }
+
+    /// Create a new run record in the `pending` state.
+    ///
+    /// Writes `meta.yml` and `state.yml` atomically (temp dir + rename).
+    pub fn create(&self, meta: &RunMeta) -> Result<Run> {
+        let pending_dir = self.base.join(RunState::Pending.dir_name());
+        let id = uuid::Uuid::now_v7().to_string();
+
+        fs_err::create_dir_all(&pending_dir)?;
+
+        let tmp_dir = pending_dir.join(format!(".tmp-{id}"));
+        fs_err::create_dir_all(&tmp_dir)?;
+
+        write_yaml(&tmp_dir.join("meta.yml"), meta)?;
+        write_yaml(
+            &tmp_dir.join("state.yml"),
+            &RunStateFile {
+                status: RunState::Pending,
+                started_at: None,
+                finished_at: None,
+            },
+        )?;
+
+        let final_dir = pending_dir.join(&id);
+        fs_err::rename(&tmp_dir, &final_dir)?;
+
+        Ok(Run {
+            base: self.base.clone(),
+            state: RunState::Pending,
+            id,
+        })
+    }
+
+    /// Scan for orphaned runs in `pending/` and `active/` directories.
+    ///
+    /// The caller decides how to reconcile them:
+    /// - `pending/` entries should be re-enqueued.
+    /// - `active/` entries with no live runner should be marked failed.
+    pub fn scan_orphans(&self) -> Vec<OrphanedRun> {
+        let mut orphans = Vec::new();
+
+        for &state in &[RunState::Pending, RunState::Active] {
+            let state_path = self.base.join(state.dir_name());
+            let Ok(entries) = fs_err::read_dir(&state_path) else {
+                continue;
+            };
+
+            for entry in entries.flatten() {
+                let name = match entry.file_name().to_str() {
+                    Some(n) => n.to_string(),
+                    None => continue,
+                };
+
+                // Skip temp files.
+                if name.starts_with('.') {
+                    continue;
+                }
+
+                let path = entry.path();
+
+                let meta = match read_yaml::<RunMeta>(&path.join("meta.yml")) {
+                    Ok(m) => m,
+                    Err(e) => {
+                        tracing::warn!(
+                            path = %path.display(),
+                            %e,
+                            "skipping orphaned run: cannot read meta"
+                        );
+                        continue;
+                    }
+                };
+
+                let state_file = match read_yaml::<RunStateFile>(&path.join("state.yml")) {
+                    Ok(s) => s,
+                    Err(e) => {
+                        tracing::warn!(
+                            path = %path.display(),
+                            %e,
+                            "skipping orphaned run: cannot read state"
+                        );
+                        continue;
+                    }
+                };
+
+                orphans.push(OrphanedRun {
+                    run: Run::open(self.base.clone(), state, name),
+                    meta,
+                    state: state_file,
+                });
+            }
+        }
+
+        orphans
+    }
+
+    /// Reconcile orphaned runs from a previous server instance.
+    ///
+    /// - `pending/` orphans are moved to `complete/` (will be re-enqueued when
+    ///   the runner exists; for now, immediately completed).
+    /// - `active/` orphans are moved to `failed/` (no live runner).
+    pub fn reconcile_orphans(&self) {
+        let orphans = self.scan_orphans();
+        for orphan in &orphans {
+            tracing::warn!(
+                run_id = %orphan.run.id(),
+                state = ?orphan.run.state(),
+                "found orphaned run"
+            );
+        }
+
+        for mut orphan in orphans {
+            match orphan.run.state() {
+                RunState::Pending => {
+                    tracing::warn!(
+                        run_id = %orphan.run.id(),
+                        "completing orphaned pending run"
+                    );
+                    if let Err(e) = orphan.run.transition(RunState::Complete) {
+                        tracing::error!(
+                            run_id = %orphan.run.id(),
+                            %e,
+                            "failed to transition orphaned pending run"
+                        );
+                    }
+                }
+                RunState::Active => {
+                    tracing::warn!(
+                        run_id = %orphan.run.id(),
+                        "marking orphaned active run as failed"
+                    );
+                    if let Err(e) = orphan.run.transition(RunState::Failed) {
+                        tracing::error!(
+                            run_id = %orphan.run.id(),
+                            %e,
+                            "failed to transition orphaned active run to failed"
+                        );
+                        continue;
+                    }
+                    if let Err(e) = orphan.run.write_state(&RunStateFile {
+                        status: RunState::Failed,
+                        started_at: orphan.state.started_at.clone(),
+                        finished_at: Some(jiff::Zoned::now().to_string()),
+                    }) {
+                        tracing::error!(
+                            run_id = %orphan.run.id(),
+                            %e,
+                            "failed to write state for failed run"
+                        );
+                    }
+                }
+                _ => unreachable!("scan_orphans only returns pending/active"),
+            }
+        }
+    }
+}
+
+/// A CI run on disk.
+///
+/// Owns the path to the run directory. Tracks current state so that
+/// `transition` can move the directory in one call.
+#[derive(Debug)]
+pub struct Run {
+    base: PathBuf,
+    state: RunState,
+    id: String,
+}
+
+impl Run {
+    /// The resolved path to this run's directory on disk.
+    pub fn path(&self) -> PathBuf {
+        self.base.join(self.state.dir_name()).join(&self.id)
+    }
+
+    /// The run's ID.
+    pub fn id(&self) -> &str {
+        &self.id
+    }
+
+    /// The run's current state.
+    pub fn state(&self) -> RunState {
+        self.state
+    }
+
+    /// Open an existing run at a known state.
+    ///
+    /// Does not verify the directory exists — used by the orphan scanner
+    /// which already read the directory listing.
+    fn open(base: PathBuf, state: RunState, id: String) -> Self {
+        Self { base, state, id }
+    }
+
+    /// Transition the run from its current state to a new state.
+    ///
+    /// Moves the run directory between state parent directories and updates
+    /// the tracked state.
+    pub fn transition(&mut self, to: RunState) -> Result<()> {
+        let src = self.path();
+        let dst_parent = self.base.join(to.dir_name());
+
+        if !src.exists() {
+            return Err(crate::Error::Io(std::io::Error::new(
+                std::io::ErrorKind::NotFound,
+                format!("run directory not found: {}", src.display()),
+            )));
+        }
+
+        fs_err::create_dir_all(&dst_parent)?;
+        let dst = dst_parent.join(&self.id);
+        fs_err::rename(&src, &dst)?;
+        self.state = to;
+        Ok(())
+    }
+
+    /// Read the mutable state file for this run.
+    pub fn read_state(&self) -> Result<RunStateFile> {
+        read_yaml(&self.path().join("state.yml"))
+    }
+
+    /// Read the immutable metadata for this run.
+    pub fn read_meta(&self) -> Result<RunMeta> {
+        read_yaml(&self.path().join("meta.yml"))
+    }
+
+    /// Update the state file for this run (atomic write).
+    pub fn write_state(&self, state: &RunStateFile) -> Result<()> {
+        write_yaml(&self.path().join("state.yml"), state)
+    }
+}
+
+/// An orphaned run found during startup scan.
+#[derive(Debug)]
+pub struct OrphanedRun {
+    pub run: Run,
+    pub meta: RunMeta,
+    pub state: RunStateFile,
+}
+
+/// Write a value to a YAML file atomically.
+fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
+    let tmp_path = path.with_extension("yml.tmp");
+    let f = fs_err::File::create(&tmp_path)?;
+    serde_yaml_ng::to_writer(std::io::BufWriter::new(f), value)
+        .map_err(|e| crate::Error::Io(std::io::Error::other(format!("yaml write error: {e}"))))?;
+    fs_err::rename(&tmp_path, path)?;
+    Ok(())
+}
+
+/// Read a value from a YAML file.
+fn read_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
+    let f = fs_err::File::open(path)?;
+    serde_yaml_ng::from_reader(std::io::BufReader::new(f))
+        .map_err(|e| crate::Error::Io(std::io::Error::other(format!("yaml read error: {e}"))))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::Quire;
+
+    fn tmp_quire() -> (tempfile::TempDir, Quire) {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let quire = Quire::new(dir.path().to_path_buf());
+        (dir, quire)
+    }
+
+    fn test_meta() -> RunMeta {
+        RunMeta {
+            sha: "abc123".to_string(),
+            r#ref: "refs/heads/main".to_string(),
+            pushed_at: "2026-04-28T12:00:00Z".to_string(),
+        }
+    }
+
+    #[test]
+    fn run_state_dir_name() {
+        assert_eq!(RunState::Pending.dir_name(), "pending");
+        assert_eq!(RunState::Active.dir_name(), "active");
+        assert_eq!(RunState::Complete.dir_name(), "complete");
+        assert_eq!(RunState::Failed.dir_name(), "failed");
+    }
+
+    #[test]
+    fn create_generates_uuidv7_id() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let run = runs.create(&test_meta()).expect("create");
+        let parsed = uuid::Uuid::parse_str(run.id()).expect("should be valid UUID");
+        assert_eq!(parsed.get_version(), Some(uuid::Version::SortRand));
+    }
+
+    #[test]
+    fn create_writes_files_in_pending() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let run = runs.create(&test_meta()).expect("create");
+
+        let path = run.path();
+        assert!(path.exists(), "run directory should exist");
+        assert!(path.join("meta.yml").exists());
+        assert!(path.join("state.yml").exists());
+        assert_eq!(run.state(), RunState::Pending);
+
+        let meta = run.read_meta().expect("read meta");
+        assert_eq!(meta.sha, "abc123");
+
+        let state = run.read_state().expect("read state");
+        assert_eq!(state.status, RunState::Pending);
+        assert!(state.started_at.is_none());
+    }
+
+    #[test]
+    fn transition_moves_directory() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let mut run = runs.create(&test_meta()).expect("create");
+        let id = run.id().to_string();
+
+        let old_path = run.path();
+        run.transition(RunState::Active).expect("transition");
+
+        assert!(!old_path.exists(), "pending dir should be gone");
+        assert_eq!(run.state(), RunState::Active);
+
+        let new_path = run.path();
+        assert!(new_path.exists(), "active dir should exist");
+
+        // Meta is byte-identical after move.
+        let meta = run.read_meta().expect("read meta");
+        assert_eq!(meta.sha, "abc123");
+        assert_eq!(run.id(), id);
+    }
+
+    #[test]
+    fn transition_full_lifecycle() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let mut run = runs.create(&test_meta()).expect("create");
+
+        run.transition(RunState::Active).expect("to active");
+        run.transition(RunState::Complete).expect("to complete");
+
+        assert_eq!(run.state(), RunState::Complete);
+        assert!(run.path().exists());
+    }
+
+    #[test]
+    fn transition_errors_on_missing_source() {
+        let mut run = Run {
+            base: PathBuf::from("/tmp/quire-test-runs/test.git"),
+            state: RunState::Pending,
+            id: uuid::Uuid::now_v7().to_string(),
+        };
+
+        let result = run.transition(RunState::Active);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn scan_orphans_finds_pending() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let run = runs.create(&test_meta()).expect("create");
+
+        let orphans = runs.scan_orphans();
+        assert_eq!(orphans.len(), 1);
+        assert_eq!(orphans[0].run.id(), run.id());
+        assert_eq!(orphans[0].run.state(), RunState::Pending);
+    }
+
+    #[test]
+    fn scan_orphans_finds_active() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let mut run = runs.create(&test_meta()).expect("create");
+        run.transition(RunState::Active).expect("transition");
+
+        let orphans = runs.scan_orphans();
+        assert_eq!(orphans.len(), 1);
+        assert_eq!(orphans[0].run.state(), RunState::Active);
+    }
+
+    #[test]
+    fn scan_orphans_skips_complete() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let mut run = runs.create(&test_meta()).expect("create");
+        run.transition(RunState::Complete).expect("transition");
+
+        let orphans = runs.scan_orphans();
+        assert!(orphans.is_empty(), "complete runs are not orphans");
+    }
+
+    #[test]
+    fn scan_orphans_empty_when_no_runs_dir() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        assert!(runs.scan_orphans().is_empty());
+    }
+
+    #[test]
+    fn write_state_updates_in_place() {
+        let (_dir, quire) = tmp_quire();
+        let runs = Runs::new(quire.base_dir().join("runs").join("test.git"));
+        let run = runs.create(&test_meta()).expect("create");
+
+        run.write_state(&RunStateFile {
+            status: RunState::Active,
+            started_at: Some("2026-04-28T12:00:01Z".to_string()),
+            finished_at: None,
+        })
+        .expect("write state");
+
+        let loaded = run.read_state().expect("read state");
+        assert_eq!(loaded.status, RunState::Active);
+        assert_eq!(loaded.started_at.as_deref(), Some("2026-04-28T12:00:01Z"));
+
+        // Meta is unchanged.
+        let loaded_meta = run.read_meta().expect("read meta");
+        assert_eq!(loaded_meta, test_meta());
+    }
+}