update: 書き込みの不要なasyncを削除

This commit is contained in:
2026-05-14 19:16:48 +09:00
parent 34c89f8739
commit 988495cfea
26 changed files with 615 additions and 688 deletions
+25 -31
View File
@@ -8,9 +8,9 @@ use crate::SessionId;
use crate::event_trace::TraceEntry;
use crate::session_log::{EntryHash, HashedEntry};
use crate::store::{Store, StoreError};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tokio::fs;
use tokio::io::AsyncWriteExt;
/// Filesystem-backed JSONL store.
///
@@ -24,9 +24,9 @@ pub struct FsStore {
impl FsStore {
/// Create a new `FsStore` rooted at the given directory.
/// Creates the directory if it does not exist.
pub async fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
let root = root.into();
fs::create_dir_all(&root).await?;
fs::create_dir_all(&root)?;
Ok(Self { root })
}
@@ -38,15 +38,13 @@ impl FsStore {
self.root.join(format!("{id}.trace.jsonl"))
}
async fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await?;
file.write_all(line.as_bytes()).await?;
file.write_all(b"\n").await?;
file.flush().await?;
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
let mut file = fs::OpenOptions::new().create(true).append(true).open(path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
// Append-mode write is the durability boundary; an explicit
// `sync_all` here would multiply latency by ~10× for no gain
// since the kernel already orders concurrent `O_APPEND` writes.
Ok(())
}
@@ -67,24 +65,24 @@ impl FsStore {
}
impl Store for FsStore {
async fn append(&self, id: SessionId, entry: &HashedEntry) -> Result<(), StoreError> {
fn append(&self, id: SessionId, entry: &HashedEntry) -> Result<(), StoreError> {
let line = serde_json::to_string(entry)?;
self.append_line(&self.log_path(id), &line).await
self.append_line(&self.log_path(id), &line)
}
async fn read_all(&self, id: SessionId) -> Result<Vec<HashedEntry>, StoreError> {
fn read_all(&self, id: SessionId) -> Result<Vec<HashedEntry>, StoreError> {
let path = self.log_path(id);
if !path.exists() {
return Err(StoreError::NotFound(id));
}
let content = fs::read_to_string(&path).await?;
let content = fs::read_to_string(&path)?;
Self::parse_jsonl(&content)
}
async fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
let mut sessions = Vec::new();
let mut dir = fs::read_dir(&self.root).await?;
while let Some(entry) = dir.next_entry().await? {
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
let path = entry.path();
// Only match .jsonl files, not .trace.jsonl
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
@@ -100,31 +98,27 @@ impl Store for FsStore {
Ok(sessions)
}
async fn create_session(
&self,
id: SessionId,
entries: &[HashedEntry],
) -> Result<(), StoreError> {
fn create_session(&self, id: SessionId, entries: &[HashedEntry]) -> Result<(), StoreError> {
let path = self.log_path(id);
let mut content = String::new();
for entry in entries {
content.push_str(&serde_json::to_string(entry)?);
content.push('\n');
}
fs::write(&path, content.as_bytes()).await?;
fs::write(&path, content.as_bytes())?;
Ok(())
}
async fn exists(&self, id: SessionId) -> Result<bool, StoreError> {
fn exists(&self, id: SessionId) -> Result<bool, StoreError> {
Ok(self.log_path(id).exists())
}
async fn read_head_hash(&self, id: SessionId) -> Result<Option<EntryHash>, StoreError> {
fn read_head_hash(&self, id: SessionId) -> Result<Option<EntryHash>, StoreError> {
let path = self.log_path(id);
if !path.exists() {
return Err(StoreError::NotFound(id));
}
let content = fs::read_to_string(&path).await?;
let content = fs::read_to_string(&path)?;
let last_line = content.lines().rev().find(|l| !l.trim().is_empty());
match last_line {
Some(line) => {
@@ -139,8 +133,8 @@ impl Store for FsStore {
}
}
async fn append_trace(&self, id: SessionId, entry: &TraceEntry) -> Result<(), StoreError> {
fn append_trace(&self, id: SessionId, entry: &TraceEntry) -> Result<(), StoreError> {
let line = serde_json::to_string(entry)?;
self.append_line(&self.trace_path(id), &line).await
self.append_line(&self.trace_path(id), &line)
}
}
+5 -4
View File
@@ -40,10 +40,11 @@ pub use llm_worker::UsageRecord;
pub use llm_worker::llm_client::types::{ContentPart, Item, Role};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use session::{
SessionStartState, append_entry, append_entry_with_hash, create_compacted_session,
create_session, create_session_with_id, ensure_head_or_fork, fork, fork_at, restore,
save_config_changed, save_delta, save_extension, save_pod_scope, save_run_completed,
save_run_errored, save_turn_end, save_usage, save_user_input,
SessionStartState, append_entry, append_entry_with_hash, append_system_item,
classify_history_item, create_compacted_session, create_session, create_session_with_id,
ensure_head_or_fork, fork, fork_at, restore, save_config_changed, save_delta, save_extension,
save_pod_scope, save_run_completed, save_run_errored, save_turn_end, save_usage,
save_user_input,
};
pub use session_log::{
EntryHash, HashedEntry, LogEntry, POD_SCOPE_EXTENSION_DOMAIN, PodScopeSnapshot, RestoredState,
+84 -96
View File
@@ -8,6 +8,7 @@ use crate::SessionId;
use crate::logged_item::{LoggedItem, to_logged};
use crate::session_log::{self, EntryHash, HashedEntry, LogEntry, PodScopeSnapshot, SessionOrigin};
use crate::store::{Store, StoreError};
use crate::system_item::SystemItem;
use llm_worker::WorkerResult;
use llm_worker::llm_client::RequestConfig;
use llm_worker::llm_client::types::Item;
@@ -23,12 +24,12 @@ pub struct SessionStartState<'a> {
/// Create a new session, writing the initial `SessionStart` entry.
///
/// Returns the new session ID and head hash.
pub async fn create_session(
pub fn create_session(
store: &impl Store,
state: SessionStartState<'_>,
) -> Result<(SessionId, EntryHash), StoreError> {
let session_id = crate::new_session_id();
let hash = create_session_with_id(store, session_id, state).await?;
let hash = create_session_with_id(store, session_id, state)?;
Ok((session_id, hash))
}
@@ -37,7 +38,7 @@ pub async fn create_session(
/// Used by callers that need to reserve a session ID synchronously but
/// defer the initial log append (e.g. Pod, which resolves a templated
/// system prompt only at first turn). Returns the resulting head hash.
pub async fn create_session_with_id(
pub fn create_session_with_id(
store: &impl Store,
session_id: SessionId,
state: SessionStartState<'_>,
@@ -56,7 +57,7 @@ pub async fn create_session_with_id(
prev_hash: None,
entry,
};
store.append(session_id, &hashed_entry).await?;
store.append(session_id, &hashed_entry)?;
Ok(hash)
}
@@ -64,7 +65,7 @@ pub async fn create_session_with_id(
///
/// Records `compacted_from` provenance linking back to the source session.
/// Returns the new session ID and head hash.
pub async fn create_compacted_session(
pub fn create_compacted_session(
store: &impl Store,
state: SessionStartState<'_>,
source_session_id: SessionId,
@@ -88,7 +89,7 @@ pub async fn create_compacted_session(
prev_hash: None,
entry,
};
store.append(session_id, &hashed_entry).await?;
store.append(session_id, &hashed_entry)?;
Ok((session_id, hash))
}
@@ -96,11 +97,11 @@ pub async fn create_compacted_session(
///
/// Returns the reconstructed state. The caller is responsible for
/// applying it to a Worker.
pub async fn restore(
pub fn restore(
store: &impl Store,
session_id: SessionId,
) -> Result<crate::session_log::RestoredState, StoreError> {
let entries = store.read_all(session_id).await?;
let entries = store.read_all(session_id)?;
Ok(session_log::collect_state(&entries))
}
@@ -108,13 +109,13 @@ pub async fn restore(
/// If not, auto-fork into a new session.
///
/// Updates `session_id` and `head_hash` in place when a fork occurs.
pub async fn ensure_head_or_fork(
pub fn ensure_head_or_fork(
store: &impl Store,
session_id: &mut SessionId,
head_hash: &mut Option<EntryHash>,
state: SessionStartState<'_>,
) -> Result<(), StoreError> {
let store_head = store.read_head_hash(*session_id).await?;
let store_head = store.read_head_hash(*session_id)?;
if store_head == *head_hash {
return Ok(());
}
@@ -133,7 +134,7 @@ pub async fn ensure_head_or_fork(
prev_hash: None,
entry,
};
store.create_session(fork_id, &[hashed_entry]).await?;
store.create_session(fork_id, &[hashed_entry])?;
*session_id = fork_id;
*head_hash = Some(hash);
Ok(())
@@ -145,7 +146,7 @@ pub async fn ensure_head_or_fork(
/// the worker pushes its flattened user message into history; replay
/// derives the worker `Item::user_message` from these segments via
/// [`Segment::flatten_to_text`].
pub async fn save_user_input(
pub fn save_user_input(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -160,17 +161,17 @@ pub async fn save_user_input(
segments,
},
)
.await
}
/// Log the history delta — new items added since the previous snapshot.
///
/// Classifies items into AssistantItems, ToolResults, and HookInjectedItems
/// entries automatically. User messages are skipped because they are
/// persisted upfront via [`save_user_input`] at submit time; the worker
/// pushes a flattened copy into its history that arrives here in
/// `new_items` and would otherwise produce a duplicate `UserInput` entry.
pub async fn save_delta(
/// Classifies items into AssistantItem / ToolResult / HookInjectedItems
/// entries automatically (one entry per item). User messages are skipped
/// because they are persisted upfront via [`save_user_input`] at submit
/// time; the worker pushes a flattened copy into its history that
/// arrives here in `new_items` and would otherwise produce a duplicate
/// `UserInput` entry.
pub fn save_delta(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -181,66 +182,63 @@ pub async fn save_delta(
}
let ts = session_log::now_millis();
let mut i = 0;
while i < new_items.len() {
let item = &new_items[i];
for item in new_items {
if item.is_user_message() {
// Already persisted by save_user_input at submit time.
i += 1;
} else if item.is_tool_result() {
let start = i;
while i < new_items.len() && new_items[i].is_tool_result() {
i += 1;
}
append_entry(
store,
session_id,
head_hash,
LogEntry::ToolResults {
ts,
items: to_logged(&new_items[start..i]),
},
)
.await?;
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
let start = i;
while i < new_items.len()
&& (new_items[i].is_assistant_message()
|| new_items[i].is_tool_call()
|| new_items[i].is_reasoning())
{
i += 1;
}
append_entry(
store,
session_id,
head_hash,
LogEntry::AssistantItems {
ts,
items: to_logged(&new_items[start..i]),
},
)
.await?;
} else {
append_entry(
store,
session_id,
head_hash,
LogEntry::HookInjectedItems {
ts,
items: vec![LoggedItem::from(&new_items[i])],
},
)
.await?;
i += 1;
continue;
}
let entry = classify_history_item(item, ts);
append_entry(store, session_id, head_hash, entry)?;
}
Ok(())
}
/// Map one history item to its singular `LogEntry` form. Used by the
/// fallback `save_delta` path and the controller's worker-callback
/// classifier so write classification lives in one place.
pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
if item.is_tool_result() {
LogEntry::ToolResult {
ts,
item: LoggedItem::from(item),
}
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
LogEntry::AssistantItem {
ts,
item: LoggedItem::from(item),
}
} else {
// Defensive: anything else (future Item kinds) routes through
// AssistantItem rather than getting silently dropped.
LogEntry::AssistantItem {
ts,
item: LoggedItem::from(item),
}
}
}
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
/// for the Pod-side interceptor commit path; mirrors the per-item
/// commit shape used for assistant / tool result entries.
pub fn append_system_item(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
item: SystemItem,
) -> Result<EntryHash, StoreError> {
append_entry_with_hash(
store,
session_id,
head_hash,
LogEntry::SystemItem {
ts: session_log::now_millis(),
item,
},
)
}
/// Log a TurnEnd entry.
pub async fn save_turn_end(
pub fn save_turn_end(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -255,11 +253,10 @@ pub async fn save_turn_end(
turn_count,
},
)
.await
}
/// Log a `RunCompleted` entry — `run()` / `resume()` returned `Ok(WorkerResult)`.
pub async fn save_run_completed(
pub fn save_run_completed(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -276,14 +273,13 @@ pub async fn save_run_completed(
result,
},
)
.await
}
/// Log a `RunErrored` entry — `run()` / `resume()` returned `Err(WorkerError)`.
///
/// `WorkerError` is not `Serialize`, so the caller passes a lossy
/// `to_string()` rendering as `message`.
pub async fn save_run_errored(
pub fn save_run_errored(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -300,7 +296,6 @@ pub async fn save_run_errored(
message,
},
)
.await
}
/// Log an `LlmUsage` entry — 1 LLM リクエスト分の Usage スナップショット。
@@ -309,7 +304,7 @@ pub async fn save_run_errored(
/// その prefix をプロバイダが実測した占有量(プロンプト全長)で、
/// プロバイダ別の正規化(Anthropic では `input + cache_read + cache_creation`)を
/// 済ませた値を渡す。
pub async fn save_usage(
pub fn save_usage(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -332,7 +327,6 @@ pub async fn save_usage(
output_tokens,
},
)
.await
}
/// Log an `Extension` entry — domain-tagged opaque payload.
@@ -340,7 +334,7 @@ pub async fn save_usage(
/// session-store treats `payload` as an unstructured `serde_json::Value`.
/// Each domain is responsible for serializing into and folding out of it.
/// Use `RestoredState.extensions` to read entries back at restore time.
pub async fn save_extension(
pub fn save_extension(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -357,11 +351,10 @@ pub async fn save_extension(
payload,
},
)
.await
}
/// Log the Pod's latest runtime scope snapshot.
pub async fn save_pod_scope(
pub fn save_pod_scope(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -375,11 +368,10 @@ pub async fn save_pod_scope(
session_log::POD_SCOPE_EXTENSION_DOMAIN,
payload,
)
.await
}
/// Log a `ConfigChanged` entry.
pub async fn save_config_changed(
pub fn save_config_changed(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -394,14 +386,10 @@ pub async fn save_config_changed(
config: config.clone(),
},
)
.await
}
/// Fork the current state into a new session.
pub async fn fork(
store: &impl Store,
state: SessionStartState<'_>,
) -> Result<SessionId, StoreError> {
pub fn fork(store: &impl Store, state: SessionStartState<'_>) -> Result<SessionId, StoreError> {
let fork_id = crate::new_session_id();
let entry = LogEntry::SessionStart {
ts: session_log::now_millis(),
@@ -417,17 +405,17 @@ pub async fn fork(
prev_hash: None,
entry,
};
store.create_session(fork_id, &[hashed_entry]).await?;
store.create_session(fork_id, &[hashed_entry])?;
Ok(fork_id)
}
/// Fork from an arbitrary point in a stored session's log.
pub async fn fork_at(
pub fn fork_at(
store: &impl Store,
source_id: SessionId,
at_hash: &EntryHash,
) -> Result<SessionId, StoreError> {
let entries = store.read_all(source_id).await?;
let entries = store.read_all(source_id)?;
let cut = entries
.iter()
.position(|e| &e.hash == at_hash)
@@ -453,7 +441,7 @@ pub async fn fork_at(
prev_hash: None,
entry,
};
store.create_session(fork_id, &[hashed_entry]).await?;
store.create_session(fork_id, &[hashed_entry])?;
Ok(fork_id)
}
@@ -462,13 +450,13 @@ pub async fn fork_at(
/// Lower-level dual of the `save_*` convenience wrappers in this module.
/// Use when the caller already builds the typed entry itself (e.g. when
/// it needs the same value for an in-memory mirror + broadcast).
pub async fn append_entry(
pub fn append_entry(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
entry: LogEntry,
) -> Result<(), StoreError> {
append_entry_with_hash(store, session_id, head_hash, entry).await?;
append_entry_with_hash(store, session_id, head_hash, entry)?;
Ok(())
}
@@ -476,7 +464,7 @@ pub async fn append_entry(
///
/// Used by paths that need the hash for downstream broadcast or mirror
/// updates (e.g. the Pod's `SessionLogSink`).
pub async fn append_entry_with_hash(
pub fn append_entry_with_hash(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
@@ -488,7 +476,7 @@ pub async fn append_entry_with_hash(
prev_hash: head_hash.clone(),
entry,
};
store.append(session_id, &hashed_entry).await?;
store.append(session_id, &hashed_entry)?;
*head_hash = Some(hash.clone());
Ok(hash)
}
+34 -12
View File
@@ -120,24 +120,37 @@ pub enum LogEntry {
/// history; the worker layer never sees segments directly.
UserInput { ts: u64, segments: Vec<Segment> },
/// Assistant response items added to history (worker.rs:1040-1041).
/// One assistant-side item appended to history — assistant message,
/// reasoning, or tool call. Singular: one entry per history item so
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
AssistantItem { ts: u64, item: LoggedItem },
/// One tool-execution result appended to history.
ToolResult { ts: u64, item: LoggedItem },
/// One typed agent-injected system item: notification, child-Pod
/// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution
/// payload. Each `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the
/// item's denormalised `body`), but live clients and replay paths
/// dispatch on `kind` for typed rendering.
SystemItem { ts: u64, item: SystemItem },
/// Legacy plural form: kept **read-only** so old session logs still
/// open. New writes always use the singular `AssistantItem`. Items
/// are flattened on replay.
AssistantItems { ts: u64, items: Vec<LoggedItem> },
/// Tool execution results added to history (worker.rs:897-900, 1072-1076).
/// Legacy plural form: kept **read-only**. New writes use the
/// singular `ToolResult`.
ToolResults { ts: u64, items: Vec<LoggedItem> },
/// Typed agent-injected system items: notifications, child-Pod
/// lifecycle events, `@<path>` / `#<slug>` / `/<slug>` resolution
/// payloads. Each `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the
/// item's `history_text()`), but live clients and replay paths
/// dispatch on `kind` for typed rendering.
/// Legacy plural form: kept **read-only**. New writes use the
/// singular `SystemItem`.
SystemItems { ts: u64, items: Vec<SystemItem> },
/// Legacy pre-`SystemItems` form. Deserialize-only — new writes
/// always use `SystemItems`. Items are flattened to
/// `Item::system_message` on replay, matching how the original
/// path worked.
/// Legacy pre-`SystemItem*` form. Deserialize-only. Items are
/// flattened to `Item::system_message` on replay.
HookInjectedItems { ts: u64, items: Vec<LoggedItem> },
/// Turn boundary. Records the turn count after increment.
@@ -282,6 +295,15 @@ pub fn collect_state(entries: &[HashedEntry]) -> RestoredState {
state.history.push(Item::user_message(text));
state.user_segments.push(segments.clone());
}
LogEntry::AssistantItem { item, .. } => {
state.history.push(Item::from(item.clone()));
}
LogEntry::ToolResult { item, .. } => {
state.history.push(Item::from(item.clone()));
}
LogEntry::SystemItem { item, .. } => {
state.history.push(item.to_history_item());
}
LogEntry::AssistantItems { items, .. } => {
state.history.extend(items.iter().cloned().map(Item::from));
}
+16 -28
View File
@@ -1,12 +1,18 @@
//! Persistence backend abstraction.
//!
//! [`Store`] defines the async interface for reading and writing session logs.
//! [`Store`] defines the sync interface for reading and writing session logs.
//! Implementations handle the physical storage (filesystem, database, etc.).
//!
//! Sync (rather than async) is intentional: a session log append is a single
//! `< 1 KiB` line on local fs and completes well below a millisecond. Going
//! through `tokio::fs` would force every caller — including `Worker`'s sync
//! `on_history_append` callback — to bridge sync → async via a channel +
//! drain task. Keeping the store sync lets the worker callback, Pod commit
//! paths, and `PodInterceptor` all share one direct `append_entry` call.
use crate::SessionId;
use crate::event_trace::TraceEntry;
use crate::session_log::{EntryHash, HashedEntry};
use std::future::Future;
/// Errors from the persistence store.
#[derive(Debug, thiserror::Error)]
@@ -24,49 +30,31 @@ pub enum StoreError {
Corrupt { line: usize, message: String },
}
/// Async persistence backend for session logs.
/// Sync persistence backend for session logs.
///
/// All methods take `&self` — implementations should use interior mutability
/// (e.g., append-mode file handles) when needed.
pub trait Store: Send + Sync {
/// Append a single hashed entry to the session log.
fn append(
&self,
id: SessionId,
entry: &HashedEntry,
) -> impl Future<Output = Result<(), StoreError>> + Send;
fn append(&self, id: SessionId, entry: &HashedEntry) -> Result<(), StoreError>;
/// Read all hashed entries for a session, in order.
fn read_all(
&self,
id: SessionId,
) -> impl Future<Output = Result<Vec<HashedEntry>, StoreError>> + Send;
fn read_all(&self, id: SessionId) -> Result<Vec<HashedEntry>, StoreError>;
/// List all session IDs, most recent first.
fn list_sessions(&self) -> impl Future<Output = Result<Vec<SessionId>, StoreError>> + Send;
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError>;
/// Create a new session with initial entries.
fn create_session(
&self,
id: SessionId,
entries: &[HashedEntry],
) -> impl Future<Output = Result<(), StoreError>> + Send;
fn create_session(&self, id: SessionId, entries: &[HashedEntry]) -> Result<(), StoreError>;
/// Check if a session exists.
fn exists(&self, id: SessionId) -> impl Future<Output = Result<bool, StoreError>> + Send;
fn exists(&self, id: SessionId) -> Result<bool, StoreError>;
/// Read the hash of the last entry in a session (the head).
///
/// Returns `None` if the session is empty.
fn read_head_hash(
&self,
id: SessionId,
) -> impl Future<Output = Result<Option<EntryHash>, StoreError>> + Send;
fn read_head_hash(&self, id: SessionId) -> Result<Option<EntryHash>, StoreError>;
/// Append a trace entry to the debug event trace file.
fn append_trace(
&self,
id: SessionId,
entry: &TraceEntry,
) -> impl Future<Output = Result<(), StoreError>> + Send;
fn append_trace(&self, id: SessionId, entry: &TraceEntry) -> Result<(), StoreError>;
}
+40 -40
View File
@@ -4,10 +4,10 @@ use session_store::{
FsStore, LogEntry, Store, TraceEntry, build_chain, collect_state, new_session_id,
};
#[tokio::test]
async fn round_trip_write_and_read() {
#[test]
fn round_trip_write_and_read() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
let id = new_session_id();
let raw = vec![
@@ -23,9 +23,9 @@ async fn round_trip_write_and_read() {
ts: 2000,
segments: vec![protocol::Segment::text("Hello")],
},
LogEntry::AssistantItems {
LogEntry::AssistantItem {
ts: 3000,
items: vec![Item::assistant_message("Hi there!").into()],
item: Item::assistant_message("Hi there!").into(),
},
LogEntry::TurnEnd {
ts: 3100,
@@ -41,11 +41,11 @@ async fn round_trip_write_and_read() {
// Write entries one by one
for entry in &entries {
store.append(id, entry).await.unwrap();
store.append(id, entry).unwrap();
}
// Read back
let read_back = store.read_all(id).await.unwrap();
let read_back = store.read_all(id).unwrap();
assert_eq!(read_back.len(), entries.len());
// Verify hashes survived round-trip
@@ -64,10 +64,10 @@ async fn round_trip_write_and_read() {
assert!(state.head_hash.is_some());
}
#[tokio::test]
async fn create_session_writes_all_entries() {
#[test]
fn create_session_writes_all_entries() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
let id = new_session_id();
let entries = build_chain(&[LogEntry::SessionStart {
@@ -82,22 +82,22 @@ async fn create_session_writes_all_entries() {
compacted_from: None,
}]);
store.create_session(id, &entries).await.unwrap();
let read_back = store.read_all(id).await.unwrap();
store.create_session(id, &entries).unwrap();
let read_back = store.read_all(id).unwrap();
assert_eq!(read_back.len(), 1);
let state = collect_state(&read_back);
assert_eq!(state.history.len(), 2);
}
#[tokio::test]
async fn list_sessions_returns_newest_first() {
#[test]
fn list_sessions_returns_newest_first() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
let id1 = new_session_id();
// Small delay to ensure different UUID v7 timestamps
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
std::thread::sleep(std::time::Duration::from_millis(2));
let id2 = new_session_id();
let entries1 = build_chain(&[LogEntry::SessionStart {
@@ -117,22 +117,22 @@ async fn list_sessions_returns_newest_first() {
compacted_from: None,
}]);
store.append(id1, &entries1[0]).await.unwrap();
store.append(id2, &entries2[0]).await.unwrap();
store.append(id1, &entries1[0]).unwrap();
store.append(id2, &entries2[0]).unwrap();
let sessions = store.list_sessions().await.unwrap();
let sessions = store.list_sessions().unwrap();
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0], id2); // newest first
assert_eq!(sessions[1], id1);
}
#[tokio::test]
async fn exists_returns_correct_state() {
#[test]
fn exists_returns_correct_state() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
let id = new_session_id();
assert!(!store.exists(id).await.unwrap());
assert!(!store.exists(id).unwrap());
let entries = build_chain(&[LogEntry::SessionStart {
ts: 1000,
@@ -142,25 +142,25 @@ async fn exists_returns_correct_state() {
forked_from: None,
compacted_from: None,
}]);
store.append(id, &entries[0]).await.unwrap();
store.append(id, &entries[0]).unwrap();
assert!(store.exists(id).await.unwrap());
assert!(store.exists(id).unwrap());
}
#[tokio::test]
async fn not_found_error_for_missing_session() {
#[test]
fn not_found_error_for_missing_session() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
let id = new_session_id();
let result = store.read_all(id).await;
let result = store.read_all(id);
assert!(result.is_err());
}
#[tokio::test]
async fn trace_entries_in_separate_file() {
#[test]
fn trace_entries_in_separate_file() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
let id = new_session_id();
// Write a log entry
@@ -172,7 +172,7 @@ async fn trace_entries_in_separate_file() {
forked_from: None,
compacted_from: None,
}]);
store.append(id, &entries[0]).await.unwrap();
store.append(id, &entries[0]).unwrap();
// Write a trace entry
let trace = TraceEntry {
@@ -182,10 +182,10 @@ async fn trace_entries_in_separate_file() {
llm_worker::llm_client::event::PingEvent { timestamp: None },
),
};
store.append_trace(id, &trace).await.unwrap();
store.append_trace(id, &trace).unwrap();
// Log should have 1 entry, unaffected by trace
let log = store.read_all(id).await.unwrap();
let log = store.read_all(id).unwrap();
assert_eq!(log.len(), 1);
// Trace file should exist separately
@@ -193,10 +193,10 @@ async fn trace_entries_in_separate_file() {
assert!(trace_path.exists());
}
#[tokio::test]
async fn read_head_hash_returns_last_entry_hash() {
#[test]
fn read_head_hash_returns_last_entry_hash() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
let id = new_session_id();
let entries = build_chain(&[
@@ -215,9 +215,9 @@ async fn read_head_hash_returns_last_entry_hash() {
]);
for entry in &entries {
store.append(id, entry).await.unwrap();
store.append(id, entry).unwrap();
}
let head = store.read_head_hash(id).await.unwrap();
let head = store.read_head_hash(id).unwrap();
assert_eq!(head.as_ref(), Some(&entries[1].hash));
}
+44 -44
View File
@@ -84,9 +84,9 @@ impl Interceptor for PausePolicy {
}
}
async fn make_store() -> (tempfile::TempDir, FsStore) {
fn make_store() -> (tempfile::TempDir, FsStore) {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).await.unwrap();
let store = FsStore::new(dir.path()).unwrap();
(dir, store)
}
@@ -108,7 +108,7 @@ async fn run_and_persist(
head_hash,
vec![protocol::Segment::text(input)],
)
.await
.unwrap();
let history_before = worker.history().len();
@@ -119,10 +119,10 @@ async fn run_and_persist(
let new_items = &worker.history()[history_before..];
session_store::save_delta(store, session_id, head_hash, new_items)
.await
.unwrap();
session_store::save_turn_end(store, session_id, head_hash, worker.turn_count())
.await
.unwrap();
match &result {
@@ -134,7 +134,7 @@ async fn run_and_persist(
r.clone(),
worker.last_run_interrupted(),
)
.await
.unwrap();
}
Err(e) => {
@@ -145,7 +145,7 @@ async fn run_and_persist(
e.to_string(),
worker.last_run_interrupted(),
)
.await
.unwrap();
}
}
@@ -160,7 +160,7 @@ async fn run_and_persist(
#[tokio::test]
async fn session_run_logs_entries() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let worker = Worker::new(client);
@@ -172,14 +172,14 @@ async fn session_run_logs_entries() {
history: worker.history(),
},
)
.await
.unwrap();
let mut head_hash = Some(head_hash);
let (worker, _) = run_and_persist(worker, &store, sid, &mut head_hash, "Hi").await;
let _ = &worker;
let entries = store.read_all(sid).await.unwrap();
let entries = store.read_all(sid).unwrap();
// SessionStart, UserInput, AssistantItems, TurnEnd, RunCompleted (at minimum)
assert!(
@@ -217,7 +217,7 @@ async fn session_run_logs_entries() {
#[tokio::test]
async fn session_restore_round_trip() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let mut worker = Worker::new(client);
worker.set_system_prompt("You are helpful.");
@@ -230,7 +230,7 @@ async fn session_restore_round_trip() {
history: worker.history(),
},
)
.await
.unwrap();
let mut head_hash = Some(head_hash);
@@ -240,7 +240,7 @@ async fn session_restore_round_trip() {
let original_turn_count = worker.turn_count();
// Restore
let state = session_store::restore(&store, sid).await.unwrap();
let state = session_store::restore(&store, sid).unwrap();
assert_eq!(state.history.len(), original_history_len);
assert_eq!(state.turn_count, original_turn_count);
@@ -250,7 +250,7 @@ async fn session_restore_round_trip() {
#[tokio::test]
async fn session_run_with_tool_call() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = Worker::new(client);
worker.register_tool(weather_tool_definition());
@@ -263,29 +263,29 @@ async fn session_run_with_tool_call() {
history: worker.history(),
},
)
.await
.unwrap();
let mut head_hash = Some(head_hash);
let (_worker, _) =
run_and_persist(worker, &store, sid, &mut head_hash, "What's the weather?").await;
let entries = store.read_all(sid).await.unwrap();
let entries = store.read_all(sid).unwrap();
let has_tool_results = entries
.iter()
.any(|e| matches!(&e.entry, LogEntry::ToolResults { .. }));
assert!(has_tool_results, "should have ToolResults entry");
.any(|e| matches!(&e.entry, LogEntry::ToolResult { .. }));
assert!(has_tool_results, "should have ToolResult entry");
let has_assistant = entries
.iter()
.any(|e| matches!(&e.entry, LogEntry::AssistantItems { .. }));
assert!(has_assistant, "should have AssistantItems entry");
.any(|e| matches!(&e.entry, LogEntry::AssistantItem { .. }));
assert!(has_assistant, "should have AssistantItem entry");
}
#[tokio::test]
async fn session_resume_after_pause() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
// First run: tool call with pause policy → Paused
let client = MockLlmClient::with_responses(tool_call_events());
@@ -301,7 +301,7 @@ async fn session_resume_after_pause() {
history: worker.history(),
},
)
.await
.unwrap();
let mut head_hash = Some(head_hash);
@@ -309,7 +309,7 @@ async fn session_resume_after_pause() {
assert!(matches!(result, llm_worker::WorkerResult::Paused));
// Check RunCompleted is Paused
let entries = store.read_all(sid).await.unwrap();
let entries = store.read_all(sid).unwrap();
let has_paused = entries.iter().any(|e| {
matches!(
&e.entry,
@@ -322,13 +322,13 @@ async fn session_resume_after_pause() {
assert!(has_paused, "should have Paused outcome");
// Restore state and verify
let state = session_store::restore(&store, sid).await.unwrap();
let state = session_store::restore(&store, sid).unwrap();
assert!(state.last_run_interrupted);
}
#[tokio::test]
async fn session_fork_preserves_state() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let mut worker = Worker::new(client);
worker.set_system_prompt("System prompt");
@@ -341,7 +341,7 @@ async fn session_fork_preserves_state() {
history: worker.history(),
},
)
.await
.unwrap();
let mut head_hash = Some(head_hash);
@@ -356,11 +356,11 @@ async fn session_fork_preserves_state() {
history: worker.history(),
},
)
.await
.unwrap();
// Fork should have a SessionStart with the current history
let fork_entries = store.read_all(fork_id).await.unwrap();
let fork_entries = store.read_all(fork_id).unwrap();
assert_eq!(fork_entries.len(), 1);
assert!(matches!(
&fork_entries[0].entry,
@@ -374,7 +374,7 @@ async fn session_fork_preserves_state() {
#[tokio::test]
async fn session_fork_at_truncates() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let worker = Worker::new(client);
@@ -386,20 +386,20 @@ async fn session_fork_at_truncates() {
history: worker.history(),
},
)
.await
.unwrap();
let mut head_hash = Some(head_hash);
let (_worker, _) = run_and_persist(worker, &store, sid, &mut head_hash, "Hello").await;
let all_entries = store.read_all(sid).await.unwrap();
let all_entries = store.read_all(sid).unwrap();
assert!(all_entries.len() > 2);
// Fork at the hash of the 2nd entry (SessionStart + UserInput)
let at_hash = &all_entries[1].hash;
let fork_id = session_store::fork_at(&store, sid, at_hash).await.unwrap();
let fork_id = session_store::fork_at(&store, sid, at_hash).unwrap();
let fork_entries = store.read_all(fork_id).await.unwrap();
let fork_entries = store.read_all(fork_id).unwrap();
assert_eq!(fork_entries.len(), 1); // Just the new SessionStart
let fork_state = collect_state(&fork_entries);
@@ -413,7 +413,7 @@ async fn session_fork_at_truncates() {
#[tokio::test]
async fn session_config_changed_logged() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
let client = MockLlmClient::new(vec![]);
let mut worker = Worker::new(client);
@@ -425,7 +425,7 @@ async fn session_config_changed_logged() {
history: worker.history(),
},
)
.await
.unwrap();
let mut head_hash = Some(head_hash);
@@ -433,10 +433,10 @@ async fn session_config_changed_logged() {
let new_config = RequestConfig::default().with_temperature(0.7);
worker.set_request_config(new_config.clone());
session_store::save_config_changed(&store, sid, &mut head_hash, &new_config)
.await
.unwrap();
let entries = store.read_all(sid).await.unwrap();
let entries = store.read_all(sid).unwrap();
let has_config_changed = entries.iter().any(|e| {
matches!(
&e.entry,
@@ -448,7 +448,7 @@ async fn session_config_changed_logged() {
#[tokio::test]
async fn session_auto_forks_on_conflict() {
let (_dir, store) = make_store().await;
let (_dir, store) = make_store();
// Create a session
let client_a = MockLlmClient::new(simple_text_events());
@@ -462,7 +462,7 @@ async fn session_auto_forks_on_conflict() {
history: worker_a.history(),
},
)
.await
.unwrap();
let mut session_id = original_sid;
let mut head_hash = Some(head_hash);
@@ -472,14 +472,14 @@ async fn session_auto_forks_on_conflict() {
ts: 9999,
segments: vec![protocol::Segment::text("Interloper")],
};
let current_head = store.read_head_hash(original_sid).await.unwrap();
let current_head = store.read_head_hash(original_sid).unwrap();
let hash = session_store::compute_hash(current_head.as_ref(), &extra_entry);
let hashed = session_store::HashedEntry {
hash,
prev_hash: current_head,
entry: extra_entry,
};
store.append(original_sid, &hashed).await.unwrap();
store.append(original_sid, &hashed).unwrap();
// Now head_hash is stale — ensure_head_or_fork should auto-fork
session_store::ensure_head_or_fork(
@@ -492,18 +492,18 @@ async fn session_auto_forks_on_conflict() {
history: worker_a.history(),
},
)
.await
.unwrap();
// session_id should now be different
assert_ne!(session_id, original_sid);
// The fork session should exist and have entries
let fork_entries = store.read_all(session_id).await.unwrap();
let fork_entries = store.read_all(session_id).unwrap();
assert!(!fork_entries.is_empty());
// Original session should still have the interloper entry
let original_entries = store.read_all(original_sid).await.unwrap();
let original_entries = store.read_all(original_sid).unwrap();
let has_interloper = original_entries
.iter()
.any(|e| matches!(&e.entry, LogEntry::UserInput { .. }));