somatize_runtime/tracking/
local_tracker.rs1use super::JsonlEventSink;
4use chrono::Utc;
5use somatize_core::error::{Result, SomaError};
6use somatize_core::study::Study;
7use somatize_core::tracking::{
8 EventSink, GitInfo, RunKind, RunManifest, RunState, RunStatus, Tracker,
9};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::sync::Arc;
14
15const EVENTS_FILE: &str = "events.jsonl";
16const METRICS_FILE: &str = "metrics.jsonl";
17const MANIFEST_FILE: &str = "manifest.json";
18const STATUS_FILE: &str = "status.json";
19const STUDY_FILE: &str = "study.json";
20const FLUSH_EVERY: usize = 20;
21
22pub struct LocalTracker {
28 run_id: String,
29 dir: PathBuf,
30 sink: Arc<JsonlEventSink>,
31}
32
33impl LocalTracker {
34 pub fn create(root: impl AsRef<Path>, kind: RunKind, name: &str) -> Result<Self> {
37 let run_id = new_run_id(kind);
38 let dir = root.as_ref().join("runs").join(&run_id);
39 fs::create_dir_all(&dir)?;
40
41 let mut manifest = RunManifest::new(&run_id, kind, name);
42 manifest.soma_version = Some(env!("CARGO_PKG_VERSION").to_string());
43 manifest.hostname = hostname();
44 manifest.git = collect_git_info(Path::new("."));
45 manifest.argv = std::env::args().collect();
46 manifest.entrypoint = manifest.argv.first().cloned();
47 manifest.cwd = std::env::current_dir()
48 .ok()
49 .map(|p| p.display().to_string());
50 if kind == RunKind::Study {
51 manifest.study_path = Some(STUDY_FILE.to_string());
52 }
53 atomic_write_json(&dir.join(MANIFEST_FILE), &manifest)?;
54 atomic_write_json(&dir.join(STATUS_FILE), &RunStatus::running())?;
55
56 let sink = JsonlEventSink::create(
57 &dir.join(EVENTS_FILE),
58 Some(&dir.join(METRICS_FILE)),
59 FLUSH_EVERY,
60 )?;
61 Ok(Self {
62 run_id,
63 dir,
64 sink: Arc::new(sink),
65 })
66 }
67
68 pub fn open(run_dir: impl AsRef<Path>) -> Result<Self> {
72 let dir = run_dir.as_ref().to_path_buf();
73 let manifest = load_manifest(&dir)?;
74 let start_seq = repair_and_count_lines(&dir.join(EVENTS_FILE))?;
75 let sink = JsonlEventSink::append(
76 &dir.join(EVENTS_FILE),
77 Some(&dir.join(METRICS_FILE)),
78 FLUSH_EVERY,
79 start_seq,
80 )?;
81 atomic_write_json(&dir.join(STATUS_FILE), &RunStatus::running())?;
82 Ok(Self {
83 run_id: manifest.run_id,
84 dir,
85 sink: Arc::new(sink),
86 })
87 }
88}
89
90impl Tracker for LocalTracker {
91 fn run_id(&self) -> &str {
92 &self.run_id
93 }
94
95 fn run_dir(&self) -> &Path {
96 &self.dir
97 }
98
99 fn sink(&self) -> Arc<dyn EventSink> {
100 self.sink.clone()
101 }
102
103 fn save_manifest(&self, manifest: &RunManifest) -> Result<()> {
104 atomic_write_json(&self.dir.join(MANIFEST_FILE), manifest)
105 }
106
107 fn save_artifact(&self, rel_path: &str, bytes: &[u8]) -> Result<()> {
108 let path = self.dir.join(rel_path);
109 if let Some(parent) = path.parent() {
110 fs::create_dir_all(parent)?;
111 }
112 fs::write(path, bytes)?;
113 Ok(())
114 }
115
116 fn save_study(&self, study: &Study) -> Result<()> {
117 atomic_write_json(&self.dir.join(STUDY_FILE), study)
118 }
119
120 fn heartbeat(&self) -> Result<()> {
121 let mut status = load_status(&self.dir)?;
122 let now = Utc::now();
123 status.heartbeat_at = Some(now);
124 status.updated_at = now;
125 atomic_write_json(&self.dir.join(STATUS_FILE), &status)
126 }
127
128 fn finalize(&self, state: RunState) -> Result<()> {
129 self.sink.flush();
130 let now = Utc::now();
131 let status = RunStatus {
132 state,
133 updated_at: now,
134 heartbeat_at: Some(now),
135 finished_at: Some(now),
136 };
137 atomic_write_json(&self.dir.join(STATUS_FILE), &status)
138 }
139}
140
141pub fn load_manifest(run_dir: &Path) -> Result<RunManifest> {
143 let bytes = fs::read(run_dir.join(MANIFEST_FILE))?;
144 serde_json::from_slice(&bytes).map_err(|e| SomaError::Serialization(e.to_string()))
145}
146
147pub fn load_status(run_dir: &Path) -> Result<RunStatus> {
149 let bytes = fs::read(run_dir.join(STATUS_FILE))?;
150 serde_json::from_slice(&bytes).map_err(|e| SomaError::Serialization(e.to_string()))
151}
152
153pub fn collect_git_info(dir: &Path) -> GitInfo {
155 let run = |args: &[&str]| -> Option<String> {
156 let out = Command::new("git")
157 .args(args)
158 .current_dir(dir)
159 .output()
160 .ok()?;
161 if !out.status.success() {
162 return None;
163 }
164 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
165 (!s.is_empty()).then_some(s)
166 };
167 GitInfo {
168 sha: run(&["rev-parse", "HEAD"]),
169 branch: run(&["rev-parse", "--abbrev-ref", "HEAD"]),
170 dirty: run(&["status", "--porcelain"]).map(|s| !s.is_empty()).or({
171 run(&["rev-parse", "HEAD"]).map(|_| false)
173 }),
174 }
175}
176
177fn hostname() -> Option<String> {
178 std::env::var("HOSTNAME")
179 .ok()
180 .filter(|h| !h.is_empty())
181 .or_else(|| {
182 fs::read_to_string("/etc/hostname")
183 .ok()
184 .map(|s| s.trim().to_string())
185 .filter(|h| !h.is_empty())
186 })
187 .or_else(|| {
188 let out = Command::new("hostname").output().ok()?;
189 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
190 (!s.is_empty()).then_some(s)
191 })
192}
193
194fn new_run_id(kind: RunKind) -> String {
196 let prefix = match kind {
197 RunKind::Study => "study",
198 RunKind::Trial => "trial",
199 _ => "run",
200 };
201 let nanos = std::time::SystemTime::now()
202 .duration_since(std::time::UNIX_EPOCH)
203 .unwrap_or_default()
204 .as_nanos();
205 format!(
206 "{prefix}_{}_{:04x}",
207 Utc::now().format("%Y%m%dT%H%M%S"),
208 (nanos & 0xffff) as u16
209 )
210}
211
212fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
215 let json =
216 serde_json::to_vec_pretty(value).map_err(|e| SomaError::Serialization(e.to_string()))?;
217 let tmp = path.with_extension("json.tmp");
218 fs::write(&tmp, &json)?;
219 fs::rename(&tmp, path)?;
220 Ok(())
221}
222
223fn repair_and_count_lines(path: &Path) -> Result<u64> {
229 let content = match fs::read(path) {
230 Ok(c) => c,
231 Err(_) => return Ok(0), };
233 let newlines = content.iter().filter(|b| **b == b'\n').count() as u64;
234 if content.is_empty() || content.last() == Some(&b'\n') {
235 return Ok(newlines);
236 }
237 let keep = content
238 .iter()
239 .rposition(|b| *b == b'\n')
240 .map(|i| i + 1)
241 .unwrap_or(0);
242 let file = fs::OpenOptions::new().write(true).open(path)?;
243 file.set_len(keep as u64)?;
244 Ok(newlines)
245}