update: 書き込みの不要なasyncを削除
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 { .. }));
|
||||
|
||||
Reference in New Issue
Block a user