use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use percent_encoding::percent_decode_str;
use tracing::warn;
use clawft_platform::Platform;
use clawft_types::error::ClawftError;
use clawft_types::session::Session;
use super::sink::{ConversationSink, Turn};
use crate::security::validate_session_id;
use crate::session::{discover_sessions_dir, session_file_path, session_from_jsonl};
pub struct LocalFileSink<P: Platform> {
sessions_dir: PathBuf,
platform: Arc<P>,
}
impl<P: Platform> LocalFileSink<P> {
pub async fn new(platform: Arc<P>) -> clawft_types::Result<Self> {
let sessions_dir = discover_sessions_dir(platform.as_ref()).await?;
Ok(Self {
sessions_dir,
platform,
})
}
pub fn with_dir(platform: Arc<P>, sessions_dir: PathBuf) -> Self {
Self {
sessions_dir,
platform,
}
}
pub fn sessions_dir(&self) -> &PathBuf {
&self.sessions_dir
}
fn resolve(&self, conv_id: &str) -> Result<PathBuf, String> {
validate_session_id(conv_id).map_err(|e| e.to_string())?;
Ok(session_file_path(&self.sessions_dir, conv_id))
}
async fn read_turns(&self, conv_id: &str) -> Result<Vec<Turn>, String> {
let path = self.resolve(conv_id)?;
if !self.platform.fs().exists(&path).await {
return Ok(Vec::new());
}
let content = self
.platform
.fs()
.read_to_string(&path)
.await
.map_err(|e| format!("read {}: {e}", path.display()))?;
let mut turns = Vec::new();
for line in content.lines().skip(1) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(msg) => {
if let Some(turn) = message_to_turn(&msg, conv_id, turns.len()) {
turns.push(turn);
} else {
warn!(
conv = conv_id,
"skipping session message line without a role"
);
}
}
Err(e) => warn!(
conv = conv_id,
error = %e,
"skipping malformed session message line"
),
}
}
Ok(turns)
}
async fn read_header(&self, path: &std::path::Path) -> Option<serde_json::Value> {
if !self.platform.fs().exists(path).await {
return None;
}
let content = self.platform.fs().read_to_string(path).await.ok()?;
let first = content.lines().next()?;
serde_json::from_str::<serde_json::Value>(first).ok()
}
pub async fn list_conversations(&self) -> clawft_types::Result<Vec<String>> {
let entries = self
.platform
.fs()
.list_dir(&self.sessions_dir)
.await
.map_err(ClawftError::Io)?;
let mut keys = Vec::new();
for entry in entries {
if let Some(name) = entry.file_name() {
let name = name.to_string_lossy();
if let Some(stem) = name.strip_suffix(".jsonl") {
match percent_decode_str(stem).decode_utf8() {
Ok(decoded) => keys.push(decoded.into_owned()),
Err(e) => {
warn!(filename = %name, error = %e, "skipping undecodable session filename");
}
}
}
}
}
keys.sort();
Ok(keys)
}
pub async fn load_session(&self, conv_id: &str) -> clawft_types::Result<Session> {
validate_session_id(conv_id)?;
let path = session_file_path(&self.sessions_dir, conv_id);
let content = self.platform.fs().read_to_string(&path).await?;
session_from_jsonl(conv_id, &content, &path)
}
pub async fn delete(&self, conv_id: &str) -> clawft_types::Result<()> {
validate_session_id(conv_id)?;
let path = session_file_path(&self.sessions_dir, conv_id);
if self.platform.fs().exists(&path).await {
self.platform
.fs()
.remove_file(&path)
.await
.map_err(ClawftError::Io)?;
}
crate::observation_pack::remove_observations_dir(
self.platform.as_ref(),
&crate::observation_pack::observations_dir(&self.sessions_dir, conv_id),
)
.await;
Ok(())
}
}
fn turn_to_message_line(turn: &Turn) -> String {
let ts = DateTime::<Utc>::from_timestamp_millis(turn.ts_ms as i64)
.unwrap_or_else(Utc::now)
.to_rfc3339();
let mut msg = serde_json::json!({
"role": turn.role,
"content": turn.content,
"timestamp": ts,
});
if let Some(obj) = msg.as_object_mut() {
if let Some(tool_calls) = &turn.tool_calls {
obj.insert("tool_calls".into(), serde_json::Value::Array(tool_calls.clone()));
}
if let Some(tool_call_id) = &turn.tool_call_id {
obj.insert(
"tool_call_id".into(),
serde_json::Value::String(tool_call_id.clone()),
);
}
}
let mut line = serde_json::to_string(&msg).unwrap_or_default();
line.push('\n');
line
}
fn message_to_turn(msg: &serde_json::Value, conv_id: &str, index: usize) -> Option<Turn> {
let role = msg.get("role").and_then(|v| v.as_str())?;
let content = msg
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let ts_ms = msg
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis().max(0) as u64)
.unwrap_or(0);
let tool_calls = msg
.get("tool_calls")
.filter(|v| !v.is_null())
.and_then(|v| v.as_array())
.map(|a| a.to_vec());
let tool_call_id = msg
.get("tool_call_id")
.filter(|v| !v.is_null())
.and_then(|v| v.as_str())
.map(str::to_string);
Some(Turn {
turn_id: format!("{conv_id}#{index}"),
role: role.to_string(),
content,
tool_calls,
tool_call_id,
ts_ms,
voice_analysis: None,
audio: None,
})
}
fn fresh_header(metadata: &serde_json::Value) -> String {
let now = Utc::now().to_rfc3339();
let header = serde_json::json!({
"_type": "metadata",
"created_at": now,
"updated_at": now,
"metadata": metadata,
"last_consolidated": 0,
});
serde_json::to_string(&header).unwrap_or_default()
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl<P: Platform + 'static> ConversationSink for LocalFileSink<P> {
async fn lock_conversation(&self, _conv_id: &str) {
}
async fn append_turn(&self, conv_id: &str, turn: Turn) -> Result<(), String> {
let path = self.resolve(conv_id)?;
let line = turn_to_message_line(&turn);
if self.platform.fs().exists(&path).await {
self.platform
.fs()
.append_string(&path, &line)
.await
.map_err(|e| format!("append {}: {e}", path.display()))
} else {
let mut content = fresh_header(&serde_json::json!({}));
content.push('\n');
content.push_str(&line);
self.platform
.fs()
.write_string(&path, &content)
.await
.map_err(|e| format!("write {}: {e}", path.display()))
}
}
async fn publish_status(
&self,
_conv_id: &str,
_status: &str,
_payload: serde_json::Value,
) -> Result<(), String> {
Ok(())
}
async fn load_history(&self, conv_id: &str) -> Result<Vec<Turn>, String> {
self.read_turns(conv_id).await
}
async fn history(&self, conv_id: &str, window: usize) -> Vec<Turn> {
let all = match self.read_turns(conv_id).await {
Ok(turns) => turns,
Err(e) => {
warn!(conv = conv_id, error = %e, "history read failed; degrading to empty");
return Vec::new();
}
};
if window == 0 || all.len() <= window {
all
} else {
all[all.len() - window..].to_vec()
}
}
async fn meta(&self, conv_id: &str) -> serde_json::Value {
let path = match self.resolve(conv_id) {
Ok(p) => p,
Err(_) => return serde_json::Value::Null,
};
match self.read_header(&path).await {
Some(header) => header
.get("metadata")
.cloned()
.unwrap_or(serde_json::Value::Null),
None => serde_json::Value::Null,
}
}
async fn set_meta(&self, conv_id: &str, meta: serde_json::Value) {
let path = match self.resolve(conv_id) {
Ok(p) => p,
Err(e) => {
warn!(conv = conv_id, error = %e, "set_meta rejected invalid conv id");
return;
}
};
let existing = self.platform.fs().read_to_string(&path).await.ok();
let (header_line, message_lines): (String, Vec<String>) = match existing {
Some(content) => {
let mut lines = content.lines();
let header = match lines.next().and_then(|l| {
serde_json::from_str::<serde_json::Value>(l).ok()
}) {
Some(mut h) => {
if let Some(obj) = h.as_object_mut() {
obj.insert("metadata".into(), meta.clone());
obj.insert(
"updated_at".into(),
serde_json::Value::String(Utc::now().to_rfc3339()),
);
}
serde_json::to_string(&h).unwrap_or_else(|_| fresh_header(&meta))
}
None => fresh_header(&meta),
};
let msgs = lines
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect();
(header, msgs)
}
None => (fresh_header(&meta), Vec::new()),
};
let mut out = header_line;
out.push('\n');
for l in message_lines {
out.push_str(&l);
out.push('\n');
}
if let Err(e) = self.platform.fs().write_string(&path, &out).await {
warn!(conv = conv_id, error = %e, "set_meta write failed; swallowed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clawft_platform::NativePlatform;
use serde_json::json;
use tempfile::TempDir;
fn temp_sink() -> (TempDir, LocalFileSink<NativePlatform>) {
let dir = TempDir::new().unwrap();
let sink = LocalFileSink::with_dir(
Arc::new(NativePlatform::new()),
dir.path().to_path_buf(),
);
(dir, sink)
}
fn turn(role: &str, content: &str) -> Turn {
Turn {
turn_id: format!("t-{role}"),
role: role.into(),
content: content.into(),
tool_calls: None,
tool_call_id: None,
ts_ms: 1_700_000_000_000,
voice_analysis: None,
audio: None,
}
}
#[tokio::test]
async fn append_then_history_round_trips() {
let (_dir, sink) = temp_sink();
sink.append_turn("telegram:1", turn("user", "hello"))
.await
.unwrap();
sink.append_turn("telegram:1", turn("assistant", "hi there"))
.await
.unwrap();
let hist = sink.history("telegram:1", 0).await;
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].role, "user");
assert_eq!(hist[0].content, "hello");
assert_eq!(hist[1].role, "assistant");
assert_eq!(hist[1].content, "hi there");
}
#[tokio::test]
async fn history_of_unknown_conv_is_empty() {
let (_dir, sink) = temp_sink();
assert!(sink.history("nope:0", 0).await.is_empty());
assert!(sink.load_history("nope:0").await.unwrap().is_empty());
}
#[tokio::test]
async fn history_windows_to_the_tail_of_the_superset() {
let (_dir, sink) = temp_sink();
for i in 0..5 {
sink.append_turn("c:1", turn("user", &i.to_string()))
.await
.unwrap();
}
let all = sink.history("c:1", 0).await;
assert_eq!(all.len(), 5);
assert_eq!(all[0].content, "0");
assert_eq!(all[4].content, "4");
let tail = sink.history("c:1", 2).await;
assert_eq!(tail.len(), 2);
assert_eq!(tail[0].content, "3");
assert_eq!(tail[1].content, "4");
assert_eq!(sink.history("c:1", 100).await.len(), 5);
}
#[tokio::test]
async fn tool_intermediates_survive_the_superset_round_trip() {
let (_dir, sink) = temp_sink();
sink.append_turn("c:1", turn("user", "q")).await.unwrap();
let tool_call = Turn {
tool_calls: Some(vec![json!({"id": "call-1", "name": "search"})]),
..turn("assistant", "calling")
};
sink.append_turn("c:1", tool_call).await.unwrap();
let tool_result = Turn {
tool_call_id: Some("call-1".into()),
..turn("tool", "result")
};
sink.append_turn("c:1", tool_result).await.unwrap();
sink.append_turn("c:1", turn("assistant", "final"))
.await
.unwrap();
let turns = sink.history("c:1", 0).await;
assert_eq!(turns.len(), 4, "superset must keep tool intermediates");
assert!(turns[1].tool_calls.is_some());
assert_eq!(turns[1].tool_calls.as_ref().unwrap().len(), 1);
assert_eq!(turns[2].tool_call_id.as_deref(), Some("call-1"));
assert_eq!(turns[3].content, "final");
}
#[tokio::test]
async fn meta_round_trips_and_defaults_to_null() {
let (_dir, sink) = temp_sink();
assert_eq!(sink.meta("c:1").await, serde_json::Value::Null);
sink.set_meta("c:1", json!({"hallucination_score": 0.42}))
.await;
assert_eq!(sink.meta("c:1").await["hallucination_score"], 0.42);
sink.set_meta("c:1", json!({"hallucination_score": 0.99}))
.await;
assert_eq!(sink.meta("c:1").await["hallucination_score"], 0.99);
assert_eq!(sink.meta("other:1").await, serde_json::Value::Null);
}
#[tokio::test]
async fn set_meta_preserves_existing_message_lines() {
let (_dir, sink) = temp_sink();
sink.append_turn("c:1", turn("user", "hello"))
.await
.unwrap();
sink.append_turn("c:1", turn("assistant", "hi"))
.await
.unwrap();
sink.set_meta("c:1", json!({"hallucination_score": 0.5}))
.await;
assert_eq!(sink.meta("c:1").await["hallucination_score"], 0.5);
let hist = sink.history("c:1", 0).await;
assert_eq!(hist.len(), 2);
assert_eq!(hist[0].content, "hello");
assert_eq!(hist[1].content, "hi");
}
#[tokio::test]
async fn set_meta_before_any_turn_then_append() {
let (_dir, sink) = temp_sink();
sink.set_meta("c:1", json!({"hallucination_score": 0.1}))
.await;
assert!(sink.history("c:1", 0).await.is_empty());
sink.append_turn("c:1", turn("user", "hi")).await.unwrap();
assert_eq!(sink.meta("c:1").await["hallucination_score"], 0.1);
let hist = sink.history("c:1", 0).await;
assert_eq!(hist.len(), 1);
assert_eq!(hist[0].content, "hi");
}
#[tokio::test]
async fn invalid_conv_id_is_rejected_or_degrades() {
let (_dir, sink) = temp_sink();
assert!(sink.append_turn("../evil", turn("user", "x")).await.is_err());
assert!(sink.load_history("../evil").await.is_err());
assert!(sink.history("../evil", 0).await.is_empty());
assert_eq!(sink.meta("../evil").await, serde_json::Value::Null);
sink.set_meta("../evil", json!({"x": 1})).await; }
#[tokio::test]
async fn keys_with_same_chat_id_across_channels_are_distinct() {
let (_dir, sink) = temp_sink();
sink.append_turn("telegram:42", turn("user", "from telegram"))
.await
.unwrap();
sink.append_turn("discord:42", turn("user", "from discord"))
.await
.unwrap();
let tg = sink.history("telegram:42", 0).await;
let dc = sink.history("discord:42", 0).await;
assert_eq!(tg.len(), 1);
assert_eq!(dc.len(), 1);
assert_eq!(tg[0].content, "from telegram");
assert_eq!(dc[0].content, "from discord");
}
#[tokio::test]
async fn reads_legacy_session_manager_format() {
let (dir, sink) = temp_sink();
let path = session_file_path(dir.path(), "telegram:99");
let legacy = concat!(
r#"{"_type":"metadata","created_at":"2026-01-01T00:00:00+00:00","updated_at":"2026-01-01T00:05:00+00:00","metadata":{"hallucination_score":0.25},"last_consolidated":0}"#, "\n",
r#"{"role":"user","content":"what's the weather?","timestamp":"2026-01-01T00:00:01+00:00"}"#, "\n",
r#"{"role":"assistant","content":"checking","timestamp":"2026-01-01T00:00:02+00:00","tool_calls":[{"id":"call-1","name":"weather"}]}"#, "\n",
r#"{"role":"tool","content":"sunny, 22C","timestamp":"2026-01-01T00:00:03+00:00","tool_call_id":"call-1"}"#, "\n",
r#"{"role":"assistant","content":"It's sunny and 22C.","timestamp":"2026-01-01T00:00:04+00:00"}"#, "\n",
);
std::fs::write(&path, legacy).unwrap();
let hist = sink.history("telegram:99", 0).await;
assert_eq!(hist.len(), 4);
assert_eq!(hist[0].role, "user");
assert_eq!(hist[0].content, "what's the weather?");
assert!(hist[1].tool_calls.is_some());
assert_eq!(hist[2].role, "tool");
assert_eq!(hist[2].tool_call_id.as_deref(), Some("call-1"));
assert_eq!(hist[3].content, "It's sunny and 22C.");
assert!(hist[0].ts_ms > 0);
assert_eq!(sink.meta("telegram:99").await["hallucination_score"], 0.25);
}
#[tokio::test]
async fn written_file_matches_session_manager_shape() {
let (dir, sink) = temp_sink();
sink.set_meta("c:1", json!({"hallucination_score": 0.7}))
.await;
sink.append_turn("c:1", turn("user", "hi")).await.unwrap();
let path = session_file_path(dir.path(), "c:1");
let content = std::fs::read_to_string(&path).unwrap();
let mut lines = content.lines();
let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(header["_type"], "metadata");
assert!(header.get("created_at").is_some());
assert!(header.get("updated_at").is_some());
assert_eq!(header["metadata"]["hallucination_score"], 0.7);
assert_eq!(header["last_consolidated"], 0);
let msg: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(msg["role"], "user");
assert_eq!(msg["content"], "hi");
assert!(msg.get("timestamp").is_some());
assert!(lines.next().is_none());
}
#[tokio::test]
async fn list_conversations_decodes_and_sorts_keys() {
let (_dir, sink) = temp_sink();
sink.append_turn("telegram:42", turn("user", "a"))
.await
.unwrap();
sink.append_turn("discord:42", turn("user", "b"))
.await
.unwrap();
sink.append_turn("slack:channel:thread", turn("user", "c"))
.await
.unwrap();
let keys = sink.list_conversations().await.unwrap();
assert_eq!(
keys,
vec![
"discord:42".to_string(),
"slack:channel:thread".to_string(),
"telegram:42".to_string(),
],
"keys must be decoded and sorted"
);
}
#[tokio::test]
async fn list_conversations_empty_dir_is_empty() {
let (_dir, sink) = temp_sink();
assert!(sink.list_conversations().await.unwrap().is_empty());
}
#[tokio::test]
async fn load_session_returns_header_and_messages() {
let (_dir, sink) = temp_sink();
sink.set_meta("telegram:7", json!({"hallucination_score": 0.3}))
.await;
sink.append_turn("telegram:7", turn("user", "hi"))
.await
.unwrap();
sink.append_turn("telegram:7", turn("assistant", "hello"))
.await
.unwrap();
let session = sink.load_session("telegram:7").await.unwrap();
assert_eq!(session.key, "telegram:7");
assert_eq!(session.messages.len(), 2);
assert_eq!(session.messages[0]["role"], "user");
assert_eq!(session.messages[0]["content"], "hi");
assert_eq!(session.messages[1]["content"], "hello");
assert_eq!(session.metadata["hallucination_score"], 0.3);
assert_eq!(session.last_consolidated, 0);
}
#[tokio::test]
async fn load_session_missing_is_error() {
let (_dir, sink) = temp_sink();
assert!(sink.load_session("nope:0").await.is_err());
}
#[tokio::test]
async fn delete_removes_file_and_is_idempotent() {
let (_dir, sink) = temp_sink();
sink.append_turn("telegram:9", turn("user", "x"))
.await
.unwrap();
assert_eq!(sink.list_conversations().await.unwrap().len(), 1);
sink.delete("telegram:9").await.unwrap();
assert!(sink.list_conversations().await.unwrap().is_empty());
sink.delete("telegram:9").await.unwrap();
}
#[tokio::test]
async fn delete_rejects_invalid_conv_id() {
let (_dir, sink) = temp_sink();
assert!(sink.delete("../evil").await.is_err());
}
}