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 failure_kind: None,
87 model: None,
88 token_usage: None,
89 summary: None,
90 }
91 }
92
93 #[test]
94 #[serial(env)]
95 fn env_log_dir_writes_jsonl() {
96 let _env = EnvVarGuard {
97 key: OPENCODE_ORCHESTRATOR_LOG_DIR,
98 previous: std::env::var_os(OPENCODE_ORCHESTRATOR_LOG_DIR),
99 };
100 let tmp = tempfile::tempdir().unwrap();
101
102 unsafe { std::env::set_var(OPENCODE_ORCHESTRATOR_LOG_DIR, tmp.path()) };
104
105 let record = sample_record();
106 append_record_best_effort(&record);
107
108 let date_prefix = format!("tool_logs_{}", record.completed_at.format("%Y-%m-%d"));
110 let jsonl_files: Vec<_> = std::fs::read_dir(tmp.path())
111 .unwrap()
112 .filter_map(std::result::Result::ok)
113 .filter(|e| {
114 let name = e.file_name().to_string_lossy().to_string();
115 name.starts_with(&date_prefix)
116 && std::path::Path::new(&name)
117 .extension()
118 .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
119 })
120 .collect();
121
122 assert_eq!(
123 jsonl_files.len(),
124 1,
125 "Expected one JSONL file with today's date"
126 );
127 let path = jsonl_files[0].path();
128
129 let mut content = String::new();
130 std::fs::File::open(path)
131 .unwrap()
132 .read_to_string(&mut content)
133 .unwrap();
134 assert!(content.contains("opencode-orchestrator-mcp"));
135 }
136
137 #[test]
138 #[serial(env)]
139 fn invalid_log_dir_is_swallowed() {
140 let _env = EnvVarGuard {
141 key: OPENCODE_ORCHESTRATOR_LOG_DIR,
142 previous: std::env::var_os(OPENCODE_ORCHESTRATOR_LOG_DIR),
143 };
144 let tmp = tempfile::tempdir().unwrap();
145 let invalid_path = tmp.path().join("not-a-directory");
146 std::fs::write(&invalid_path, "file").unwrap();
147
148 unsafe { std::env::set_var(OPENCODE_ORCHESTRATOR_LOG_DIR, &invalid_path) };
150
151 append_record_best_effort(&sample_record());
152 assert!(invalid_path.is_file());
153 }
154}