Add opt-in `quire-ci` executor for CI dispatch
The orchestrator can now shell out to the `quire-ci` binary instead
of evaluating the pipeline in-process. Opt in by adding
`:executor :quire-ci` to `/var/quire/config.fnl`; default stays
`:host`. `global_config()` re-reads on every trigger, so the next
push after editing the config picks up the change without a server
restart.

`Run::execute_via_quire_ci` spawns `quire-ci run --workspace ...`
with combined stdout+stderr captured to `<run_dir>/quire-ci.log`.
The run finishes `complete` on exit 0 and `failed` otherwise.
Per-job and per-sh database records are not written in this path —
quire-ci doesn't yet emit a structured report for the orchestrator
to ingest. Plumbing only; the structured-report design is a
follow-up.

The orchestrator still compiles the ci.fnl before dispatching so
bad pipelines fail fast at trigger time. quire-ci recompiles inside
its own process; that redundant compile is cheap and worth the
early-fail.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change uuqlurnwuoykvmsplotonrzpusyukyoq
commit 2ad07cf94dd1be333e3374d5887e2adf89b808fc
author Alpha Chen <alpha@kejadlen.dev>
date
parent pzmsnnyv
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index 3edb624..5278b38 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -63,6 +63,9 @@ pub enum Error {
         #[source]
         source: std::io::Error,
     },
+
+    #[error("quire-ci exited with status {exit:?}")]
+    QuireCiExit { exit: Option<i32> },
 }
 
 pub type Result<T> = std::result::Result<T, Error>;
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 14abe06..c9ff2e1 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -13,7 +13,7 @@ pub use quire_core::ci::pipeline::{
 };
 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};
+pub use run::{Executor, Run, RunState, Runs, materialize_workspace, reconcile_orphans};
 
 /// A resolved commit reference.
 ///
@@ -121,8 +121,8 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
         }
     };
 
