Make transport optional for local CI runs
`quire ci run` no longer mints a fake server session with port=0.
Instead it uses `Transport::Local`, which carries no session info.
`quire-ci run` now accepts `--run-id` and `--server-url` as optional;
when both are absent with filesystem transport it runs without a session,
matching the behaviour of `quire-ci local`. API transport still requires
both flags and `QUIRE_CI_TOKEN`.
change
commit 5c6d62b6324bf06a09cc48e765e28eead10aded2
author Claude <noreply@anthropic.com>
date
parent 3ab112bf
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 69e0d88..611842d 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -95,15 +95,18 @@ enum Commands {
 }
 
 /// Session and transport flags for orchestrator-dispatched runs.
+/// All flags are optional — when `--run-id` and `--server-url` are
+/// absent and `--transport` is `filesystem` (the default), the run
+/// executes without a server session (local mode).
 #[derive(clap::Args, Debug)]
 struct TransportFlags {
     /// Run ID assigned by the orchestrator.
     #[arg(long)]
-    run_id: String,
+    run_id: Option<String>,
 
     /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
     #[arg(long)]
-    server_url: String,
+    server_url: Option<String>,
 
     /// Transport for CI ↔ server communication.
     #[arg(long, default_value = "filesystem", value_enum)]
@@ -112,17 +115,38 @@ struct TransportFlags {
 
 impl TransportFlags {
     fn resolve(self, auth_token: Option<String>) -> miette::Result<TransportArgs> {
-        let auth_token =
-            auth_token.ok_or_else(|| miette::miette!("QUIRE_CI_TOKEN env var is required"))?;
-        let session = ApiSession {
-            run_id: self.run_id,
-            server_url: self.server_url,
-            auth_token,
-        };
-        Ok(match self.transport {
-            Transport::Filesystem => TransportArgs::Filesystem(Some(session)),
-            Transport::Api => TransportArgs::Api(session),
-        })
+        match self.transport {
+            Transport::Filesystem => match (self.run_id, self.server_url) {
+                (None, None) => Ok(TransportArgs::Filesystem(None)),
+                (Some(run_id), Some(server_url)) => {
+                    let auth_token = auth_token
+                        .ok_or_else(|| miette::miette!("QUIRE_CI_TOKEN env var is required"))?;
+                    Ok(TransportArgs::Filesystem(Some(ApiSession {
+                        run_id,
+                        server_url,
+                        auth_token,
+                    })))
+                }
+                _ => Err(miette::miette!(
+                    "--run-id and --server-url must both be provided or both omitted"
+                )),
+            },
+            Transport::Api => {
+                let run_id = self
+                    .run_id
+                    .ok_or_else(|| miette::miette!("--run-id is required for api transport"))?;
+                let server_url = self
+                    .server_url
+                    .ok_or_else(|| miette::miette!("--server-url is required for api transport"))?;
+                let auth_token = auth_token
+                    .ok_or_else(|| miette::miette!("QUIRE_CI_TOKEN env var is required"))?;
+                Ok(TransportArgs::Api(ApiSession {
+                    run_id,
+                    server_url,
+                    auth_token,
+                }))
+            }
+        }
     }
 }
 
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index 1ba27a3..56ea9ed 100644
--- a/quire-server/src/bin/quire/commands/ci.rs
+++ b/quire-server/src/bin/quire/commands/ci.rs
@@ -2,7 +2,7 @@ use std::path::PathBuf;
 
 use miette::{IntoDiagnostic, Result};
 use quire::Quire;
-use quire::ci::{Ci, CommitRef, RunMeta, Runs, Transport, TransportMode};
+use quire::ci::{Ci, CommitRef, RunMeta, Runs, Transport};
 
 /// Validate a repo's ci.fnl without executing any jobs.
 ///
@@ -74,11 +74,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
         pushed_at: jiff::Timestamp::now(),
     };
 
-    // Local `quire ci run` uses filesystem transport. Session info is
-    // minted so quire-ci receives the standard remote-mode flags; the
-    // server_url is a loopback placeholder (never contacted in filesystem
-    // mode).
-    let transport = Transport::for_new_run(TransportMode::Filesystem, 0);
+    let transport = Transport::local();
     let run = runs.create(&meta, &transport)?;
     let run_id = run.id().to_string();
     println!(
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 381959b..2fa38b1 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -42,15 +42,23 @@ pub enum TransportMode {
 /// Runtime transport for a single CI run. Built once per run from
 /// the config-shape [`TransportMode`] + the server's listen port,
 /// then passed to `Runs::create` and `Run::execute_via_quire_ci`.
-/// Both variants carry an [`ApiSession`] so quire-ci always receives
-/// session info — enabling forward-compatible adoption of API routes.
+/// `Filesystem` and `Api` carry an [`ApiSession`] so quire-ci receives
+/// session info for API route use. `Local` carries no session and is
+/// used for `quire ci run` where no server is involved.
 #[derive(Clone, Debug)]
 pub enum Transport {
+    Local,
     Filesystem(ApiSession),
     Api(ApiSession),
 }
 
 impl Transport {
+    /// Build a transport for a local `quire ci run` invocation.
+    /// No session info is minted — quire-ci runs without transport flags.
+    pub fn local() -> Self {
+        Transport::Local
+    }
+
     /// Build a runtime transport for a new run. Always mints a fresh
     /// run ID and CSPRNG bearer token, deriving the loopback server
     /// URL from `port`. The variant controls whether quire-ci uses
@@ -143,6 +151,7 @@ impl Runs {
     /// agree on which run a token belongs to.
     pub fn create(&self, meta: &RunMeta, transport: &Transport) -> Result<Run> {
         let (id, auth_token_str) = match transport {
+            Transport::Local => (uuid::Uuid::now_v7().to_string(), None),
             Transport::Filesystem(api) | Transport::Api(api) => {
                 (api.run_id.clone(), Some(api.auth_token.as_str()))
             }
@@ -356,6 +365,14 @@ impl Run {
         cmd.arg("run").arg("--workspace").arg(workspace);
 
         match transport {
+            Transport::Local => {
+                cmd.arg("--out-dir")
+                    .arg(&run_dir)
+                    .arg("--events")
+                    .arg(&events_path)
+                    .arg("--bootstrap")
+                    .arg(&bootstrap_path);
+            }
             Transport::Filesystem(api) => {
                 cmd.arg("--run-id")
                     .arg(&api.run_id)
@@ -996,6 +1013,7 @@ mod tests {
             ),
         ] {
             let api = match &transport {
+                Transport::Local => unreachable!("test transports carry sessions"),
                 Transport::Filesystem(api) | Transport::Api(api) => api,
             };
             assert_eq!(api.server_url, expected_url);