Move per-sh CRI log writes into Runtime
The runtime owns the data and the redaction registry, so writing the
log file inline in sh() — instead of draining outputs at end-of-run
and writing them on the executor side — is the natural home. Each
sh produces <log_dir>/jobs/<job_id>/sh-<n>.log as it finishes.

Runtime::new now takes a mandatory log_dir; callers manage the dir's
lifetime. The host path passes the run dir (same on-disk layout as
before, just written earlier). quire-ci adds --out-dir; if absent it
allocates a tempdir and prints its path on stdout at the end of the
run so users can find the logs.

write_sh_records loses its log-writing branch — purely DB inserts now.
The CRI-format helper moves from quire-server to quire-core so both
crates can use it.

Assisted-by: claude-opus-4-7
change spquozurvunkmxpuypymyrzpxtwymlwy
commit f1a1b5066d50124841048136f88d7e7ecb70892b
author Alpha Chen <alpha@kejadlen.dev>
date
parent lspxvrvl
diff --git a/Cargo.lock b/Cargo.lock
index cbe6437..d69f4b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2135,6 +2135,7 @@ dependencies = [
  "quire-core",
  "serde",
  "serde_json",
+ "tempfile",
 ]
 
 [[package]]
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 3d52c67..b22b53a 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -12,3 +12,4 @@ mlua = { workspace = true }
 quire-core = { path = "../quire-core" }
 serde = { workspace = true }
 serde_json = { workspace = true }
+tempfile = { workspace = true }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 994d4bf..0bf81c0 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -45,6 +45,12 @@ enum Commands {
         /// Where to send structured run events.
         #[arg(long, value_enum, default_value_t = EventsKind::Null)]
         events: EventsKind,
+
+        /// Directory for per-sh CRI log files. Defaults to a fresh
+        /// tempdir whose path is printed on stdout at the end of the
+        /// run.
+        #[arg(long)]
+        out_dir: Option<PathBuf>,
     },
 }
 