-    let secrets = match quire.global_config() {
-        Ok(config) => config.secrets,
+    let config = match quire.global_config() {
+        Ok(config) => config,
         Err(e) => {
             tracing::error!(repo = %event.repo, error = %display_chain(&e), "failed to load global config");
             return;
@@ -131,7 +131,14 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
 
     let db_path = quire.db_path();
     for push_ref in event.updated_refs() {
-        if let Err(e) = trigger_ref(&repo, &db_path, event.pushed_at, push_ref, &secrets) {
+        if let Err(e) = trigger_ref(
+            &repo,
+            &db_path,
+            event.pushed_at,
+            push_ref,
+            &config.secrets,
+            config.executor,
+        ) {
             tracing::error!(
                 repo = %event.repo,
                 sha = %push_ref.new_sha, // cov-excl-line
@@ -149,6 +156,7 @@ fn trigger_ref(
     pushed_at: jiff::Timestamp,
     push_ref: &PushRef,
     secrets: &HashMap<String, quire_core::secret::SecretString>,
+    executor: Executor,
 ) -> error::Result<()> {
     let ci = repo.ci();
 
@@ -182,7 +190,17 @@ fn trigger_ref(
 
     let workspace = run.path().join("workspace");
     run::materialize_workspace(&repo.path(), &push_ref.new_sha, &workspace)?;
-    run.execute(pipeline, secrets.clone(), &repo.path(), &workspace)?;
+    match executor {
+        Executor::Host => {
+            run.execute(pipeline, secrets.clone(), &repo.path(), &workspace)?;
+        }
+        Executor::QuireCi => {
+            // The orchestrator already validated `pipeline` to fail-fast on
+            // bad ci.fnl; `quire-ci` recompiles inside its own process.
+            drop(pipeline);
+            run.execute_via_quire_ci(&workspace)?;
+        }
+    }
     Ok(())
 }
 
@@ -359,6 +377,7 @@ mod tests {
             pushed_at,
             &push_ref,
             &HashMap::new(),
+            Executor::Host,
         )
         .expect("trigger_ref should succeed");
 
@@ -392,6 +411,7 @@ mod tests {
             pushed_at,
             &push_ref,
             &HashMap::new(),
+            Executor::Host,
         )
         .expect("should succeed without ci.fnl");
     }
@@ -415,6 +435,7 @@ mod tests {
             pushed_at,
             &push_ref,
             &HashMap::new(),
+            Executor::Host,
         );
         assert!(result.is_err(), "invalid pipeline should fail");
     }
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index e7562e4..225d02f 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -19,6 +19,20 @@ use quire_core::secret::SecretString;
 
 pub use quire_core::ci::run::RunMeta;
 
+/// How a run dispatches its pipeline.
+///
+/// `Host` evaluates the Lua/Fennel pipeline in-process on the
+/// orchestrator. `QuireCi` shells out to the `quire-ci` binary,
+/// which compiles and runs the pipeline in a separate process.
+/// Selected by the `:executor` key in the global config.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Executor {
+    #[default]
+    Host,
+    QuireCi,
+}
+
 /// The state of a CI run.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum RunState {
@@ -299,6 +313,52 @@ impl Run {
         Ok(outputs)
     }
 
+    /// Run the pipeline by shelling out to the `quire-ci` binary.
+    ///
+    /// Combined stdout+stderr is captured to `<run_dir>/quire-ci.log`.
+    /// Run finishes `Complete` on exit 0, `Failed` otherwise. Per-job
+    /// and per-sh database records are not written in this path —
+    /// quire-ci doesn't yet emit a structured report for the
+    /// orchestrator to ingest.
+    pub fn execute_via_quire_ci(mut self, workspace: &Path) -> Result<()> {
+        self.transition(RunState::Active)?;
+
+        let log_path = self.path().join("quire-ci.log");
+        // fs_err for the path-bearing IO error; unwrap to std::fs::File so
+        // it's convertible into Stdio.
+        let log = fs_err::File::create(&log_path)?.into_parts().0;
+        let log_clone = log.try_clone()?;
+
+        tracing::info!(
+            run_id = %self.id,
+            log = %log_path.display(),
+            "dispatching run to quire-ci",
+        );
+
+        let status = std::process::Command::new("quire-ci")
+            .arg("run")
+            .arg("--workspace")
+            .arg(workspace)
+            .stdout(std::process::Stdio::from(log))
+            .stderr(std::process::Stdio::from(log_clone))
+            .status()
+            .map_err(|source| Error::CommandSpawnFailed {
+                program: "quire-ci".to_string(),
+                cwd: workspace.to_path_buf(),
+                source,
+            })?;
+
+        if !status.success() {
+            self.transition(RunState::Failed)?;
+            return Err(Error::QuireCiExit {
+                exit: status.code(),
+            });
+        }
+
+        self.transition(RunState::Complete)?;
+        Ok(())
+    }
+
     /// Write sh events to the database and per-sh CRI log files to
     /// disk. Written before the final state transition so logs are
     /// available for both successful and failed runs.
diff --git a/quire-server/src/quire/mod.rs b/quire-server/src/quire/mod.rs
index 0fb6ccb..8a9896c 100644
--- a/quire-server/src/quire/mod.rs
+++ b/quire-server/src/quire/mod.rs
@@ -6,7 +6,7 @@ use miette::{Context, IntoDiagnostic, Result, ensure};
 
 pub mod web;
 
-use crate::ci::{Ci, Runs};
+use crate::ci::{Ci, Executor, Runs};
 use crate::{Error, Result as AppResult};
 use quire_core::fennel::Fennel;
 use quire_core::secret::SecretString;
@@ -22,6 +22,11 @@ pub struct GlobalConfig {
     /// Each value is a `SecretString` (plain literal or `{:file "..."}`).
     #[serde(default)]
     pub secrets: HashMap<String, SecretString>,
+    /// How the orchestrator dispatches CI runs. Defaults to in-process
+    /// host evaluation; set `:executor :quire-ci` to opt into shelling
+    /// out to the `quire-ci` binary.
+    #[serde(default)]
+    pub executor: Executor,
 }
 
 #[derive(serde::Deserialize, Debug)]