1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Phase {
43 Start,
49 Decision,
51 ToolCall,
53 ToolResult,
55 Verify,
57 Artifact,
59 Note,
61 End,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum Outcome {
70 Success,
72 Failure,
74 Aborted,
76}
77
78#[derive(Debug, Clone)]
84pub enum TraceTarget {
85 RepoLocal,
87 Global,
89 Custom(PathBuf),
91}
92
93impl TraceTarget {
94 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 cur = cur.parent()?;
126 }
127}
128
129fn home_dir() -> Result<PathBuf> {
130 if let Some(p) = std::env::var_os("DEVBOY_HOME_OVERRIDE")
131 && !p.is_empty()
132 {
133 return Ok(PathBuf::from(p));
134 }
135 dirs::home_dir().ok_or_else(|| SkillError::Io {
136 path: PathBuf::from("~"),
137 source: std::io::Error::other("home directory is not set"),
138 })
139}
140
141pub struct SessionTracer {
153 session_id: String,
154 skill: String,
155 session_dir: PathBuf,
156 trace_path: PathBuf,
157 meta_path: PathBuf,
158 trace_file: Mutex<File>,
159 started_at: DateTime<Utc>,
160 tool_calls: std::sync::atomic::AtomicU64,
161 errors: std::sync::atomic::AtomicU64,
162 redactor: redact::Redactor,
167}
168
169impl SessionTracer {
170 pub fn begin(skill: &str, target: &TraceTarget) -> Result<Self> {
176 let root = target.sessions_root()?;
177 let started_at = Utc::now();
178 let date = started_at.format("%Y-%m-%d").to_string();
179 let session_id = new_session_id();
180 let session_dir = root.join(&date).join(skill).join(&session_id);
181 create_dir_all(&session_dir).map_err(|source| SkillError::Io {
182 path: session_dir.clone(),
183 source,
184 })?;
185
186 let trace_path = session_dir.join("trace.jsonl");
187 let meta_path = session_dir.join("meta.json");
188 let file = OpenOptions::new()
189 .create(true)
190 .append(true)
191 .open(&trace_path)
192 .map_err(|source| SkillError::Io {
193 path: trace_path.clone(),
194 source,
195 })?;
196
197 let tracer = Self {
198 session_id,
199 skill: skill.to_string(),
200 session_dir,
201 trace_path,
202 meta_path,
203 trace_file: Mutex::new(file),
204 started_at,
205 tool_calls: std::sync::atomic::AtomicU64::new(0),
206 errors: std::sync::atomic::AtomicU64::new(0),
207 redactor: redact::Redactor::snapshot(),
208 };
209
210 tracer.write_event(Phase::Start, json!({}))?;
213 Ok(tracer)
214 }
215
216 pub fn event(&self, phase: Phase, payload: Value) -> Result<()> {
219 if phase == Phase::ToolCall {
220 self.tool_calls
221 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
222 }
223 if phase == Phase::ToolResult
224 && payload
225 .get("ok")
226 .and_then(|v| v.as_bool())
227 .is_some_and(|ok| !ok)
228 {
229 self.errors
230 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
231 }
232 self.write_event(phase, payload)
233 }
234
235 pub fn end(self, outcome: Outcome, summary: &str) -> Result<()> {
238 let ended_at = Utc::now();
239 self.write_event(
240 Phase::End,
241 json!({ "outcome": outcome, "summary": summary }),
242 )?;
243
244 let meta = SessionMeta {
245 session_id: self.session_id.clone(),
246 skill: self.skill.clone(),
247 skill_version: None,
248 devboy_version: env!("CARGO_PKG_VERSION").to_string(),
249 started_at: self.started_at,
250 ended_at: Some(ended_at),
251 outcome: Some(outcome),
252 input_summary: None,
253 tool_calls: self.tool_calls.load(std::sync::atomic::Ordering::Relaxed),
254 errors: self.errors.load(std::sync::atomic::Ordering::Relaxed),
255 summary: Some(summary.to_string()),
256 };
257 let bytes = serde_json::to_vec_pretty(&meta).map_err(|source| SkillError::SerdeJson {
258 operation: "serialise session meta",
259 path: self.meta_path.clone(),
260 source,
261 })?;
262 std::fs::write(&self.meta_path, bytes).map_err(|source| SkillError::Io {
263 path: self.meta_path.clone(),
264 source,
265 })
266 }
267
268 pub fn session_dir(&self) -> &Path {
270 &self.session_dir
271 }
272
273 pub fn trace_path(&self) -> &Path {
275 &self.trace_path
276 }
277
278 pub fn session_id(&self) -> &str {
281 &self.session_id
282 }
283
284 fn write_event(&self, phase: Phase, payload: Value) -> Result<()> {
285 let redacted = self.redactor.sanitize(payload);
286 let record = TraceRecord {
287 ts: Utc::now(),
288 skill: self.skill.clone(),
289 session_id: self.session_id.clone(),
290 phase,
291 payload: redacted,
292 };
293 let line = serde_json::to_string(&record).map_err(|source| SkillError::SerdeJson {
294 operation: "serialise trace record",
295 path: self.trace_path.clone(),
296 source,
297 })?;
298
299 let mut guard = self.trace_file.lock().map_err(|_| SkillError::Io {
300 path: self.trace_path.clone(),
301 source: std::io::Error::other("trace mutex poisoned"),
302 })?;
303 guard
304 .write_all(line.as_bytes())
305 .and_then(|()| guard.write_all(b"\n"))
306 .map_err(|source| SkillError::Io {
307 path: self.trace_path.clone(),
308 source,
309 })
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct TraceRecord {
320 pub ts: DateTime<Utc>,
322 pub skill: String,
326 pub session_id: String,
328 pub phase: Phase,
330 pub payload: Value,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct SessionMeta {
338 pub session_id: String,
340 pub skill: String,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub skill_version: Option<u32>,
344 pub devboy_version: String,
346 pub started_at: DateTime<Utc>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub ended_at: Option<DateTime<Utc>>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub outcome: Option<Outcome>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub input_summary: Option<String>,
357 pub tool_calls: u64,
359 pub errors: u64,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub summary: Option<String>,
364}
365
366pub fn create_session(skill: &str, target: &TraceTarget) -> Result<(String, PathBuf)> {
383 let root = target.sessions_root()?;
384 let started_at = Utc::now();
385 let date = started_at.format("%Y-%m-%d").to_string();
386 let session_id = new_session_id();
387 let session_dir = root.join(&date).join(skill).join(&session_id);
388 create_dir_all(&session_dir).map_err(|source| SkillError::Io {
389 path: session_dir.clone(),
390 source,
391 })?;
392 append_event_inner(
393 &session_dir,
394 &session_id,
395 skill,
396 Phase::Start,
397 Value::Object(Default::default()),
398 )?;
399 let skeleton = SessionMeta {
402 session_id: session_id.clone(),
403 skill: skill.to_string(),
404 skill_version: None,
405 devboy_version: env!("CARGO_PKG_VERSION").to_string(),
406 started_at,
407 ended_at: None,
408 outcome: None,
409 input_summary: None,
410 tool_calls: 0,
411 errors: 0,
412 summary: None,
413 };
414 write_meta_file(&session_dir.join("meta.json"), &skeleton)?;
415 Ok((session_id, session_dir))
416}
417
418pub fn append_event(
420 session_dir: &Path,
421 session_id: &str,
422 skill: &str,
423 phase: Phase,
424 payload: Value,
425) -> Result<()> {
426 append_event_inner(session_dir, session_id, skill, phase, payload)
427}
428
429fn append_event_inner(
430 session_dir: &Path,
431 session_id: &str,
432 skill: &str,
433 phase: Phase,
434 payload: Value,
435) -> Result<()> {
436 let redacted = redact::sanitize(payload);
437 let record = TraceRecord {
438 ts: Utc::now(),
439 skill: skill.to_string(),
440 session_id: session_id.to_string(),
441 phase,
442 payload: redacted,
443 };
444 let line = serde_json::to_string(&record).map_err(|source| SkillError::SerdeJson {
445 operation: "serialise trace record",
446 path: session_dir.join("trace.jsonl"),
447 source,
448 })?;
449
450 let trace_path = session_dir.join("trace.jsonl");
451 let mut file = OpenOptions::new()
452 .create(true)
453 .append(true)
454 .open(&trace_path)
455 .map_err(|source| SkillError::Io {
456 path: trace_path.clone(),
457 source,
458 })?;
459 file.write_all(line.as_bytes())
460 .and_then(|()| file.write_all(b"\n"))
461 .map_err(|source| SkillError::Io {
462 path: trace_path.clone(),
463 source,
464 })
465}
466
467pub fn finalise_session(
470 session_dir: &Path,
471 session_id: &str,
472 skill: &str,
473 outcome: Outcome,
474 summary: &str,
475) -> Result<()> {
476 append_event_inner(
477 session_dir,
478 session_id,
479 skill,
480 Phase::End,
481 json!({ "outcome": outcome, "summary": summary }),
482 )?;
483
484 let trace_path = session_dir.join("trace.jsonl");
485 let (tool_calls, errors, started_at) = scan_counts(&trace_path)?;
486
487 let meta = SessionMeta {
488 session_id: session_id.to_string(),
489 skill: skill.to_string(),
490 skill_version: None,
491 devboy_version: env!("CARGO_PKG_VERSION").to_string(),
492 started_at,
493 ended_at: Some(Utc::now()),
494 outcome: Some(outcome),
495 input_summary: None,
496 tool_calls,
497 errors,
498 summary: Some(summary.to_string()),
499 };
500 write_meta_file(&session_dir.join("meta.json"), &meta)
501}
502
503fn write_meta_file(path: &Path, meta: &SessionMeta) -> Result<()> {
504 let bytes = serde_json::to_vec_pretty(meta).map_err(|source| SkillError::SerdeJson {
505 operation: "serialise session meta",
506 path: path.to_path_buf(),
507 source,
508 })?;
509 std::fs::write(path, bytes).map_err(|source| SkillError::Io {
510 path: path.to_path_buf(),
511 source,
512 })
513}
514
515fn scan_counts(trace_path: &Path) -> Result<(u64, u64, DateTime<Utc>)> {
516 use std::io::{BufRead, BufReader};
517
518 let file = std::fs::File::open(trace_path).map_err(|source| SkillError::Io {
523 path: trace_path.to_path_buf(),
524 source,
525 })?;
526 let reader = BufReader::new(file);
527
528 let mut tool_calls = 0u64;
529 let mut errors = 0u64;
530 let mut started_at: Option<DateTime<Utc>> = None;
531 for line in reader.lines() {
532 let line = match line {
533 Ok(l) => l,
534 Err(source) => {
535 return Err(SkillError::Io {
536 path: trace_path.to_path_buf(),
537 source,
538 });
539 }
540 };
541 if line.trim().is_empty() {
542 continue;
543 }
544 let record: TraceRecord = match serde_json::from_str(&line) {
545 Ok(r) => r,
546 Err(_) => continue,
547 };
548 if started_at.is_none() && record.phase == Phase::Start {
549 started_at = Some(record.ts);
550 }
551 if record.phase == Phase::ToolCall {
552 tool_calls += 1;
553 }
554 if record.phase == Phase::ToolResult
555 && record
556 .payload
557 .get("ok")
558 .and_then(|v| v.as_bool())
559 .is_some_and(|ok| !ok)
560 {
561 errors += 1;
562 }
563 }
564 Ok((tool_calls, errors, started_at.unwrap_or_else(Utc::now)))
565}
566
567#[cfg(feature = "trace")]
572fn new_session_id() -> String {
573 ulid::Ulid::new().to_string()
574}
575
576#[cfg(not(feature = "trace"))]
577fn new_session_id() -> String {
578 format!(
581 "{}-{:x}",
582 Utc::now().format("%Y%m%d%H%M%S%f"),
583 std::process::id()
584 )
585}
586
587#[cfg(test)]
592mod tests {
593 use super::*;
594 use tempfile::tempdir;
595
596 fn events_in(path: &Path) -> Vec<TraceRecord> {
597 let text = std::fs::read_to_string(path).unwrap();
598 text.lines()
599 .filter(|l| !l.trim().is_empty())
600 .map(|l| serde_json::from_str(l).unwrap())
601 .collect()
602 }
603
604 #[test]
605 fn session_round_trip() {
606 let dir = tempdir().unwrap();
607 let target = TraceTarget::Custom(dir.path().to_path_buf());
608
609 let tracer = SessionTracer::begin("setup", &target).unwrap();
610 let trace_path = tracer.trace_path().to_path_buf();
611 let meta_path = tracer.session_dir().join("meta.json");
612
613 tracer
614 .event(
615 Phase::Decision,
616 json!({ "question": "provider?", "decision": "github" }),
617 )
618 .unwrap();
619 tracer
620 .event(
621 Phase::ToolCall,
622 json!({ "tool": "get_issues", "args": { "limit": 3 } }),
623 )
624 .unwrap();
625 tracer
626 .event(
627 Phase::ToolResult,
628 json!({ "tool": "get_issues", "ok": true, "duration_ms": 42 }),
629 )
630 .unwrap();
631 tracer.end(Outcome::Success, "configured github").unwrap();
632
633 let events = events_in(&trace_path);
634 assert_eq!(events.len(), 5);
636 assert_eq!(events[0].phase, Phase::Start);
637 assert_eq!(events.last().unwrap().phase, Phase::End);
638 assert!(events.iter().all(|e| e.skill == "setup"));
639 assert!(events.iter().all(|e| e.session_id == events[0].session_id));
640
641 let meta_bytes = std::fs::read(&meta_path).unwrap();
642 let meta: SessionMeta = serde_json::from_slice(&meta_bytes).unwrap();
643 assert_eq!(meta.skill, "setup");
644 assert_eq!(meta.outcome, Some(Outcome::Success));
645 assert_eq!(meta.tool_calls, 1);
646 assert_eq!(meta.errors, 0);
647 }
648
649 #[test]
650 fn failed_tool_result_is_counted_as_error() {
651 let dir = tempdir().unwrap();
652 let target = TraceTarget::Custom(dir.path().to_path_buf());
653 let tracer = SessionTracer::begin("devboy-test", &target).unwrap();
654 tracer
655 .event(Phase::ToolCall, json!({ "tool": "get_issues" }))
656 .unwrap();
657 tracer
658 .event(
659 Phase::ToolResult,
660 json!({ "tool": "get_issues", "ok": false, "error": "401 Unauthorized" }),
661 )
662 .unwrap();
663 let meta_path = tracer.session_dir().join("meta.json");
664 tracer.end(Outcome::Failure, "401").unwrap();
665
666 let meta: SessionMeta = serde_json::from_slice(&std::fs::read(meta_path).unwrap()).unwrap();
667 assert_eq!(meta.tool_calls, 1);
668 assert_eq!(meta.errors, 1);
669 assert_eq!(meta.outcome, Some(Outcome::Failure));
670 }
671
672 #[test]
673 fn events_are_redacted_before_writing() {
674 super::redact::test_support::with_clean_env(|| {
682 let dir = tempdir().unwrap();
683 let target = TraceTarget::Custom(dir.path().to_path_buf());
684 let tracer = SessionTracer::begin("devboy-test", &target).unwrap();
685 let trace_path = tracer.trace_path().to_path_buf();
686 tracer
687 .event(
688 Phase::ToolCall,
689 json!({
690 "tool": "create_issue",
691 "args": { "token": "ghp_012345678901234567890123456789012345" }
692 }),
693 )
694 .unwrap();
695 tracer.end(Outcome::Success, "").unwrap();
696
697 let text = std::fs::read_to_string(&trace_path).unwrap();
698 assert!(
699 !text.contains("ghp_0123456789"),
700 "trace contained raw GitHub token: {text}"
701 );
702 assert!(
703 text.contains("<redacted"),
704 "trace did not include redaction marker: {text}"
705 );
706 });
707 }
708
709 #[test]
710 fn global_target_respects_home_override() {
711 let home = tempdir().unwrap();
712 let home_path = home.path().to_path_buf();
713 temp_env::with_var("DEVBOY_HOME_OVERRIDE", Some(home.path()), || {
714 let root = TraceTarget::Global.sessions_root().unwrap();
715 assert!(root.starts_with(&home_path));
716 });
717 }
718
719 #[test]
720 fn custom_target_writes_exactly_where_asked() {
721 let dir = tempdir().unwrap();
722 let target = TraceTarget::Custom(dir.path().to_path_buf());
723 let tracer = SessionTracer::begin("x", &target).unwrap();
724 let trace_path = tracer.trace_path().to_path_buf();
725 assert!(trace_path.starts_with(dir.path()));
726 tracer.end(Outcome::Success, "").unwrap();
727 }
728}