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(
55        cwd: &Path,
56        agent: Option<&AgentEnv>,
57        git: &repo::GitInvoker,
58    ) -> anyhow::Result<RunCtx> {
59        let (repo_root, layout, session_id) = if let Some(a) = agent {
60            (
61                a.repo_root.clone(),
62                CacheLayout::from_dir(a.cache_dir.clone()),
63                a.session_id.clone(),
64            )
65        } else {
66            let root = git.detect_repo_root(cwd);
67            let layout = CacheLayout::for_repo(&root)?;
68            let sid = std::env::var(env::SESSION_ID).unwrap_or_else(|_| "no-session".to_string());
69            (root, layout, sid)
70        };
71        let config = Config::load(&repo_root)?;
72        Ok(RunCtx {
73            repo_root,
74            cwd: cwd.to_path_buf(),
75            layout,
76            config,
77            session_id,
78        })
79    }
80}
81
82pub fn run_shim(shim_name: &str, args: &[String]) -> anyhow::Result<i32> {
83    let started = Instant::now();
84    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
85    let agent = AgentEnv::from_current();
86
87    // Where our shim dir and own dir live (for anti-recursion resolution).
88    let dejavu_dir = current_exe_dir();
89    let shim_dir = agent
90        .as_ref()
91        .map(|a| a.shim_dir.clone())
92        .or_else(|| std::env::var_os(env::SHIM_DIR).map(PathBuf::from))
93        .or_else(|| {
94            // Global activation sets no DEJAVU_* env: the shim lives in the
95            // repo-independent global dir. Knowing the right dir here both
96            // speeds resolution (dir compare instead of content sniffing) and
97            // lets `without_dir` actually sanitize the child PATH.
98            crate::paths::global_shims_bin().ok().filter(|d| d.is_dir())
99        })
100        .unwrap_or_else(|| dejavu_dir.clone());
101
102    let path_os = std::env::var_os("PATH").unwrap_or_default();
103    let resolve_env = resolve::ResolveEnv {
104        path: &path_os,
105        shim_dir: &shim_dir,
106        dejavu_dir: &dejavu_dir,
107    };
108
109    // Resolve the real binary. Not found → behave like the shell: 127.
110    let real = match resolve::resolve_real(shim_name, &resolve_env) {
111        Some(p) => p,
112        None => {
113            eprintln!("{shim_name}: command not found");
114            return Ok(127);
115        }
116    };
117
118    let sanitized_path = exec_path::without_dir(&shim_dir, &path_os);
119
120    // Fast passthrough when globally disabled — no context, no capture.
121    if env::is_disabled() {
122        return passthrough_exec(&real, args, &cwd, &sanitized_path);
123    }
124
125    // Fast path: nothing here will ever be reduced — no dejavu session, no
126    // pipe-capturing agent (Claude Code / Codex / Cursor), no Copilot pty, and
127    // no DEJAVU_FORCE. The consumer is a human terminal or an output-parsing
128    // program, so resolve and exec, full stop: no repo detection, no config
129    // load, no database, no recording. This keeps shell prompts and IDE
130    // internals at native speed under global activation, and keeps the user's
131    // own terminal history out of the local cache.
132    {
133        use std::io::IsTerminal;
134        if !env::reduction_allowed(std::io::stdout().is_terminal()) {
135            return passthrough_exec(&real, args, &cwd, &sanitized_path);
136        }
137    }
138
139    // Internal git metadata queries spawn the resolved real git directly
140    // against the sanitized PATH — one process per query instead of
141    // re-entering a shim (`sh → dejavu → git`).
142    let git = repo::GitInvoker::resolved(
143        if shim_name == "git" {
144            real.clone()
145        } else {
146            resolve::resolve_real("git", &resolve_env).unwrap_or_else(|| PathBuf::from("git"))
147        },
148        sanitized_path.clone(),
149    );
150
151    // Build context; any failure → safe passthrough (never break the command).
152    let ctx = match RunCtx::resolve(&cwd, agent.as_ref(), &git) {
153        Ok(c) => c,
154        Err(_) => return passthrough_exec(&real, args, &cwd, &sanitized_path),
155    };
156
157    let repo_disabled = state::is_repo_disabled(&ctx.layout);
158    let stdin_tty = crate::exec::interactive::stdin_is_tty();
159    let classified = classify(
160        shim_name,
161        args,
162        &ctx.config,
163        false,
164        repo_disabled,
165        stdin_tty,
166    );
167
168    match &classified.mode {
169        ExecMode::Passthrough(reason) => {
170            let code = passthrough_exec(&real, args, &cwd, &sanitized_path)?;
171            record_passthrough(&ctx, shim_name, args, &classified, *reason, code, started);
172            Ok(code)
173        }
174        ExecMode::Optimize {
175            family,
176            command_key,
177        } => {
178            let spec = SpawnSpec {
179                program: real.clone(),
180                args: args.iter().map(OsString::from).collect(),
181                cwd: cwd.clone(),
182                env_path: sanitized_path.clone(),
183                capture: true,
184                inherit_stdin: !stdin_tty,
185                capture_limit: Some(ctx.config.max_raw_output_bytes as usize),
186            };
187            // Capture git state concurrently with the real command — `git
188            // status` on a large worktree costs hundreds of ms, fully hidden
189            // whenever the command outlasts it. Joined at store time.
190            let git_state = repo::GitStatePrefetch::spawn(git.clone(), ctx.repo_root.clone());
191            let outcome = match RealRunner.run(&spec) {
192                Ok(o) => o,
193                // Could not spawn despite resolving — fall back to passthrough.
194                // The prefetch handle is dropped: its read-only git queries
195                // finish on their own in the background.
196                Err(_) => return passthrough_exec(&real, args, &cwd, &sanitized_path),
197            };
198
199            let real_code = outcome.exit_code;
200            let meta = OptimizedMeta {
201                shim_name: shim_name.to_string(),
202                args: args.to_vec(),
203                command_original: classified.command_original.clone(),
204                family: *family,
205                command_key: command_key.clone(),
206            };
207
208            // Exit-code guard: reduction/recording must never change the code.
209            let built = std::panic::catch_unwind(AssertUnwindSafe(|| {
210                finalize_optimized(&ctx, &meta, &outcome, started, git_state)
211            }));
212            let plan = match built {
213                Ok(Ok(plan)) => plan,
214                Ok(Err(err)) => {
215                    eprintln!("dejavu internal error: {err}");
216                    eprintln!("falling back to raw output");
217                    EmitPlan::raw(&outcome)
218                }
219                Err(_) => {
220                    eprintln!("dejavu internal error: panic during reduction");
221                    eprintln!("falling back to raw output");
222                    EmitPlan::raw(&outcome)
223                }
224            };
225            plan.write();
226            Ok(real_code)
227        }
228    }
229}
230
231struct OptimizedMeta {
232    shim_name: String,
233    args: Vec<String>,
234    command_original: String,
235    family: Family,
236    command_key: String,
237}
238
239/// M4 reduction: redact, then normalize/compare/classify, store the redacted
240/// output + normalized text, record the run, and emit the compact output.
241fn finalize_optimized(
242    ctx: &RunCtx,
243    meta: &OptimizedMeta,
244    outcome: &crate::exec::ExecOutcome,
245    started: Instant,
246    git_state: repo::GitStatePrefetch,
247) -> anyhow::Result<EmitPlan> {
248    let cfg = &ctx.config;
249    let run_id = util::new_id();
250    let created_at = util::now_rfc3339();
251
252    // Redact BEFORE anything is stored, hashed, or normalized.
253    let (red_stdout, red_stderr) = if cfg.redact_secrets {
254        (
255            reduce::redact::redact_bytes(&outcome.stdout).0,
256            reduce::redact::redact_bytes(&outcome.stderr).0,
257        )
258    } else {
259        (outcome.stdout.clone(), outcome.stderr.clone())
260    };
261
262    // Join the git-state capture started before the command ran.
263    let git_state = git_state.join();
264    let git_head = git_state.head;
265    let git_worktree = git_state.worktree_hash;
266    let repo_root_s = ctx.repo_root.to_string_lossy().into_owned();
267    let cwd_s = ctx.cwd.to_string_lossy().into_owned();
268
269    let db = Db::open(&ctx.layout.db())?;
270    let reduced = reduce::reduce(
271        &db,
272        cfg,
273        &reduce::ReduceInput {
274            run_id: &run_id,
275            created_at: &created_at,
276            repo_root: &repo_root_s,
277            cwd: &cwd_s,
278            shim_name: &meta.shim_name,
279            command_original: &meta.command_original,
280            command_family: meta.family.as_str(),
281            command_key: &meta.command_key,
282            exit_code: outcome.exit_code,
283            git_head: git_head.as_deref(),
284            git_worktree_hash: git_worktree.as_deref(),
285            redacted_stdout: &red_stdout,
286            redacted_stderr: &red_stderr,
287        },
288    )?;
289
290    // Store logs. Normalized text is always persisted (future comparisons need
291    // it) even when raw storage is disabled.
292    let stored = if cfg.store_raw_outputs {
293        write_logs(
294            &ctx.layout,
295            &run_id,
296            &red_stdout,
297            &red_stderr,
298            Some(&reduced.normalized),
299            cfg.max_raw_output_bytes as usize,
300        )?
301    } else {
302        let mut s = StoredLogs::default();
303        let _ = std::fs::create_dir_all(ctx.layout.logs_dir());
304        let np = ctx.layout.normalized_log(&run_id);
305        if std::fs::write(&np, &reduced.normalized).is_ok() {
306            s.normalized_path = Some(np);
307        }
308        s
309    };
310
311    let raw_stdout = outcome.stdout.len() as i64;
312    let raw_stderr = outcome.stderr.len() as i64;
313    let emitted_bytes = (reduced.emit_stdout.len() + reduced.emit_stderr.len()) as i64;
314
315    let record = RunRecord {
316        id: run_id,
317        session_id: ctx.session_id.clone(),
318        created_at,
319        repo_root: repo_root_s,
320        cwd: cwd_s,
321        shim_name: meta.shim_name.clone(),
322        argv_json: serde_json::to_string(&meta.args).unwrap_or_else(|_| "[]".to_string()),
323        command_original: meta.command_original.clone(),
324        command_family: meta.family.as_str().to_string(),
325        command_key: meta.command_key.clone(),
326        classification: reduced.classification.as_str().to_string(),
327        exit_code: outcome.exit_code as i64,
328        duration_ms: outcome.duration.as_millis() as i64,
329        overhead_ms: overhead_ms(started, outcome),
330        stdout_path: path_str(&stored.stdout_path),
331        stderr_path: path_str(&stored.stderr_path),
332        normalized_path: path_str(&stored.normalized_path),
333        raw_stdout_bytes: raw_stdout,
334        raw_stderr_bytes: raw_stderr,
335        raw_total_bytes: raw_stdout + raw_stderr,
336        emitted_bytes,
337        estimated_raw_tokens: reduced.estimated_raw_tokens,
338        estimated_emitted_tokens: reduced.estimated_emitted_tokens,
339        estimated_saved_tokens: reduced.estimated_saved_tokens,
340        normalized_hash: Some(reduced.normalized_hash),
341        stdout_hash: Some(util::sha256_hex(&red_stdout)),
342        stderr_hash: Some(util::sha256_hex(&red_stderr)),
343        git_head,
344        git_worktree_hash: git_worktree,
345        comparison_base_run_id: reduced.comparison_base_run_id,
346        comparison_result: reduced.comparison_result,
347        summary: reduced.summary,
348        full_output_requested: 0,
349        internal_error: None,
350    };
351
352    db.insert_run(&record)?;
353    let _ = db.accumulate_session_tokens(
354        &ctx.session_id,
355        reduced.estimated_raw_tokens,
356        reduced.estimated_emitted_tokens,
357        reduced.estimated_saved_tokens,
358    );
359
360    Ok(EmitPlan {
361        stdout: reduced.emit_stdout,
362        stderr: reduced.emit_stderr,
363    })
364}
365
366fn path_str(path: &Option<std::path::PathBuf>) -> Option<String> {
367    path.as_ref().map(|p| p.to_string_lossy().into_owned())
368}
369
370fn record_passthrough(
371    ctx: &RunCtx,
372    shim_name: &str,
373    args: &[String],
374    classified: &Classified,
375    _reason: PassthroughReason,
376    exit_code: i32,
377    started: Instant,
378) {
379    let record = RunRecord {
380        id: util::new_id(),
381        session_id: ctx.session_id.clone(),
382        created_at: util::now_rfc3339(),
383        repo_root: ctx.repo_root.to_string_lossy().into_owned(),
384        cwd: ctx.cwd.to_string_lossy().into_owned(),
385        shim_name: shim_name.to_string(),
386        argv_json: serde_json::to_string(args).unwrap_or_else(|_| "[]".to_string()),
387        command_original: classified.command_original.clone(),
388        command_family: "passthrough".to_string(),
389        command_key: format!("passthrough:{shim_name}"),
390        classification: "passthrough".to_string(),
391        exit_code: exit_code as i64,
392        duration_ms: 0,
393        overhead_ms: started.elapsed().as_millis() as i64,
394        stdout_path: None,
395        stderr_path: None,
396        normalized_path: None,
397        raw_stdout_bytes: 0,
398        raw_stderr_bytes: 0,
399        raw_total_bytes: 0,
400        emitted_bytes: 0,
401        estimated_raw_tokens: 0,
402        estimated_emitted_tokens: 0,
403        estimated_saved_tokens: 0,
404        normalized_hash: None,
405        stdout_hash: None,
406        stderr_hash: None,
407        git_head: None,
408        git_worktree_hash: None,
409        comparison_base_run_id: None,
410        comparison_result: "passthrough".to_string(),
411        summary: None,
412        full_output_requested: 0,
413        internal_error: None,
414    };
415    // Best-effort: a storage failure must never affect a passthrough command.
416    let _ = persist(ctx, &record, 0, 0, 0);
417}
418
419/// Insert the run and accumulate session totals. Storage failures propagate to
420/// the caller, which treats them as a reason to fall back to raw output.
421fn persist(
422    ctx: &RunCtx,
423    record: &RunRecord,
424    raw_tokens: i64,
425    emitted_tokens: i64,
426    saved_tokens: i64,
427) -> anyhow::Result<()> {
428    let db = Db::open(&ctx.layout.db())?;
429    db.insert_run(record)?;
430    let _ = db.accumulate_session_tokens(&ctx.session_id, raw_tokens, emitted_tokens, saved_tokens);
431    Ok(())
432}
433
434fn passthrough_exec(
435    program: &Path,
436    args: &[String],
437    cwd: &Path,
438    env_path: &OsStr,
439) -> anyhow::Result<i32> {
440    let spec = SpawnSpec {
441        program: program.to_path_buf(),
442        args: args.iter().map(OsString::from).collect(),
443        cwd: cwd.to_path_buf(),
444        env_path: env_path.to_os_string(),
445        capture: false,
446        inherit_stdin: true,
447        capture_limit: None,
448    };
449    Ok(RealRunner.run(&spec)?.exit_code)
450}
451
452fn overhead_ms(started: Instant, outcome: &crate::exec::ExecOutcome) -> i64 {
453    let total = started.elapsed().as_millis() as i64;
454    let cmd = outcome.duration.as_millis() as i64;
455    (total - cmd).max(0)
456}
457
458fn current_exe_dir() -> PathBuf {
459    std::env::current_exe()
460        .ok()
461        .and_then(|p| p.parent().map(Path::to_path_buf))
462        .unwrap_or_else(|| PathBuf::from("."))
463}