Skip to main content

devboy_skills/
trace.rs

1//! Session traces for the self-feedback loop (ADR-015).
2//!
3//! A session is the execution of one skill (or any caller that opts in
4//! through the `devboy trace` CLI). Events land as JSON Lines at
5//! `<target>/.devboy/sessions/<YYYY-MM-DD>/<skill>/<session_id>/trace.jsonl`,
6//! and a sibling `meta.json` in the same per-session directory carries
7//! session-level metadata. The `<session_id>` segment keeps concurrent
8//! or repeated invocations of the same skill on the same day isolated
9//! from each other (ADR-015 requires self-contained sessions).
10//!
11//! The [`SessionTracer`] writer is intentionally small — it serialises
12//! one event per line with no framing, no network I/O, and no reliance
13//! on the host logging stack. The companion [`devboy trace`] CLI
14//! subcommand family in `devboy-cli` lets shell-based skills write into
15//! the same format.
16//!
17//! The redaction pass in [`redact::sanitize`] strips values that match
18//! known credential shapes and values of environment variables named
19//! `*_TOKEN` / `*_SECRET` / `*_KEY` / `*_PASSWORD` / `*_PASSPHRASE` /
20//! `AUTHORIZATION` / `COOKIE` before anything is written to disk.
21
22use std::fs::{File, OpenOptions, create_dir_all};
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use crate::error::{Result, SkillError};
32
33pub mod redact;
34
35// ---------------------------------------------------------------------------
36// Phase + Outcome enums
37// ---------------------------------------------------------------------------
38
39/// Event phases. Kept small; readers must silently ignore unknown values.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Phase {
43    /// First event of the session, marking the start of execution.
44    /// The tracer synthesises a `start` record with an empty payload;
45    /// any input / cwd / version metadata is caller-defined — pass it
46    /// via a regular `event` call after `begin` if you want it in the
47    /// stream, or record it in `meta.json` via a later `end` call.
48    Start,
49    /// Skill-level reasoning outcome.
50    Decision,
51    /// Immediately before invoking a tool.
52    ToolCall,
53    /// Immediately after a tool returns.
54    ToolResult,
55    /// A verification check ran (tests / clippy / dry-run etc.).
56    Verify,
57    /// Non-trivial file produced by the skill.
58    Artifact,
59    /// Free-form human-readable log entry.
60    Note,
61    /// Last event of the session.
62    End,
63}
64
65/// Session outcome — recorded on `End` in both the event stream and the
66/// per-session `meta.json`.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum Outcome {
70    /// Skill completed and achieved its objective.
71    Success,
72    /// Skill ran to completion but the objective was not met.
73    Failure,
74    /// Skill stopped before completing (cancelled, interrupted).
75    Aborted,
76}
77
78// ---------------------------------------------------------------------------
79// Target resolution
80// ---------------------------------------------------------------------------
81
82/// Where to write session traces.
83#[derive(Debug, Clone)]
84pub enum TraceTarget {
85    /// Repo-local at `<repo>/.devboy/sessions/`. Default.
86    RepoLocal,
87    /// Per-user at `~/.devboy/sessions/`.
88    Global,
89    /// Explicit directory — used by tests.
90    Custom(PathBuf),
91}
92
93impl TraceTarget {
94    /// Resolve the `sessions/` root directory for this target.
95    pub fn sessions_root(&self) -> Result<PathBuf> {
96        match self {
97            Self::RepoLocal => {
98                let cwd = std::env::current_dir().map_err(|source| SkillError::Io {
99                    path: PathBuf::from("."),
100                    source,
101                })?;
102                let repo = locate_repo_root(&cwd).ok_or_else(|| SkillError::Io {
103                    path: cwd,
104                    source: std::io::Error::other(
105                        "no git repository / .devboy.toml at the current path (pass --global to write traces under ~/.devboy/sessions/)",
106                    ),
107                })?;
108                Ok(repo.join(".devboy").join("sessions"))
109            }
110            Self::Global => {
111                let home = home_dir()?;
112                Ok(home.join(".devboy").join("sessions"))
113            }
114            Self::Custom(p) => Ok(p.clone()),
115        }
116    }
117}
118
119fn locate_repo_root(start: &Path) -> Option<PathBuf> {
120    let mut cur = start;
121    loop {
122        if cur.join(".git").exists() || cur.join(".devboy.toml").exists() {
123            return Some(cur.to_path_buf());
124        }
125        match cur.parent() {
126            Some(p) => cur = p,
127            None => return None,
128        }
129    }
130}
131
132fn home_dir() -> Result<PathBuf> {
133    if let Some(p) = std::env::var_os("DEVBOY_HOME_OVERRIDE")
134        && !p.is_empty()
135    {
136        return Ok(PathBuf::from(p));
137    }
138    dirs::home_dir().ok_or_else(|| SkillError::Io {
139        path: PathBuf::from("~"),
140        source: std::io::Error::other("home directory is not set"),
141    })
142}
143
144// ---------------------------------------------------------------------------
145// SessionTracer
146// ---------------------------------------------------------------------------
147
148/// Appends events to a session's `trace.jsonl`.
149///
150/// A new instance is built via [`SessionTracer::begin`], events are
151/// appended with [`SessionTracer::event`], and the session is finalised
152/// with [`SessionTracer::end`]. Dropping a tracer without calling `end`
153/// leaves the `trace.jsonl` intact but does not write `meta.json` — the
154/// stream still round-trips, just without the convenience summary.
155pub struct SessionTracer {
156    session_id: String,
157    skill: String,
158    session_dir: PathBuf,
159    trace_path: PathBuf,
160    meta_path: PathBuf,
161    trace_file: Mutex<File>,
162    started_at: DateTime<Utc>,
163    tool_calls: std::sync::atomic::AtomicU64,
164    errors: std::sync::atomic::AtomicU64,
165    /// Env-var snapshot captured at `begin`, reused for every
166    /// `write_event` so the redactor does not rescan `std::env::vars()`
167    /// on each record (can be tens of thousands of events in a long
168    /// session).
169    redactor: redact::Redactor,
170}
171
172impl SessionTracer {
173    /// Start a new session. Creates a unique directory
174    /// `<root>/<date>/<skill>/<session_id>/` and opens `trace.jsonl`
175    /// for append. The per-session directory ensures that concurrent
176    /// or repeated invocations of the same skill on the same day do
177    /// not share any files (ADR-015 requires self-contained sessions).
178    pub fn begin(skill: &str, target: &TraceTarget) -> Result<Self> {
179        let root = target.sessions_root()?;
180        let started_at = Utc::now();
181        let date = started_at.format("%Y-%m-%d").to_string();
182        let session_id = new_session_id();
183        let session_dir = root.join(&date).join(skill).join(&session_id);
184        create_dir_all(&session_dir).map_err(|source| SkillError::Io {
185            path: session_dir.clone(),
186            source,
187        })?;
188
189        let trace_path = session_dir.join("trace.jsonl");
190        let meta_path = session_dir.join("meta.json");
191        let file = OpenOptions::new()
192            .create(true)
193            .append(true)
194            .open(&trace_path)
195            .map_err(|source| SkillError::Io {
196                path: trace_path.clone(),
197                source,
198            })?;
199
200        let tracer = Self {
201            session_id,
202            skill: skill.to_string(),
203            session_dir,
204            trace_path,
205            meta_path,
206            trace_file: Mutex::new(file),
207            started_at,
208            tool_calls: std::sync::atomic::AtomicU64::new(0),
209            errors: std::sync::atomic::AtomicU64::new(0),
210            redactor: redact::Redactor::snapshot(),
211        };
212
213        // Emit the `start` event synthesised from the call arguments;
214        // callers can decorate it further via a regular `event` call.
215        tracer.write_event(Phase::Start, json!({}))?;
216        Ok(tracer)
217    }
218
219    /// Append one event to the trace. Payload is redacted before
220    /// writing.
221    pub fn event(&self, phase: Phase, payload: Value) -> Result<()> {
222        if phase == Phase::ToolCall {
223            self.tool_calls
224                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
225        }
226        if phase == Phase::ToolResult
227            && payload
228                .get("ok")
229                .and_then(|v| v.as_bool())
230                .is_some_and(|ok| !ok)
231        {
232            self.errors
233                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
234        }
235        self.write_event(phase, payload)
236    }
237
238    /// Record the final `end` event and write the per-session
239    /// `meta.json`. Consumes the tracer.
240    pub fn end(self, outcome: Outcome, summary: &str) -> Result<()> {
241        let ended_at = Utc::now();
242        self.write_event(
243            Phase::End,
244            json!({ "outcome": outcome, "summary": summary }),
245        )?;
246
247        let meta = SessionMeta {
248            session_id: self.session_id.clone(),
249            skill: self.skill.clone(),
250            skill_version: None,
251            devboy_version: env!("CARGO_PKG_VERSION").to_string(),
252            started_at: self.started_at,
253            ended_at: Some(ended_at),
254            outcome: Some(outcome),
255            input_summary: None,
256            tool_calls: self.tool_calls.load(std::sync::atomic::Ordering::Relaxed),
257            errors: self.errors.load(std::sync::atomic::Ordering::Relaxed),
258            summary: Some(summary.to_string()),
259        };
260        let bytes = serde_json::to_vec_pretty(&meta).map_err(|source| SkillError::SerdeJson {
261            operation: "serialise session meta",
262            path: self.meta_path.clone(),
263            source,
264        })?;
265        std::fs::write(&self.meta_path, bytes).map_err(|source| SkillError::Io {
266            path: self.meta_path.clone(),
267            source,
268        })
269    }
270
271    /// The session directory on disk.
272    pub fn session_dir(&self) -> &Path {
273        &self.session_dir
274    }
275
276    /// The `trace.jsonl` path.
277    pub fn trace_path(&self) -> &Path {
278        &self.trace_path
279    }
280
281    /// The session id (ULID, or a randomly-generated fallback when the
282    /// `trace` Cargo feature is disabled).
283    pub fn session_id(&self) -> &str {
284        &self.session_id
285    }
286
287    fn write_event(&self, phase: Phase, payload: Value) -> Result<()> {
288        let redacted = self.redactor.sanitize(payload);
289        let record = TraceRecord {
290            ts: Utc::now(),
291            skill: self.skill.clone(),
292            session_id: self.session_id.clone(),
293            phase,
294            payload: redacted,
295        };
296        let line = serde_json::to_string(&record).map_err(|source| SkillError::SerdeJson {
297            operation: "serialise trace record",
298            path: self.trace_path.clone(),
299            source,
300        })?;
301
302        let mut guard = self.trace_file.lock().map_err(|_| SkillError::Io {
303            path: self.trace_path.clone(),
304            source: std::io::Error::other("trace mutex poisoned"),
305        })?;
306        guard
307            .write_all(line.as_bytes())
308            .and_then(|()| guard.write_all(b"\n"))
309            .map_err(|source| SkillError::Io {
310                path: self.trace_path.clone(),
311                source,
312            })
313    }
314}
315
316// ---------------------------------------------------------------------------
317// On-disk formats
318// ---------------------------------------------------------------------------
319
320/// One line of `trace.jsonl`.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct TraceRecord {
323    /// Timestamp of the event.
324    pub ts: DateTime<Utc>,
325    /// Skill name — duplicated on every event so that readers that
326    /// merge traces from different sessions can still attribute each
327    /// line.
328    pub skill: String,
329    /// Session id (ULID).
330    pub session_id: String,
331    /// Event phase.
332    pub phase: Phase,
333    /// Event payload. Redacted before writing.
334    pub payload: Value,
335}
336
337/// `meta.json` — written at session end and optionally touched during
338/// long sessions.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct SessionMeta {
341    /// Session id matching every record in `trace.jsonl`.
342    pub session_id: String,
343    pub skill: String,
344    /// Skill version at run time, if the caller provided it.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub skill_version: Option<u32>,
347    /// devboy-tools version that emitted the session.
348    pub devboy_version: String,
349    /// Session start timestamp.
350    pub started_at: DateTime<Utc>,
351    /// Session end timestamp (missing on in-flight sessions).
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub ended_at: Option<DateTime<Utc>>,
354    /// Outcome, if the session ended cleanly.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub outcome: Option<Outcome>,
357    /// One-line input summary recorded at start, if available.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub input_summary: Option<String>,
360    /// Number of tool calls observed.
361    pub tool_calls: u64,
362    /// Number of tool calls that reported `ok: false`.
363    pub errors: u64,
364    /// Closing summary string (mirrors the `end` event payload).
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub summary: Option<String>,
367}
368
369// ---------------------------------------------------------------------------
370// Stateless helpers (used by `devboy trace` CLI subcommands)
371// ---------------------------------------------------------------------------
372
373/// Create the session directory, write the opening `start` event, and
374/// return the session id + session directory. The returned path is the
375/// value the user passed through `TraceTarget` with the `<date>/<skill>
376/// /<session_id>` suffix appended — it is not canonicalised, so a
377/// relative `TraceTarget::Custom(".traces")` produces a relative path
378/// and an absolute target produces an absolute path. Callers pass both
379/// values back into [`append_event`] and [`finalise_session`] on
380/// subsequent CLI invocations.
381///
382/// The per-session directory level is required so that concurrent or
383/// repeated invocations of the same skill on the same day never share
384/// `trace.jsonl` or `meta.json` (ADR-015).
385pub fn create_session(skill: &str, target: &TraceTarget) -> Result<(String, PathBuf)> {
386    let root = target.sessions_root()?;
387    let started_at = Utc::now();
388    let date = started_at.format("%Y-%m-%d").to_string();
389    let session_id = new_session_id();
390    let session_dir = root.join(&date).join(skill).join(&session_id);
391    create_dir_all(&session_dir).map_err(|source| SkillError::Io {
392        path: session_dir.clone(),
393        source,
394    })?;
395    append_event_inner(
396        &session_dir,
397        &session_id,
398        skill,
399        Phase::Start,
400        Value::Object(Default::default()),
401    )?;
402    // Write a skeletal meta.json so long-running sessions are
403    // introspectable before `end` runs.
404    let skeleton = SessionMeta {
405        session_id: session_id.clone(),
406        skill: skill.to_string(),
407        skill_version: None,
408        devboy_version: env!("CARGO_PKG_VERSION").to_string(),
409        started_at,
410        ended_at: None,
411        outcome: None,
412        input_summary: None,
413        tool_calls: 0,
414        errors: 0,
415        summary: None,
416    };
417    write_meta_file(&session_dir.join("meta.json"), &skeleton)?;
418    Ok((session_id, session_dir))
419}
420
421/// Append one event to an existing session's `trace.jsonl`.
422pub fn append_event(
423    session_dir: &Path,
424    session_id: &str,
425    skill: &str,
426    phase: Phase,
427    payload: Value,
428) -> Result<()> {
429    append_event_inner(session_dir, session_id, skill, phase, payload)
430}
431
432fn append_event_inner(
433    session_dir: &Path,
434    session_id: &str,
435    skill: &str,
436    phase: Phase,
437    payload: Value,
438) -> Result<()> {
439    let redacted = redact::sanitize(payload);
440    let record = TraceRecord {
441        ts: Utc::now(),
442        skill: skill.to_string(),
443        session_id: session_id.to_string(),
444        phase,
445        payload: redacted,
446    };
447    let line = serde_json::to_string(&record).map_err(|source| SkillError::SerdeJson {
448        operation: "serialise trace record",
449        path: session_dir.join("trace.jsonl"),
450        source,
451    })?;
452
453    let trace_path = session_dir.join("trace.jsonl");
454    let mut file = OpenOptions::new()
455        .create(true)
456        .append(true)
457        .open(&trace_path)
458        .map_err(|source| SkillError::Io {
459            path: trace_path.clone(),
460            source,
461        })?;
462    file.write_all(line.as_bytes())
463        .and_then(|()| file.write_all(b"\n"))
464        .map_err(|source| SkillError::Io {
465            path: trace_path.clone(),
466            source,
467        })
468}
469
470/// Write the final `end` event and refresh `meta.json` with the
471/// outcome + aggregated counts derived from the existing trace.
472pub fn finalise_session(
473    session_dir: &Path,
474    session_id: &str,
475    skill: &str,
476    outcome: Outcome,
477    summary: &str,
478) -> Result<()> {
479    append_event_inner(
480        session_dir,
481        session_id,
482        skill,
483        Phase::End,
484        json!({ "outcome": outcome, "summary": summary }),
485    )?;
486
487    let trace_path = session_dir.join("trace.jsonl");
488    let (tool_calls, errors, started_at) = scan_counts(&trace_path)?;
489
490    let meta = SessionMeta {
491        session_id: session_id.to_string(),
492        skill: skill.to_string(),
493        skill_version: None,
494        devboy_version: env!("CARGO_PKG_VERSION").to_string(),
495        started_at,
496        ended_at: Some(Utc::now()),
497        outcome: Some(outcome),
498        input_summary: None,
499        tool_calls,
500        errors,
501        summary: Some(summary.to_string()),
502    };
503    write_meta_file(&session_dir.join("meta.json"), &meta)
504}
505
506fn write_meta_file(path: &Path, meta: &SessionMeta) -> Result<()> {
507    let bytes = serde_json::to_vec_pretty(meta).map_err(|source| SkillError::SerdeJson {
508        operation: "serialise session meta",
509        path: path.to_path_buf(),
510        source,
511    })?;
512    std::fs::write(path, bytes).map_err(|source| SkillError::Io {
513        path: path.to_path_buf(),
514        source,
515    })
516}
517
518fn scan_counts(trace_path: &Path) -> Result<(u64, u64, DateTime<Utc>)> {
519    use std::io::{BufRead, BufReader};
520
521    // Stream the file line-by-line rather than reading the whole
522    // `trace.jsonl` into memory. ADR-015 explicitly flags that long
523    // sessions can produce very large traces; keeping the memory
524    // footprint bounded matters.
525    let file = std::fs::File::open(trace_path).map_err(|source| SkillError::Io {
526        path: trace_path.to_path_buf(),
527        source,
528    })?;
529    let reader = BufReader::new(file);
530
531    let mut tool_calls = 0u64;
532    let mut errors = 0u64;
533    let mut started_at: Option<DateTime<Utc>> = None;
534    for line in reader.lines() {
535        let line = match line {
536            Ok(l) => l,
537            Err(source) => {
538                return Err(SkillError::Io {
539                    path: trace_path.to_path_buf(),
540                    source,
541                });
542            }
543        };
544        if line.trim().is_empty() {
545            continue;
546        }
547        let record: TraceRecord = match serde_json::from_str(&line) {
548            Ok(r) => r,
549            Err(_) => continue,
550        };
551        if started_at.is_none() && record.phase == Phase::Start {
552            started_at = Some(record.ts);
553        }
554        if record.phase == Phase::ToolCall {
555            tool_calls += 1;
556        }
557        if record.phase == Phase::ToolResult
558            && record
559                .payload
560                .get("ok")
561                .and_then(|v| v.as_bool())
562                .is_some_and(|ok| !ok)
563        {
564            errors += 1;
565        }
566    }
567    Ok((tool_calls, errors, started_at.unwrap_or_else(Utc::now)))
568}
569
570// ---------------------------------------------------------------------------
571// Session id
572// ---------------------------------------------------------------------------
573
574#[cfg(feature = "trace")]
575fn new_session_id() -> String {
576    ulid::Ulid::new().to_string()
577}
578
579#[cfg(not(feature = "trace"))]
580fn new_session_id() -> String {
581    // Deterministic-enough fallback when the `trace` feature is off —
582    // time-prefixed so logs stay sortable.
583    format!(
584        "{}-{:x}",
585        Utc::now().format("%Y%m%d%H%M%S%f"),
586        std::process::id()
587    )
588}
589
590// ---------------------------------------------------------------------------
591// Tests
592// ---------------------------------------------------------------------------
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use tempfile::tempdir;
598
599    fn events_in(path: &Path) -> Vec<TraceRecord> {
600        let text = std::fs::read_to_string(path).unwrap();
601        text.lines()
602            .filter(|l| !l.trim().is_empty())
603            .map(|l| serde_json::from_str(l).unwrap())
604            .collect()
605    }
606
607    #[test]
608    fn session_round_trip() {
609        let dir = tempdir().unwrap();
610        let target = TraceTarget::Custom(dir.path().to_path_buf());
611
612        let tracer = SessionTracer::begin("setup", &target).unwrap();
613        let trace_path = tracer.trace_path().to_path_buf();
614        let meta_path = tracer.session_dir().join("meta.json");
615
616        tracer
617            .event(
618                Phase::Decision,
619                json!({ "question": "provider?", "decision": "github" }),
620            )
621            .unwrap();
622        tracer
623            .event(
624                Phase::ToolCall,
625                json!({ "tool": "get_issues", "args": { "limit": 3 } }),
626            )
627            .unwrap();
628        tracer
629            .event(
630                Phase::ToolResult,
631                json!({ "tool": "get_issues", "ok": true, "duration_ms": 42 }),
632            )
633            .unwrap();
634        tracer.end(Outcome::Success, "configured github").unwrap();
635
636        let events = events_in(&trace_path);
637        // start + decision + tool_call + tool_result + end = 5
638        assert_eq!(events.len(), 5);
639        assert_eq!(events[0].phase, Phase::Start);
640        assert_eq!(events.last().unwrap().phase, Phase::End);
641        assert!(events.iter().all(|e| e.skill == "setup"));
642        assert!(events.iter().all(|e| e.session_id == events[0].session_id));
643
644        let meta_bytes = std::fs::read(&meta_path).unwrap();
645        let meta: SessionMeta = serde_json::from_slice(&meta_bytes).unwrap();
646        assert_eq!(meta.skill, "setup");
647        assert_eq!(meta.outcome, Some(Outcome::Success));
648        assert_eq!(meta.tool_calls, 1);
649        assert_eq!(meta.errors, 0);
650    }
651
652    #[test]
653    fn failed_tool_result_is_counted_as_error() {
654        let dir = tempdir().unwrap();
655        let target = TraceTarget::Custom(dir.path().to_path_buf());
656        let tracer = SessionTracer::begin("devboy-test", &target).unwrap();
657        tracer
658            .event(Phase::ToolCall, json!({ "tool": "get_issues" }))
659            .unwrap();
660        tracer
661            .event(
662                Phase::ToolResult,
663                json!({ "tool": "get_issues", "ok": false, "error": "401 Unauthorized" }),
664            )
665            .unwrap();
666        let meta_path = tracer.session_dir().join("meta.json");
667        tracer.end(Outcome::Failure, "401").unwrap();
668
669        let meta: SessionMeta = serde_json::from_slice(&std::fs::read(meta_path).unwrap()).unwrap();
670        assert_eq!(meta.tool_calls, 1);
671        assert_eq!(meta.errors, 1);
672        assert_eq!(meta.outcome, Some(Outcome::Failure));
673    }
674
675    #[test]
676    fn events_are_redacted_before_writing() {
677        // Share the env-serialisation lock with `redact::tests`; without
678        // it a concurrent `DEVBOY_TRACE_REDACTION=off` in a sibling test
679        // can disable redaction for the window this test runs and let
680        // the raw token reach disk (observed as a hard failure on
681        // ubuntu-24.04-arm in CI). Strictly stronger than a bare
682        // `temp_env::with_var` reset because the mutex serialises against
683        // every redact-tests sibling that legitimately toggles the var.
684        super::redact::test_support::with_clean_env(|| {
685            let dir = tempdir().unwrap();
686            let target = TraceTarget::Custom(dir.path().to_path_buf());
687            let tracer = SessionTracer::begin("devboy-test", &target).unwrap();
688            let trace_path = tracer.trace_path().to_path_buf();
689            tracer
690                .event(
691                    Phase::ToolCall,
692                    json!({
693                        "tool": "create_issue",
694                        "args": { "token": "ghp_012345678901234567890123456789012345" }
695                    }),
696                )
697                .unwrap();
698            tracer.end(Outcome::Success, "").unwrap();
699
700            let text = std::fs::read_to_string(&trace_path).unwrap();
701            assert!(
702                !text.contains("ghp_0123456789"),
703                "trace contained raw GitHub token: {text}"
704            );
705            assert!(
706                text.contains("<redacted"),
707                "trace did not include redaction marker: {text}"
708            );
709        });
710    }
711
712    #[test]
713    fn global_target_respects_home_override() {
714        let home = tempdir().unwrap();
715        let home_path = home.path().to_path_buf();
716        temp_env::with_var("DEVBOY_HOME_OVERRIDE", Some(home.path()), || {
717            let root = TraceTarget::Global.sessions_root().unwrap();
718            assert!(root.starts_with(&home_path));
719        });
720    }
721
722    #[test]
723    fn custom_target_writes_exactly_where_asked() {
724        let dir = tempdir().unwrap();
725        let target = TraceTarget::Custom(dir.path().to_path_buf());
726        let tracer = SessionTracer::begin("x", &target).unwrap();
727        let trace_path = tracer.trace_path().to_path_buf();
728        assert!(trace_path.starts_with(dir.path()));
729        tracer.end(Outcome::Success, "").unwrap();
730    }
731}