KeiSeiKit-1.0/_primitives/_rust/kei-cache/src/exec.rs
Parfii-bot 76dcdc5c87 feat(r2): new kei-cache crate — deterministic result cache
Wraps pure (query/transform) atom invocations with SHA-256 keyed
cache. Refuses Command/Stream kind atoms as unsafe.

22/22 tests (14 unit + 8 integration). Canonical JSON keying
(formatting-drift safe). TTL expiry. AtomExecutor trait decouples
subprocess from test mocks.

Default DB ~/.claude/cache/cache.sqlite, overridable via --db or
$KEI_CACHE_DB.

Workspace Cargo.toml: +kei-cache member.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 05:55:13 +08:00

139 lines
4.8 KiB
Rust

//! Atom invocation on cache miss.
//!
//! Constructor Pattern: `AtomExecutor` trait = one-method contract
//! (atom_id + canonical input → JSON payload string). `SubprocessExecutor`
//! is the production impl — mirrors the kei-runtime binary-resolution
//! rules (`KEI_RUNTIME_BIN_DIR` → `$PATH`) and spawns
//! `<crate> run-atom <verb>` with the input on stdin.
//!
//! Kind-safety: before invoking we consult `kei-atom-discovery` to obtain
//! `AtomKind`. `command` and `stream` are refused ("unsafe to cache");
//! `query` and `transform` pass through.
use anyhow::{anyhow, Context, Result};
use kei_atom_discovery::{discover_atoms, AtomKind, AtomMeta};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// Strategy for invoking an atom after a cache miss.
///
/// Implementations MUST return the atom's raw JSON stdout as a String.
/// They MUST NOT perform any caching themselves.
pub trait AtomExecutor {
fn execute(&self, atom_id: &str, input_json: &str) -> Result<String>;
}
/// Production executor: resolves atom metadata via kei-atom-discovery,
/// refuses non-cacheable kinds, and spawns `<crate> run-atom <verb>`.
pub struct SubprocessExecutor {
atoms_root: PathBuf,
}
impl SubprocessExecutor {
pub fn new(atoms_root: impl Into<PathBuf>) -> Self {
Self { atoms_root: atoms_root.into() }
}
fn find_meta(&self, atom_id: &str) -> Result<AtomMeta> {
discover_atoms(&self.atoms_root)
.into_iter()
.find(|a| a.full_id == atom_id)
.ok_or_else(|| anyhow!("no atom matching `{atom_id}` under {}", self.atoms_root.display()))
}
}
impl AtomExecutor for SubprocessExecutor {
fn execute(&self, atom_id: &str, input_json: &str) -> Result<String> {
let meta = self.find_meta(atom_id)?;
ensure_cacheable(&meta.kind, atom_id)?;
run_subprocess(&meta, input_json)
}
}
/// Gate: only pure kinds may be cached. Command has side effects; stream is
/// incremental so caching the first frame would be misleading.
pub fn ensure_cacheable(kind: &AtomKind, atom_id: &str) -> Result<()> {
match kind {
AtomKind::Query | AtomKind::Transform => Ok(()),
AtomKind::Command => Err(anyhow!(
"atom `{atom_id}` has kind=command (side effects); unsafe to cache"
)),
AtomKind::Stream => Err(anyhow!(
"atom `{atom_id}` has kind=stream (incremental); unsafe to cache"
)),
}
}
/// Spawn `<crate> run-atom <verb>` with `input_json` on stdin; return stdout.
fn run_subprocess(meta: &AtomMeta, input_json: &str) -> Result<String> {
let bin = resolve_binary(&meta.crate_name)
.ok_or_else(|| anyhow!("binary `{}` not on PATH or KEI_RUNTIME_BIN_DIR", meta.crate_name))?;
let mut child = Command::new(&bin)
.arg("run-atom")
.arg(&meta.verb)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawn {}", bin.display()))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(input_json.as_bytes())
.context("write stdin to atom subprocess")?;
}
let out = child.wait_with_output().context("wait on atom subprocess")?;
if !out.status.success() {
let code = out.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(anyhow!("atom `{}` exited {code}: {stderr}", meta.full_id));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// Resolve binary by name:
/// 1. `$KEI_RUNTIME_BIN_DIR/<crate>` when env var is set and file exists
/// 2. Walk `$PATH`, return first `<dir>/<crate>` that exists
pub fn resolve_binary(crate_name: &str) -> Option<PathBuf> {
if let Ok(dir) = std::env::var("KEI_RUNTIME_BIN_DIR") {
let candidate = PathBuf::from(dir).join(crate_name);
if candidate.is_file() {
return Some(candidate);
}
}
let path = std::env::var("PATH").ok()?;
for dir in std::env::split_paths(&path) {
let candidate: PathBuf = Path::new(&dir).join(crate_name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_command_kind() {
let err = ensure_cacheable(&AtomKind::Command, "atom:x").unwrap_err();
assert!(err.to_string().contains("unsafe to cache"));
}
#[test]
fn rejects_stream_kind() {
let err = ensure_cacheable(&AtomKind::Stream, "atom:x").unwrap_err();
assert!(err.to_string().contains("unsafe to cache"));
}
#[test]
fn accepts_query_kind() {
ensure_cacheable(&AtomKind::Query, "atom:x").unwrap();
}
#[test]
fn accepts_transform_kind() {
ensure_cacheable(&AtomKind::Transform, "atom:x").unwrap();
}
}