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
change qzkmlqtvuzsxmykqytrwrppppworvktr
commit 325ea6b328ab643aaa9b575385e017e8c3ba5cc5
author Alpha Chen <alpha@kejadlen.dev>
date
parent ylyzwkqq
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");
     }
 }