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
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());
+ }
+}