Skip to main content

oxidized_state/
ci.rs

1//! CI domain objects for AIVCS.
2//!
3//! These types are designed to be serialized, content-addressed, and linked
4//! together by digest/ID so CI runs become durable, replayable objects.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11/// Input snapshot metadata used to identify a CI run context.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CiSnapshot {
14    /// Source repository commit SHA.
15    pub repo_sha: String,
16    /// Hash of working tree contents.
17    pub workspace_hash: String,
18    /// Hash of `.local-ci.toml` contents.
19    pub local_ci_config_hash: String,
20    /// Fingerprint of execution environment/toolchain.
21    pub env_hash: String,
22}
23
24impl CiSnapshot {
25    /// Returns a deterministic content digest for this snapshot.
26    pub fn digest(&self) -> String {
27        digest_json(self)
28    }
29}
30
31/// A single command to execute inside CI.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CiCommand {
34    /// Binary or shell command.
35    pub program: String,
36    /// Program arguments.
37    pub args: Vec<String>,
38    /// Environment key/value overrides.
39    pub env: BTreeMap<String, String>,
40    /// Optional working directory relative to repo root.
41    pub cwd: Option<String>,
42}
43
44impl CiCommand {
45    /// Returns a deterministic content digest for this command.
46    pub fn digest(&self) -> String {
47        digest_json(self)
48    }
49}
50
51/// Declarative definition for one CI step.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct CiStepSpec {
54    /// Human-friendly step name.
55    pub name: String,
56    /// Command to execute.
57    pub command: CiCommand,
58    /// Optional hard timeout in seconds.
59    pub timeout_secs: Option<u64>,
60    /// If true, step failure does not fail the whole run.
61    pub allow_failure: bool,
62}
63
64impl CiStepSpec {
65    /// Returns a deterministic content digest for this step spec.
66    pub fn digest(&self) -> String {
67        digest_json(self)
68    }
69}
70
71/// A CI pipeline specification (ordered step list).
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct CiPipelineSpec {
74    /// Pipeline name.
75    pub name: String,
76    /// Ordered list of steps.
77    pub steps: Vec<CiStepSpec>,
78}
79
80impl CiPipelineSpec {
81    /// Returns a deterministic content digest for this pipeline spec.
82    pub fn digest(&self) -> String {
83        digest_json(self)
84    }
85}
86
87/// Runtime status for a step/run.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum CiRunStatus {
91    Queued,
92    Running,
93    Succeeded,
94    Failed,
95    Cancelled,
96}
97
98impl CiRunStatus {
99    /// True when the status represents a terminal state.
100    pub fn is_terminal(self) -> bool {
101        matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
102    }
103}
104
105/// Immutable result for an executed step.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct CiStepResult {
108    /// Step name (copied from spec for denormalized queryability).
109    pub step_name: String,
110    /// Final status.
111    pub status: CiRunStatus,
112    /// Process exit code when available.
113    pub exit_code: Option<i32>,
114    /// Start timestamp in RFC3339 format.
115    pub started_at: Option<String>,
116    /// End timestamp in RFC3339 format.
117    pub finished_at: Option<String>,
118    /// Digest of step stdout blob, if persisted.
119    pub stdout_digest: Option<String>,
120    /// Digest of step stderr blob, if persisted.
121    pub stderr_digest: Option<String>,
122}
123
124/// Metadata for a produced artifact.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct CiArtifact {
127    /// Artifact logical name.
128    pub name: String,
129    /// Path relative to workspace root.
130    pub path: String,
131    /// Content digest for immutable retrieval.
132    pub digest: String,
133    /// Artifact size in bytes.
134    pub size_bytes: u64,
135    /// Optional MIME type.
136    pub media_type: Option<String>,
137}
138
139/// Durable CI run object (content-linked to snapshot and pipeline).
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct CiRunRecord {
142    /// Stable run identifier.
143    pub run_id: String,
144    /// Digest of [`CiSnapshot`].
145    pub snapshot_digest: String,
146    /// Digest of [`CiPipelineSpec`].
147    pub pipeline_digest: String,
148    /// Final run status.
149    pub status: CiRunStatus,
150    /// Per-step execution results.
151    pub step_results: Vec<CiStepResult>,
152    /// Produced artifacts.
153    pub artifacts: Vec<CiArtifact>,
154    /// Start timestamp in RFC3339 format.
155    pub started_at: Option<String>,
156    /// End timestamp in RFC3339 format.
157    pub finished_at: Option<String>,
158    /// Extra query metadata (branch, actor, host, etc).
159    pub metadata: BTreeMap<String, String>,
160}
161
162impl CiRunRecord {
163    /// Creates a queued run record from snapshot+pipeline digests.
164    pub fn queued(snapshot_digest: impl AsRef<str>, pipeline_digest: impl AsRef<str>) -> Self {
165        let snapshot_digest = snapshot_digest.as_ref().to_string();
166        let pipeline_digest = pipeline_digest.as_ref().to_string();
167        let run_id = digest_two(&snapshot_digest, &pipeline_digest);
168
169        Self {
170            run_id,
171            snapshot_digest,
172            pipeline_digest,
173            status: CiRunStatus::Queued,
174            step_results: Vec::new(),
175            artifacts: Vec::new(),
176            started_at: None,
177            finished_at: None,
178            metadata: BTreeMap::new(),
179        }
180    }
181
182    /// Returns a deterministic content digest for this run record.
183    pub fn digest(&self) -> String {
184        digest_json(self)
185    }
186}
187
188fn digest_json<T: Serialize>(value: &T) -> String {
189    let bytes =
190        serde_json::to_vec(value).expect("CI domain objects must be serializable for hashing");
191    let mut hasher = Sha256::new();
192    hasher.update(bytes);
193    hex::encode(hasher.finalize())
194}
195
196fn digest_two(left: &str, right: &str) -> String {
197    let mut hasher = Sha256::new();
198    hasher.update(left.as_bytes());
199    hasher.update([0u8]);
200    hasher.update(right.as_bytes());
201    hex::encode(hasher.finalize())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn snapshot_digest_is_deterministic() {
210        let a = CiSnapshot {
211            repo_sha: "abc123".to_string(),
212            workspace_hash: "ws1".to_string(),
213            local_ci_config_hash: "cfg1".to_string(),
214            env_hash: "env1".to_string(),
215        };
216        let b = a.clone();
217        assert_eq!(a.digest(), b.digest());
218    }
219
220    #[test]
221    fn pipeline_digest_changes_when_step_changes() {
222        let mut env = BTreeMap::new();
223        env.insert("RUST_LOG".to_string(), "info".to_string());
224
225        let p1 = CiPipelineSpec {
226            name: "default".to_string(),
227            steps: vec![CiStepSpec {
228                name: "test".to_string(),
229                command: CiCommand {
230                    program: "cargo".to_string(),
231                    args: vec!["test".to_string()],
232                    env: env.clone(),
233                    cwd: None,
234                },
235                timeout_secs: Some(600),
236                allow_failure: false,
237            }],
238        };
239
240        let p2 = CiPipelineSpec {
241            steps: vec![CiStepSpec {
242                name: "test".to_string(),
243                command: CiCommand {
244                    program: "cargo".to_string(),
245                    args: vec!["test".to_string(), "--all-features".to_string()],
246                    env,
247                    cwd: None,
248                },
249                timeout_secs: Some(600),
250                allow_failure: false,
251            }],
252            ..p1.clone()
253        };
254
255        assert_ne!(p1.digest(), p2.digest());
256    }
257
258    #[test]
259    fn queued_run_id_is_stable_for_same_inputs() {
260        let r1 = CiRunRecord::queued("snap-a", "pipe-b");
261        let r2 = CiRunRecord::queued("snap-a", "pipe-b");
262        assert_eq!(r1.run_id, r2.run_id);
263        assert_eq!(r1.status, CiRunStatus::Queued);
264    }
265
266    #[test]
267    fn status_terminal_semantics_are_correct() {
268        assert!(!CiRunStatus::Queued.is_terminal());
269        assert!(!CiRunStatus::Running.is_terminal());
270        assert!(CiRunStatus::Succeeded.is_terminal());
271        assert!(CiRunStatus::Failed.is_terminal());
272        assert!(CiRunStatus::Cancelled.is_terminal());
273    }
274}