Skip to main content

skiff_cli/
spool.rs

1//! Spill large / binary MCP payloads to `$SKIFF_CACHE_DIR/spool/` for agent grep.
2
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde_json::{json, Value};
7use sha2::{Digest, Sha256};
8
9use crate::error::{Error, Result};
10use crate::fsutil::{atomic_write_0600, ensure_dir_0700};
11use crate::paths::cache_dir;
12
13/// Default max rendered stdout bytes before spill (agent profile).
14pub const DEFAULT_AGENT_MAX_BYTES: usize = 65_536;
15
16/// Preview size included in spool pointer JSON.
17pub const PREVIEW_BYTES: usize = 512;
18
19/// Default spool file TTL (24h).
20pub const DEFAULT_SPOOL_TTL_SECS: u64 = 86_400;
21
22pub fn spool_dir() -> PathBuf {
23    cache_dir().join("spool")
24}
25
26fn extension_for(kind: &str) -> &'static str {
27    match kind {
28        "toon" => "toon",
29        "txt" => "txt",
30        "bin" => "bin",
31        _ => "json",
32    }
33}
34
35/// Write `bytes` to a new spool file; returns absolute path.
36pub fn write_spool(bytes: &[u8], kind: &str) -> Result<PathBuf> {
37    let dir = spool_dir();
38    ensure_dir_0700(&dir)?;
39    let secs = SystemTime::now()
40        .duration_since(UNIX_EPOCH)
41        .map(|d| d.as_secs())
42        .unwrap_or(0);
43    let mut hasher = Sha256::new();
44    hasher.update(bytes);
45    let digest = hex::encode(hasher.finalize());
46    let short = &digest[..12.min(digest.len())];
47    let path = dir.join(format!("{secs}_{short}.{}", extension_for(kind)));
48    atomic_write_0600(&path, bytes)?;
49    Ok(path)
50}
51
52pub fn sha256_hex(bytes: &[u8]) -> String {
53    let mut hasher = Sha256::new();
54    hasher.update(bytes);
55    hex::encode(hasher.finalize())
56}
57
58/// Build the small stdout pointer object for a spooled payload.
59pub fn pointer_json(path: &Path, bytes: &[u8], preview_src: &str) -> Value {
60    let preview: String = preview_src.chars().take(PREVIEW_BYTES).collect();
61    let path_str = path.display().to_string();
62    json!({
63        "spooled": true,
64        "path": path_str,
65        "bytes": bytes.len(),
66        "sha256": sha256_hex(bytes),
67        "preview": preview,
68        "hint": format!("rg -n PATTERN '{path_str}'"),
69    })
70}
71
72/// Remove spool files older than `ttl_secs` (0 = delete all).
73pub fn clean_spool(ttl_secs: u64) -> Result<usize> {
74    let dir = spool_dir();
75    if !dir.is_dir() {
76        return Ok(0);
77    }
78    let now = SystemTime::now();
79    let mut removed = 0usize;
80    for entry in std::fs::read_dir(&dir).map_err(Error::from)? {
81        let entry = entry.map_err(Error::from)?;
82        let path = entry.path();
83        if !path.is_file() {
84            continue;
85        }
86        let meta = entry.metadata().map_err(Error::from)?;
87        let age_ok = if ttl_secs == 0 {
88            true
89        } else {
90            match meta.modified() {
91                Ok(mtime) => now
92                    .duration_since(mtime)
93                    .map(|d| d.as_secs() >= ttl_secs)
94                    .unwrap_or(true),
95                Err(_) => true,
96            }
97        };
98        if age_ok {
99            let _ = std::fs::remove_file(&path);
100            removed += 1;
101        }
102    }
103    Ok(removed)
104}
105
106/// Best-effort cleanup of expired spool files (ignore errors).
107pub fn maybe_clean_expired() {
108    let _ = clean_spool(DEFAULT_SPOOL_TTL_SECS);
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::paths::{set_cache_dir_override, TEST_PATHS_LOCK};
115    use tempfile::tempdir;
116
117    #[test]
118    fn write_and_clean() {
119        let _g = TEST_PATHS_LOCK.lock().unwrap();
120        let dir = tempdir().unwrap();
121        set_cache_dir_override(Some(dir.path().to_path_buf()));
122        let path = write_spool(b"hello needle world", "txt").unwrap();
123        assert!(path.exists());
124        let ptr = pointer_json(&path, b"hello needle world", "hello needle world");
125        assert_eq!(ptr["spooled"], true);
126        assert!(ptr["path"].as_str().unwrap().contains("spool"));
127        let n = clean_spool(0).unwrap();
128        assert!(n >= 1);
129        set_cache_dir_override(None);
130    }
131}