Skip to content

Commit 128bd98

Browse files
authored
Merge pull request #5737 from Hmbown/feat/readmedia-byte-budget-20260830
feat(tui): read_media learns byte-budget delivery with an encoding ladder and an originals store
2 parents 2f200ee + 151acbe commit 128bd98

8 files changed

Lines changed: 995 additions & 43 deletions

File tree

crates/tui/src/exec_agent.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ pub(crate) async fn run_exec_agent(
198198
let runtime_services = crate::tools::spec::RuntimeToolServices {
199199
shell_manager: Some(exec_shell_manager.clone()),
200200
persist_services_enabled,
201+
media_originals_dir: crate::media_originals::default_store_dir(),
201202
..crate::tools::spec::RuntimeToolServices::default()
202203
};
203204

crates/tui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ mod logging;
7272
mod lsp;
7373
mod mcp;
7474
mod mcp_server;
75+
mod media_originals;
7576
mod model_catalog;
7677
mod model_context;
7778
mod model_inventory;

crates/tui/src/media_originals.rs

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
//! Content-addressed store for pre-compression image originals.
2+
//!
3+
//! When `read_media` downsamples or lossily re-encodes an image to fit the
4+
//! delivery budget, the model only ever sees the degraded copy. Persisting
5+
//! the pre-compression bytes — named by content hash — lets a later
6+
//! `read_media` call with a crop region read the full-resolution source back
7+
//! (the tool's delivery note points at the stored path), even if the
8+
//! workspace file has moved or changed since the first read.
9+
//!
10+
//! Design notes:
11+
//! - Content-addressed (sha256): repeated reads of the same image reuse one
12+
//! file and repeated writes are idempotent.
13+
//! - Best effort: any filesystem failure returns `None`; media delivery never
14+
//! blocks on persistence.
15+
//! - Size-capped: after each write the store is swept oldest-first (mtime)
16+
//! until it fits [`MAX_TOTAL_BYTES`], so long sessions cannot fill the disk.
17+
//! - Placement: the production engine wires the store dir through
18+
//! `RuntimeToolServices::media_originals_dir`
19+
//! ([`default_store_dir`]); test and one-off contexts leave it `None`, which
20+
//! disables persistence so unit tests never touch the real state dir.
21+
22+
use std::path::{Path, PathBuf};
23+
24+
use sha2::{Digest as _, Sha256};
25+
26+
/// Total size ceiling for the store (1 GiB). Each admitted source is already
27+
/// capped at `read_media`'s 20 MiB source limit, so the store holds dozens of
28+
/// full-resolution originals before the sweep engages.
29+
pub const MAX_TOTAL_BYTES: u64 = 1024 * 1024 * 1024;
30+
31+
/// Store subdirectory under the CodeWhale home directory.
32+
pub const MEDIA_ORIGINALS_SUBDIR: &str = "media-originals";
33+
34+
/// The production store location: `<codewhale home>/media-originals`.
35+
///
36+
/// The directory is created lazily on first persist, not here, so resolving
37+
/// the path never has a filesystem side effect.
38+
#[must_use]
39+
pub fn default_store_dir() -> Option<PathBuf> {
40+
codewhale_config::codewhale_home()
41+
.ok()
42+
.map(|home| home.join(MEDIA_ORIGINALS_SUBDIR))
43+
}
44+
45+
fn extension_for_mime(mime_type: &str) -> &'static str {
46+
match mime_type.trim().to_ascii_lowercase().as_str() {
47+
"image/png" => "png",
48+
"image/jpeg" | "image/jpg" => "jpg",
49+
"image/gif" => "gif",
50+
"image/webp" => "webp",
51+
"image/bmp" => "bmp",
52+
"image/tiff" => "tif",
53+
_ => "img",
54+
}
55+
}
56+
57+
/// Persist `bytes` under `dir`, returning the content-addressed path.
58+
///
59+
/// Best effort: returns `None` on any filesystem failure. A same-named entry
60+
/// with the same length is reused as-is (content addressing makes a length
61+
/// match a content match for practical purposes).
62+
pub fn persist_original_image(bytes: &[u8], mime_type: &str, dir: &Path) -> Option<PathBuf> {
63+
if bytes.is_empty() {
64+
return None;
65+
}
66+
let digest = Sha256::digest(bytes);
67+
let mut hash = String::with_capacity(32);
68+
for byte in digest.iter().take(16) {
69+
use std::fmt::Write as _;
70+
let _ = write!(hash, "{byte:02x}");
71+
}
72+
let path = dir.join(format!("{hash}.{}", extension_for_mime(mime_type)));
73+
std::fs::create_dir_all(dir).ok()?;
74+
let write_needed = match std::fs::metadata(&path) {
75+
Ok(meta) => meta.len() != bytes.len() as u64,
76+
Err(_) => true,
77+
};
78+
if write_needed {
79+
std::fs::write(&path, bytes).ok()?;
80+
}
81+
sweep_store(dir, MAX_TOTAL_BYTES);
82+
std::fs::metadata(&path).ok()?;
83+
Some(path)
84+
}
85+
86+
/// Resolve `raw` as a read-back path inside the store.
87+
///
88+
/// Returns the canonical path only when the file exists and lives under
89+
/// `store_dir`. `read_media` uses this as a fallback after the workspace
90+
/// resolver rejects a path: the store only contains image bytes that already
91+
/// passed the tool's read guards, so read-back widens nothing.
92+
#[must_use]
93+
pub fn resolve_stored_original(raw: &str, workspace: &Path, store_dir: &Path) -> Option<PathBuf> {
94+
let candidate = if Path::new(raw).is_absolute() {
95+
PathBuf::from(raw)
96+
} else {
97+
workspace.join(raw)
98+
};
99+
let candidate = candidate.canonicalize().ok()?;
100+
let store_dir = store_dir.canonicalize().ok()?;
101+
candidate.starts_with(store_dir).then_some(candidate)
102+
}
103+
104+
/// Oldest-first (mtime) sweep until the store fits `max_total_bytes`.
105+
/// Best effort: individual stat/unlink failures are skipped.
106+
fn sweep_store(dir: &Path, max_total_bytes: u64) {
107+
let Ok(entries) = std::fs::read_dir(dir) else {
108+
return;
109+
};
110+
let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
111+
for entry in entries.flatten() {
112+
let Ok(meta) = entry.metadata() else {
113+
continue;
114+
};
115+
if !meta.is_file() {
116+
continue;
117+
}
118+
files.push((
119+
entry.path(),
120+
meta.len(),
121+
meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH),
122+
));
123+
}
124+
let mut total: u64 = files.iter().map(|(_, len, _)| *len).sum();
125+
if total <= max_total_bytes {
126+
return;
127+
}
128+
files.sort_by_key(|(_, _, mtime)| *mtime);
129+
for (path, len, _) in files {
130+
if total <= max_total_bytes {
131+
break;
132+
}
133+
if std::fs::remove_file(&path).is_ok() {
134+
total = total.saturating_sub(len);
135+
}
136+
}
137+
}
138+
139+
#[cfg(test)]
140+
mod tests {
141+
use super::*;
142+
143+
#[test]
144+
fn persist_is_content_addressed_and_idempotent() {
145+
let dir = tempfile::tempdir().expect("tempdir");
146+
let store = dir.path().join("media-originals");
147+
let bytes = b"\x89PNG fake-but-deterministic payload";
148+
149+
let first = persist_original_image(bytes, "image/png", &store).expect("persist");
150+
assert!(first.exists());
151+
assert_eq!(first.parent(), Some(store.as_path()));
152+
assert_eq!(
153+
first.extension().and_then(|e| e.to_str()),
154+
Some("png"),
155+
"mime drives the extension"
156+
);
157+
let stem = first.file_stem().unwrap().to_str().unwrap().to_string();
158+
assert_eq!(stem.len(), 32, "sha256 prefix names the file: {stem}");
159+
160+
let second = persist_original_image(bytes, "image/png", &store).expect("re-persist");
161+
assert_eq!(first, second, "same bytes reuse one file");
162+
assert_eq!(std::fs::read_dir(&store).unwrap().count(), 1);
163+
}
164+
165+
#[test]
166+
fn sweep_removes_oldest_until_under_cap() {
167+
let dir = tempfile::tempdir().expect("tempdir");
168+
let store = dir.path().to_path_buf();
169+
let mut paths = Vec::new();
170+
for i in 0..4u8 {
171+
let path = store.join(format!("img{i}.png"));
172+
std::fs::write(&path, vec![i; 100]).expect("write");
173+
paths.push(path);
174+
}
175+
// 400 bytes total; cap at 250 -> at least the two oldest go.
176+
sweep_store(&store, 250);
177+
let remaining = std::fs::read_dir(&store).unwrap().count();
178+
assert!(
179+
remaining <= 2,
180+
"sweep must evict oldest-first until under cap, {remaining} left"
181+
);
182+
let total: u64 = std::fs::read_dir(&store)
183+
.unwrap()
184+
.flatten()
185+
.filter_map(|e| e.metadata().ok())
186+
.map(|m| m.len())
187+
.sum();
188+
assert!(total <= 250);
189+
}
190+
191+
#[test]
192+
fn resolve_stored_original_admits_store_paths_only() {
193+
let dir = tempfile::tempdir().expect("tempdir");
194+
let store = dir.path().join("media-originals");
195+
std::fs::create_dir_all(&store).unwrap();
196+
let inside = store.join("abc123.png");
197+
std::fs::write(&inside, b"png").unwrap();
198+
let outside = dir.path().join("other.png");
199+
std::fs::write(&outside, b"png").unwrap();
200+
201+
let resolved =
202+
resolve_stored_original(inside.to_str().unwrap(), dir.path(), &store).expect("inside");
203+
assert!(resolved.ends_with("abc123.png"));
204+
assert!(
205+
resolve_stored_original(outside.to_str().unwrap(), dir.path(), &store).is_none(),
206+
"paths outside the store must not resolve through the fallback"
207+
);
208+
assert!(
209+
resolve_stored_original("missing.png", dir.path(), &store).is_none(),
210+
"non-existent files do not resolve"
211+
);
212+
}
213+
}

crates/tui/src/runtime_threads.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7682,6 +7682,7 @@ impl RuntimeThreadManager {
76827682
hook_executor: None,
76837683
handle_store: crate::tools::handle::new_shared_handle_store(),
76847684
rlm_sessions: crate::rlm::session::new_shared_rlm_session_store(),
7685+
media_originals_dir: crate::media_originals::default_store_dir(),
76857686
},
76867687
subagent_model_overrides: if isolated_chat {
76877688
HashMap::new()

0 commit comments

Comments
 (0)