Switch per-job CI logs from plain text to YAML
Logs now live at `jobs/<job-id>/log.yml` as a YAML list of ShOutput
entries, consistent with the meta.yml/times.yml convention used
elsewhere in the run directory. Removes the custom text formatter
in favor of the existing `write_yaml` helper.
Updated the streaming TODO to note that the eventual streaming
rewrite should write to log.yml incrementally instead of batching
into write_all_logs at the end.
Assisted-by: GLM-5.1 via pi
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index 91bde25..c402096 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -361,10 +361,12 @@ impl Cmd {
// TODO: stream stdout/stderr live instead of buffering. `output()`
// captures the full child output in memory and only returns at exit,
// so long-running or chatty jobs show nothing until they finish.
- // The streaming rewrite should also revisit the `from_utf8_lossy`
- // calls below — non-UTF-8 bytes are silently replaced with U+FFFD
- // and `:stdout` / `:stderr` end up as mojibake with no signal that
- // anything was lost.
+ // The streaming rewrite should write to the per-job log file
+ // (`jobs/<id>/log.yml`) as output arrives instead of batching
+ // everything into `write_all_logs` at the end — see `Run::execute`.
+ // Also revisit the `from_utf8_lossy` calls below — non-UTF-8 bytes
+ // are silently replaced with U+FFFD and `:stdout` / `:stderr` end
+ // up as mojibake with no signal that anything was lost.
fn run(self, opts: ShOpts) -> std::io::Result<ShOutput> {
let cmd_str = format!("{self}");
let mut command: std::process::Command = self.into();
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 92b49be..3d4453e 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -320,11 +320,11 @@ impl Run {
/// Write per-job log files from the captured `(sh …)` outputs.
///
- /// Creates `jobs/<job-id>/log` in the run directory for each job
- /// that has outputs. Each log entry shows the command, exit code,
- /// and any stdout/stderr output. Called before the final state
- /// transition so logs are available for both successful and failed
- /// runs.
+ /// Creates `jobs/<job-id>/log.yml` in the run directory for each
+ /// job that has outputs. The file contains a YAML list of `ShOutput`
+ /// entries — command, exit code, stdout, stderr — one per `(sh …)`
+ /// call. Written before the final state transition so logs are
+ /// available for both successful and failed runs.
fn write_all_logs(&self, outputs: &HashMap<String, Vec<ShOutput>>) -> Result<()> {
for (job_id, sh_outputs) in outputs {
if sh_outputs.is_empty() {
@@ -332,11 +332,7 @@ impl Run {
}
let job_dir = self.path().join("jobs").join(job_id);
fs_err::create_dir_all(&job_dir)?;
- let mut log = String::new();
- for output in sh_outputs {
- log.push_str(&format_log_entry(output));
- }
- fs_err::write(job_dir.join("log"), &log)?;
+ write_yaml(&job_dir.join("log.yml"), sh_outputs)?;
}
Ok(())
}
@@ -420,34 +416,6 @@ fn read_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
Ok(serde_yaml_ng::from_reader(std::io::BufReader::new(f))?)
}
-/// Format a single `sh` invocation as a readable log entry.
-fn format_log_entry(output: &ShOutput) -> String {
- let mut s = String::new();
- s.push_str("$ ");
- s.push_str(&output.cmd);
- s.push('\n');
- s.push_str("exit: ");
- s.push_str(&output.exit.to_string());
- s.push('\n');
- if !output.stdout.is_empty() {
- s.push('\n');
- s.push_str(&output.stdout);
- if !output.stdout.ends_with('\n') {
- s.push('\n');
- }
- }
- if !output.stderr.is_empty() {
- s.push('\n');
- s.push_str("[stderr]\n");
- s.push_str(&output.stderr);
- if !output.stderr.ends_with('\n') {
- s.push('\n');
- }
- }
- s.push_str("---\n");
- s
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -1015,16 +983,17 @@ mod tests {
.join(&run_id)
.join("jobs")
.join("greet")
- .join("log");
+ .join("log.yml");
assert!(log_path.exists(), "job log file should exist");
- let log = fs_err::read_to_string(&log_path).expect("read log");
- assert!(
- log.contains("$ [\"echo\", \"hello\"]"),
- "log should show command: {log}"
- );
- assert!(log.contains("exit: 0"), "log should show exit code: {log}");
- assert!(log.contains("hello"), "log should show stdout: {log}");
+ let entries: Vec<ShOutput> =
+ serde_yaml_ng::from_str(&fs_err::read_to_string(&log_path).expect("read log"))
+ .expect("parse log");
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].exit, 0);
+ assert_eq!(entries[0].stdout, "hello\n");
+ assert!(entries[0].stderr.is_empty());
+ assert_eq!(entries[0].cmd, "[\"echo\", \"hello\"]");
}
#[test]
@@ -1046,16 +1015,16 @@ mod tests {
let failed_dir = runs.base.join(RunState::Failed.dir_name()).join(&run_id);
assert!(failed_dir.exists(), "run should be in failed/");
- let log_path = failed_dir.join("jobs").join("a").join("log");
+ let log_path = failed_dir.join("jobs").join("a").join("log.yml");
assert!(
log_path.exists(),
"job 'a' log should exist even though 'b' failed"
);
- let log = fs_err::read_to_string(&log_path).expect("read log");
- assert!(
- log.contains("from-a"),
- "log should contain a's output: {log}"
- );
+ let entries: Vec<ShOutput> =
+ serde_yaml_ng::from_str(&fs_err::read_to_string(&log_path).expect("read log"))
+ .expect("parse log");
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].stdout, "from-a\n");
}
}