git_internal/internal/object/run.rs
1//! AI Run Definition
2//!
3//! A [`Run`] is a single execution attempt of a
4//! [`Task`](super::task::Task). It captures the execution context
5//! (baseline commit, environment, Plan version) and accumulates
6//! artifacts ([`PatchSet`](super::patchset::PatchSet)s,
7//! [`Evidence`](super::evidence::Evidence),
8//! [`ToolInvocation`](super::tool::ToolInvocation)s) during execution.
9//! The Run is step ⑤ in the end-to-end flow described in
10//! [`mod.rs`](super).
11//!
12//! # Position in Lifecycle
13//!
14//! ```text
15//! ④ Task ──runs──▶ [Run₀, Run₁, ...]
16//! │
17//! ▼
18//! ⑤ Run (Created → Patching → Validating → Completed/Failed)
19//! │
20//! ├──task──▶ Task (mandatory, 1:1)
21//! ├──plan──▶ Plan (snapshot reference)
22//! ├──snapshot──▶ ContextSnapshot (optional)
23//! │
24//! │ ┌─── agent execution loop ───┐
25//! │ │ │
26//! │ │ ⑥ ToolInvocation (1:N) │
27//! │ │ │ │
28//! │ │ ▼ │
29//! │ │ ⑦ PatchSet (Proposed) │
30//! │ │ │ │
31//! │ │ ▼ │
32//! │ │ ⑧ Evidence (1:N) │
33//! │ │ │ │
34//! │ │ ├─ pass ─────────────┘
35//! │ │ └─ fail → new PatchSet
36//! │ └────────────────────────────┘
37//! │
38//! ▼
39//! ⑨ Decision (terminal verdict)
40//! ```
41//!
42//! # Status Transitions
43//!
44//! ```text
45//! Created ──▶ Patching ──▶ Validating ──▶ Completed
46//! │ │
47//! └──────────────┴──▶ Failed
48//! ```
49//!
50//! # Relationships
51//!
52//! | Field | Target | Cardinality | Notes |
53//! |-------|--------|-------------|-------|
54//! | `task` | Task | 1 | Mandatory owning Task |
55//! | `plan` | Plan | 0..1 | Snapshot reference (frozen at Run start) |
56//! | `snapshot` | ContextSnapshot | 0..1 | Static context at Run start |
57//! | `patchsets` | PatchSet | 0..N | Candidate diffs, chronological |
58//!
59//! Reverse references (by `run_id`):
60//! - `Provenance.run_id` → this Run (1:1, LLM config)
61//! - `ToolInvocation.run_id` → this Run (1:N, action log)
62//! - `Evidence.run_id` → this Run (1:N, validation results)
63//! - `Decision.run_id` → this Run (1:1, terminal verdict)
64//!
65//! # Purpose
66//!
67//! - **Execution Context**: Records the baseline `commit`, host
68//! `environment`, and Plan version so that results can be
69//! reproduced.
70//! - **Artifact Collection**: Accumulates PatchSets (candidate diffs)
71//! during the agent execution loop.
72//! - **Isolation**: Each Run is independent — a retry creates a new
73//! Run with potentially different parameters, without mutating the
74//! previous Run's state.
75
76use std::{collections::HashMap, fmt};
77
78use serde::{Deserialize, Serialize};
79use uuid::Uuid;
80
81use crate::{
82 errors::GitError,
83 hash::ObjectHash,
84 internal::object::{
85 ObjectTrait,
86 integrity::IntegrityHash,
87 types::{ActorRef, Header, ObjectType},
88 },
89};
90
91/// Lifecycle status of a [`Run`].
92///
93/// See module docs for the status transition diagram.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "snake_case")]
96pub enum RunStatus {
97 /// Run has been created but the agent has not started execution.
98 /// Environment and baseline commit are captured at this point.
99 Created,
100 /// Agent is actively generating code changes. One or more
101 /// [`ToolInvocation`](super::tool::ToolInvocation)s are being
102 /// produced.
103 Patching,
104 /// Agent has produced a candidate
105 /// [`PatchSet`](super::patchset::PatchSet) and is running
106 /// validation tools (tests, lint, build). One or more
107 /// [`Evidence`](super::evidence::Evidence) objects are being
108 /// produced.
109 Validating,
110 /// Agent has finished successfully. A
111 /// [`Decision`](super::decision::Decision) has been created.
112 Completed,
113 /// Agent encountered an unrecoverable error. `Run.error` should
114 /// contain the error message.
115 Failed,
116}
117
118impl RunStatus {
119 pub fn as_str(&self) -> &'static str {
120 match self {
121 RunStatus::Created => "created",
122 RunStatus::Patching => "patching",
123 RunStatus::Validating => "validating",
124 RunStatus::Completed => "completed",
125 RunStatus::Failed => "failed",
126 }
127 }
128}
129
130impl fmt::Display for RunStatus {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 write!(f, "{}", self.as_str())
133 }
134}
135
136/// Host environment snapshot captured at Run creation time.
137///
138/// Records the OS, CPU architecture, and working directory so that
139/// results can be correlated with the execution environment. The
140/// `extra` map allows capturing additional environment details
141/// (e.g. tool versions, environment variables) without schema changes.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Environment {
144 /// Operating system identifier (e.g. "macos", "linux", "windows").
145 pub os: String,
146 /// CPU architecture (e.g. "aarch64", "x86_64").
147 pub arch: String,
148 /// Current working directory at Run creation time.
149 pub cwd: String,
150 /// Additional environment details (tool versions, etc.).
151 #[serde(flatten)]
152 pub extra: HashMap<String, serde_json::Value>,
153}
154
155impl Environment {
156 /// Create a new environment object from the current system environment
157 pub fn capture() -> Self {
158 Self {
159 os: std::env::consts::OS.to_string(),
160 arch: std::env::consts::ARCH.to_string(),
161 cwd: std::env::current_dir()
162 .map(|p| p.to_string_lossy().to_string())
163 .unwrap_or_else(|e| {
164 tracing::warn!("Failed to get current directory: {}", e);
165 "unknown".to_string()
166 }),
167 extra: HashMap::new(),
168 }
169 }
170}
171
172/// A single execution attempt of a [`Task`](super::task::Task).
173///
174/// A Run captures the execution context and accumulates artifacts
175/// during the agent's work. It is step ⑤ in the end-to-end flow.
176/// See module documentation for lifecycle, relationships, and
177/// status transitions.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Run {
180 /// Common header (object ID, type, timestamps, creator, etc.).
181 #[serde(flatten)]
182 header: Header,
183 /// The [`Task`](super::task::Task) this Run belongs to.
184 ///
185 /// Mandatory — every Run is an execution attempt of exactly one
186 /// Task. `Task.runs` holds the reverse reference. This field is
187 /// set at creation and never changes.
188 task: Uuid,
189 /// The [`Plan`](super::plan::Plan) this Run is executing.
190 ///
191 /// This is a **snapshot reference**: it records the specific Plan
192 /// version that was active when this Run started. After
193 /// replanning, existing Runs keep their original `plan` unchanged
194 /// — only new Runs reference the revised Plan.
195 /// `Intent.plan` always points to the latest revision, but a Run
196 /// may be executing an older version. `None` when no Plan was
197 /// associated (e.g. ad-hoc execution without formal planning).
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 plan: Option<Uuid>,
200 /// Git commit hash of the working tree when this Run started.
201 ///
202 /// Serves as the baseline for all code changes: the agent reads
203 /// files at this commit, and the resulting
204 /// [`PatchSet`](super::patchset::PatchSet) diffs are relative to
205 /// it. If the Run fails and a new Run is created, the new Run
206 /// may start from a different commit (e.g. after upstream changes
207 /// are pulled).
208 commit: IntegrityHash,
209 /// Current lifecycle status.
210 ///
211 /// Transitions follow the sequence:
212 /// `Created → Patching → Validating → Completed` (happy path),
213 /// or `→ Failed` from any active state. The orchestrator advances
214 /// the status as the agent progresses through execution phases.
215 status: RunStatus,
216 /// Optional [`ContextSnapshot`](super::context::ContextSnapshot)
217 /// captured at Run creation time.
218 ///
219 /// Records the file tree, documentation fragments, and other
220 /// static context the agent observed when the Run began. Used
221 /// for reproducibility: given the same snapshot and Plan, the
222 /// agent should produce equivalent results. `None` when no
223 /// snapshot was captured.
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 snapshot: Option<Uuid>,
226 /// Chronological list of [`PatchSet`](super::patchset::PatchSet)
227 /// IDs generated during this Run.
228 ///
229 /// Append-only — each new PatchSet is pushed to the end. The
230 /// last entry is the most recent candidate. A Run may produce
231 /// multiple PatchSets when the agent iterates on validation
232 /// failures (step ⑦ → ⑧ retry loop). Empty when no PatchSet
233 /// has been generated yet.
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 patchsets: Vec<Uuid>,
236 /// Execution metrics (token usage, timing, etc.).
237 ///
238 /// Free-form JSON for metrics not captured by
239 /// [`Provenance`](super::provenance::Provenance). For example,
240 /// wall-clock duration, number of tool calls, or retry count.
241 /// `None` when no metrics are available.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 metrics: Option<serde_json::Value>,
244 /// Error message if the Run failed.
245 ///
246 /// Set when `status` transitions to `Failed`. Contains a
247 /// human-readable description of what went wrong. `None` while
248 /// the Run is in progress or completed successfully.
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 error: Option<String>,
251 /// Host [`Environment`] snapshot captured at Run creation time.
252 ///
253 /// Automatically populated by [`Run::new`] via
254 /// [`Environment::capture`]. Records OS, architecture, and
255 /// working directory for reproducibility.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 environment: Option<Environment>,
258}
259
260impl Run {
261 /// Create a new Run.
262 ///
263 /// # Arguments
264 /// * `created_by` - Actor (usually the Orchestrator)
265 /// * `task` - The Task this run belongs to
266 /// * `commit` - The Git commit hash of the checkout
267 pub fn new(created_by: ActorRef, task: Uuid, commit: impl AsRef<str>) -> Result<Self, String> {
268 let commit = commit.as_ref().parse()?;
269 Ok(Self {
270 header: Header::new(ObjectType::Run, created_by)?,
271 task,
272 plan: None,
273 commit,
274 status: RunStatus::Created,
275 snapshot: None,
276 patchsets: Vec::new(),
277 metrics: None,
278 error: None,
279 environment: Some(Environment::capture()),
280 })
281 }
282
283 pub fn header(&self) -> &Header {
284 &self.header
285 }
286
287 pub fn task(&self) -> Uuid {
288 self.task
289 }
290
291 /// Returns the Plan this Run is executing, if set.
292 pub fn plan(&self) -> Option<Uuid> {
293 self.plan
294 }
295
296 /// Sets the Plan this Run will execute.
297 pub fn set_plan(&mut self, plan: Option<Uuid>) {
298 self.plan = plan;
299 }
300
301 pub fn commit(&self) -> &IntegrityHash {
302 &self.commit
303 }
304
305 pub fn status(&self) -> &RunStatus {
306 &self.status
307 }
308
309 pub fn snapshot(&self) -> Option<Uuid> {
310 self.snapshot
311 }
312
313 /// Returns the chronological list of PatchSet IDs generated during this Run.
314 pub fn patchsets(&self) -> &[Uuid] {
315 &self.patchsets
316 }
317
318 pub fn metrics(&self) -> Option<&serde_json::Value> {
319 self.metrics.as_ref()
320 }
321
322 pub fn error(&self) -> Option<&str> {
323 self.error.as_deref()
324 }
325
326 pub fn environment(&self) -> Option<&Environment> {
327 self.environment.as_ref()
328 }
329
330 pub fn set_status(&mut self, status: RunStatus) {
331 self.status = status;
332 }
333
334 pub fn set_snapshot(&mut self, snapshot: Option<Uuid>) {
335 self.snapshot = snapshot;
336 }
337
338 /// Appends a PatchSet ID to this Run's generation history.
339 pub fn add_patchset(&mut self, patchset_id: Uuid) {
340 self.patchsets.push(patchset_id);
341 }
342
343 pub fn set_metrics(&mut self, metrics: Option<serde_json::Value>) {
344 self.metrics = metrics;
345 }
346
347 pub fn set_error(&mut self, error: Option<String>) {
348 self.error = error;
349 }
350}
351
352impl fmt::Display for Run {
353 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354 write!(f, "Run: {}", self.header.object_id())
355 }
356}
357
358impl ObjectTrait for Run {
359 fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
360 where
361 Self: Sized,
362 {
363 serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
364 }
365
366 fn get_type(&self) -> ObjectType {
367 ObjectType::Run
368 }
369
370 fn get_size(&self) -> usize {
371 match serde_json::to_vec(self) {
372 Ok(v) => v.len(),
373 Err(e) => {
374 tracing::warn!("failed to compute Run size: {}", e);
375 0
376 }
377 }
378 }
379
380 fn to_data(&self) -> Result<Vec<u8>, GitError> {
381 serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn test_hash_hex() -> String {
390 IntegrityHash::compute(b"ai-process-test").to_hex()
391 }
392
393 #[test]
394 fn test_new_objects_creation() {
395 let actor = ActorRef::agent("test-agent").expect("actor");
396 let base_hash = test_hash_hex();
397
398 // Run with environment (auto captured)
399 let run = Run::new(actor.clone(), Uuid::from_u128(0x1), &base_hash).expect("run");
400
401 let env = run.environment().unwrap();
402 // Check if it captured real values (assuming we are running on some OS)
403 assert!(!env.os.is_empty());
404 assert!(!env.arch.is_empty());
405 assert!(!env.cwd.is_empty());
406 }
407}