Skip to main content

mecha_core/
work.rs

1//! `~/.mecha/work/<producer>/` — where a run's generated output goes.
2//!
3//! Two directories, and they mean opposite things:
4//!
5//! ```text
6//! ~/.mecha/work/<producer>/     generated · mutable · disposable · cleanable
7//! ~/.mecha/bundles/<id>/<ver>/  published · immutable · versioned · never deleted
8//! ```
9//!
10//! A *producer* is whatever made the output: a trigger's name, or `chat`, or a
11//! session id. The directory is **stable across runs of the same producer**,
12//! which is the whole point — yesterday's briefing is an ordinary file in
13//! today's run rather than something that has to be fetched back from
14//! somewhere. It is also the run's workspace, and that fixes three things at
15//! once:
16//!
17//! - **The jail default.** A trigger with no explicit workspace fell through to
18//!   `std::env::current_dir()`, and the daemon's unit sets
19//!   `WorkingDirectory=%h`. So an unattended run with filesystem tools was
20//!   path-jailed to `$HOME`, which contains `~/.mecha/` — the mail OAuth
21//!   tokens, every session transcript, the learning store. Rooting it here
22//!   roots it somewhere holding nothing sensitive. (The interactive half of
23//!   that hazard is [`ensure_outside_mecha_home`].)
24//! - **Cross-run read-back**, as above.
25//! - **`notify`.** The shipped morning trigger ended with
26//!   `mkdir -p ~/.mecha/briefings && cat > …` — a shell redirect into a
27//!   directory it created on the way past, outside every path jail, so no
28//!   later run could read it. That existed only because there was no
29//!   designated place to write.
30//!
31//! **Retention is a policy, not an intention.** Anything without one becomes a
32//! pile nobody opens, so [`clean`] keeps the last *N* entries per producer and
33//! says what it removed. One hard rule: it never removes anything a published
34//! bundle names as a source, because "regenerate last week's report" must not
35//! silently lose its input.
36
37use anyhow::{Context, Result};
38use std::collections::BTreeSet;
39use std::path::{Path, PathBuf};
40
41/// How many entries per producer survive a [`clean`] that does not say.
42///
43/// Enough to hold a week and a half of a daily producer, so "what did
44/// yesterday's run say" and "what changed since Monday" are both still on
45/// disk. A placeholder in the honest sense: it wants a week of real output to
46/// tune, and `[work] keep` in config is where that tuning goes.
47pub const DEFAULT_KEEP: usize = 10;
48
49/// `~/.mecha`, or `$MECHA_HOME`.
50///
51/// The override exists for tests and for anyone running two mechas side by
52/// side; nothing in a normal install sets it.
53pub fn mecha_home() -> Result<PathBuf> {
54    if let Ok(dir) = std::env::var("MECHA_HOME") {
55        if !dir.is_empty() {
56            return Ok(PathBuf::from(dir));
57        }
58    }
59    let home = dirs::home_dir().context("cannot determine home directory")?;
60    Ok(home.join(".mecha"))
61}
62
63/// `~/.mecha/work`.
64pub fn root() -> Result<PathBuf> {
65    Ok(mecha_home()?.join("work"))
66}
67
68/// `~/.mecha/bundles` — the published mirror. Written by the publisher, read
69/// here only to find out what [`clean`] must not remove.
70pub fn bundles_root() -> Result<PathBuf> {
71    Ok(mecha_home()?.join("bundles"))
72}
73
74/// A producer name is a directory name, a CLI argument and a log line. Keep it
75/// to what is unambiguous in all three — the same rule trigger names follow,
76/// because a trigger name *is* a producer name.
77pub fn valid_producer(name: &str) -> Result<()> {
78    anyhow::ensure!(!name.is_empty(), "a producer needs a name");
79    anyhow::ensure!(
80        name.len() <= 64,
81        "producer name `{name}` is too long (64 characters max)"
82    );
83    anyhow::ensure!(
84        name.chars()
85            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'),
86        "producer name `{name}` may only contain lowercase letters, digits, `-` and `_`"
87    );
88    Ok(())
89}
90
91/// The directory for one producer, without creating it.
92pub fn producer_dir(producer: &str) -> Result<PathBuf> {
93    valid_producer(producer)?;
94    Ok(root()?.join(producer))
95}
96
97/// The directory for one producer, created if absent.
98///
99/// Owner-only like every other directory under `~/.mecha`: a run's scratch
100/// output is as private as the transcript it came from.
101pub fn ensure(producer: &str) -> Result<PathBuf> {
102    let dir = producer_dir(producer)?;
103    crate::create_private_dir(&dir).with_context(|| format!("creating {}", dir.display()))?;
104    Ok(dir)
105}
106
107/// Refuse a workspace that **contains** the mecha home.
108///
109/// `$HOME` contains `~/.mecha`, so a run started there is jailed over the mail
110/// OAuth tokens, every session transcript and the learning store — which is
111/// close to no jail at all, and is the silently-degrading-sandbox shape this
112/// project keeps naming. The interlock is still a backstop (reading a token
113/// arms `private_data`, and exfiltration needs an `external_send` it refuses),
114/// but a backstop is not a boundary.
115///
116/// Note the direction. A workspace *inside* the mecha home is fine and is in
117/// fact the new default — `~/.mecha/work/morning/` holds nothing sensitive.
118/// What is refused is a workspace the mecha home sits *under*.
119pub fn ensure_outside_mecha_home(workspace: &Path) -> Result<()> {
120    let home = mecha_home()?;
121    // The home may not exist yet on a first run, and a path that cannot be
122    // canonicalized is compared as written — over-refusing a workspace is
123    // recoverable, under-refusing one is the bug.
124    let home = home.canonicalize().unwrap_or(home);
125    let workspace_c = workspace.canonicalize();
126    let ws = workspace_c.as_deref().unwrap_or(workspace);
127    if home.starts_with(ws) {
128        anyhow::bail!(
129            "workspace {} contains the mecha home ({}), so the path jail would \
130             cover the mail tokens, every session transcript and the learning \
131             store.\n\
132             Run from a project directory instead, or name one explicitly with \
133             `--workspace <dir>`.",
134            ws.display(),
135            home.display()
136        );
137    }
138    Ok(())
139}
140
141/// One producer's directory, as [`list`] reports it.
142#[derive(Debug, Clone)]
143pub struct Producer {
144    pub name: String,
145    pub path: PathBuf,
146    /// Top-level entries, newest first.
147    pub entries: Vec<Entry>,
148    pub bytes: u64,
149}
150
151/// One top-level entry in a producer's directory. A run's output may be a file
152/// or a directory (a rendered bundle is a directory), so retention counts
153/// entries rather than files.
154#[derive(Debug, Clone)]
155pub struct Entry {
156    pub path: PathBuf,
157    pub modified: std::time::SystemTime,
158    pub bytes: u64,
159    pub is_dir: bool,
160}
161
162/// Every producer with a directory, alphabetically.
163pub fn list() -> Result<Vec<Producer>> {
164    let root = root()?;
165    if !root.is_dir() {
166        return Ok(Vec::new());
167    }
168    let mut out = Vec::new();
169    for dir_entry in std::fs::read_dir(&root)? {
170        let path = dir_entry?.path();
171        if !path.is_dir() {
172            continue;
173        }
174        let name = match path.file_name().and_then(|n| n.to_str()) {
175            Some(n) => n.to_string(),
176            None => continue,
177        };
178        let entries = entries_of(&path)?;
179        let bytes = entries.iter().map(|e| e.bytes).sum();
180        out.push(Producer {
181            name,
182            path,
183            entries,
184            bytes,
185        });
186    }
187    out.sort_by(|a, b| a.name.cmp(&b.name));
188    Ok(out)
189}
190
191/// A producer's top-level entries, newest first.
192fn entries_of(dir: &Path) -> Result<Vec<Entry>> {
193    let mut out = Vec::new();
194    for entry in std::fs::read_dir(dir)? {
195        let entry = entry?;
196        let path = entry.path();
197        let meta = entry.metadata()?;
198        let is_dir = meta.is_dir();
199        out.push(Entry {
200            modified: meta.modified().unwrap_or(std::time::UNIX_EPOCH),
201            bytes: if is_dir { dir_bytes(&path) } else { meta.len() },
202            path,
203            is_dir,
204        });
205    }
206    // Newest first, with the path as a tiebreak so a `clean` is deterministic
207    // when two entries share a timestamp — which they will, since a single run
208    // writes them.
209    out.sort_by(|a, b| b.modified.cmp(&a.modified).then(a.path.cmp(&b.path)));
210    Ok(out)
211}
212
213fn dir_bytes(dir: &Path) -> u64 {
214    let mut total = 0;
215    let Ok(read) = std::fs::read_dir(dir) else {
216        return 0;
217    };
218    for entry in read.flatten() {
219        let Ok(meta) = entry.metadata() else { continue };
220        total += if meta.is_dir() {
221            dir_bytes(&entry.path())
222        } else {
223            meta.len()
224        };
225    }
226    total
227}
228
229/// Paths a published bundle names as its source, which [`clean`] must never
230/// remove.
231///
232/// The contract with the publisher, and it is deliberately one field of data
233/// rather than a shared type: a mirrored version directory
234/// (`~/.mecha/bundles/<id>/<ver>/`) may carry a `bundle.json` with a
235/// `"sources": ["<absolute path>", …]` array naming what it was rendered from.
236/// Anything else in that file is the publisher's business. A mirror that does
237/// not exist yet — which is every install until `mecha-factory-publish` is
238/// wired — protects nothing, and that is correct rather than a stub.
239pub fn protected_sources() -> Result<BTreeSet<PathBuf>> {
240    let mut out = BTreeSet::new();
241    let root = bundles_root()?;
242    if !root.is_dir() {
243        return Ok(out);
244    }
245    for bundle in std::fs::read_dir(&root)?.flatten() {
246        let Ok(versions) = std::fs::read_dir(bundle.path()) else {
247            continue;
248        };
249        for version in versions.flatten() {
250            let manifest = version.path().join("bundle.json");
251            let Ok(text) = std::fs::read_to_string(&manifest) else {
252                continue;
253            };
254            let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
255                tracing::warn!("unreadable bundle manifest {}", manifest.display());
256                continue;
257            };
258            let Some(sources) = value.get("sources").and_then(|s| s.as_array()) else {
259                continue;
260            };
261            for source in sources.iter().filter_map(|s| s.as_str()) {
262                let path = PathBuf::from(source);
263                out.insert(path.canonicalize().unwrap_or(path));
264            }
265        }
266    }
267    Ok(out)
268}
269
270/// What a [`clean`] did, or would do.
271#[derive(Debug, Default)]
272pub struct CleanReport {
273    pub removed: Vec<Entry>,
274    /// Entries that were past the keep window but survive because a published
275    /// bundle names them. Reported rather than silent — an unexplained
276    /// survivor reads as a bug in the retention.
277    pub protected: Vec<Entry>,
278    pub dry_run: bool,
279}
280
281impl CleanReport {
282    pub fn bytes_removed(&self) -> u64 {
283        self.removed.iter().map(|e| e.bytes).sum()
284    }
285}
286
287/// Keep the `keep` most recent entries in each producer's directory and remove
288/// the rest.
289///
290/// `only` restricts it to one producer. The producer directories themselves are
291/// never removed: a producer with nothing left in it is an empty directory, not
292/// an absence, and deleting it would make tomorrow's run recreate it.
293pub fn clean(keep: usize, only: Option<&str>, dry_run: bool) -> Result<CleanReport> {
294    let protected = protected_sources()?;
295    let mut report = CleanReport {
296        dry_run,
297        ..Default::default()
298    };
299    for producer in list()? {
300        if only.is_some_and(|name| name != producer.name) {
301            continue;
302        }
303        for entry in producer.entries.into_iter().skip(keep) {
304            let canonical = entry
305                .path
306                .canonicalize()
307                .unwrap_or_else(|_| entry.path.clone());
308            if protected.contains(&canonical) {
309                report.protected.push(entry);
310                continue;
311            }
312            if !dry_run {
313                let removed = if entry.is_dir {
314                    std::fs::remove_dir_all(&entry.path)
315                } else {
316                    std::fs::remove_file(&entry.path)
317                };
318                removed.with_context(|| format!("removing {}", entry.path.display()))?;
319            }
320            report.removed.push(entry);
321        }
322    }
323    Ok(report)
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    /// `MECHA_HOME` is process-global, so the tests that set it hold one lock
331    /// and restore it. Cheaper than threading a root parameter through an API
332    /// whose whole job is to know where the mecha home is.
333    static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
334
335    struct HomeGuard {
336        _lock: std::sync::MutexGuard<'static, ()>,
337        previous: Option<String>,
338        dir: PathBuf,
339    }
340
341    impl HomeGuard {
342        fn new() -> Self {
343            let lock = ENV.lock().unwrap_or_else(|e| e.into_inner());
344            let previous = std::env::var("MECHA_HOME").ok();
345            let dir = std::env::temp_dir().join(format!("mecha-work-{}", uuid::Uuid::new_v4()));
346            std::fs::create_dir_all(&dir).unwrap();
347            std::env::set_var("MECHA_HOME", &dir);
348            HomeGuard {
349                _lock: lock,
350                previous,
351                dir,
352            }
353        }
354    }
355
356    impl Drop for HomeGuard {
357        fn drop(&mut self) {
358            match &self.previous {
359                Some(v) => std::env::set_var("MECHA_HOME", v),
360                None => std::env::remove_var("MECHA_HOME"),
361            }
362            let _ = std::fs::remove_dir_all(&self.dir);
363        }
364    }
365
366    /// Write a file with a modification time `age` seconds in the past, so the
367    /// ordering under test is the one being asserted rather than whatever the
368    /// filesystem's timestamp granularity happened to record for four writes
369    /// in the same millisecond.
370    fn write_aged(dir: &Path, name: &str, age: i64) {
371        use std::os::unix::ffi::OsStrExt;
372        let path = dir.join(name);
373        std::fs::write(&path, name).unwrap();
374        let c = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
375        let when = libc::timeval {
376            tv_sec: 1_700_000_000 - age,
377            tv_usec: 0,
378        };
379        let times = [when, when];
380        // SAFETY: a valid NUL-terminated path and a two-element timeval array,
381        // which is exactly what utimes(2) takes.
382        assert_eq!(unsafe { libc::utimes(c.as_ptr(), times.as_ptr()) }, 0);
383    }
384
385    #[test]
386    fn a_producer_directory_is_stable_and_private() {
387        let home = HomeGuard::new();
388        let first = ensure("morning").unwrap();
389        let second = ensure("morning").unwrap();
390        assert_eq!(first, second, "the same producer gets the same directory");
391        assert_eq!(first, home.dir.join("work").join("morning"));
392        #[cfg(unix)]
393        {
394            use std::os::unix::fs::PermissionsExt;
395            let mode = std::fs::metadata(&first).unwrap().permissions().mode();
396            assert_eq!(mode & 0o777, 0o700, "owner-only, like every ~/.mecha leaf");
397        }
398    }
399
400    #[test]
401    fn a_producer_name_that_is_not_a_safe_directory_name_is_refused() {
402        let _home = HomeGuard::new();
403        for bad in ["", "../escape", "has space", "Upper", "a/b"] {
404            assert!(
405                producer_dir(bad).is_err(),
406                "`{bad}` should not be a producer name"
407            );
408        }
409        assert!(producer_dir("morning-brief_2").is_ok());
410    }
411
412    /// The bug this whole module exists to close: a workspace that contains
413    /// `~/.mecha` jails over the mail tokens and the transcripts. Fails on the
414    /// old behaviour, which accepted any directory that existed.
415    #[test]
416    fn a_workspace_containing_the_mecha_home_is_refused() {
417        let home = HomeGuard::new();
418        let parent = home.dir.parent().unwrap();
419
420        let err = ensure_outside_mecha_home(parent).unwrap_err().to_string();
421        assert!(
422            err.contains("contains the mecha home"),
423            "unexpected message: {err}"
424        );
425        assert!(
426            err.contains("--workspace"),
427            "the message names the fix: {err}"
428        );
429
430        // The home itself contains itself, and holds the secrets directly.
431        assert!(ensure_outside_mecha_home(&home.dir).is_err());
432    }
433
434    /// And the direction that must stay allowed, or the new default workspace
435    /// would refuse itself.
436    #[test]
437    fn a_workspace_inside_the_mecha_home_is_allowed() {
438        let _home = HomeGuard::new();
439        let work = ensure("morning").unwrap();
440        ensure_outside_mecha_home(&work).unwrap();
441    }
442
443    #[test]
444    fn clean_keeps_the_newest_n_per_producer_and_reports_what_it_removed() {
445        let _home = HomeGuard::new();
446        let morning = ensure("morning").unwrap();
447        let evening = ensure("evening").unwrap();
448        for (i, name) in ["a.md", "b.md", "c.md", "d.md"].iter().enumerate() {
449            write_aged(&morning, name, i as i64 * 100);
450            write_aged(&evening, name, i as i64 * 100);
451        }
452
453        let preview = clean(2, None, true).unwrap();
454        assert_eq!(preview.removed.len(), 4, "two producers, two stale each");
455        assert!(
456            morning.join("d.md").exists(),
457            "a dry run removes nothing at all"
458        );
459
460        let report = clean(2, Some("morning"), false).unwrap();
461        let removed: Vec<_> = report
462            .removed
463            .iter()
464            .map(|e| e.path.file_name().unwrap().to_str().unwrap())
465            .collect();
466        assert_eq!(removed, ["c.md", "d.md"], "the two oldest, newest kept");
467        assert!(morning.join("a.md").exists());
468        assert!(morning.join("b.md").exists());
469        assert!(
470            evening.join("d.md").exists(),
471            "`--producer` restricts the sweep"
472        );
473        assert!(morning.is_dir(), "the producer directory itself survives");
474    }
475
476    /// The one hard rule: an input a published bundle names is not scratch,
477    /// however old it is.
478    #[test]
479    fn clean_never_removes_a_published_bundles_source() {
480        let home = HomeGuard::new();
481        let work = ensure("morning").unwrap();
482        for (i, name) in ["new.md", "old.md"].iter().enumerate() {
483            write_aged(&work, name, i as i64 * 100);
484        }
485        let source = work.join("old.md").canonicalize().unwrap();
486
487        let version = home.dir.join("bundles").join("brief").join("3");
488        std::fs::create_dir_all(&version).unwrap();
489        std::fs::write(
490            version.join("bundle.json"),
491            serde_json::json!({ "sources": [source] }).to_string(),
492        )
493        .unwrap();
494
495        let report = clean(1, None, false).unwrap();
496        assert!(
497            report.removed.is_empty(),
498            "nothing was eligible but the source"
499        );
500        assert_eq!(report.protected.len(), 1);
501        assert!(work.join("old.md").exists());
502    }
503
504    /// A mirror that does not exist protects nothing, and must not be an error
505    /// — that is every install until the publisher is wired.
506    #[test]
507    fn no_bundle_mirror_means_no_protected_sources() {
508        let _home = HomeGuard::new();
509        assert!(protected_sources().unwrap().is_empty());
510    }
511}