Skip to main content

dejavu/runtime/
mod.rs

1//! The `dejavu run` pipeline: resolve → classify → exec → reduce → emit, with
2//! the absolute exit-code guard.
3//!
4//! Invariant: once the real command has run, its normalized exit code is
5//! returned no matter what. All post-exec work runs inside `catch_unwind`; any
6//! failure prints the raw output and still exits the real code.
7
8use crate::config::Config;
9use crate::env::{self, AgentEnv};
10use crate::exec::classify::{classify, Classified};
11use crate::exec::spawn::{CommandRunner, RealRunner, SpawnSpec};
12use crate::exec::{path as exec_path, resolve, ExecMode, Family, PassthroughReason};
13use crate::paths::CacheLayout;
14use crate::reduce;
15use crate::store::{write_logs, Db, RunRecord, StoredLogs};
16use crate::{repo, state, util};
17use std::ffi::{OsStr, OsString};
18use std::io::Write;
19use std::panic::AssertUnwindSafe;
20use std::path::{Path, PathBuf};
21use std::time::Instant;
22
23/// Bytes to write to our own stdout/stderr after reduction.
24struct EmitPlan {
25    stdout: Vec<u8>,
26    stderr: Vec<u8>,
27}
28
29impl EmitPlan {
30    fn raw(outcome: &crate::exec::ExecOutcome) -> EmitPlan {
31        EmitPlan {
32            stdout: outcome.stdout.clone(),
33            stderr: outcome.stderr.clone(),
34        }
35    }
36    fn write(self) {
37        let _ = std::io::stdout().write_all(&self.stdout);
38        let _ = std::io::stdout().flush();
39        let _ = std::io::stderr().write_all(&self.stderr);
40        let _ = std::io::stderr().flush();
41    }
42}
43
44/// Resolved per-invocation context.
45struct RunCtx {
46    repo_root: PathBuf,
47    cwd: PathBuf,
48    layout: CacheLayout,
49    config: Config,
50    session_id: String,
51}
52
53impl RunCtx {
54    fn resolve(cwd: &Path, agent: Option<&AgentEnv>) -> anyhow::Result<RunCtx> {
55        let (repo_root, layout, session_id) = if let Some(a) = agent {
56            (
57                a.repo_root.clone(),
58                CacheLayout::from_dir(a.cache_dir.clone()),
59                a.session_id.clone(),
60            )
61        } else {
62            let root = repo::detect_repo_root(cwd);
63            let layout = CacheLayout::for_repo(&root)?;
64            let sid = std::env::var(env::SESSION_ID).unwrap_or_else(|_| "no-session".to_string());
65            (root, layout, sid)
66        };
67        let config = Config::load(&repo_root)?;
68        Ok(RunCtx {
69            repo_root,
70            cwd: cwd.to_path_buf(),
71            layout,
72            config,
73            session_id,
74        })
75    }
76}
77
78pub fn run_shim(shim_name: &str, args: &[String]) -> anyhow::Result<i32> {
79    let started = Instant::now();
80    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
81    let agent = AgentEnv::from_current();
82
83    // Where our shim dir and own dir live (for anti-recursion resolution).
84    let dejavu_dir = current_exe_dir();
85    let shim_dir = agent
86        .as_ref()
87        .map(|a| a.shim_dir.clone())
88        .or_else(|| std::env::var_os(env::SHIM_DIR).map(PathBuf::from))
89        .unwrap_or_else(|| dejavu_dir.clone());
90
91    let path_os = std::env::var_os("PATH").unwrap_or_default();
92
93    // Resolve the real binary. Not found → behave like the shell: 127.
94    let real = match resolve::resolve_real(
95        shim_name,
96        &resolve::ResolveEnv {
97            path: &path_os,
98            shim_dir: &shim_dir,
99            dejavu_dir: &dejavu_dir,
100        },
101    ) {
102        Some(p) => p,
103        None => {
104            eprintln!("{shim_name}: command not found");
105            return Ok(127);
106        }
107    };
108
109    let sanitized_path = exec_path::without_dir(&shim_dir, &path_os);
110
111    // Fast passthrough when globally disabled — no context, no capture.
112    if env::is_disabled() {
113        return passthrough_exec(&real, args, &cwd, &sanitized_path);
114    }
115
116    // Build context; any failure → safe passthrough (never break the command).
117    let ctx = match RunCtx::resolve(&cwd, agent.as_ref()) {
118        Ok(c) => c,
119        Err(_) => return passthrough_exec(&real, args, &cwd, &sanitized_path),
120    };
121
122    let repo_disabled = state::is_repo_disabled(&ctx.layout);
123    let stdin_tty = crate::exec::interactive::stdin_is_tty();
124    let mut classified = classify(
125        shim_name,
126        args,
127        &ctx.config,
128        false,
129        repo_disabled,
130        stdin_tty,
131    );
132
133    // Global-activation gate: reduce only for an agent (session, DEJAVU_FORCE,
134    // or agent marker + pty). A user terminal or an output-parsing program
135    // must always receive the raw output.
136    if matches!(classified.mode, ExecMode::Optimize { .. }) {
137        use std::io::IsTerminal;
138        if !env::reduction_allowed(std::io::stdout().is_terminal()) {
139            classified.mode = ExecMode::Passthrough(PassthroughReason::NoAgentContext);
140        }
141    }
142
143    match &classified.mode {
144        ExecMode::Passthrough(reason) => {
145            let code = passthrough_exec(&real, args, &cwd, &sanitized_path)?;
146            record_passthrough(&ctx, shim_name, args, &classified, *reason, code, started);
147            Ok(code)
148        }
149        ExecMode::Optimize {
150            family,
151            command_key,
152        } => {
153            let spec = SpawnSpec {
154                program: real.clone(),
155                args: args.iter().map(OsString::from).collect(),
156                cwd: cwd.clone(),
157                env_path: sanitized_path.clone(),
158                capture: true,
159                inherit_stdin: !stdin_tty,
160                capture_limit: Some(ctx.config.max_raw_output_bytes as usize),
161            };
162            let outcome = match RealRunner.run(&spec) {
163                Ok(o) => o,
164                // Could not spawn despite resolving — fall back to passthrough.
165                Err(_) => return passthrough_exec(&real, args, &cwd, &sanitized_path),
166            };
167
168            let real_code = outcome.exit_code;
169            let meta = OptimizedMeta {
170                shim_name: shim_name.to_string(),
171                args: args.to_vec(),
172                command_original: classified.command_original.clone(),
173                family: *family,
174                command_key: command_key.clone(),
175            };
176
177            // Exit-code guard: reduction/recording must never change the code.
178            let built = std::panic::catch_unwind(AssertUnwindSafe(|| {
179                finalize_optimized(&ctx, &meta, &outcome, started)
180            }));
181            let plan = match built {
182                Ok(Ok(plan)) => plan,
183                Ok(Err(err)) => {
184                    eprintln!("dejavu internal error: {err}");
185                    eprintln!("falling back to raw output");
186                    EmitPlan::raw(&outcome)
187                }
188                Err(_) => {
189                    eprintln!("dejavu internal error: panic during reduction");
190                    eprintln!("falling back to raw output");
191                    EmitPlan::raw(&outcome)
192                }
193            };
194            plan.write();
195            Ok(real_code)
196        }
197    }
198}
199
200struct OptimizedMeta {
201    shim_name: String,
202    args: Vec<String>,
203    command_original: String,
204    family: Family,
205    command_key: String,
206}
207
208/// M4 reduction: redact, then normalize/compare/classify, store the redacted
209/// output + normalized text, record the run, and emit the compact output.
210fn finalize_optimized(
211    ctx: &RunCtx,
212    meta: &OptimizedMeta,
213    outcome: &crate::exec::ExecOutcome,
214    started: Instant,
215) -> anyhow::Result<EmitPlan> {
216    let cfg = &ctx.config;
217    let run_id = util::new_id();
218    let created_at = util::now_rfc3339();
219
220    // Redact BEFORE anything is stored, hashed, or normalized.
221    let (red_stdout, red_stderr) = if cfg.redact_secrets {
222        (
223            reduce::redact::redact_bytes(&outcome.stdout).0,
224            reduce::redact::redact_bytes(&outcome.stderr).0,
225        )
226    } else {
227        (outcome.stdout.clone(), outcome.stderr.clone())
228    };
229
230    let git_head = repo::git_head(&ctx.repo_root);
231    let git_worktree = repo::git_worktree_hash(&ctx.repo_root);
232    let repo_root_s = ctx.repo_root.to_string_lossy().into_owned();
233    let cwd_s = ctx.cwd.to_string_lossy().into_owned();
234
235    let db = Db::open(&ctx.layout.db())?;
236    let reduced = reduce::reduce(
237        &db,
238        cfg,
239        &reduce::ReduceInput {
240            run_id: &run_id,
241            created_at: &created_at,
242            repo_root: &repo_root_s,
243            cwd: &cwd_s,
244            shim_name: &meta.shim_name,
245            command_original: &meta.command_original,
246            command_family: meta.family.as_str(),
247            command_key: &meta.command_key,
248            exit_code: outcome.exit_code,
249            git_head: git_head.as_deref(),
250            git_worktree_hash: git_worktree.as_deref(),
251            redacted_stdout: &red_stdout,
252            redacted_stderr: &red_stderr,
253        },
254    )?;
255
256    // Store logs. Normalized text is always persisted (future comparisons need
257    // it) even when raw storage is disabled.
258    let stored = if cfg.store_raw_outputs {
259        write_logs(
260            &ctx.layout,
261            &run_id,
262            &red_stdout,
263            &red_stderr,
264            Some(&reduced.normalized),
265            cfg.max_raw_output_bytes as usize,
266        )?
267    } else {
268        let mut s = StoredLogs::default();
269        let _ = std::fs::create_dir_all(ctx.layout.logs_dir());
270        let np = ctx.layout.normalized_log(&run_id);
271        if std::fs::write(&np, &reduced.normalized).is_ok() {
272            s.normalized_path = Some(np);
273        }
274        s
275    };
276
277    let raw_stdout = outcome.stdout.len() as i64;
278    let raw_stderr = outcome.stderr.len() as i64;
279    let emitted_bytes = (reduced.emit_stdout.len() + reduced.emit_stderr.len()) as i64;
280
281    let record = RunRecord {
282        id: run_id,
283        session_id: ctx.session_id.clone(),
284        created_at,
285        repo_root: repo_root_s,
286        cwd: cwd_s,
287        shim_name: meta.shim_name.clone(),
288        argv_json: serde_json::to_string(&meta.args).unwrap_or_else(|_| "[]".to_string()),
289        command_original: meta.command_original.clone(),
290        command_family: meta.family.as_str().to_string(),
291        command_key: meta.command_key.clone(),
292        classification: reduced.classification.as_str().to_string(),
293        exit_code: outcome.exit_code as i64,
294        duration_ms: outcome.duration.as_millis() as i64,
295        overhead_ms: overhead_ms(started, outcome),
296        stdout_path: path_str(&stored.stdout_path),
297        stderr_path: path_str(&stored.stderr_path),
298        normalized_path: path_str(&stored.normalized_path),
299        raw_stdout_bytes: raw_stdout,
300        raw_stderr_bytes: raw_stderr,
301        raw_total_bytes: raw_stdout + raw_stderr,
302        emitted_bytes,
303        estimated_raw_tokens: reduced.estimated_raw_tokens,
304        estimated_emitted_tokens: reduced.estimated_emitted_tokens,
305        estimated_saved_tokens: reduced.estimated_saved_tokens,
306        normalized_hash: Some(reduced.normalized_hash),
307        stdout_hash: Some(util::sha256_hex(&red_stdout)),
308        stderr_hash: Some(util::sha256_hex(&red_stderr)),
309        git_head,
310        git_worktree_hash: git_worktree,
311        comparison_base_run_id: reduced.comparison_base_run_id,
312        comparison_result: reduced.comparison_result,
313        summary: reduced.summary,
314        full_output_requested: 0,
315        internal_error: None,
316    };
317
318    db.insert_run(&record)?;
319    let _ = db.accumulate_session_tokens(
320        &ctx.session_id,
321        reduced.estimated_raw_tokens,
322        reduced.estimated_emitted_tokens,
323        reduced.estimated_saved_tokens,
324    );
325
326    Ok(EmitPlan {
327        stdout: reduced.emit_stdout,
328        stderr: reduced.emit_stderr,
329    })
330}
331
332fn path_str(path: &Option<std::path::PathBuf>) -> Option<String> {
333    path.as_ref().map(|p| p.to_string_lossy().into_owned())
334}
335
336fn record_passthrough(
337    ctx: &RunCtx,
338    shim_name: &str,
339    args: &[String],
340    classified: &Classified,
341    _reason: PassthroughReason,
342    exit_code: i32,
343    started: Instant,
344) {
345    let record = RunRecord {
346        id: util::new_id(),
347        session_id: ctx.session_id.clone(),
348        created_at: util::now_rfc3339(),
349        repo_root: ctx.repo_root.to_string_lossy().into_owned(),
350        cwd: ctx.cwd.to_string_lossy().into_owned(),
351        shim_name: shim_name.to_string(),
352        argv_json: serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string()),
353        command_original: classified.command_original.clone(),
354        command_family: "passthrough".to_string(),
355        command_key: format!("passthrough:{shim_name}"),
356        classification: "passthrough".to_string(),
357        exit_code: exit_code as i64,
358        duration_ms: 0,
359        overhead_ms: started.elapsed().as_millis() as i64,
360        stdout_path: None,
361        stderr_path: None,
362        normalized_path: None,
363        raw_stdout_bytes: 0,
364        raw_stderr_bytes: 0,
365        raw_total_bytes: 0,
366        emitted_bytes: 0,
367        estimated_raw_tokens: 0,
368        estimated_emitted_tokens: 0,
369        estimated_saved_tokens: 0,
370        normalized_hash: None,
371        stdout_hash: None,
372        stderr_hash: None,
373        git_head: None,
374        git_worktree_hash: None,
375        comparison_base_run_id: None,
376        comparison_result: "passthrough".to_string(),
377        summary: None,
378        full_output_requested: 0,
379        internal_error: None,
380    };
381    // Best-effort: a storage failure must never affect a passthrough command.
382    let _ = persist(ctx, &record, 0, 0, 0);
383}
384
385/// Insert the run and accumulate session totals. Storage failures propagate to
386/// the caller, which treats them as a reason to fall back to raw output.
387fn persist(
388    ctx: &RunCtx,
389    record: &RunRecord,
390    raw_tokens: i64,
391    emitted_tokens: i64,
392    saved_tokens: i64,
393) -> anyhow::Result<()> {
394    let db = Db::open(&ctx.layout.db())?;
395    db.insert_run(record)?;
396    let _ = db.accumulate_session_tokens(&ctx.session_id, raw_tokens, emitted_tokens, saved_tokens);
397    Ok(())
398}
399
400fn passthrough_exec(
401    program: &Path,
402    args: &[String],
403    cwd: &Path,
404    env_path: &OsStr,
405) -> anyhow::Result<i32> {
406    let spec = SpawnSpec {
407        program: program.to_path_buf(),
408        args: args.iter().map(OsString::from).collect(),
409        cwd: cwd.to_path_buf(),
410        env_path: env_path.to_os_string(),
411        capture: false,
412        inherit_stdin: true,
413        capture_limit: None,
414    };
415    Ok(RealRunner.run(&spec)?.exit_code)
416}
417
418fn overhead_ms(started: Instant, outcome: &crate::exec::ExecOutcome) -> i64 {
419    let total = started.elapsed().as_millis() as i64;
420    let cmd = outcome.duration.as_millis() as i64;
421    (total - cmd).max(0)
422}
423
424fn current_exe_dir() -> PathBuf {
425    std::env::current_exe()
426        .ok()
427        .and_then(|p| p.parent().map(Path::to_path_buf))
428        .unwrap_or_else(|| PathBuf::from("."))
429}