Skip to main content

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}