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 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
144pub 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 redactor: redact::Redactor,
170}
171
172impl SessionTracer {
173 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 tracer.write_event(Phase::Start, json!({}))?;
216 Ok(tracer)
217 }
218
219 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 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 pub fn session_dir(&self) -> &Path {
273 &self.session_dir
274 }
275
276 pub fn trace_path(&self) -> &Path {
278 &self.trace_path
279 }
280
281 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#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct TraceRecord {
323 pub ts: DateTime<Utc>,
325 pub skill: String,
329 pub session_id: String,
331 pub phase: Phase,
333 pub payload: Value,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct SessionMeta {
341 pub session_id: String,
343 pub skill: String,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub skill_version: Option<u32>,
347 pub devboy_version: String,
349 pub started_at: DateTime<Utc>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub ended_at: Option<DateTime<Utc>>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub outcome: Option<Outcome>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub input_summary: Option<String>,
360 pub tool_calls: u64,
362 pub errors: u64,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub summary: Option<String>,
367}
368
369pub 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 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
421pub 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
470pub 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 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#[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 format!(
584 "{}-{:x}",
585 Utc::now().format("%Y%m%d%H%M%S%f"),
586 std::process::id()
587 )
588}
589
590#[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 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 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}