Move runtime CI modules into `quire-core`
`pipeline`, `registration`, `mirror`, `runtime`, and `RunMeta` land
in `quire-core::ci`. quire-server's `ci::mod.rs` re-exports the
modules so existing `super::pipeline::*` (etc.) imports keep working
across the boundary. The orchestrator-only pieces — `Run`, `Runs`,
`RunState`, `materialize_workspace`, `reconcile_orphans`, the
`error::Error` kitchen-sink — stay in `quire-server::ci`.

Visibility note: `pub(super)`/`pub(crate)` items used by
quire-server (`Pipeline`, `RunFn`, `Runtime`, `RuntimeHandle`,
`ShOutput`, `compile`, etc.) are widened to `pub` since they now
cross a crate boundary. `Pipeline::replace_first_run_fn` is
ungated from `#[cfg(test)]` and marked `#[doc(hidden)]` — a
quire-server test still needs it, and a cross-crate test-helper
feature flag would be heavier than the small unused method in
release.

Sets up the next step: `quire-ci`'s main can now read
`.quire/ci.fnl` and call `quire_core::ci::pipeline::compile` to
return a Pipeline.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change toqquzytltwuvovrownkowpymszqxkrm
commit ccdba4fd9c6ff97e7f3ab885aadc0997a31f36ba
author Alpha Chen <alpha@kejadlen.dev>
date
parent rsotykrz
diff --git a/Cargo.lock b/Cargo.lock
index 27aa9ac..e846408 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2135,8 +2135,10 @@ name = "quire-core"
 version = "0.1.0"
 dependencies = [
  "fs-err",
+ "jiff",
  "miette",
  "mlua",
+ "petgraph",
  "regex",
  "serde",
  "serde_json",
diff --git a/quire-core/Cargo.toml b/quire-core/Cargo.toml
index deb6125..8c50272 100644
--- a/quire-core/Cargo.toml
+++ b/quire-core/Cargo.toml
@@ -5,8 +5,10 @@ edition = "2024"
 
 [dependencies]
 fs-err = "*"
+jiff = { version = "*", features = ["serde"] }
 miette = "*"
 mlua = { version = "*", features = ["lua54", "serde", "vendored", "error-send"] }
+petgraph = "*"
 regex = "*"
 serde = { version = "*", features = ["derive"] }
 thiserror = "*"
diff --git a/quire-server/src/ci/mirror.rs b/quire-core/src/ci/mirror.rs
similarity index 99%
rename from quire-server/src/ci/mirror.rs
rename to quire-core/src/ci/mirror.rs
index 265770c..fd5f27d 100644
--- a/quire-server/src/ci/mirror.rs
+++ b/quire-core/src/ci/mirror.rs
@@ -25,7 +25,7 @@ use super::runtime::{Cmd, Runtime, RuntimeError, RuntimeResult, ShOpts};
 
 /// Closure state for the `quire/mirror` job's run-fn: everything the
 /// tag-and-push needs at execute time, captured once at registration.
-pub(super) struct MirrorJob {
+pub struct MirrorJob {
     url: String,
     secret: String,
     /// Refs to push to the remote. Empty means "push whatever ref
@@ -189,7 +189,7 @@ impl MirrorJob {
     /// tag-and-push at execute time. Singleton-ness is enforced by
     /// generic id uniqueness in `Registration::add_job` — a second
     /// `(ci.mirror …)` collides on the `quire/mirror` id.
-    pub(super) fn register(lua: &Lua, (url, opts): (String, mlua::Table)) -> mlua::Result<()> {
+    pub fn register(lua: &Lua, (url, opts): (String, mlua::Table)) -> mlua::Result<()> {
         let r = lua.app_data_ref::<Registration>().ok_or_else(|| {
             mlua::Error::external("quire.ci registration not installed on Lua VM")
         })?;
@@ -236,7 +236,7 @@ mod tests {
     use crate::ci::pipeline::{Diagnostic, RustRunFn, compile};
     use crate::ci::run::RunMeta;
     use crate::ci::runtime::RuntimeHandle;
-    use quire_core::secret::{Error as SecretError, SecretString};
+    use crate::secret::{Error as SecretError, SecretString};
 
     /// Set up a bare git repo with one commit. Returns the tempdir,
     /// the bare repo path, and the head SHA.
diff --git a/quire-core/src/ci/mod.rs b/quire-core/src/ci/mod.rs
new file mode 100644
index 0000000..6d9f036
--- /dev/null
+++ b/quire-core/src/ci/mod.rs
@@ -0,0 +1,12 @@
+//! Compile-time and runtime CI primitives shared with `quire-ci`.
+//!
+//! The orchestrator lives in `quire-server::ci`; this module owns the
+//! pieces that need to run identically inside a per-run container
+//! (where `quire-ci` invokes them) and on the server (where the
+//! orchestrator drives them).
+
+pub mod mirror;
+pub mod pipeline;
+pub mod registration;
+pub mod run;
+pub mod runtime;
diff --git a/quire-server/src/ci/pipeline.rs b/quire-core/src/ci/pipeline.rs
similarity index 97%
rename from quire-server/src/ci/pipeline.rs
rename to quire-core/src/ci/pipeline.rs
index 899e01a..42b9971 100644
--- a/quire-server/src/ci/pipeline.rs
+++ b/quire-core/src/ci/pipeline.rs
@@ -12,7 +12,7 @@ use petgraph::graph::NodeIndex;
 use petgraph::visit::{Bfs, Reversed};
 
 use super::registration::{self, Registrations};
-use quire_core::fennel::{Fennel, FennelError};
+use crate::fennel::{Fennel, FennelError};
 
 /// A registration-time error caught while individual `(ci.job …)` and
 /// `(ci.image …)` calls are being processed.
@@ -104,14 +104,14 @@ pub struct Job {
     pub inputs: Vec<String>,
     /// Span covering the `(ci.job …)` call site. Used as the label
     /// location for both per-job and post-graph diagnostics.
-    pub(crate) span: SourceSpan,
+    pub span: SourceSpan,
     /// What to run when the executor reaches this job.
-    pub(super) run_fn: RunFn,
+    pub run_fn: RunFn,
 }
 
 /// A Rust-side run-fn: a closure invoked synchronously by the
 /// executor with the runtime in scope.
-pub(super) type RustRunFn =
+pub type RustRunFn =
     std::rc::Rc<dyn Fn(&super::runtime::Runtime) -> super::runtime::RuntimeResult<()>>;
 
 /// How a job runs at execute time.
@@ -126,7 +126,7 @@ pub(super) type RustRunFn =
 /// before invoking — `mlua::Function` is cheap to clone (a registry
 /// handle); the `Rc` makes the `Rust` variant cheap too.
 #[derive(Clone)]
-pub(super) enum RunFn {
+pub enum RunFn {
     Lua(mlua::Function),
     #[allow(dead_code)] // Wired up by `(ci.mirror …)` and friends.
     Rust(RustRunFn),
@@ -155,7 +155,7 @@ impl Job {
     ///
     /// Visible to the sibling `registration` module which constructs
     /// jobs from the registration callbacks.
-    pub(super) fn new(
+    pub fn new(
         id: String,
         inputs: Vec<String>,
         run_fn: RunFn,
@@ -209,7 +209,7 @@ impl Pipeline {
     }
 
     /// Borrow the underlying Fennel/Lua VM.
-    pub(crate) fn fennel(&self) -> &Fennel {
+    pub fn fennel(&self) -> &Fennel {
         &self.fennel
     }
 
@@ -223,7 +223,7 @@ impl Pipeline {
     /// Return job IDs in topological order — dependencies before
     /// dependents. The pipeline is already validated as acyclic, so
     /// this never fails.
-    pub(crate) fn topo_order(&self) -> Vec<&str> {
+    pub fn topo_order(&self) -> Vec<&str> {
         petgraph::algo::toposort(&self.graph, None)
             .expect("pipeline is validated as acyclic")
             .into_iter()
@@ -242,7 +242,7 @@ impl Pipeline {
     /// the job) via petgraph's BFS. Source refs aren't graph nodes,
     /// so they're scooped up from the inputs lists of every visited
     /// job.
-    pub(crate) fn transitive_inputs(&self) -> HashMap<String, HashSet<String>> {
+    pub fn transitive_inputs(&self) -> HashMap<String, HashSet<String>> {
         let reversed = Reversed(&self.graph);
         let mut result: HashMap<String, HashSet<String>> = HashMap::new();
         for job in &self.jobs {
@@ -266,12 +266,12 @@ impl Pipeline {
     }
 }
 
-#[cfg(test)]
 impl Pipeline {
     /// Replace the first job's run-fn — for tests that need to
     /// exercise a `RunFn::Rust` execution path without building the
     /// full helper machinery (which doesn't exist yet).
-    pub(super) fn replace_first_run_fn(&mut self, run_fn: RunFn) {
+    #[doc(hidden)]
+    pub fn replace_first_run_fn(&mut self, run_fn: RunFn) {
         if let Some(job) = self.jobs.first_mut() {
             job.run_fn = run_fn;
         }
@@ -301,7 +301,7 @@ fn build_graph(jobs: &[Job]) -> (JobGraph, HashMap<String, NodeIndex>) {
 
 /// Compute a span covering the given 1-indexed line in `source`.
 /// Returns an empty span at offset 0 when the line is unknown.
-pub(super) fn span_for_line(source: &str, line: u32) -> SourceSpan {
+pub fn span_for_line(source: &str, line: u32) -> SourceSpan {
     if line == 0 {
         return SourceSpan::from((0, 0)); // cov-excl-line
     }
@@ -330,10 +330,10 @@ pub struct PipelineError {
     // Named `src` rather than `source` so thiserror doesn't auto-treat
     // it as the error chain.
     #[source_code]
-    pub(crate) src: NamedSource<String>,
+    pub src: NamedSource<String>,
 
     #[related]
-    pub(crate) diagnostics: Vec<Diagnostic>,
+    pub diagnostics: Vec<Diagnostic>,
 }
 
 /// Errors from [`compile`] — Fennel evaluation failures and pipeline-shape
@@ -372,7 +372,7 @@ pub type CompileResult<T> = std::result::Result<T, CompileError>;
 /// [`validate_post_graph`] checks the dependency graph. Errors from a
 /// phase are wrapped in a [`PipelineError`] for miette to render with
 /// inline labels.
-pub(crate) fn compile(source: &str, name: &str) -> CompileResult<Pipeline> {
+pub fn compile(source: &str, name: &str) -> CompileResult<Pipeline> {
     let fennel = Fennel::new()?;
     let Registrations { jobs, image } = registration::register(&fennel, source, name)?;
 
diff --git a/quire-server/src/ci/registration.rs b/quire-core/src/ci/registration.rs
similarity index 93%
rename from quire-server/src/ci/registration.rs
rename to quire-core/src/ci/registration.rs
index 2e06228..d973f62 100644
--- a/quire-server/src/ci/registration.rs
+++ b/quire-core/src/ci/registration.rs
@@ -18,15 +18,15 @@ use super::mirror;
 use super::pipeline::{
     self, CompileResult, DefinitionError, Diagnostic, Job, PipelineError, RunFn,
 };
-use quire_core::fennel::Fennel;
+use crate::fennel::Fennel;
 
 /// Output of [`register`]: jobs and image successfully registered
 /// from the script. Definition-time errors are returned via the `Err`
 /// arm, not collected here.
 #[derive(Debug)]
-pub(super) struct Registrations {
-    pub(super) jobs: Vec<Job>,
-    pub(super) image: Option<String>,
+pub struct Registrations {
+    pub jobs: Vec<Job>,
+    pub image: Option<String>,
 }
 
 /// Evaluate `source` with the registration module bound and collect
@@ -36,7 +36,7 @@ pub(super) struct Registrations {
 /// not abort the rest of the script — but if any rule fired, the
 /// whole batch is returned as a `PipelineError` instead of partial
 /// registrations.
-pub(super) fn register(fennel: &Fennel, source: &str, name: &str) -> CompileResult<Registrations> {
+pub fn register(fennel: &Fennel, source: &str, name: &str) -> CompileResult<Registrations> {
     let jobs: Rc<RefCell<Vec<Job>>> = Rc::new(RefCell::new(Vec::new()));
     let image = Rc::new(RefCell::new(None));
     let src = Rc::new(source.to_string());
@@ -91,11 +91,11 @@ pub(super) fn register(fennel: &Fennel, source: &str, name: &str) -> CompileResu
 ///   (fn [{: sh : secret}]
 ///     (sh ["echo" (secret :github_token)])))
 /// ```
-pub(super) struct Registration {
-    pub(super) jobs: Rc<RefCell<Vec<Job>>>,
-    pub(super) errors: Rc<RefCell<Vec<DefinitionError>>>,
+pub struct Registration {
+    pub jobs: Rc<RefCell<Vec<Job>>>,
+    pub errors: Rc<RefCell<Vec<DefinitionError>>>,
     image: Rc<RefCell<Option<ImageRegistration>>>,
-    pub(super) source: Rc<String>,
+    pub source: Rc<String>,
 }
 
 impl IntoLua for Registration {
@@ -113,7 +113,7 @@ impl Registration {
     /// Push a registered job after enforcing id uniqueness. On
     /// collision, records `DuplicateJob` against the caller's source
     /// line and drops the new job; the first registration wins.
-    pub(super) fn add_job(&self, job: Job, line: u32) {
+    pub fn add_job(&self, job: Job, line: u32) {
         let mut jobs = self.jobs.borrow_mut();
         if jobs.iter().any(|j| j.id == job.id) {
             let span = pipeline::span_for_line(&self.source, line);
diff --git a/quire-core/src/ci/run.rs b/quire-core/src/ci/run.rs
new file mode 100644
index 0000000..b6ba4db
--- /dev/null
+++ b/quire-core/src/ci/run.rs
@@ -0,0 +1,20 @@
+//! Run-level data shared between the orchestrator and the runtime.
+//!
+//! The full `Run` lifecycle (state machine, db rows, log files) lives
+//! in `quire-server::ci::run`. This module carries only the immutable
+//! metadata that the runtime needs at execute time so it can run in
+//! either a server or an in-container `quire-ci` context.
+
+use jiff::Timestamp;
+
+/// Immutable metadata for a CI run. Passed to `Runs::create` at
+/// enqueue time; the fields are written to the `runs` row once.
+#[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,
+    /// When the push occurred.
+    pub pushed_at: Timestamp,
+}
diff --git a/quire-server/src/ci/runtime.rs b/quire-core/src/ci/runtime.rs
similarity index 95%
rename from quire-server/src/ci/runtime.rs
rename to quire-core/src/ci/runtime.rs
index ab8ef79..fed40f2 100644
--- a/quire-server/src/ci/runtime.rs
+++ b/quire-core/src/ci/runtime.rs
@@ -15,7 +15,7 @@ use mlua::{IntoLua, Lua, LuaSerdeExt};
 
 use super::pipeline::{Job, Pipeline};
 use super::run::RunMeta;
-use quire_core::secret::{self, SecretRegistry, SecretString, redact};
+use crate::secret::{self, SecretRegistry, SecretString, redact};
 
 /// Errors produced by [`Runtime`] methods and the `RunFn::Rust`
 /// callbacks that hold them. A small sum carved out of the
@@ -50,7 +50,7 @@ impl From<mlua::Error> for RuntimeError {
 pub type RuntimeResult<T> = std::result::Result<T, RuntimeError>;
 
 /// Per-sh timing: (index, started_at, finished_at).
-pub(super) type ShTimings = Vec<(usize, Timestamp, Timestamp)>;
+pub type ShTimings = Vec<(usize, Timestamp, Timestamp)>;
 
 /// Per-execution runtime: owns the Lua VM, holds the secrets exposed
 /// to the job, the per-job `(jobs name)` views, the current-job
@@ -73,18 +73,18 @@ pub(super) type ShTimings = Vec<(usize, Timestamp, Timestamp)>;
 /// All three handle primitives — `(sh …)`, `(secret …)`, and
 /// `(jobs …)` — require a runtime to be installed on the VM. Calls
 /// from a VM without one error.
-pub(super) struct Runtime {
+pub struct Runtime {
     pipeline: Pipeline,
     /// Unified secret store: holds declared secrets and their revealed
     /// values for both lookup and redaction. No Debug impl on the
     /// registry; Runtime must not derive Debug either.
-    pub(super) registry: RefCell<SecretRegistry>,
-    pub(super) inputs: HashMap<String, HashMap<String, Option<mlua::Value>>>,
-    pub(super) current_job: RefCell<Option<String>>,
-    pub(super) outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
+    pub registry: RefCell<SecretRegistry>,
+    pub inputs: HashMap<String, HashMap<String, Option<mlua::Value>>>,
+    pub current_job: RefCell<Option<String>>,
+    pub outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
     /// Per-sh timing records: job_id → (sh_index, started_at, finished_at).
     /// Parallel to `outputs`; each entry at the same index corresponds.
-    pub(super) sh_timings: RefCell<HashMap<String, ShTimings>>,
+    pub sh_timings: RefCell<HashMap<String, ShTimings>>,
     /// Per-job sh call counter for assigning sequential indices.
     sh_counter: RefCell<HashMap<String, usize>>,
     /// The materialized workspace for this run. Every `(sh …)` call
@@ -104,7 +104,7 @@ impl Runtime {
     /// failure — abort is the right answer there. The matching
     /// `RuntimeHandle::into_lua` call at the executor's call site uses
     /// the same boundary.
-    pub(super) fn new(
+    pub fn new(
         pipeline: Pipeline,
         secrets: HashMap<String, SecretString>,
         meta: &RunMeta,
@@ -155,17 +155,17 @@ impl Runtime {
     }
 
     /// Borrow the underlying Lua VM.
-    pub(super) fn lua(&self) -> &Lua {
+    pub fn lua(&self) -> &Lua {
         self.pipeline.fennel().lua()
     }
 
     /// The topo-sorted job IDs in execution order.
-    pub(super) fn topo_order(&self) -> Vec<&str> {
+    pub fn topo_order(&self) -> Vec<&str> {
         self.pipeline.topo_order()
     }
 
     /// Look up a job by id.
-    pub(super) fn job(&self, id: &str) -> Option<&Job> {
+    pub fn job(&self, id: &str) -> Option<&Job> {
         self.pipeline.job(id)
     }
 
@@ -176,7 +176,7 @@ impl Runtime {
     /// Panics if `id` has no inputs view — every job built by
     /// `Runtime::new` gets one, so a missing view means the executor
     /// is calling `enter_job` with an id that wasn't in the pipeline.
-    pub(super) fn enter_job(&self, id: &str) {
+    pub fn enter_job(&self, id: &str) {
         assert!(
             self.inputs.contains_key(id),
             "enter_job called with unknown job id '{id}'"
@@ -186,17 +186,17 @@ impl Runtime {
 
     /// Clear the current-job cursor. Subsequent `(sh …)` calls (if
     /// any) won't be attributed to a job until `enter_job` is called again.
-    pub(super) fn leave_job(&self) {
+    pub fn leave_job(&self) {
         *self.current_job.borrow_mut() = None;
     }
 
     /// Drain all recorded outputs, returning them keyed by job id.
-    pub(super) fn take_outputs(&self) -> HashMap<String, Vec<ShOutput>> {
+    pub fn take_outputs(&self) -> HashMap<String, Vec<ShOutput>> {
         std::mem::take(&mut *self.outputs.borrow_mut())
     }
 
     /// Drain all recorded sh timings, returning them keyed by job id.
-    pub(super) fn take_sh_timings(&self) -> HashMap<String, ShTimings> {
+    pub fn take_sh_timings(&self) -> HashMap<String, ShTimings> {
         std::mem::take(&mut *self.sh_timings.borrow_mut())
     }
 
@@ -209,14 +209,14 @@ impl Runtime {
     /// the full caveat.
     ///
     /// [`SecretRegistry::resolve`]: quire_core::secret::SecretRegistry::resolve
-    pub(super) fn secret(&self, name: &str) -> RuntimeResult<String> {
+    pub fn secret(&self, name: &str) -> RuntimeResult<String> {
         self.registry.borrow_mut().resolve(name).map_err(Into::into)
     }
 
     /// Run `cmd` with `opts` and record its output against the
     /// current job (if one is active). Non-zero exits come back in
     /// `:exit`, not as `Err`.
-    pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> RuntimeResult<ShOutput> {
+    pub fn sh(&self, cmd: Cmd, opts: ShOpts) -> RuntimeResult<ShOutput> {
         let started_at = Timestamp::now();
         let program = cmd.program().to_string();
         let output =
@@ -279,7 +279,7 @@ impl Runtime {
 
 /// `IntoLua` carrier for an `Rc<Runtime>`. Stows the Rc on the VM as
 /// app data and returns the handle table — `{sh, secret, jobs}`.
-pub(super) struct RuntimeHandle(pub Rc<Runtime>);
+pub struct RuntimeHandle(pub Rc<Runtime>);
 
 impl IntoLua for RuntimeHandle {
     // Errors raised by the closures below cross the mlua boundary via
@@ -362,7 +362,7 @@ impl IntoLua for RuntimeHandle {
 /// `From<Cmd> for Command` can't be handed an empty argv. The
 /// non-empty invariant is enforced in [`mlua::FromLua`] before this
 /// type is ever built.
-pub(super) enum Cmd {
+pub enum Cmd {
     Shell(String),
     Argv { program: String, args: Vec<String> },
 }
@@ -421,7 +421,7 @@ impl Cmd {
     // Also revisit the `from_utf8_lossy` calls below — non-UTF-8 bytes
     // are silently replaced with U+FFFD and `:stdout` / `:stderr` end
     // up as mojibake with no signal that anything was lost.
-    pub(super) fn run(self, opts: ShOpts, cwd: &std::path::Path) -> std::io::Result<ShOutput> {
+    pub fn run(self, opts: ShOpts, cwd: &std::path::Path) -> std::io::Result<ShOutput> {
         let cmd_str = format!("{self}");
         let mut command: std::process::Command = self.into();
         for (k, v) in opts.env {
@@ -478,8 +478,8 @@ impl mlua::FromLua for Cmd {
 /// closed so typos surface rather than being silently ignored.
 #[derive(Clone, Default, serde::Deserialize)]
 #[serde(default, deny_unknown_fields)]
-pub(super) struct ShOpts {
-    pub(super) env: HashMap<String, String>,
+pub struct ShOpts {
+    pub env: HashMap<String, String>,
 }
 
 impl mlua::FromLua for ShOpts {
diff --git a/quire-core/src/lib.rs b/quire-core/src/lib.rs
index 9d2811f..f3f8311 100644
--- a/quire-core/src/lib.rs
+++ b/quire-core/src/lib.rs
@@ -1,5 +1,6 @@
 //! Shared runtime modules for the quire orchestrator (`quire-server`)
 //! and the in-container runner (`quire-ci`).
 
+pub mod ci;
 pub mod fennel;
 pub mod secret;
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index cf8f36b..14abe06 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -3,17 +3,17 @@
 use std::collections::HashMap;
 
 pub(crate) mod logs;
-mod mirror;
-mod pipeline;
-mod registration;
 mod run;
-mod runtime;
 
 pub(crate) mod error;
 
 pub use error::{Error, Result};
-pub use pipeline::{DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError};
-pub use run::{Run, RunMeta, RunState, Runs, materialize_workspace, reconcile_orphans};
+pub use quire_core::ci::pipeline::{
+    DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError,
+};
+pub use quire_core::ci::run::RunMeta;
+pub use quire_core::ci::{mirror, pipeline, registration, runtime};
+pub use run::{Run, RunState, Runs, materialize_workspace, reconcile_orphans};
 
 /// A resolved commit reference.
 ///
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 19bc899..e7562e4 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -13,10 +13,12 @@ use jiff::Timestamp;
 use mlua::IntoLua;
 
 use super::error::{Error, Result};
-use super::pipeline::{Pipeline, RunFn};
-use super::runtime::{Runtime, RuntimeHandle, ShOutput};
+use quire_core::ci::pipeline::{Pipeline, RunFn};
+use quire_core::ci::runtime::{Runtime, RuntimeHandle, ShOutput};
 use quire_core::secret::SecretString;
 
+pub use quire_core::ci::run::RunMeta;
+
 /// The state of a CI run.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum RunState {
@@ -55,18 +57,6 @@ impl std::str::FromStr for RunState {
     }
 }
 
-/// Immutable metadata for a CI run. Passed to `Runs::create` at
-/// enqueue time; the fields are written to the `runs` row once.
-#[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,
-    /// When the push occurred.
-    pub pushed_at: Timestamp,
-}
-
 /// Access to CI runs for a single repo.
 ///
 /// Owns the database path, repo name, and base directory for run
@@ -315,7 +305,7 @@ impl Run {
     fn write_sh_records(
         &self,
         outputs: &HashMap<String, Vec<ShOutput>>,
-        timings: &HashMap<String, super::runtime::ShTimings>,
+        timings: &HashMap<String, quire_core::ci::runtime::ShTimings>,
     ) -> Result<()> {
         if outputs.is_empty() {
             return Ok(());
@@ -826,7 +816,7 @@ mod tests {
     }
 
     fn load(source: &str) -> Pipeline {
-        super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed")
+        quire_core::ci::pipeline::compile(source, "ci.fnl").expect("compile should succeed")
     }
 
     #[test]