Separate Ci and Runs, extract CI_FNL constant
Ci only holds repo_path for eval/validation. Runs owns runs_base
for the run lifecycle. Ci::runs() takes runs_base as an argument.
The .quire/ci.fnl path is now CI_FNL so it is not repeated.
Assisted-by: GLM-5.1 via pi
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index f35e580..59255d4 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -4,39 +4,41 @@ pub mod graph;
pub mod run;
pub use graph::{EvalResult, JobDef, ValidationError, eval_ci, validate};
-pub use run::{Run, RunMeta, RunState, RunTimes};
+pub use run::{Run, RunMeta, RunState, RunTimes, Runs};
use std::path::{Path, PathBuf};
use crate::Result;
use crate::event::{PushEvent, PushRef};
-use crate::fennel::Fennel;
use crate::quire::Repo;
+/// Path to the CI config within a bare repo, relative to the repo root.
+pub const CI_FNL: &str = ".quire/ci.fnl";
+
/// Access to CI operations for a single repo.
///
-/// Owns the base path for runs (`runs/<repo>/`) and provides eval,
-/// validation, and run lifecycle methods. Obtain one via `Repo::ci()`.
+/// Provides eval and validation methods scoped to a bare repo.
+/// Obtain one via `Repo::ci()`. Run lifecycle is on `Runs`, obtainable
+/// via `Repo::runs()`.
pub struct Ci {
repo_path: PathBuf,
- runs_base: PathBuf,
}
impl Ci {
- pub(crate) fn new(repo_path: PathBuf, runs_base: PathBuf) -> Self {
- Self {
- repo_path,
- runs_base,
- }
+ pub(crate) fn new(repo_path: PathBuf) -> Self {
+ Self { repo_path }
}
- // --- Eval & validation ---
+ /// Access CI runs for this repo.
+ pub fn runs(&self, runs_base: PathBuf) -> Runs {
+ Runs::new(runs_base)
+ }
/// Evaluate ci.fnl at a given SHA and return the registration table.
pub fn eval(&self, sha: &str) -> Result<EvalResult> {
let source = self.ci_fnl_source(sha)?;
- let fennel = Fennel::new()?;
- let name = format!("{sha}:.quire/ci.fnl");
+ let fennel = crate::fennel::Fennel::new()?;
+ let name = format!("{sha}:{CI_FNL}");
let result = eval_ci(&fennel, &source, &name)?;
Ok(result)
}
@@ -52,7 +54,7 @@ impl Ci {
pub fn validate_file(path: &Path) -> Result<EvalResult> {
let source = fs_err::read_to_string(path)?;
let name = path.display().to_string();
- let fennel = Fennel::new()?;
+ let fennel = crate::fennel::Fennel::new()?;
let result = eval_ci(&fennel, &source, &name)?;
validate(&result.jobs)?;
Ok(result)
@@ -60,7 +62,7 @@ impl Ci {
/// Check whether this bare repo has `.quire/ci.fnl` at a given commit SHA.
fn has_ci_fnl(&self, sha: &str) -> bool {
- self.git(&["show", &format!("{sha}:.quire/ci.fnl")])
+ self.git(&["show", &format!("{sha}:{CI_FNL}")])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
@@ -71,7 +73,7 @@ impl Ci {
/// Read the contents of `.quire/ci.fnl` at a given commit SHA.
fn ci_fnl_source(&self, sha: &str) -> Result<String> {
let output = self
- .git(&["show", &format!("{sha}:.quire/ci.fnl")])
+ .git(&["show", &format!("{sha}:{CI_FNL}")])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()?;
@@ -79,7 +81,7 @@ impl Ci {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(crate::Error::Git(format!(
- "failed to read ci.fnl at {sha}: {stderr}"
+ "failed to read {CI_FNL} at {sha}: {stderr}"
)));
}
@@ -92,126 +94,6 @@ impl Ci {
cmd.args(args).current_dir(&self.repo_path);
cmd
}
-
- // --- Run lifecycle ---
-
- /// Create a new run record in the `pending` state.
- ///
- /// Writes `meta.yml` and `times.yml` atomically (temp dir + rename).
- pub fn create_run(&self, meta: &RunMeta) -> Result<Run> {
- let pending_dir = self.runs_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)?;
-
- run::write_yaml(&tmp_dir.join("meta.yml"), meta)?;
- run::write_yaml(&tmp_dir.join("times.yml"), &RunTimes::default())?;
-
- let final_dir = pending_dir.join(&id);
- fs_err::rename(&tmp_dir, &final_dir)?;
-
- Run::open(self.runs_base.clone(), RunState::Pending, id)
- }
-
- /// Scan for orphaned runs in `pending/` and `active/` directories.
- pub fn scan_orphans(&self) -> Result<Vec<Run>> {
- let mut orphans = Vec::new();
-
- for &state in &[RunState::Pending, RunState::Active] {
- let state_path = self.runs_base.join(state.dir_name());
- let entries = match fs_err::read_dir(&state_path) {
- Ok(entries) => entries,
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
- Err(e) => return Err(e.into()),
- };
-
- for entry in entries {
- let entry = entry?;
- let name = match entry.file_name().to_str() {
- Some(n) => n.to_string(),
- None => continue,
- };
-
- if name.starts_with('.') {
- continue;
- }
-
- match Run::open(self.runs_base.clone(), state, name.clone()) {
- Ok(run) => orphans.push(run),
- Err(e) => {
- tracing::warn!(
- state = ?state,
- run_id = %name,
- %e,
- "quarantining unreadable run to failed/"
- );
- self.quarantine(&state_path.join(&name), &name)?;
- }
- }
- }
- }
-
- Ok(orphans)
- }
-
- /// Move a broken run directory into `failed/`.
- fn quarantine(&self, src: &Path, id: &str) -> Result<()> {
- let failed_dir = self.runs_base.join(RunState::Failed.dir_name());
- fs_err::create_dir_all(&failed_dir)?;
- fs_err::rename(src, failed_dir.join(id))?;
- Ok(())
- }
-
- /// Reconcile orphaned runs from a previous server instance.
- pub fn reconcile_orphans(&self) -> Result<()> {
- let orphans = self.scan_orphans()?;
- for orphan in &orphans {
- tracing::warn!(
- run_id = %orphan.id(),
- state = ?orphan.state(),
- "found orphaned run"
- );
- }
-
- for mut orphan in orphans {
- match orphan.state() {
- RunState::Pending => {
- tracing::warn!(
- run_id = %orphan.id(),
- "completing orphaned pending run"
- );
- if let Err(e) = orphan.transition(RunState::Complete) {
- tracing::error!(
- run_id = %orphan.id(),
- %e,
- "failed to transition orphaned pending run"
- );
- }
- }
- RunState::Active => {
- tracing::warn!(
- run_id = %orphan.id(),
- "marking orphaned active run as failed"
- );
- if let Err(e) = orphan.transition(RunState::Failed) {
- tracing::error!(
- run_id = %orphan.id(),
- %e,
- "failed to transition orphaned active run to failed"
- );
- }
- }
- RunState::Complete | RunState::Failed => {
- unreachable!("scan_orphans only returns pending/active")
- }
- }
- }
-
- Ok(())
- }
}
/// Trigger CI for a push event: scan each updated ref for `.quire/ci.fnl`,
@@ -255,7 +137,7 @@ fn trigger_ref(repo: &Repo, pushed_at: jiff::Timestamp, push_ref: &PushRef) -> R
pushed_at,
};
- let mut run = ci.create_run(&meta)?;
+ let mut run = ci.runs(repo.runs_base()).create(&meta)?;
tracing::info!(
run_id = %run.id(),
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 4fe7a37..4bca1bb 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -55,6 +55,153 @@ pub struct RunTimes {
pub finished_at: Option<Timestamp>,
}
+/// Access to CI runs for a single repo.
+///
+/// Owns the base path (`runs/<repo>/`) and provides run creation
+/// and orphan reconciliation. Obtain one via `Ci::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 `times.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("times.yml"), &RunTimes::default())?;
+
+ let final_dir = pending_dir.join(&id);
+ fs_err::rename(&tmp_dir, &final_dir)?;
+
+ Run::open(self.base.clone(), RunState::Pending, id)
+ }
+
+ /// Scan for orphaned runs in `pending/` and `active/` directories.
+ ///
+ /// Entries that cannot be opened (missing/unreadable `meta.yml` or
+ /// `times.yml`) are quarantined to `failed/` so they don't stay
+ /// stuck in pending/active forever.
+ ///
+ /// The caller decides how to reconcile the returned runs:
+ /// - `pending/` entries should be re-enqueued.
+ /// - `active/` entries with no live runner should be marked failed.
+ pub fn scan_orphans(&self) -> Result<Vec<Run>> {
+ let mut orphans = Vec::new();
+
+ for &state in &[RunState::Pending, RunState::Active] {
+ let state_path = self.base.join(state.dir_name());
+ let entries = match fs_err::read_dir(&state_path) {
+ Ok(entries) => entries,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(e) => return Err(e.into()),
+ };
+
+ for entry in entries {
+ let entry = entry?;
+ let name = match entry.file_name().to_str() {
+ Some(n) => n.to_string(),
+ None => continue,
+ };
+
+ if name.starts_with('.') {
+ continue;
+ }
+
+ match Run::open(self.base.clone(), state, name.clone()) {
+ Ok(run) => orphans.push(run),
+ Err(e) => {
+ tracing::warn!(
+ state = ?state,
+ run_id = %name,
+ %e,
+ "quarantining unreadable run to failed/"
+ );
+ self.quarantine(&state_path.join(&name), &name)?;
+ }
+ }
+ }
+ }
+
+ Ok(orphans)
+ }
+
+ /// Move a broken run directory into `failed/` so it stops blocking
+ /// pending/active. The contents may be unreadable; we only care
+ /// about getting it out of the active state buckets.
+ fn quarantine(&self, src: &Path, id: &str) -> Result<()> {
+ let failed_dir = self.base.join(RunState::Failed.dir_name());
+ fs_err::create_dir_all(&failed_dir)?;
+ fs_err::rename(src, failed_dir.join(id))?;
+ Ok(())
+ }
+
+ /// 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) -> Result<()> {
+ let orphans = self.scan_orphans()?;
+ for orphan in &orphans {
+ tracing::warn!(
+ run_id = %orphan.id(),
+ state = ?orphan.state(),
+ "found orphaned run"
+ );
+ }
+
+ for mut orphan in orphans {
+ match orphan.state() {
+ RunState::Pending => {
+ tracing::warn!(
+ run_id = %orphan.id(),
+ "completing orphaned pending run"
+ );
+ if let Err(e) = orphan.transition(RunState::Complete) {
+ tracing::error!(
+ run_id = %orphan.id(),
+ %e,
+ "failed to transition orphaned pending run"
+ );
+ }
+ }
+ RunState::Active => {
+ tracing::warn!(
+ run_id = %orphan.id(),
+ "marking orphaned active run as failed"
+ );
+ if let Err(e) = orphan.transition(RunState::Failed) {
+ tracing::error!(
+ run_id = %orphan.id(),
+ %e,
+ "failed to transition orphaned active run to failed"
+ );
+ }
+ }
+ RunState::Complete | RunState::Failed => {
+ unreachable!("scan_orphans only returns pending/active")
+ }
+ }
+ }
+
+ Ok(())
+ }
+}
+
/// A CI run on disk.
///
/// Owns the path to the run directory. Tracks current state so that
@@ -177,7 +324,6 @@ fn read_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
mod tests {
use super::*;
use crate::Quire;
- use crate::ci::Ci;
fn tmp_quire() -> (tempfile::TempDir, Quire) {
let dir = tempfile::tempdir().expect("tempdir");
@@ -185,11 +331,8 @@ mod tests {
(dir, quire)
}
- fn test_ci(quire: &Quire) -> Ci {
- Ci::new(
- quire.repos_dir().join("test.git"),
- quire.base_dir().join("runs").join("test.git"),
- )
+ fn test_runs(quire: &Quire) -> Runs {
+ Runs::new(quire.base_dir().join("runs").join("test.git"))
}
fn test_meta() -> RunMeta {
@@ -211,8 +354,8 @@ mod tests {
#[test]
fn create_generates_uuidv7_id() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ 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));
}
@@ -220,8 +363,8 @@ mod tests {
#[test]
fn create_writes_files_in_pending() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let run = runs.create(&test_meta()).expect("create");
let path = run.path();
assert!(path.exists(), "run directory should exist");
@@ -240,8 +383,8 @@ mod tests {
#[test]
fn transition_moves_directory() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let mut run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).expect("create");
let id = run.id().to_string();
let old_path = run.path();
@@ -262,8 +405,8 @@ mod tests {
#[test]
fn transition_stamps_started_at_on_active() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let mut run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).expect("create");
run.transition(RunState::Active).expect("to active");
let times = run.read_times().expect("read state");
@@ -274,9 +417,9 @@ mod tests {
#[test]
fn transition_stamps_finished_at_on_complete_and_failed() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
+ let runs = test_runs(&quire);
- let mut completed = ci.create_run(&test_meta()).expect("create");
+ let mut completed = runs.create(&test_meta()).expect("create");
completed.transition(RunState::Active).expect("to active");
completed
.transition(RunState::Complete)
@@ -284,7 +427,7 @@ mod tests {
let times = completed.read_times().expect("read state");
assert!(times.finished_at.is_some());
- let mut failed = ci.create_run(&test_meta()).expect("create");
+ let mut failed = runs.create(&test_meta()).expect("create");
failed.transition(RunState::Active).expect("to active");
failed.transition(RunState::Failed).expect("to failed");
let failed_times = failed.read_times().expect("read state");
@@ -294,14 +437,14 @@ mod tests {
#[test]
fn transition_rejects_invalid_transitions() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
+ let runs = test_runs(&quire);
// Pending -> Failed is not allowed (must go via Active).
- let mut run = ci.create_run(&test_meta()).expect("create");
+ let mut run = runs.create(&test_meta()).expect("create");
assert!(run.transition(RunState::Failed).is_err());
// Terminal -> anything is not allowed.
- let mut completed = ci.create_run(&test_meta()).expect("create");
+ let mut completed = runs.create(&test_meta()).expect("create");
completed.transition(RunState::Active).expect("to active");
completed
.transition(RunState::Complete)
@@ -313,8 +456,8 @@ mod tests {
#[test]
fn transition_preserves_started_at_through_completion() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let mut run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).expect("create");
run.transition(RunState::Active).expect("to active");
let active_times = run.read_times().expect("read state");
@@ -328,8 +471,8 @@ mod tests {
#[test]
fn transition_full_lifecycle() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let mut run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).expect("create");
run.transition(RunState::Active).expect("to active");
run.transition(RunState::Complete).expect("to complete");
@@ -353,10 +496,10 @@ mod tests {
#[test]
fn scan_orphans_finds_pending() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let run = runs.create(&test_meta()).expect("create");
- let orphans = ci.scan_orphans().expect("scan");
+ let orphans = runs.scan_orphans().expect("scan");
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].id(), run.id());
assert_eq!(orphans[0].state(), RunState::Pending);
@@ -365,11 +508,11 @@ mod tests {
#[test]
fn scan_orphans_finds_active() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let mut run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).expect("create");
run.transition(RunState::Active).expect("transition");
- let orphans = ci.scan_orphans().expect("scan");
+ let orphans = runs.scan_orphans().expect("scan");
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].state(), RunState::Active);
}
@@ -377,28 +520,28 @@ mod tests {
#[test]
fn scan_orphans_skips_complete() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let mut run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let mut run = runs.create(&test_meta()).expect("create");
run.transition(RunState::Complete).expect("transition");
- let orphans = ci.scan_orphans().expect("scan");
+ let orphans = runs.scan_orphans().expect("scan");
assert!(orphans.is_empty(), "complete runs are not orphans");
}
#[test]
fn scan_orphans_quarantines_unreadable_runs() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
+ let base = quire.base_dir().join("runs").join("test.git");
+ let runs = Runs::new(base.clone());
// Create a run, then break it by removing meta.yml.
- let run = ci.create_run(&test_meta()).expect("create");
+ let run = runs.create(&test_meta()).expect("create");
let id = run.id().to_string();
fs_err::remove_file(run.path().join("meta.yml")).expect("remove meta");
- let orphans = ci.scan_orphans().expect("scan");
+ let orphans = runs.scan_orphans().expect("scan");
assert!(orphans.is_empty(), "broken run should not be returned");
- let base = quire.base_dir().join("runs").join("test.git");
let pending = base.join(RunState::Pending.dir_name()).join(&id);
assert!(!pending.exists(), "broken run should leave pending/");
@@ -409,15 +552,15 @@ mod tests {
#[test]
fn scan_orphans_empty_when_no_runs_dir() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- assert!(ci.scan_orphans().expect("scan").is_empty());
+ let runs = test_runs(&quire);
+ assert!(runs.scan_orphans().expect("scan").is_empty());
}
#[test]
fn write_times_updates_in_place() {
let (_dir, quire) = tmp_quire();
- let ci = test_ci(&quire);
- let run = ci.create_run(&test_meta()).expect("create");
+ let runs = test_runs(&quire);
+ let run = runs.create(&test_meta()).expect("create");
let started: Timestamp = "2026-04-28T12:00:01Z".parse().expect("parse");
run.write_times(&RunTimes {
diff --git a/src/quire.rs b/src/quire.rs
index 2e11cd5..ef87ecf 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
use miette::{Context, IntoDiagnostic, Result, ensure};
-use crate::ci::Ci;
+use crate::ci::{Ci, Runs};
use crate::fennel::Fennel;
use crate::secret::SecretString;
use crate::{Error, Result as AppResult};
@@ -151,7 +151,17 @@ impl Repo {
/// Access CI operations for this repo.
pub fn ci(&self) -> Ci {
- Ci::new(self.path(), self.base_dir.join("runs").join(&self.name))
+ Ci::new(self.path())
+ }
+
+ /// The base directory for CI runs (`runs/<repo>/`).
+ pub fn runs_base(&self) -> PathBuf {
+ self.base_dir.join("runs").join(&self.name)
+ }
+
+ /// Access CI runs for this repo.
+ pub fn runs(&self) -> Runs {
+ Runs::new(self.runs_base())
}
/// Push `main` to the configured mirror, injecting the GitHub token via
diff --git a/src/server.rs b/src/server.rs
index a036e89..d307f0a 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -42,7 +42,7 @@ pub async fn run(quire: &Quire) -> Result<()> {
// Scan for orphaned runs from a previous server instance.
for repo in quire.repos().context("failed to list repos")? {
- repo.ci().reconcile_orphans()?;
+ repo.runs().reconcile_orphans()?;
}
let quire_handle = quire.clone();