1use std::io::Write;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::{AgentKind, TaskConfig};
7use crate::error::{Error, Result};
8use crate::event::Event;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub(crate) struct SessionMeta {
13 pub session_id: String,
14 pub agent: String,
15 pub prompt: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub model: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub cwd: Option<String>,
20 pub start_time: String,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub duration_ms: Option<u64>,
23 pub success: bool,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub name: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub tags: Option<Vec<String>>,
30}
31
32pub struct SessionLogger {
34 session_id: String,
35 session_dir: PathBuf,
36 writer: std::io::BufWriter<std::fs::File>,
37 config: LoggerConfig,
38 start_secs: u64,
39}
40
41struct LoggerConfig {
42 agent: AgentKind,
43 prompt: String,
44 model: Option<String>,
45 cwd: Option<String>,
46 name: Option<String>,
47}
48
49impl SessionLogger {
50 pub fn new(session_id: &str, config: &TaskConfig) -> Result<Self> {
54 Self::new_with_name(session_id, config, None)
55 }
56
57 pub fn new_with_name(
63 session_id: &str,
64 config: &TaskConfig,
65 name: Option<String>,
66 ) -> Result<Self> {
67 let session_dir = Self::sessions_dir()?;
68 std::fs::create_dir_all(&session_dir)
69 .map_err(|e| Error::Other(format!("failed to create session dir: {e}")))?;
70
71 let tmp_path = session_dir.join(format!("{session_id}.ndjson.tmp"));
73 let file = std::fs::File::create(&tmp_path)
74 .map_err(|e| Error::Other(format!("failed to create session log: {e}")))?;
75
76 let start_secs = std::time::SystemTime::now()
77 .duration_since(std::time::UNIX_EPOCH)
78 .unwrap_or_default()
79 .as_secs();
80
81 Ok(Self {
82 session_id: session_id.to_string(),
83 session_dir,
84 writer: std::io::BufWriter::new(file),
85 config: LoggerConfig {
86 agent: config.agent,
87 prompt: config.prompt.clone(),
88 model: config.model.clone(),
89 cwd: config.cwd.as_ref().map(|p| p.display().to_string()),
90 name,
91 },
92 start_secs,
93 })
94 }
95
96 pub fn log_event(&mut self, event: &Event) {
98 match serde_json::to_string(event) {
99 Ok(json) => {
100 if let Err(e) = writeln!(self.writer, "{json}") {
101 tracing::warn!("failed to write session log: {e}");
102 }
103 }
104 Err(e) => {
105 tracing::warn!("failed to serialize event for session log: {e}");
106 }
107 }
108 }
109
110 pub fn finalize(&mut self, success: bool, duration_ms: Option<u64>) {
113 if let Err(e) = self.writer.flush() {
114 tracing::warn!("failed to flush session log: {e}");
115 }
116
117 let tmp_path = self
119 .session_dir
120 .join(format!("{}.ndjson.tmp", self.session_id));
121 let final_path = self.session_dir.join(format!("{}.ndjson", self.session_id));
122 if let Err(e) = std::fs::rename(&tmp_path, &final_path) {
123 tracing::warn!("failed to rename session log: {e}");
124 }
125
126 let meta = SessionMeta {
127 session_id: self.session_id.clone(),
128 agent: self.config.agent.display_name().to_string(),
129 prompt: self.config.prompt.clone(),
130 model: self.config.model.clone(),
131 cwd: self.config.cwd.clone(),
132 start_time: self.start_secs.to_string(),
133 duration_ms,
134 success,
135 name: self.config.name.clone(),
136 tags: None,
137 };
138
139 let meta_path = self.session_dir.join(format!("{}.meta.json", self.session_id));
140 if let Ok(json) = serde_json::to_string_pretty(&meta) {
141 if let Err(e) = std::fs::write(&meta_path, json) {
142 tracing::warn!("failed to write session metadata: {e}");
143 }
144 }
145 }
146
147 fn is_finalized(&self) -> bool {
149 let tmp_path = self
151 .session_dir
152 .join(format!("{}.ndjson.tmp", self.session_id));
153 !tmp_path.exists()
154 }
155}
156
157impl Drop for SessionLogger {
158 fn drop(&mut self) {
159 if !self.is_finalized() {
162 if let Err(e) = self.writer.flush() {
163 tracing::warn!("SessionLogger dropped without finalize, flush failed: {e}");
164 }
165 }
166 }
167}
168
169impl SessionLogger {
170 pub fn sessions_dir() -> Result<PathBuf> {
172 dirs::data_local_dir()
173 .map(|d| d.join("harness").join("sessions"))
174 .ok_or_else(|| Error::Other("cannot determine data directory".into()))
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::event::*;
182
183 #[test]
184 fn session_meta_round_trip() {
185 let meta = SessionMeta {
186 session_id: "test-123".into(),
187 agent: "Claude Code".into(),
188 prompt: "fix the bug".into(),
189 model: Some("opus".into()),
190 cwd: Some("/tmp".into()),
191 start_time: "1700000000".into(),
192 duration_ms: Some(5000),
193 success: true,
194 name: Some("fix auth bug".into()),
195 tags: Some(vec!["bug-fix".into(), "auth".into()]),
196 };
197 let json = serde_json::to_string(&meta).unwrap();
198 let parsed: SessionMeta = serde_json::from_str(&json).unwrap();
199 assert_eq!(parsed.session_id, "test-123");
200 assert!(parsed.success);
201 assert_eq!(parsed.name, Some("fix auth bug".into()));
202 assert_eq!(parsed.tags, Some(vec!["bug-fix".into(), "auth".into()]));
203 }
204
205 #[test]
206 fn session_meta_backward_compat() {
207 let json = r#"{"session_id":"old","agent":"Claude Code","prompt":"hi","start_time":"0","success":true}"#;
209 let parsed: SessionMeta = serde_json::from_str(json).unwrap();
210 assert_eq!(parsed.session_id, "old");
211 assert!(parsed.name.is_none());
212 assert!(parsed.tags.is_none());
213 }
214
215 #[test]
216 fn logger_creates_files() {
217 let tmp = tempfile::tempdir().unwrap();
218 let session_dir = tmp.path().join("sessions");
219 std::fs::create_dir_all(&session_dir).unwrap();
220
221 let tmp_path = session_dir.join("test-session.ndjson.tmp");
223 let file = std::fs::File::create(&tmp_path).unwrap();
224
225 let config = TaskConfig::new("test prompt", AgentKind::Claude);
226 let mut logger = SessionLogger {
227 session_id: "test-session".into(),
228 session_dir: session_dir.clone(),
229 writer: std::io::BufWriter::new(file),
230 config: LoggerConfig {
231 agent: config.agent,
232 prompt: config.prompt.clone(),
233 model: config.model.clone(),
234 cwd: None,
235 name: None,
236 },
237 start_secs: 1700000000,
238 };
239
240 let event = Event::Message(MessageEvent {
241 role: Role::Assistant,
242 text: "Hello".into(),
243 usage: None,
244 timestamp_ms: 123456,
245 });
246 logger.log_event(&event);
247 logger.finalize(true, Some(1000));
248
249 let ndjson_path = session_dir.join("test-session.ndjson");
251 let content = std::fs::read_to_string(&ndjson_path).unwrap();
252 assert!(content.contains("Hello"));
253 assert!(!tmp_path.exists());
255
256 let meta_path = session_dir.join("test-session.meta.json");
258 assert!(meta_path.exists());
259 let meta_content = std::fs::read_to_string(&meta_path).unwrap();
260 assert!(meta_content.contains("test-session"));
261 }
262}