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
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, ®),
+ stderr: redact(&output.stderr, ®),
+ cmd: redact(&output.cmd, ®),
+ }
+ };
+
+ 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, ®),
- stderr: redact(&output.stderr, ®),
- cmd: redact(&output.cmd, ®),
- }
- });
+ .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)",