opencode_orchestrator_mcp/
logging.rs1use agentic_logging::LogWriter;
2use agentic_logging::ToolCallRecord;
3use chrono::Utc;
4use std::path::PathBuf;
5
6pub const OPENCODE_ORCHESTRATOR_LOG_DIR: &str = "OPENCODE_ORCHESTRATOR_LOG_DIR";
7
8pub fn resolve_logs_dir() -> Option<PathBuf> {
9 if let Ok(value) = std::env::var(OPENCODE_ORCHESTRATOR_LOG_DIR) {
10 let value = value.trim();
11 if !value.is_empty() {
12 return Some(PathBuf::from(value));
13 }
14 }
15
16 thoughts_tool::documents::active_logs_dir().ok()
17}
18
19pub fn append_record_best_effort(record: &ToolCallRecord) {
20 let Some(dir) = resolve_logs_dir() else {
21 return;
22 };
23
24 let writer = LogWriter::new(dir);
25 if let Err(error) = writer.append_jsonl(record) {
26 tracing::warn!(error = %error, "Failed to append orchestrator JSONL log");
27 }
28}
29
30pub fn write_markdown_best_effort(
31 completed_at: agentic_logging::chrono::DateTime<Utc>,
32 call_id: &str,
33 content: &str,
34) -> Option<String> {
35 let dir = resolve_logs_dir()?;
36
37 let writer = LogWriter::new(dir);
38 match writer.write_markdown_response(completed_at, call_id, content) {
39 Ok(filename) if !filename.is_empty() => Some(filename),
40 Ok(_) => None,
41 Err(error) => {
42 tracing::warn!(error = %error, "Failed to write orchestrator markdown log");
43 None
44 }
45 }
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51 use agentic_logging::CallTimer;
52 use agentic_logging::ToolCallRecord;
53 use serial_test::serial;
54 use std::io::Read;
55
56 struct EnvVarGuard {
57 key: &'static str,
58 previous: Option<std::ffi::OsString>,
59 }
60
61 impl Drop for EnvVarGuard {
62 fn drop(&mut self) {
63 match &self.previous {
64 Some(value) => unsafe { std::env::set_var(self.key, value) },
66 None => unsafe { std::env::remove_var(self.key) },
68 }
69 }
70 }
71
72 fn sample_record() -> ToolCallRecord {
73 let timer = CallTimer::start();
74 let (completed_at, duration_ms) = timer.finish();
75 ToolCallRecord {
76 call_id: timer.call_id,
77 server: "opencode-orchestrator-mcp".into(),
78 tool: "run".into(),
79 started_at: timer.started_at,
80 completed_at,
81 duration_ms,
82 request: serde_json::json!({"message": "hello"}),
83 response_file: None,
84 success: true,
85 error: None,
86 model: None,
87 token_usage: None,
88 summary: None,
89 }
90 }
91
92 #[test]
93 #[serial(env)]
94 fn env_log_dir_writes_jsonl() {
95 let _env = EnvVarGuard {
96 key: OPENCODE_ORCHESTRATOR_LOG_DIR,
97 previous: std::env::var_os(OPENCODE_ORCHESTRATOR_LOG_DIR),
98 };
99 let tmp = tempfile::tempdir().unwrap();
100
101 unsafe { std::env::set_var(OPENCODE_ORCHESTRATOR_LOG_DIR, tmp.path()) };
103
104 let record = sample_record();
105 append_record_best_effort(&record);
106
107 let date_prefix = format!("tool_logs_{}", record.completed_at.format("%Y-%m-%d"));
109 let jsonl_files: Vec<_> = std::fs::read_dir(tmp.path())
110 .unwrap()
111 .filter_map(std::result::Result::ok)
112 .filter(|e| {
113 let name = e.file_name().to_string_lossy().to_string();
114 name.starts_with(&date_prefix)
115 && std::path::Path::new(&name)
116 .extension()
117 .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
118 })
119 .collect();
120
121 assert_eq!(
122 jsonl_files.len(),
123 1,
124 "Expected one JSONL file with today's date"
125 );
126 let path = jsonl_files[0].path();
127
128 let mut content = String::new();
129 std::fs::File::open(path)
130 .unwrap()
131 .read_to_string(&mut content)
132 .unwrap();
133 assert!(content.contains("opencode-orchestrator-mcp"));
134 }
135
136 #[test]
137 #[serial(env)]
138 fn invalid_log_dir_is_swallowed() {
139 let _env = EnvVarGuard {
140 key: OPENCODE_ORCHESTRATOR_LOG_DIR,
141 previous: std::env::var_os(OPENCODE_ORCHESTRATOR_LOG_DIR),
142 };
143 let tmp = tempfile::tempdir().unwrap();
144 let invalid_path = tmp.path().join("not-a-directory");
145 std::fs::write(&invalid_path, "file").unwrap();
146
147 unsafe { std::env::set_var(OPENCODE_ORCHESTRATOR_LOG_DIR, &invalid_path) };
149
150 append_record_best_effort(&sample_record());
151 assert!(invalid_path.is_file());
152 }
153}