Share ApiSession between quire-ci and quire-server
Pull the run_id + server_url + auth_token tuple into
quire_core::ci::transport so both sides exchange one struct
instead of two parallel definitions. QUIRE_CI_TOKEN is now
required when --transport api — the token is non-optional once
shared.

Assisted-by: Owl Alpha via pi
change nlkpnomuwylryrskyzrwoskyxwxpmywx
commit 18aa0960e04acbad53eb01a12bbc8f411c6e502c
author Alpha Chen <alpha@kejadlen.dev>
date
parent pqlyworu
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index bd4fbc5..a33495a 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -12,6 +12,7 @@ use quire_core::ci::event::{Event, EventKind, JobOutcome};
 use quire_core::ci::pipeline::{self, Pipeline, RunFn};
 use quire_core::ci::run::RunMeta;
 use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeEvent, RuntimeHandle};
+use quire_core::ci::transport::ApiSession;
 use quire_core::fennel::FennelError;
 use quire_core::telemetry::{self, FmtMode, MietteLayer};
 
@@ -111,19 +112,25 @@ struct TransportFlags {
 impl TransportFlags {
     /// Promote into the resolved [`TransportArgs`], folding in the
     /// `QUIRE_CI_TOKEN` env var. clap's `required_if_eq` guarantees
-    /// the unwraps are safe for `Transport::Api`.
-    fn resolve(self, auth_token: Option<String>) -> TransportArgs {
+    /// `--run-id` and `--server-url` are present for `Transport::Api`;
+    /// the token must arrive via env and is non-optional on the wire.
+    fn resolve(self, auth_token: Option<String>) -> miette::Result<TransportArgs> {
         match self.transport {
-            Transport::Filesystem => TransportArgs::Filesystem,
-            Transport::Api => TransportArgs::Api {
-                run_id: self
-                    .run_id
-                    .expect("clap requires --run-id when --transport api"),
-                server_url: self
-                    .server_url
-                    .expect("clap requires --server-url when --transport api"),
-                auth_token,
-            },
+            Transport::Filesystem => Ok(TransportArgs::Filesystem),
+            Transport::Api => {
+                let auth_token = auth_token.ok_or_else(|| {
+                    miette::miette!("--transport api requires the QUIRE_CI_TOKEN env var")
+                })?;
+                Ok(TransportArgs::Api(ApiSession {
+                    run_id: self
+                        .run_id
+                        .expect("clap requires --run-id when --transport api"),
+                    server_url: self
+                        .server_url
+                        .expect("clap requires --server-url when --transport api"),
+                    auth_token,
+                }))
+            }
         }
     }
 }
@@ -202,27 +209,22 @@ fn parse_events_target(s: &str) -> Result<EventsTarget, String> {
 
 /// Transport for CI ↔ server communication.
 ///
-/// CLI-shape only; the resolved variant (with run_id/server_url
-/// promoted out of their Options) is `TransportArgs`.
+/// CLI-shape only; the resolved variant (carrying the shared
+/// [`ApiSession`]) is `TransportArgs`.
 #[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
 pub enum Transport {
     Filesystem,
     Api,
 }
 
-/// Resolved transport produced by [`TransportFlags::resolve`].
-///
-/// The `Api` variant's fields are wired up ahead of the HTTP client
-/// that will consume them.
+/// Resolved transport produced by [`TransportFlags::resolve`]. The
+/// `Api` variant carries the shared [`ApiSession`] — same shape the
+/// server constructed when it created the run.
 #[derive(Debug)]
-#[allow(dead_code)] // fields read by the upcoming API client
+#[allow(dead_code)] // session read by the upcoming API client
 enum TransportArgs {
     Filesystem,
-    Api {
-        run_id: String,
-        server_url: String,
-        auth_token: Option<String>,
-    },
+    Api(ApiSession),
 }
 
 fn main() -> miette::Result<()> {
@@ -256,7 +258,7 @@ fn main() -> miette::Result<()> {
                 }
             };
             let auth_token = std::env::var("QUIRE_CI_TOKEN").ok();
-            let transport = transport.resolve(auth_token);
+            let transport = transport.resolve(auth_token)?;
             let (git_dir, meta, secrets, sentry_handoff) = match dispatch {
                 Some(path) => load_dispatch(&path)?,
                 None => (
diff --git a/quire-core/src/ci/mod.rs b/quire-core/src/ci/mod.rs
index 7b454cf..40a310e 100644
--- a/quire-core/src/ci/mod.rs
+++ b/quire-core/src/ci/mod.rs
@@ -12,3 +12,4 @@ pub mod pipeline;
 pub mod registration;
 pub mod run;
 pub mod runtime;
+pub mod transport;
diff --git a/quire-core/src/ci/transport.rs b/quire-core/src/ci/transport.rs
new file mode 100644
index 0000000..b27f55a
--- /dev/null
+++ b/quire-core/src/ci/transport.rs
@@ -0,0 +1,22 @@
+//! Shared transport types for CI ↔ server communication.
+//!
+//! The on-the-wire pairing both sides agree on. The orchestrator
+//! constructs an `ApiSession` per run (minting the auth token and
+//! using the run's UUID); quire-ci reconstructs it from CLI flags
+//! plus `QUIRE_CI_TOKEN`.
+
+/// Credentials and endpoint coordinates for a single CI run's API
+/// channel. Holds everything quire-ci needs to call back to the
+/// server about *this* run: which run, where the server is, and
+/// the bearer token it issued.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ApiSession {
+    /// Run UUID assigned by the orchestrator. Also the value stored
+    /// in `runs.id` server-side.
+    pub run_id: String,
+    /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
+    pub server_url: String,
+    /// Bearer token minted at run creation time. Matches
+    /// `runs.auth_token` server-side.
+    pub auth_token: String,
+}
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 5ee76bb..3068146 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -11,9 +11,10 @@ pub use quire_core::ci::pipeline::{
     DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError,
 };
 pub use quire_core::ci::run::RunMeta;
+pub use quire_core::ci::transport::ApiSession;
 pub use quire_core::ci::{pipeline, registration, runtime};
 pub use run::{
-    ApiTransport, Executor, Run, RunState, Runs, Transport, TransportMode, materialize_workspace,
+    Executor, Run, RunState, Runs, Transport, TransportMode, materialize_workspace,
     reconcile_orphans,
 };
 
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index f8790fb..2765ed8 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -9,6 +9,7 @@ use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 
 use jiff::Timestamp;
+use quire_core::ci::transport::ApiSession;
 use quire_core::secret::SecretString;
 use rand::{Rng, distr::Alphanumeric};
 
@@ -41,17 +42,20 @@ pub enum TransportMode {
 /// Runtime transport for a single CI run. Built once per run from
 /// the config-shape [`TransportMode`] + the top-level `server_url`,
 /// then passed to `Runs::create` and `Run::execute_via_quire_ci`.
+/// The `Api` variant carries the shared [`ApiSession`] — quire-ci
+/// receives a structurally identical value via its CLI flags.
 #[derive(Clone, Debug, Default)]
 pub enum Transport {
     #[default]
     Filesystem,
-    Api(ApiTransport),
+    Api(ApiSession),
 }
 
 impl Transport {
     /// Build a runtime transport for a new run. For `Api`, mints a
-    /// fresh CSPRNG bearer token and pairs it with `server_url`.
-    /// Errors if `mode == Api` and `server_url` is missing.
+    /// fresh run ID and CSPRNG bearer token, pairs them with
+    /// `server_url`, and bundles them into an [`ApiSession`]. Errors
+    /// if `mode == Api` and `server_url` is missing.
     pub fn for_new_run(mode: TransportMode, server_url: Option<&str>) -> Result<Self> {
         match mode {
             TransportMode::Filesystem => Ok(Transport::Filesystem),
@@ -59,7 +63,8 @@ impl Transport {
                 let server_url = server_url
                     .ok_or(Error::ApiTransportMissingServerUrl)?
                     .to_string();
-                Ok(Transport::Api(ApiTransport {
+                Ok(Transport::Api(ApiSession {
+                    run_id: uuid::Uuid::now_v7().to_string(),
                     server_url,
                     auth_token: mint_auth_token(),
                 }))
@@ -68,15 +73,6 @@ impl Transport {
     }
 }
 
-/// Runtime config for the API transport, produced at run creation.
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct ApiTransport {
-    /// Base URL of quire-server (e.g. `http://127.0.0.1:3000`).
-    pub server_url: String,
-    /// Bearer token for this run, minted at creation time.
-    pub auth_token: String,
-}
-
 /// The state of a CI run.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum RunState {
@@ -147,15 +143,16 @@ impl Runs {
     /// workspace materialization and log storage.
     ///
     /// `transport` is built by the caller via [`Transport::for_new_run`];
-    /// its `Api` auth_token (if any) is persisted in the `auth_token`
-    /// column so server-side request handlers can look it up.
+    /// for `Api`, the run's id comes from the `ApiSession` (so quire-ci
+    /// and the DB agree on which run a bearer token belongs to) and
+    /// the token is persisted in `runs.auth_token`. For `Filesystem`,
+    /// a fresh UUID is minted here.
     pub fn create(&self, meta: &RunMeta, transport: &Transport) -> Result<Run> {
-        let id = uuid::Uuid::now_v7().to_string();
-        let workspace_path = self.base_dir.join(&id).join("workspace");
-        let auth_token_str = match transport {
-            Transport::Filesystem => None,
-            Transport::Api(api) => Some(api.auth_token.as_str()),
+        let (id, auth_token_str) = match transport {
+            Transport::Filesystem => (uuid::Uuid::now_v7().to_string(), None),
+            Transport::Api(api) => (api.run_id.clone(), Some(api.auth_token.as_str())),
         };
+        let workspace_path = self.base_dir.join(&id).join("workspace");
 
         let db = crate::db::open(&self.db_path)?;
 
@@ -934,6 +931,10 @@ mod tests {
             panic!("expected Api transport");
         };
 
+        // Run id and ApiSession.run_id are the same value — quire-ci
+        // and the orchestrator agree on which run a token belongs to.
+        assert_eq!(run.id(), api.run_id);
+
         let conn = crate::db::open(&quire.db_path()).expect("db");
         let stored: Option<String> = conn
             .query_row(
@@ -959,6 +960,11 @@ mod tests {
             "token should be alphanumeric, got {:?}",
             api.auth_token
         );
+        assert!(
+            uuid::Uuid::parse_str(&api.run_id).is_ok(),
+            "run_id should be a UUID, got {:?}",
+            api.run_id
+        );
     }
 
     #[test]