Consolidate ci.fnl compilation in quire-ci
The orchestrator previously pre-compiled ci.fnl in trigger_ref as a
fail-fast check, then quire-ci recompiled inside its own process. The
duplicate path meant compile failures could be reported from either
binary (and were, since both have tracing/Sentry wired). Two Sentry
events per malformed ci.fnl — the orchestrator's "ci.fnl has errors"
on quire-server, and quire-ci's own miette-printed exit.

Move compilation to quire-ci alone:

  - trigger_ref drops the ci.compile match block and just dispatches to
    quire-ci via materialize_workspace + execute_via_quire_ci, which
    already handles the run-state transitions on quire-ci's exit code.
  - quire-ci's run_pipeline wraps pipeline::compile with tracing::error
    so compile failures surface as Sentry events on the worker's side,
    where the SentryHandoff carries the orchestrator's trace_id — the
    two binaries' events still group on the same trace.
  - quire-ci's MietteLayer registers pipeline::CompileError so the
    rendered diagnostic gets stashed for extra["diagnostic"].

Inline the now single-caller Ci::compile into Ci::pipeline (used by
the `quire ci validate`/`run` CLI commands). Drop the
trigger_errors_on_invalid_pipeline test — it asserted a behavior the
orchestrator no longer owns; quire-ci-exits-nonzero coverage already
exercises the failure path.

The orchestrator still emits its own "CI trigger failed" event when
execute_via_quire_ci returns QuireCiExit, which is now redundant with
quire-ci's report; addressing that is left for a follow-up alongside
the Sentry data-scrubbing issue.

Assisted-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
change srmtwtzvrwslnwuwsutlyqnqtyowuluv
commit e9e2adb38c306aef61cf6764622233036e8fa845
author Alpha Chen <alpha@kejadlen.dev>
date
parent ttwmxslt
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index c9be6b5..0e0c253 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -213,6 +213,7 @@ fn main() -> miette::Result<()> {
             let _sentry = init_sentry(sentry_handoff.as_ref(), &meta);
             let miette_layer = MietteLayer::new()
                 .with_type::<JobError>()
+                .with_type::<pipeline::CompileError>()
                 .with_type::<quire_core::fennel::FennelError>();
             telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
 
@@ -335,7 +336,18 @@ fn run_pipeline(
     meta: RunMeta,
     secrets: HashMap<String, quire_core::secret::SecretString>,
 ) -> miette::Result<()> {
-    let pipeline = compile_at(&workspace)?;
+    let path = workspace.join(".quire").join("ci.fnl");
+    let source = fs_err::read_to_string(&path).into_diagnostic()?;
+    let pipeline = match pipeline::compile(&source, &path.display().to_string()) {
+        Ok(p) => p,
+        Err(e) => {
+            tracing::error!(
+                error = &e as &(dyn std::error::Error + 'static),
+                "ci.fnl compilation failed",
+            );
+            return Err(e.into());
+        }
+    };
 
     let job_ids: Vec<String> = pipeline
         .topo_order()
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 27badc5..e91fa00 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -61,15 +61,7 @@ impl Ci {
         let Some(source) = self.source(&commit.sha)? else {
             return Ok(None);
         };
-        Ok(Some(self.compile(&source)?))
-    }
-
-    /// Compile `.quire/ci.fnl` source into a validated [`Pipeline`].
-    ///
-    /// 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> {
-        Ok(pipeline::compile(source, CI_FNL)?)
+        Ok(Some(pipeline::compile(&source, CI_FNL)?))
     }
 
     /// Read the contents of `.quire/ci.fnl` at a given commit SHA.
@@ -203,9 +195,9 @@ fn trigger_ref(
 ) -> error::Result<()> {
     let ci = repo.ci();
 
-    let Some(source) = ci.source(&push_ref.new_sha)? else {
+    if ci.source(&push_ref.new_sha)?.is_none() {
         return Ok(());
-    };
+    }
 
     let meta = RunMeta {
         sha: push_ref.new_sha.clone(),
@@ -213,7 +205,7 @@ fn trigger_ref(
         pushed_at,
     };
 
-    let mut run = repo.runs(db_path).create(&meta)?;
+    let run = repo.runs(db_path).create(&meta)?;
 
     tracing::info!(
         run_id = %run.id(), // cov-excl-line
@@ -222,22 +214,12 @@ fn trigger_ref(
         "created CI run"
     );
 
-    let pipeline = match ci.compile(&source) {
-        Ok(p) => p,
-        Err(e) => {
-            run.transition(RunState::Active)?;
-            run.transition(RunState::Failed)?;
-            return Err(e);
-        }
-    };
-
     let workspace = run.path().join("workspace");
     run::materialize_workspace(&repo.path(), &push_ref.new_sha, &workspace)?;
     match executor {
         Executor::QuireCi => {
-            // The orchestrator already validated `pipeline` to fail-fast on
-            // bad ci.fnl; `quire-ci` recompiles inside its own process.
-            drop(pipeline);
+            // Compilation happens inside quire-ci so a malformed ci.fnl is
+            // reported once, with the worker's trace context.
             run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry)?;
         }
     }
@@ -574,31 +556,6 @@ mod tests {
         .expect("should succeed without ci.fnl");
     }
 
-    #[test]
-    fn trigger_errors_on_invalid_pipeline() {
-        let source = "(local ci (require :quire.ci))\n(ci.job :a [] (fn [] nil))";
-        let (_dir, quire, name) = bare_repo_with_ci(source);
-        let repo = quire.repo(&name).expect("repo");
-        let sha = head_sha(&repo);
-        let pushed_at: jiff::Timestamp = "2026-04-28T12:00:00Z".parse().unwrap();
-        let push_ref = PushRef {
-            old_sha: "0000000000000000000000000000000000000000".to_string(),
-            new_sha: sha,
-            r#ref: "refs/heads/main".to_string(),
-        };
-
-        let result = trigger_ref(
-            &repo,
-            &quire.db_path(),
-            pushed_at,
-            &push_ref,
-            &HashMap::new(),
-            Executor::QuireCi,
-            None,
-        );
-        assert!(result.is_err(), "invalid pipeline should fail");
-    }
-
     fn push_event(repo: &str, sha: &str) -> PushEvent {
         PushEvent::new(
             repo.to_string(),