Skip to main content

codetether_agent/session/tasks/
log.rs

1//! Append-only JSONL IO for the session task log.
2
3use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5use tokio::fs::{self, OpenOptions};
6use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
7
8use super::event::TaskEvent;
9use super::path::task_log_path;
10
11/// Handle to a session's task log file.
12///
13/// All writes append a single JSON line; reads return events in
14/// insertion order. The log is intentionally tiny and synchronous
15/// at the line level — one event per line, newline-terminated.
16pub struct TaskLog {
17    path: PathBuf,
18}
19
20impl TaskLog {
21    /// Open (or lazily create) the log for the given session id.
22    pub fn for_session(session_id: &str) -> Result<Self> {
23        Ok(Self {
24            path: task_log_path(session_id)?,
25        })
26    }
27
28    /// Open a log at an explicit path (used by tests).
29    pub fn at(path: impl Into<PathBuf>) -> Self {
30        Self { path: path.into() }
31    }
32
33    /// Path to the underlying `.tasks.jsonl` file.
34    pub fn path(&self) -> &Path {
35        &self.path
36    }
37
38    /// Append a single event as a JSON line.
39    pub async fn append(&self, event: &TaskEvent) -> Result<()> {
40        if let Some(parent) = self.path.parent() {
41            fs::create_dir_all(parent).await.ok();
42        }
43        let mut line = serde_json::to_string(event).context("serialize task event")?;
44        line.push('\n');
45        let mut f = OpenOptions::new()
46            .create(true)
47            .append(true)
48            .open(&self.path)
49            .await
50            .with_context(|| format!("open {} for append", self.path.display()))?;
51        f.write_all(line.as_bytes()).await?;
52        f.flush().await?;
53        Ok(())
54    }
55
56    /// Read every event, skipping malformed lines.
57    pub async fn read_all(&self) -> Result<Vec<TaskEvent>> {
58        if !self.path.exists() {
59            return Ok(Vec::new());
60        }
61        let file = fs::File::open(&self.path).await?;
62        let mut out = Vec::new();
63        let mut lines = BufReader::new(file).lines();
64        while let Some(line) = lines.next_line().await? {
65            if line.trim().is_empty() {
66                continue;
67            }
68            if let Ok(event) = serde_json::from_str::<TaskEvent>(&line) {
69                out.push(event);
70            }
71        }
72        Ok(out)
73    }
74
75    /// Blocking read — suitable for sync contexts like system-prompt
76    /// assembly, which runs on each turn and must not depend on a
77    /// tokio runtime. The log is small (goal + open tasks), so this
78    /// is cheap in practice.
79    pub fn read_all_blocking(&self) -> Result<Vec<TaskEvent>> {
80        if !self.path.exists() {
81            return Ok(Vec::new());
82        }
83        let content = std::fs::read_to_string(&self.path)?;
84        let mut out = Vec::new();
85        for line in content.lines() {
86            if line.trim().is_empty() {
87                continue;
88            }
89            if let Ok(event) = serde_json::from_str::<TaskEvent>(line) {
90                out.push(event);
91            }
92        }
93        Ok(out)
94    }
95
96    /// Blocking append — used by the TUI slash-command handler which
97    /// runs inside an async context but wants a single synchronous write.
98    pub fn append_blocking(&self, event: &TaskEvent) -> Result<()> {
99        use std::io::Write;
100        if let Some(parent) = self.path.parent() {
101            std::fs::create_dir_all(parent).ok();
102        }
103        let mut line = serde_json::to_string(event).context("serialize task event")?;
104        line.push('\n');
105        let mut f = std::fs::OpenOptions::new()
106            .create(true)
107            .append(true)
108            .open(&self.path)
109            .with_context(|| format!("open {} for append", self.path.display()))?;
110        f.write_all(line.as_bytes())?;
111        Ok(())
112    }
113}