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            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        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
103        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        // Filename format: tool_logs_YYYY-MM-DD_{session_id}.jsonl
109        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        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
149        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}