feat: implement remaining PRD features (10 beads)
Complete the PRD feature set with shell gating pipeline, cache
improvements, layout enhancements, and diagnostics:
- Shell: exec_gated with allowlist/denylist, circuit breaker, env merge
- Shell: parallel prefetch via std::thread::scope for cold renders
- Cache: TTL jitter (FNV-1a), config hash namespace, garbage collection
- Cache: diagnostic tracking (hit/miss, age) for dump-state
- Layout: gradual drop strategy (one-by-one vs tiered)
- Layout: render budget timer with graceful priority-based degradation
- Layout: breakpoint hysteresis to prevent preset toggling
- Width: detection source tracking for diagnostics
- CLI: --no-cache, --no-shell, --clear-cache, env var overrides
- Diagnostics: enhanced --dump-state with section timing and cache stats
Closes: bd-3oy, bd-62g, bd-khk, bd-3q1, bd-ywx, bd-3l2,
bd-2vm, bd-1if, bd-2qr, bd-30o, bd-3ax, bd-3uw, bd-4b1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
210
src/cache.rs
210
src/cache.rs
@@ -1,21 +1,56 @@
|
||||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Diagnostic entry for a single cache lookup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheDiag {
|
||||
pub key: String,
|
||||
pub hit: bool,
|
||||
pub age_ms: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct Cache {
|
||||
dir: Option<PathBuf>,
|
||||
jitter_pct: u8,
|
||||
diagnostics: RefCell<Vec<CacheDiag>>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// Create a disabled cache where all operations are no-ops.
|
||||
/// Used for --no-cache mode.
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
dir: None,
|
||||
jitter_pct: 0,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create cache with secure directory. Returns disabled cache on failure.
|
||||
pub fn new(template: &str, session_id: &str) -> Self {
|
||||
let dir_str = template.replace("{session_id}", session_id);
|
||||
/// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
|
||||
pub fn new(
|
||||
template: &str,
|
||||
session_id: &str,
|
||||
cache_version: u32,
|
||||
config_hash: &str,
|
||||
jitter_pct: u8,
|
||||
) -> Self {
|
||||
let dir_str = template
|
||||
.replace("{session_id}", session_id)
|
||||
.replace("{cache_version}", &cache_version.to_string())
|
||||
.replace("{config_hash}", config_hash);
|
||||
let dir = PathBuf::from(&dir_str);
|
||||
|
||||
if !dir.exists() {
|
||||
if fs::create_dir_all(&dir).is_err() {
|
||||
return Self { dir: None };
|
||||
return Self {
|
||||
dir: None,
|
||||
jitter_pct,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
};
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -26,29 +61,93 @@ impl Cache {
|
||||
|
||||
// Security: verify ownership, not a symlink, not world-writable
|
||||
if !verify_cache_dir(&dir) {
|
||||
return Self { dir: None };
|
||||
return Self {
|
||||
dir: None,
|
||||
jitter_pct,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
};
|
||||
}
|
||||
|
||||
Self { dir: Some(dir) }
|
||||
Self {
|
||||
dir: Some(dir),
|
||||
jitter_pct,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> Option<&Path> {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// Get cached value if fresher than TTL.
|
||||
/// Get cached value if fresher than TTL (with per-key jitter applied).
|
||||
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
|
||||
let path = self.key_path(key)?;
|
||||
let meta = fs::metadata(&path).ok()?;
|
||||
let path = match self.key_path(key) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.record_diag(key, false, None);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let meta = match fs::metadata(&path).ok() {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
self.record_diag(key, false, None);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let modified = meta.modified().ok()?;
|
||||
let age = SystemTime::now().duration_since(modified).ok()?;
|
||||
if age < ttl {
|
||||
fs::read_to_string(&path).ok()
|
||||
let age_ms = age.as_millis() as u64;
|
||||
let effective_ttl = self.jittered_ttl(key, ttl);
|
||||
if age < effective_ttl {
|
||||
let value = fs::read_to_string(&path).ok();
|
||||
self.record_diag(key, value.is_some(), Some(age_ms));
|
||||
value
|
||||
} else {
|
||||
self.record_diag(key, false, Some(age_ms));
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn record_diag(&self, key: &str, hit: bool, age_ms: Option<u64>) {
|
||||
if let Ok(mut diags) = self.diagnostics.try_borrow_mut() {
|
||||
diags.push(CacheDiag {
|
||||
key: key.to_string(),
|
||||
hit,
|
||||
age_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Return collected cache diagnostics (for --dump-state).
|
||||
pub fn diagnostics(&self) -> Vec<CacheDiag> {
|
||||
self.diagnostics.borrow().clone()
|
||||
}
|
||||
|
||||
/// Apply deterministic per-key jitter to TTL.
|
||||
/// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time).
|
||||
fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {
|
||||
if self.jitter_pct == 0 {
|
||||
return base_ttl;
|
||||
}
|
||||
|
||||
// FNV-1a hash of key for deterministic per-key jitter
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in key.bytes() {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
|
||||
// Map hash to range [-jitter_pct, +jitter_pct]
|
||||
let jitter_range = f64::from(self.jitter_pct) / 100.0;
|
||||
let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]
|
||||
let multiplier = 1.0 + (normalized * jitter_range);
|
||||
|
||||
let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;
|
||||
// Clamp: minimum 100ms to avoid zero TTL
|
||||
Duration::from_millis(jittered_ms.max(100))
|
||||
}
|
||||
|
||||
/// Get stale cached value (ignores TTL). Used as fallback on command failure.
|
||||
pub fn get_stale(&self, key: &str) -> Option<String> {
|
||||
let path = self.key_path(key)?;
|
||||
@@ -152,3 +251,94 @@ pub fn session_id(project_dir: &str) -> String {
|
||||
let hash = Md5::digest(project_dir.as_bytes());
|
||||
format!("{:x}", hash)[..12].to_string()
|
||||
}
|
||||
|
||||
/// Garbage-collect old cache directories.
|
||||
/// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days`
|
||||
/// that match /tmp/claude-sl-* and are owned by the current user.
|
||||
/// Never blocks: uses non-blocking flock on a sentinel file.
|
||||
pub fn gc(gc_days: u16, gc_interval_hours: u16) {
|
||||
let lock_path = Path::new("/tmp/claude-sl-gc.lock");
|
||||
|
||||
// Check interval: if lock file exists and is younger than gc_interval, skip
|
||||
if let Ok(meta) = fs::metadata(lock_path) {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if let Ok(age) = SystemTime::now().duration_since(modified) {
|
||||
if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try non-blocking lock
|
||||
let lock_file = match fs::File::create(lock_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
if !try_flock(&lock_file) {
|
||||
return; // another process is GC-ing
|
||||
}
|
||||
|
||||
// Touch the lock file (create already set mtime to now)
|
||||
let max_age = Duration::from_secs(u64::from(gc_days) * 86400);
|
||||
|
||||
let entries = match fs::read_dir("/tmp") {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
unlock(&lock_file);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let uid = current_uid();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if !name_str.starts_with("claude-sl-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
// Safety: skip symlinks
|
||||
let meta = match fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !meta.is_dir() || meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only delete dirs owned by current user
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if meta.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check age
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if let Ok(age) = SystemTime::now().duration_since(modified) {
|
||||
if age > max_age {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unlock(&lock_file);
|
||||
}
|
||||
|
||||
fn current_uid() -> u32 {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::getuid() }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user