Skip to main content

harness/
logger.rs

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/// Metadata about a session, stored alongside the NDJSON event log.
11#[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    /// Optional human-readable name for the session.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub name: Option<String>,
27    /// User-assigned tags for filtering/searching.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub tags: Option<Vec<String>>,
30}
31
32/// Logger that tees events to an NDJSON file.
33pub 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    /// Create a new session logger.
51    ///
52    /// Creates `~/.local/share/harness/sessions/<id>.ndjson` and writes events there.
53    pub fn new(session_id: &str, config: &TaskConfig) -> Result<Self> {
54        Self::new_with_name(session_id, config, None)
55    }
56
57    /// Create a new session logger with an optional human-readable name.
58    ///
59    /// Writes events to a `.ndjson.tmp` file, which is atomically renamed
60    /// to `.ndjson` on [`finalize`]. If the process crashes, the `.tmp` file
61    /// remains for debugging.
62    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        // Write to .tmp initially, rename to final path on finalize().
72        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    /// Log a single event to the session file.
97    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    /// Finalize the session: flush and atomically rename the NDJSON file,
111    /// then write meta.json.
112    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        // Atomic rename: .ndjson.tmp → .ndjson
118        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    /// Whether `finalize()` has been called.
148    fn is_finalized(&self) -> bool {
149        // After finalize(), the .tmp file no longer exists.
150        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 finalize() was never called, at least flush the buffer so
160        // the .tmp file has all data for post-mortem debugging.
161        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    /// Default sessions directory: `~/.local/share/harness/sessions/`.
171    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        // Old metadata without name/tags fields should still deserialize.
208        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        // Logger writes to .ndjson.tmp, renamed on finalize.
222        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        // After finalize, the .tmp should have been renamed to .ndjson.
250        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        // The .tmp file should no longer exist.
254        assert!(!tmp_path.exists());
255
256        // Verify meta.json was written.
257        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}