Skip to main content

capsula_core/
run.rs

1use chrono::{DateTime, Utc};
2use serde::ser::SerializeStruct;
3use serde::{Deserialize, Serialize, Serializer};
4use std::collections::HashMap;
5use std::io::{self, Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Command, ExitStatus, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10use tracing::debug;
11use ulid::Ulid;
12
13#[derive(Debug, Clone, Deserialize)]
14pub struct Run<Dir = PathBuf> {
15    pub id: Ulid,
16    pub name: String,
17    pub command: Vec<String>,
18    pub run_dir: Dir,
19    pub project_root: PathBuf,
20}
21
22pub type UnpreparedRun = Run<()>;
23pub type PreparedRun = Run<PathBuf>;
24
25impl<Dir> Run<Dir> {
26    pub fn timestamp(&self) -> DateTime<Utc> {
27        // Calculate start time from ULID timestamp
28        let dt: DateTime<Utc> = self.id.datetime().into();
29        dt
30    }
31
32    fn gen_run_dir(&self, vault_dir: impl AsRef<Path>) -> PathBuf {
33        vault_dir
34            .as_ref()
35            .join(run_dir_relative_path(&self.id, &self.name))
36    }
37}
38
39/// Relative path of a run directory within its vault:
40/// `{YYYY-MM-DD}/{HHMMSS}-{name}`.
41///
42/// Both components derive from the ULID's embedded timestamp (UTC), so the
43/// location is reproducible from `id` and `name` alone (e.g. when restoring
44/// a run pulled from a server).
45///
46/// The directory is prefixed with the time because folders are sorted in
47/// natural order, not in lexicographical order. For example, on macOS
48/// Finder, the order is:
49/// 1. `01K5K478KNQ2ZXZG68MWM1Z9X6`
50/// 2. `01K5K4571FGKBFTTRJCG1J3DCZ`
51///
52/// which is not the correct chronological order. By adding the time
53/// prefix, it will be sorted correctly.
54#[must_use]
55pub fn run_dir_relative_path(id: &Ulid, name: &str) -> PathBuf {
56    let timestamp: DateTime<Utc> = id.datetime().into();
57    let date_str = timestamp.format("%Y-%m-%d").to_string();
58    let time_str = timestamp.format("%H%M%S").to_string();
59    PathBuf::from(date_str).join(format!("{time_str}-{name}"))
60}
61
62impl UnpreparedRun {
63    pub fn setup_run_dir(
64        &self,
65        vault_dir: impl AsRef<std::path::Path>,
66        max_retries: usize,
67    ) -> io::Result<PreparedRun> {
68        debug!(
69            "Setting up vault directory: {}",
70            vault_dir.as_ref().display()
71        );
72        setup_vault(&vault_dir)?;
73
74        // TODO: Consider removing retries as it is too conservative?
75        let run_dir = {
76            let mut attempt = 0;
77            loop {
78                let candidate = self.gen_run_dir(&vault_dir);
79                debug!("Candidate run directory: {}", candidate.display());
80                if candidate.exists() {
81                    debug!(
82                        "Directory already exists, retrying (attempt {})",
83                        attempt + 1
84                    );
85                    // Slight delay before retrying
86                    thread::sleep(Duration::from_millis(10 * (attempt as u64 + 1)));
87                    attempt += 1;
88                    if attempt >= max_retries {
89                        return Err(io::Error::new(
90                            io::ErrorKind::AlreadyExists,
91                            format!(
92                                "Failed to create unique run directory after {max_retries} attempts",
93                            ),
94                        ));
95                    }
96                } else {
97                    break candidate;
98                }
99            }
100        };
101
102        debug!("Creating run directory: {}", run_dir.display());
103        std::fs::create_dir_all(&run_dir)?;
104        debug!("Run directory created successfully");
105
106        Ok(PreparedRun {
107            id: self.id,
108            name: self.name.clone(),
109            command: self.command.clone(),
110            run_dir,
111            project_root: self.project_root.clone(),
112        })
113    }
114}
115
116impl Serialize for UnpreparedRun {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where
119        S: Serializer,
120    {
121        let mut state = serializer.serialize_struct("Run", 5)?;
122        state.serialize_field("id", &self.id)?;
123        state.serialize_field("name", &self.name)?;
124        state.serialize_field("command", &self.command)?;
125        state.serialize_field("timestamp", &self.timestamp().to_rfc3339())?;
126        state.serialize_field("project_root", &self.project_root.to_string_lossy())?;
127        state.end()
128    }
129}
130
131impl Serialize for PreparedRun {
132    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
133    where
134        S: Serializer,
135    {
136        let mut state = serializer.serialize_struct("Run", 6)?;
137        state.serialize_field("id", &self.id)?;
138        state.serialize_field("name", &self.name)?;
139        state.serialize_field("command", &self.command)?;
140        state.serialize_field("timestamp", &self.timestamp().to_rfc3339())?;
141        state.serialize_field("run_dir", &self.run_dir.to_string_lossy())?;
142        state.serialize_field("project_root", &self.project_root.to_string_lossy())?;
143        state.end()
144    }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct RunOutput {
149    pub exit_code: i32,
150    stdout: String,
151    stderr: String,
152    duration: Duration,
153}
154
155fn exit_code_from_status(status: ExitStatus) -> i32 {
156    status.code().unwrap_or_else(|| {
157        // On Unix, process may be terminated by a signal.
158        #[cfg(unix)]
159        {
160            use std::os::unix::process::ExitStatusExt;
161            status.signal().map_or(1, |s| 128 + s)
162        }
163        #[cfg(not(unix))]
164        {
165            1
166        }
167    })
168}
169
170impl PreparedRun {
171    pub fn exec(&self) -> std::io::Result<RunOutput> {
172        if self.command.is_empty() {
173            return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty command"));
174        }
175        let program = &self.command[0];
176        let args: Vec<&str> = self.command[1..].iter().map(String::as_str).collect();
177
178        debug!("Executing command: {} with {} args", program, args.len());
179
180        let mut env_vars = HashMap::new();
181        env_vars.insert("CAPSULA_RUN_ID", self.id.to_string());
182        env_vars.insert("CAPSULA_RUN_NAME", self.name.clone());
183        env_vars.insert(
184            "CAPSULA_RUN_DIRECTORY",
185            self.run_dir.to_string_lossy().to_string(),
186        );
187        env_vars.insert("CAPSULA_RUN_TIMESTAMP", self.timestamp().to_rfc3339());
188        let command_display = shlex::try_join(self.command.iter().map(String::as_str))
189            .unwrap_or_else(|_| self.command.join(" "));
190        env_vars.insert("CAPSULA_RUN_COMMAND", command_display);
191
192        let pre_run_output_json_path = self.run_dir.join("_capsula").join("pre-run.json");
193        env_vars.insert(
194            "CAPSULA_PRE_RUN_OUTPUT_PATH",
195            pre_run_output_json_path.to_string_lossy().to_string(),
196        );
197        env_vars.insert(
198            "CAPSULA_PROJECT_ROOT",
199            self.project_root.to_string_lossy().to_string(),
200        );
201
202        debug!(
203            "Setting {} environment variables for command execution",
204            env_vars.len()
205        );
206
207        let start = Instant::now();
208
209        debug!("Spawning child process");
210        let mut child = Command::new(program)
211            .args(&args)
212            .envs(&env_vars)
213            .stdout(Stdio::piped())
214            .stderr(Stdio::piped())
215            .spawn()?;
216
217        let mut child_stdout = child
218            .stdout
219            .take()
220            .ok_or_else(|| io::Error::other("Failed to capture stdout"))?;
221        let mut child_stderr = child
222            .stderr
223            .take()
224            .ok_or_else(|| io::Error::other("Failed to capture stderr"))?;
225
226        let t_out = thread::spawn(move || -> io::Result<Vec<u8>> {
227            let mut cap = Vec::with_capacity(8 * 1024);
228            let mut buf = [0u8; 8192];
229            let mut console = io::stdout().lock();
230
231            loop {
232                let n = child_stdout.read(&mut buf)?;
233                if n == 0 {
234                    break;
235                }
236                console.write_all(&buf[..n])?;
237                cap.extend_from_slice(&buf[..n]);
238            }
239            console.flush()?;
240            Ok(cap)
241        });
242
243        let t_err = thread::spawn(move || -> io::Result<Vec<u8>> {
244            let mut cap = Vec::with_capacity(8 * 1024);
245            let mut buf = [0u8; 8192];
246            let mut console = io::stderr().lock();
247
248            loop {
249                let n = child_stderr.read(&mut buf)?;
250                if n == 0 {
251                    break;
252                }
253                console.write_all(&buf[..n])?;
254                cap.extend_from_slice(&buf[..n]);
255            }
256            console.flush()?;
257            Ok(cap)
258        });
259
260        debug!("Waiting for child process to complete");
261        let status = child.wait()?;
262        let duration = start.elapsed();
263        debug!("Child process completed in {:?}", duration);
264
265        let cap_out = t_out
266            .join()
267            .map_err(|_| io::Error::other("stdout capture thread panicked"))??;
268        let cap_err = t_err
269            .join()
270            .map_err(|_| io::Error::other("stderr capture thread panicked"))??;
271
272        let exit_code = exit_code_from_status(status);
273        debug!(
274            "Command finished with exit code {}, captured {} bytes stdout, {} bytes stderr",
275            exit_code,
276            cap_out.len(),
277            cap_err.len()
278        );
279
280        Ok(RunOutput {
281            exit_code,
282            stdout: String::from_utf8_lossy(&cap_out).to_string(),
283            stderr: String::from_utf8_lossy(&cap_err).to_string(),
284            duration,
285        })
286    }
287}
288
289/// Create the vault directory (and its `.gitignore`) if it does not exist.
290pub fn setup_vault(path: impl AsRef<std::path::Path>) -> io::Result<()> {
291    let path = path.as_ref();
292    if path.exists() {
293        debug!("Vault directory already exists: {}", path.display());
294        return Ok(());
295    }
296
297    debug!("Creating vault directory: {}", path.display());
298    std::fs::create_dir_all(path)?;
299
300    // Place a .gitignore file to ignore all contents
301    let gitignore_path = path.join(".gitignore");
302    debug!("Creating .gitignore in vault directory");
303    std::fs::write(
304        gitignore_path,
305        "\
306# Automatically generated by Capsula
307*",
308    )?;
309
310    Ok(())
311}