Add container.yml schema and Run accessors
Assisted-by: Claude Opus 4.7 via Claude Code
change omuvzyqrrvqtzunsxtqospvutnkywmqt
commit 5dced6f5a51648f7befa820613c353864915764d
author Alpha Chen <alpha@kejadlen.dev>
date
parent otmxtyyx
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 6af0f8d..532bc1a 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -70,6 +70,26 @@ pub struct RunTimes {
     pub finished_at: Option<Timestamp>,
 }
 
+/// Container metadata for a docker-mode run, persisted to
+/// `<run-dir>/container.yml`. Each field is populated incrementally as
+/// the lifecycle progresses; absence implies "not yet (or never)
+/// reached." Host-mode runs do not write this file.
+#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct ContainerRecord {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub image_tag: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub container_id: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub build_started_at: Option<Timestamp>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub build_finished_at: Option<Timestamp>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub container_started_at: Option<Timestamp>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub container_stopped_at: Option<Timestamp>,
+}
+
 /// Access to CI runs for a single repo.
 ///
 /// Owns the base path (`runs/<repo>/`) and provides run creation
@@ -442,6 +462,21 @@ impl Run {
     pub fn write_times(&self, times: &RunTimes) -> Result<()> {
         write_yaml(&self.path().join("times.yml"), times)
     }
+
+    /// Read this run's `container.yml` record. Returns the deserialized
+    /// `ContainerRecord`. Errors if the file is missing or malformed —
+    /// callers should use `path().join("container.yml").exists()` if they
+    /// want to handle the absent case as "host mode."
+    pub fn read_container_record(&self) -> Result<ContainerRecord> {
+        read_yaml(&self.path().join("container.yml"))
+    }
+
+    /// Atomically write this run's `container.yml` record (temp file +
+    /// rename). Each call replaces the file; partial fields are
+    /// represented as `None` and skipped from the output.
+    pub fn write_container_record(&self, record: &ContainerRecord) -> Result<()> {
+        write_yaml(&self.path().join("container.yml"), record)
+    }
 }
 
 /// Materialize a working tree at `sha` into `workspace` via
@@ -1452,4 +1487,26 @@ mod tests {
             "expected source to surface rust error, got: {source}"
         );
     }
+
+    #[test]
+    fn container_record_round_trips_through_yaml() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let run = runs.create(&test_meta()).expect("create");
+
+        let now: Timestamp = "2026-05-04T16:20:01Z".parse().expect("parse");
+        let later: Timestamp = "2026-05-04T16:21:09Z".parse().expect("parse");
+        let record = ContainerRecord {
+            image_tag: Some("quire-ci/test:run-id".into()),
+            container_id: Some("9f3b8a72c1d4".into()),
+            build_started_at: Some(now),
+            build_finished_at: Some(later),
+            container_started_at: Some(later),
+            container_stopped_at: None,
+        };
+        run.write_container_record(&record).expect("write");
+
+        let read = run.read_container_record().expect("read");
+        assert_eq!(read, record);
+    }
 }