@@ -62,12 +68,23 @@ fn main() -> miette::Result<()> {
     let cli = Cli::parse();
     match cli.command {
         Commands::Validate => validate(cli.workspace),
-        Commands::Run { events } => {
+        Commands::Run { events, out_dir } => {
             let sink: Box<dyn EventSink> = match events {
                 EventsKind::Null => Box::new(NullSink),
                 EventsKind::Stdout => Box::new(JsonlSink::new(io::stdout())),
             };
-            run_pipeline(cli.workspace, sink)
+            let (log_dir, announce_path) = match out_dir {
+                Some(path) => {
+                    fs_err::create_dir_all(&path).into_diagnostic()?;
+                    (path, false)
+                }
+                None => (tempfile::tempdir().into_diagnostic()?.keep(), true),
+            };
+            run_pipeline(cli.workspace, sink, log_dir.clone())?;
+            if announce_path {
+                println!("logs at {}", log_dir.display());
+            }
+            Ok(())
         }
     }
 }
@@ -97,7 +114,11 @@ fn validate(workspace: PathBuf) -> miette::Result<()> {
     Ok(())
 }
 
-fn run_pipeline(workspace: PathBuf, sink: Box<dyn EventSink>) -> miette::Result<()> {
+fn run_pipeline(
+    workspace: PathBuf,
+    sink: Box<dyn EventSink>,
+    log_dir: PathBuf,
+) -> miette::Result<()> {
     let pipeline = compile_at(&workspace)?;
 
     let job_ids: Vec<String> = pipeline
@@ -124,6 +145,7 @@ fn run_pipeline(workspace: PathBuf, sink: Box<dyn EventSink>) -> miette::Result<
         &meta,
         &git_dir,
         workspace,
+        log_dir,
     ));
 
     // Active job pointer, shared between the main loop and the runtime
diff --git a/quire-server/src/ci/logs.rs b/quire-core/src/ci/logs.rs
similarity index 100%
rename from quire-server/src/ci/logs.rs
rename to quire-core/src/ci/logs.rs
diff --git a/quire-core/src/ci/mirror.rs b/quire-core/src/ci/mirror.rs
index fd5f27d..04c214e 100644
--- a/quire-core/src/ci/mirror.rs
+++ b/quire-core/src/ci/mirror.rs
@@ -439,12 +439,14 @@ mod tests {
             RunFn::Rust(f) => f,
             RunFn::Lua(_) => panic!("mirror should register a RunFn::Rust"),
         };
+        let log_dir = tempfile::tempdir().expect("tempdir for mirror logs").keep();
         let runtime = Rc::new(Runtime::new(
             pipeline,
             secrets,
             meta,
             git_dir,
             std::env::current_dir().expect("cwd"),
+            log_dir,
         ));
         let _ = RuntimeHandle(runtime.clone())
             .into_lua(runtime.lua())
diff --git a/quire-core/src/ci/mod.rs b/quire-core/src/ci/mod.rs
index 6d9f036..2541629 100644
--- a/quire-core/src/ci/mod.rs
+++ b/quire-core/src/ci/mod.rs
@@ -5,6 +5,7 @@
 //! (where `quire-ci` invokes them) and on the server (where the
 //! orchestrator drives them).
 
+pub mod logs;
 pub mod mirror;
 pub mod pipeline;
 pub mod registration;
diff --git a/quire-core/src/ci/runtime.rs b/quire-core/src/ci/runtime.rs
index 3b2e64e..1f0a60e 100644
--- a/quire-core/src/ci/runtime.rs
+++ b/quire-core/src/ci/runtime.rs
@@ -39,6 +39,13 @@ pub enum RuntimeError {
 
     #[error("git error: {0}")]
     Git(String),
+
+    #[error("failed to write CRI log at {path}")]
+    LogWriteFailed {
+        path: std::path::PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
 }
 
 impl From<mlua::Error> for RuntimeError {
@@ -112,6 +119,10 @@ pub struct Runtime {
     /// Observer notified of [`RuntimeEvent`]s. Defaults to a no-op;
     /// callers install a real one via [`Runtime::set_event_callback`].
     event_callback: RefCell<RuntimeCallback>,
+    /// Directory under which [`Runtime::sh`] writes per-sh CRI log
+    /// files at `<log_dir>/jobs/<job_id>/sh-<n>.log`. Set at
+    /// construction time; callers manage the directory's lifetime.
+    log_dir: std::path::PathBuf,
     /// The materialized workspace for this run. Every `(sh …)` call
     /// runs here.
     workspace: std::path::PathBuf,
@@ -135,6 +146,7 @@ impl Runtime {
         meta: &RunMeta,
         git_dir: &std::path::Path,
         workspace: std::path::PathBuf,
+        log_dir: std::path::PathBuf,
     ) -> Self {
         let transitive = pipeline.transitive_inputs();
         let lua = pipeline.fennel().lua();
@@ -175,6 +187,7 @@ impl Runtime {
             outputs: RefCell::new(HashMap::new()),
             sh_timings: RefCell::new(HashMap::new()),
             event_callback: RefCell::new(noop_event_callback()),
+            log_dir,
             workspace,
         }
     }
@@ -282,19 +295,35 @@ impl Runtime {
         if let Some(job_id) = current_job {
             (self.event_callback.borrow_mut())(RuntimeEvent::ShFinished { exit: output.exit });
 
+            let recorded = {
+                let reg = self.registry.borrow();
+                ShOutput {
+                    exit: output.exit,
+                    stdout: redact(&output.stdout, &reg),
+                    stderr: redact(&output.stderr, &reg),
+                    cmd: redact(&output.cmd, &reg),
+                }
+            };
+
+            let job_dir = self.log_dir.join("jobs").join(&job_id);
+            fs_err::create_dir_all(&job_dir).map_err(|source| RuntimeError::LogWriteFailed {
+                path: job_dir.clone(),
+                source,
+            })?;
+            let n = self.outputs.borrow().get(&job_id).map_or(0, Vec::len) + 1;
+            let log_path = job_dir.join(format!("sh-{n}.log"));
+            super::logs::write_cri_log(&log_path, &recorded, &started_at.to_string()).map_err(
+                |source| RuntimeError::LogWriteFailed {
+                    path: log_path,
+                    source,
+                },
+            )?;
+
             self.outputs
                 .borrow_mut()
                 .entry(job_id.clone())
                 .or_default()
-                .push({
-                    let reg = self.registry.borrow();
-                    ShOutput {
-                        exit: output.exit,
-                        stdout: redact(&output.stdout, &reg),
-                        stderr: redact(&output.stderr, &reg),
-                        cmd: redact(&output.cmd, &reg),
-                    }
-                });
+                .push(recorded);
             self.sh_timings
                 .borrow_mut()
                 .entry(job_id)
@@ -308,10 +337,19 @@ impl Runtime {
 
 #[cfg(test)]
 impl Runtime {
+    /// Test-only accessor for the runtime's log directory.
+    pub(crate) fn log_dir(&self) -> &std::path::Path {
+        &self.log_dir
+    }
+
     /// Minimal constructor for tests — no source outputs, just
-    /// secrets and the pipeline's VM. Defaults the workspace to the
-    /// process CWD so tests that don't care about cwd keep working.
+    /// secrets and the pipeline's VM. Workspace defaults to cwd; logs
+    /// land under a fresh tempdir each call (leaked into the system
+    /// temp area, since tests don't share a TempDir handle).
     fn for_test(pipeline: Pipeline, secrets: HashMap<String, SecretString>) -> Self {
+        let log_dir = tempfile::tempdir()
+            .expect("tempdir for runtime logs")
+            .keep();
         Self {
             pipeline,
             registry: RefCell::new(SecretRegistry::from(secrets)),
@@ -320,6 +358,7 @@ impl Runtime {
             outputs: RefCell::new(HashMap::new()),
             sh_timings: RefCell::new(HashMap::new()),
             event_callback: RefCell::new(noop_event_callback()),
+            log_dir,
             workspace: std::env::current_dir().expect("cwd"),
         }
     }
@@ -645,6 +684,30 @@ mod tests {
         assert_eq!(calls[1], "finished:0");
     }
 
+    #[test]
+    fn sh_writes_cri_log_inline() {
+        let (runtime, run_fn) = rt(
+            r#"(local ci (require :quire.ci))
+(ci.job :go [:quire/push] (fn [{: sh}] (sh ["echo" "hi"])))"#,
+            HashMap::new(),
+        );
+        let log_dir = runtime.log_dir().to_path_buf();
+        *runtime.current_job.borrow_mut() = Some("go".to_string());
+
+        let handle = RuntimeHandle(runtime.clone())
+            .into_lua(runtime.lua())
+            .expect("install runtime");
+        let _: mlua::Value = run_fn.call(handle).expect("sh call");
+
+        let log_path = log_dir.join("jobs").join("go").join("sh-1.log");
+        assert!(log_path.exists(), "expected sh-1.log at {log_path:?}");
+        let contents = std::fs::read_to_string(&log_path).expect("read log");
+        assert!(
+            contents.contains("stdout F hi"),
+            "expected stdout line in log, got: {contents:?}"
+        );
+    }
+
     #[test]
     fn event_callback_not_fired_without_current_job() {
         let count = Rc::new(RefCell::new(0u32));
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index 5278b38..9fcfa1f 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -100,6 +100,11 @@ impl From<RuntimeError> for Error {
                 source,
             },
             RuntimeError::Git(s) => Error::Git(s),
+            RuntimeError::LogWriteFailed { path, source } => Error::CommandSpawnFailed {
+                program: "write-cri-log".to_string(),
+                cwd: path,
+                source,
+            },
         }
     }
 }
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index c9ff2e1..922ee8c 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -2,7 +2,6 @@
 
 use std::collections::HashMap;
 
-pub(crate) mod logs;
 mod run;
 
 pub(crate) mod error;
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index df5f9aa..c150876 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -231,6 +231,7 @@ impl Run {
             &meta,
             git_dir,
             workspace.to_path_buf(),
+            self.path(),
         ));
 
         let lua = runtime.lua();
@@ -359,9 +360,13 @@ impl Run {
         Ok(())
     }
 
-    /// Write sh events to the database and per-sh CRI log files to
-    /// disk. Written before the final state transition so logs are
+    /// Insert sh_events DB rows from the runtime's captured outputs and
+    /// timings. Written before the final state transition so events are
     /// available for both successful and failed runs.
+    ///
+    /// Per-sh CRI log files are written by [`Runtime::sh`] inline as
+    /// the run progresses (see `Runtime::log_dir`), so this is purely
+    /// a database concern.
     fn write_sh_records(
         &self,
         outputs: &HashMap<String, Vec<ShOutput>>,
@@ -375,25 +380,16 @@ impl Run {
 
         for (job_id, sh_outputs) in outputs {
             let job_timings = timings.get(job_id);
-            let job_dir = self.path().join("jobs").join(job_id);
 
             for (i, output) in sh_outputs.iter().enumerate() {
-                let n = i + 1;
                 let (started_at, finished_at) = job_timings
                     .and_then(|t| t.get(i))
                     .copied()
                     .unwrap_or_else(|| {
-                        // Fallback if timing wasn't captured (shouldn't happen).
                         let now = jiff::Timestamp::now();
                         (now, now)
                     });
 
-                // Write CRI log file.
-                fs_err::create_dir_all(&job_dir)?;
-                let sh_path = job_dir.join(format!("sh-{n}.log"));
-                super::logs::write_cri_log(&sh_path, output, &started_at.to_string())?;
-
-                // Insert sh event into the database.
                 db.execute(
                     "INSERT INTO sh_events (run_id, job_id, started_at_ms, finished_at_ms, exit_code, cmd)
                      VALUES (?1, ?2, ?3, ?4, ?5, ?6)",