Decouple `pipeline`/`registration` from the kitchen-sink `ci::Error`
`pipeline::compile` and `registration::register` now thread a small
sum-of-failures `CompileError` (Fennel evaluation or PipelineError)
through their public signatures instead of reaching into the
orchestrator-flavored `ci::Error`. A flattening `From<CompileError>
for Error` at the boundary keeps existing callers working.

Sets up the next move: `pipeline` and `registration` no longer
reference `super::error`, so they can land in `quire-core` without
dragging rusqlite, docker, and the rest of the kitchen-sink along.
The runtime-side `RustRunFn` callback still uses the kitchen-sink
result type — runtime errors are still a quire-server concern.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change zvmpxxlswzwxtrmtwxzqqzqvvnmqosry
commit 56710defdbd7d5e3e8aa863fe96e6d854a0be049
author Alpha Chen <alpha@kejadlen.dev>
date
parent zsusnpmq
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index a464723..e35b4d5 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -2,7 +2,7 @@
 
 use miette::Diagnostic;
 
-use super::pipeline::PipelineError;
+use super::pipeline::{CompileError, PipelineError};
 use super::run::RunState;
 use quire_core::fennel::FennelError;
 use quire_core::secret;
@@ -90,6 +90,15 @@ impl From<PipelineError> for Error {
     }
 }
 
+impl From<CompileError> for Error {
+    fn from(err: CompileError) -> Self {
+        match err {
+            CompileError::Fennel(e) => Error::Fennel(e),
+            CompileError::Pipeline(e) => Error::Pipeline(e),
+        }
+    }
+}
+
 impl From<FennelError> for Error {
     fn from(err: FennelError) -> Self {
         Error::Fennel(Box::new(err))
diff --git a/quire-server/src/ci/mirror.rs b/quire-server/src/ci/mirror.rs
index 9d7f394..3fedcc4 100644
--- a/quire-server/src/ci/mirror.rs
+++ b/quire-server/src/ci/mirror.rs
@@ -337,7 +337,7 @@ mod tests {
         let Err(err) = compile(source, "ci.fnl") else {
             panic!("expected error");
         };
-        let crate::ci::error::Error::Pipeline(pe) = err else {
+        let crate::ci::pipeline::CompileError::Pipeline(pe) = err else {
             panic!("expected PipelineError, got {err:?}");
         };
         assert!(
@@ -356,7 +356,7 @@ mod tests {
         let Err(err) = compile(source, "ci.fnl") else {
             panic!("expected error");
         };
-        let crate::ci::error::Error::Pipeline(pe) = err else {
+        let crate::ci::pipeline::CompileError::Pipeline(pe) = err else {
             panic!("expected PipelineError, got {err:?}");
         };
         pe.diagnostics
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index f323bf5..1f7a996 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -72,7 +72,7 @@ impl Ci {
     /// Single chokepoint for compile + structural validation, used by
     /// [`Ci::pipeline`] and `trigger_ref` so the two paths can't drift.
     fn compile(&self, source: &str) -> error::Result<Pipeline> {
-        pipeline::compile(source, CI_FNL)
+        Ok(pipeline::compile(source, CI_FNL)?)
     }
 
     /// Read the contents of `.quire/ci.fnl` at a given commit SHA.
diff --git a/quire-server/src/ci/pipeline.rs b/quire-server/src/ci/pipeline.rs
index fb5977e..564c553 100644
--- a/quire-server/src/ci/pipeline.rs
+++ b/quire-server/src/ci/pipeline.rs
@@ -11,9 +11,8 @@ use petgraph::Graph;
 use petgraph::graph::NodeIndex;
 use petgraph::visit::{Bfs, Reversed};
 
-use super::error::Result;
 use super::registration::{self, Registrations};
-use quire_core::fennel::Fennel;
+use quire_core::fennel::{Fennel, FennelError};
 
 /// A registration-time error caught while individual `(ci.job …)` and
 /// `(ci.image …)` calls are being processed.
@@ -112,7 +111,8 @@ pub struct Job {
 
 /// A Rust-side run-fn: a closure invoked synchronously by the
 /// executor with the runtime in scope.
-pub(super) type RustRunFn = std::rc::Rc<dyn Fn(&super::runtime::Runtime) -> Result<()>>;
+pub(super) type RustRunFn =
+    std::rc::Rc<dyn Fn(&super::runtime::Runtime) -> super::error::Result<()>>;
 
 /// How a job runs at execute time.
 ///
@@ -336,6 +336,35 @@ pub struct PipelineError {
     pub(crate) diagnostics: Vec<Diagnostic>,
 }
 
+/// Errors from [`compile`] — Fennel evaluation failures and pipeline-shape
+/// failures unified at the compile boundary, so callers can match on
+/// the compile result without reaching into the kitchen-sink
+/// `ci::Error`.
+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
+pub enum CompileError {
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Fennel(#[from] Box<FennelError>),
+
+    #[error(transparent)]
+    #[diagnostic(transparent)]
+    Pipeline(#[from] Box<PipelineError>),
+}
+
+impl From<FennelError> for CompileError {
+    fn from(err: FennelError) -> Self {
+        Self::Fennel(Box::new(err))
+    }
+}
+
+impl From<PipelineError> for CompileError {
+    fn from(err: PipelineError) -> Self {
+        Self::Pipeline(Box::new(err))
+    }
+}
+
+pub type CompileResult<T> = std::result::Result<T, CompileError>;
+
 /// Compile a ci.fnl source string into a validated [`Pipeline`].
 ///
 /// Two phases, fail-fast between them: [`registration::register`]
@@ -343,7 +372,7 @@ pub struct PipelineError {
 /// [`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) -> Result<Pipeline> {
+pub(crate) fn compile(source: &str, name: &str) -> CompileResult<Pipeline> {
     let fennel = Fennel::new()?;
     let Registrations { jobs, image } = registration::register(&fennel, source, name)?;
 
@@ -515,7 +544,7 @@ mod tests {
         let f = Fennel::new().expect("Fennel::new() should succeed");
         let err =
             registration::register(&f, source, "ci.fnl").expect_err("expected registration errors");
-        let crate::ci::error::Error::Pipeline(pe) = err else {
+        let CompileError::Pipeline(pe) = err else {
             panic!("expected PipelineError, got {err:?}")
         };
         pe.diagnostics
@@ -808,7 +837,7 @@ mod tests {
 (ci.job :orphan [:does-not-exist] (fn [_] nil))"#,
             "ci.fnl",
         );
-        let Err(crate::ci::error::Error::Pipeline(pe)) = result else {
+        let Err(CompileError::Pipeline(pe)) = result else {
             panic!("expected PipelineError")
         };
         for d in &pe.diagnostics {
diff --git a/quire-server/src/ci/registration.rs b/quire-server/src/ci/registration.rs
index 4a25939..2e06228 100644
--- a/quire-server/src/ci/registration.rs
+++ b/quire-server/src/ci/registration.rs
@@ -14,9 +14,10 @@ use mlua::{IntoLua, Lua};
 
 use miette::NamedSource;
 
-use super::error::Result;
 use super::mirror;
-use super::pipeline::{self, DefinitionError, Diagnostic, Job, PipelineError, RunFn};
+use super::pipeline::{
+    self, CompileResult, DefinitionError, Diagnostic, Job, PipelineError, RunFn,
+};
 use quire_core::fennel::Fennel;
 
 /// Output of [`register`]: jobs and image successfully registered
@@ -35,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) -> Result<Registrations> {
+pub(super) 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());