Skip to main content

start_command/
execution_attempt.rs

1//! Attempt boundaries and evidence for executions sharing one UUID/log.
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use std::fs;
6use std::io::Read;
7use std::path::PathBuf;
8
9use crate::execution_store::ExecutionRecord;
10use crate::isolation::isolation_log::{append_log_file, read_log_tail_from};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ExecutionAttempt {
15    pub number: u64,
16    pub started_at: String,
17    pub log_offset: Option<u64>,
18    pub last_output_at: Option<String>,
19    pub launch_accepted_at: Option<String>,
20    pub watcher_attached_at: Option<String>,
21    pub mode: String,
22    pub previous_session_name: Option<String>,
23    pub session_name: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub watcher_error: Option<String>,
26}
27
28pub fn create_attempt(
29    record: &ExecutionRecord,
30    mode: &str,
31    session_name: &str,
32) -> ExecutionAttempt {
33    let counter = |key| record.options.get(key).and_then(Value::as_u64).unwrap_or(0);
34    let previous = record
35        .attempt
36        .as_ref()
37        .map(|a| a.number)
38        .unwrap_or(counter("resumeCount") + counter("recoveryAttempts") + 1);
39    let log_offset = if record.log_path.is_empty() {
40        None
41    } else {
42        match fs::metadata(&record.log_path) {
43            Ok(metadata) => Some(metadata.len()),
44            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(0),
45            Err(_) => None,
46        }
47    };
48    ExecutionAttempt {
49        number: previous + 1,
50        started_at: Utc::now().to_rfc3339(),
51        log_offset,
52        last_output_at: None,
53        launch_accepted_at: None,
54        watcher_attached_at: None,
55        mode: mode.into(),
56        previous_session_name: record
57            .options
58            .get("sessionName")
59            .and_then(Value::as_str)
60            .map(str::to_string),
61        session_name: Some(session_name.into()),
62        watcher_error: None,
63    }
64}
65
66pub fn archive_attempt(record: &mut ExecutionRecord) {
67    read_attempt_activity(record);
68    let value = serde_json::to_value(&*record).unwrap_or_default();
69    let mut snapshot = record.attempt.as_ref().and_then(|a| serde_json::to_value(a).ok())
70        .unwrap_or_else(|| json!({ "number": record.options.get("resumeCount").and_then(Value::as_u64).unwrap_or(0)
71            + record.options.get("recoveryAttempts").and_then(Value::as_u64).unwrap_or(0) + 1,
72            "startedAt": record.options.get("resumedAt").cloned().unwrap_or(json!(record.start_time)), "logOffset": null }));
73    for key in [
74        "command",
75        "status",
76        "exitCode",
77        "endTime",
78        "endTimeSource",
79        "observedAt",
80        "staleDetectedAt",
81        "containerStartedAt",
82        "exitReason",
83        "oomKilled",
84        "memoryExhausted",
85        "memoryExhaustedReason",
86        "cgroupMemory",
87    ] {
88        if let Some(field) = value.get(key) {
89            snapshot[key] = field.clone();
90        }
91    }
92    for key in ["sessionName", "containerId", "containerError"] {
93        snapshot[key] = record.options.get(key).cloned().unwrap_or(Value::Null);
94    }
95    if let Some(evidence) = record.options.get("exitEvidence") {
96        snapshot["exitEvidence"] = evidence.clone();
97    }
98    record.attempt_history.push(snapshot);
99    record.end_time_source = None;
100    record.observed_at = None;
101    record.stale_detected_at = None;
102    record.container_started_at = None;
103    record.exit_reason = None;
104    record.oom_killed = None;
105    record.memory_exhausted = None;
106    record.memory_exhausted_reason = None;
107    record.cgroup_memory = None;
108    record.options.remove("containerError");
109}
110
111pub fn append_lifecycle(record: &ExecutionRecord, event: &str, details: Value) {
112    if let Some(attempt) = &record.attempt {
113        if record.log_path.is_empty() {
114            return;
115        }
116        let mut entry = serde_json::to_value(attempt).unwrap_or_default();
117        entry["event"] = json!(event);
118        entry["at"] = json!(Utc::now().to_rfc3339());
119        entry["uuid"] = json!(record.uuid);
120        entry["attemptNumber"] = json!(attempt.number);
121        if let Some(details) = details.as_object() {
122            for (key, value) in details {
123                entry[key] = value.clone();
124            }
125        }
126        append_log_file(
127            &PathBuf::from(&record.log_path),
128            &format!("\n[Start Command Lifecycle] {}\n", entry),
129        );
130    }
131}
132
133pub fn read_attempt_log_tail(record: &ExecutionRecord, bytes: u64) -> Option<String> {
134    let offset = match &record.attempt {
135        Some(attempt) => attempt.log_offset?,
136        None => 0,
137    };
138    read_log_tail_from(&record.log_path, bytes, offset)
139}
140
141pub fn activity_path(log_path: &str, number: u64) -> String {
142    format!("{}.attempt-{}.activity", log_path, number)
143}
144
145pub fn read_attempt_activity(record: &mut ExecutionRecord) {
146    let Some(attempt) = record.attempt.as_mut() else {
147        return;
148    };
149    if record.log_path.is_empty() {
150        return;
151    }
152    let Ok(file) = fs::File::open(activity_path(&record.log_path, attempt.number)) else {
153        return;
154    };
155    let mut text = String::new();
156    if file.take(128).read_to_string(&mut text).is_err() {
157        return;
158    }
159    let time = DateTime::parse_from_rfc3339(text.trim());
160    let since = DateTime::parse_from_rfc3339(&attempt.started_at);
161    if let (Ok(time), Ok(since)) = (time, since) {
162        if time >= since {
163            attempt.last_output_at = Some(text.trim().into());
164        }
165    }
166}