Skip to main content

opencode_orchestrator_mcp/
logging.rs

1use 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                // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
65                Some(value) => unsafe { std::env::set_var(self.key, value) },
66                // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
67                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        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
102        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        // Filename format: tool_logs_YYYY-MM-DD_{session_id}.jsonl
108        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        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
148        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}