feat: Session(Segment 群の grouping)を導入

- SessionId 型を新設、各 SegmentStart に session_id を持たせる
- compaction / 内部 fork は同 SessionId を継承、fork() は新 Session を発行
- Store API を (SessionId, SegmentId) ベースに、FsStore layout は
  <root>/<session_id>/<segment_id>.jsonl に
- Store::list_sessions / list_segments(session_id) / lookup_session_of を追加
- restore_by_segment shim を session-store に提供(pod-cli --session で使用)
- SegmentState に SegmentLocation (session_id, segment_id) を保持し ArcSwap で更新
- RestoredState に session_id: Option<SessionId> を追加
- Picker は Session 単位に列挙、leaf segment を解決して resume
This commit is contained in:
2026-05-20 06:17:56 +09:00
parent d2b3c2f53d
commit 5edc4d3b03
18 changed files with 715 additions and 316 deletions
+105 -23
View File
@@ -1,13 +1,16 @@
//! Filesystem-backed JSONL store.
//!
//! Layout:
//! - Segment log: `{root}/{segment_id}.jsonl`
//! - Event trace: `{root}/{segment_id}.trace.jsonl`
//! - Segment log: `{root}/{session_id}/{segment_id}.jsonl`
//! - Event trace: `{root}/{session_id}/{segment_id}.trace.jsonl`
//!
//! The per-Session directory makes `list_segments(session_id)` an O(dir)
//! scan and gives the fork tree a visible grouping in the filesystem.
use crate::SegmentId;
use crate::event_trace::TraceEntry;
use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError};
use crate::{SegmentId, SessionId};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
@@ -30,15 +33,24 @@ impl FsStore {
Ok(Self { root })
}
fn log_path(&self, id: SegmentId) -> PathBuf {
self.root.join(format!("{id}.jsonl"))
fn session_dir(&self, session_id: SessionId) -> PathBuf {
self.root.join(session_id.to_string())
}
fn trace_path(&self, id: SegmentId) -> PathBuf {
self.root.join(format!("{id}.trace.jsonl"))
fn log_path(&self, session_id: SessionId, segment_id: SegmentId) -> PathBuf {
self.session_dir(session_id)
.join(format!("{segment_id}.jsonl"))
}
fn trace_path(&self, session_id: SessionId, segment_id: SegmentId) -> PathBuf {
self.session_dir(session_id)
.join(format!("{segment_id}.trace.jsonl"))
}
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new().create(true).append(true).open(path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
@@ -65,23 +77,56 @@ impl FsStore {
}
impl Store for FsStore {
fn append(&self, id: SegmentId, entry: &LogEntry) -> Result<(), StoreError> {
fn append(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &LogEntry,
) -> Result<(), StoreError> {
let line = serde_json::to_string(entry)?;
self.append_line(&self.log_path(id), &line)
self.append_line(&self.log_path(session_id, segment_id), &line)
}
fn read_all(&self, id: SegmentId) -> Result<Vec<LogEntry>, StoreError> {
let path = self.log_path(id);
fn read_all(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError> {
let path = self.log_path(session_id, segment_id);
if !path.exists() {
return Err(StoreError::NotFound(id));
return Err(StoreError::NotFound(segment_id));
}
let content = fs::read_to_string(&path)?;
Self::parse_jsonl(&content)
}
fn list_segments(&self) -> Result<Vec<SegmentId>, StoreError> {
let mut segments = Vec::new();
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
let mut sessions = Vec::new();
if !self.root.exists() {
return Ok(sessions);
}
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if let Some(name) = entry.file_name().to_str() {
if let Ok(id) = name.parse::<SessionId>() {
sessions.push(id);
}
}
}
sessions.sort_by(|a, b| b.cmp(a));
Ok(sessions)
}
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, StoreError> {
let dir = self.session_dir(session_id);
let mut segments = Vec::new();
if !dir.exists() {
return Ok(segments);
}
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
// Only match .jsonl files, not .trace.jsonl
@@ -98,8 +143,36 @@ impl Store for FsStore {
Ok(segments)
}
fn create_segment(&self, id: SegmentId, entries: &[LogEntry]) -> Result<(), StoreError> {
let path = self.log_path(id);
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError> {
if !self.root.exists() {
return Ok(None);
}
let needle = format!("{segment_id}.jsonl");
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if entry.path().join(&needle).exists()
&& let Some(name) = entry.file_name().to_str()
&& let Ok(id) = name.parse::<SessionId>()
{
return Ok(Some(id));
}
}
Ok(None)
}
fn create_segment(
&self,
session_id: SessionId,
segment_id: SegmentId,
entries: &[LogEntry],
) -> Result<(), StoreError> {
let path = self.log_path(session_id, segment_id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut content = String::new();
for entry in entries {
content.push_str(&serde_json::to_string(entry)?);
@@ -109,21 +182,30 @@ impl Store for FsStore {
Ok(())
}
fn exists(&self, id: SegmentId) -> Result<bool, StoreError> {
Ok(self.log_path(id).exists())
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
Ok(self.log_path(session_id, segment_id).exists())
}
fn read_entry_count(&self, id: SegmentId) -> Result<usize, StoreError> {
let path = self.log_path(id);
fn read_entry_count(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<usize, StoreError> {
let path = self.log_path(session_id, segment_id);
if !path.exists() {
return Err(StoreError::NotFound(id));
return Err(StoreError::NotFound(segment_id));
}
let content = fs::read_to_string(&path)?;
Ok(content.lines().filter(|l| !l.trim().is_empty()).count())
}
fn append_trace(&self, id: SegmentId, entry: &TraceEntry) -> Result<(), StoreError> {
fn append_trace(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &TraceEntry,
) -> Result<(), StoreError> {
let line = serde_json::to_string(entry)?;
self.append_line(&self.trace_path(id), &line)
self.append_line(&self.trace_path(session_id, segment_id), &line)
}
}
+25 -8
View File
@@ -1,10 +1,14 @@
//! Segment persistence via append-only JSONL logs.
//! Session persistence via append-only JSONL logs.
//!
//! # Architecture
//!
//! Sessions are recorded as a sequence of [`LogEntry`] values, one per line
//! in a `.jsonl` file. Reading the log and collecting entries reconstructs
//! the full Worker state — no separate snapshots or checkpoints needed.
//! A [`Session`](SessionId) is a fork-tree of [`Segment`](SegmentId)s
//! belonging to the same logical conversation. Each Segment is recorded
//! as a sequence of [`LogEntry`] values, one per line in a `.jsonl`
//! file. Reading a segment log and collecting entries reconstructs the
//! Worker state at that segment — no separate snapshots or checkpoints
//! needed. Compaction and fork operations mint a fresh Segment within
//! the same Session.
//!
//! This crate provides free functions for persistence operations.
//! The caller (typically Pod) holds the Worker directly and calls these
@@ -19,7 +23,7 @@
//! use session_store::{create_segment, restore, save_delta, FsStore, SegmentStartState};
//!
//! let store = FsStore::new("./sessions")?;
//! let segment_id = create_segment(&store, SegmentStartState {
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
//! system_prompt: None,
//! config: &config,
//! history: &[],
@@ -41,9 +45,10 @@ pub use llm_worker::llm_client::types::{ContentPart, Item, Role};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_history_item,
create_compacted_segment, create_segment, create_segment_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,
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
fork_at, restore, restore_by_segment, 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 segment_log::{
LogEntry, POD_SCOPE_EXTENSION_DOMAIN, PodScopeSnapshot, RestoredState, SegmentOrigin,
@@ -52,9 +57,21 @@ pub use segment_log::{
pub use system_item::{SystemItem, render_pod_event};
pub use store::{Store, StoreError};
/// Session identifier — the fork-tree root. UUID v7 (time-ordered).
///
/// All Segments belonging to the same Session share this ID. Compaction
/// and fork operations create a new Segment within the same Session, so
/// `WHERE session_id = ?` retrieves the full lineage.
pub type SessionId = uuid::Uuid;
/// Segment identifier. UUID v7 (time-ordered, lexicographically sortable).
pub type SegmentId = uuid::Uuid;
/// Generate a new session ID.
pub fn new_session_id() -> SessionId {
uuid::Uuid::now_v7()
}
/// Generate a new segment ID.
pub fn new_segment_id() -> SegmentId {
uuid::Uuid::now_v7()
+84 -26
View File
@@ -4,7 +4,7 @@
//! The caller (typically Pod) holds the Worker directly and calls these
//! functions after state-mutating operations.
use crate::SegmentId;
use crate::{SegmentId, SessionId};
use crate::logged_item::{LoggedItem, to_logged};
use crate::segment_log::{self, LogEntry, PodScopeSnapshot, SegmentOrigin};
use crate::store::{Store, StoreError};
@@ -21,38 +21,43 @@ pub struct SegmentStartState<'a> {
pub history: &'a [Item],
}
/// Create a new segment, writing the initial `SegmentStart` entry.
/// Create a new session + initial segment, writing the initial
/// `SegmentStart` entry. Returns the freshly minted `(session_id, segment_id)`.
pub fn create_segment(
store: &impl Store,
state: SegmentStartState<'_>,
) -> Result<SegmentId, StoreError> {
) -> Result<(SessionId, SegmentId), StoreError> {
let session_id = crate::new_session_id();
let segment_id = crate::new_segment_id();
create_segment_with_id(store, segment_id, state)?;
Ok(segment_id)
create_segment_with_ids(store, session_id, segment_id, state)?;
Ok((session_id, segment_id))
}
/// Write a fresh `SegmentStart` entry using a pre-generated segment ID.
/// Write a fresh `SegmentStart` entry using pre-generated IDs.
///
/// Used by callers that need to reserve a segment ID synchronously but
/// defer the initial log append (e.g. Pod, which resolves a templated
/// system prompt only at first turn).
pub fn create_segment_with_id(
/// Used by callers that need to reserve `(session_id, segment_id)`
/// synchronously but defer the initial log append (e.g. Pod, which
/// resolves a templated system prompt only at first turn).
pub fn create_segment_with_ids(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
state: SegmentStartState<'_>,
) -> Result<(), StoreError> {
let entry = LogEntry::SegmentStart {
ts: segment_log::now_millis(),
session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
forked_from: None,
compacted_from: None,
};
store.append(segment_id, &entry)
store.append(session_id, segment_id, &entry)
}
/// Create a compacted segment from an existing one.
/// Create a compacted segment from an existing one. Inherits the source's
/// `session_id` so the compacted lineage stays within the same Session.
///
/// Records `compacted_from` provenance linking back to the source segment
/// at the turn boundary captured by `source_turn_count` (the most recent
@@ -60,12 +65,14 @@ pub fn create_segment_with_id(
pub fn create_compacted_segment(
store: &impl Store,
state: SegmentStartState<'_>,
source_session_id: SessionId,
source_segment_id: SegmentId,
source_turn_count: usize,
) -> Result<SegmentId, StoreError> {
let segment_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
ts: segment_log::now_millis(),
session_id: source_session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
@@ -75,7 +82,7 @@ pub fn create_compacted_segment(
at_turn_index: source_turn_count,
}),
};
store.append(segment_id, &entry)?;
store.append(source_session_id, segment_id, &entry)?;
Ok(segment_id)
}
@@ -85,36 +92,54 @@ pub fn create_compacted_segment(
/// applying it to a Worker.
pub fn restore(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<crate::segment_log::RestoredState, StoreError> {
let entries = store.read_all(segment_id)?;
let entries = store.read_all(session_id, segment_id)?;
Ok(segment_log::collect_state(&entries))
}
/// Restore segment state when only the segment ID is known. Uses
/// [`Store::lookup_session_of`] to resolve the parent Session.
///
/// Shim for legacy entry points (`pod-cli --session <UUID>` etc.) that
/// receive a Segment ID without a Session ID.
pub fn restore_by_segment(
store: &impl Store,
segment_id: SegmentId,
) -> Result<crate::segment_log::RestoredState, StoreError> {
let session_id = store
.lookup_session_of(segment_id)?
.ok_or(StoreError::NotFound(segment_id))?;
restore(store, session_id, segment_id)
}
/// Check if the store's entry count still matches the writer's tally.
/// If not, auto-fork into a new segment.
/// If not, auto-fork into a new segment within the same Session.
///
/// Updates `segment_id` and `entries_written` in place when a fork occurs.
pub fn ensure_head_or_fork(
store: &impl Store,
session_id: SessionId,
segment_id: &mut SegmentId,
entries_written: &mut usize,
state: SegmentStartState<'_>,
) -> Result<(), StoreError> {
let store_count = store.read_entry_count(*segment_id)?;
let store_count = store.read_entry_count(session_id, *segment_id)?;
if store_count == *entries_written {
return Ok(());
}
let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
ts: segment_log::now_millis(),
session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
forked_from: None,
compacted_from: None,
};
store.create_segment(fork_id, &[entry])?;
store.create_segment(session_id, fork_id, &[entry])?;
*segment_id = fork_id;
*entries_written = 1;
Ok(())
@@ -128,11 +153,13 @@ pub fn ensure_head_or_fork(
/// [`Segment::flatten_to_text`].
pub fn save_user_input(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
segments: Vec<Segment>,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::UserInput {
ts: segment_log::now_millis(),
@@ -151,6 +178,7 @@ pub fn save_user_input(
/// `UserInput` entry.
pub fn save_delta(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
new_items: &[Item],
) -> Result<(), StoreError> {
@@ -165,7 +193,7 @@ pub fn save_delta(
continue;
}
let entry = classify_history_item(item, ts);
append_entry(store, segment_id, entry)?;
append_entry(store, session_id, segment_id, entry)?;
}
Ok(())
}
@@ -199,11 +227,13 @@ pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
/// commit shape used for assistant / tool result entries.
pub fn append_system_item(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
item: SystemItem,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::SystemItem {
ts: segment_log::now_millis(),
@@ -215,11 +245,13 @@ pub fn append_system_item(
/// Log a TurnEnd entry.
pub fn save_turn_end(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
turn_count: usize,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::TurnEnd {
ts: segment_log::now_millis(),
@@ -231,12 +263,14 @@ pub fn save_turn_end(
/// Log a `RunCompleted` entry — `run()` / `resume()` returned `Ok(WorkerResult)`.
pub fn save_run_completed(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
result: WorkerResult,
interrupted: bool,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::RunCompleted {
ts: segment_log::now_millis(),
@@ -252,12 +286,14 @@ pub fn save_run_completed(
/// `to_string()` rendering as `message`.
pub fn save_run_errored(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
message: String,
interrupted: bool,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::RunErrored {
ts: segment_log::now_millis(),
@@ -275,6 +311,7 @@ pub fn save_run_errored(
/// 済ませた値を渡す。
pub fn save_usage(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
history_len: usize,
input_total_tokens: u64,
@@ -284,6 +321,7 @@ pub fn save_usage(
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::LlmUsage {
ts: segment_log::now_millis(),
@@ -303,12 +341,14 @@ pub fn save_usage(
/// Use `RestoredState.extensions` to read entries back at restore time.
pub fn save_extension(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
domain: impl Into<String>,
payload: serde_json::Value,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::Extension {
ts: segment_log::now_millis(),
@@ -321,12 +361,14 @@ pub fn save_extension(
/// Log the Pod's latest runtime scope snapshot.
pub fn save_pod_scope(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
snapshot: &PodScopeSnapshot,
) -> Result<(), StoreError> {
let payload = serde_json::to_value(snapshot)?;
save_extension(
store,
session_id,
segment_id,
segment_log::POD_SCOPE_EXTENSION_DOMAIN,
payload,
@@ -336,11 +378,13 @@ pub fn save_pod_scope(
/// Log a `ConfigChanged` entry.
pub fn save_config_changed(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
config: &RequestConfig,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::ConfigChanged {
ts: segment_log::now_millis(),
@@ -349,22 +393,33 @@ pub fn save_config_changed(
)
}
/// Fork the current state into a new segment.
pub fn fork(store: &impl Store, state: SegmentStartState<'_>) -> Result<SegmentId, StoreError> {
/// Fork the current state into a brand-new Session (no parent lineage).
///
/// Use this for "start a fresh conversation from this state" — the
/// returned segment does not share `session_id` with any prior segment.
/// In-Session forks (live auto-fork / past-turn fork) go through
/// [`fork_at`] or [`ensure_head_or_fork`] instead.
pub fn fork(
store: &impl Store,
state: SegmentStartState<'_>,
) -> Result<(SessionId, SegmentId), StoreError> {
let session_id = crate::new_session_id();
let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
ts: segment_log::now_millis(),
session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
forked_from: None,
compacted_from: None,
};
store.create_segment(fork_id, &[entry])?;
Ok(fork_id)
store.create_segment(session_id, fork_id, &[entry])?;
Ok((session_id, fork_id))
}
/// Fork from a turn boundary in a stored segment log.
/// Fork from a turn boundary in a stored segment log, keeping the new
/// segment in the same Session as `source_id`.
///
/// `at_turn_index` is the `turn_count` of the most recent completed
/// `TurnEnd` in the source segment that the fork should branch from.
@@ -372,10 +427,11 @@ pub fn fork(store: &impl Store, state: SegmentStartState<'_>) -> Result<SegmentI
/// after it are not carried into the new segment.
pub fn fork_at(
store: &impl Store,
source_session_id: SessionId,
source_id: SegmentId,
at_turn_index: usize,
) -> Result<SegmentId, StoreError> {
let entries = store.read_all(source_id)?;
let entries = store.read_all(source_session_id, source_id)?;
let cut = if at_turn_index == 0 {
// Branch directly after the SegmentStart (or whatever opens the
// segment), before any turn completes.
@@ -395,6 +451,7 @@ pub fn fork_at(
let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
ts: segment_log::now_millis(),
session_id: source_session_id,
system_prompt: state.system_prompt,
config: state.config,
history: to_logged(&state.history),
@@ -404,7 +461,7 @@ pub fn fork_at(
}),
compacted_from: None,
};
store.create_segment(fork_id, &[entry])?;
store.create_segment(source_session_id, fork_id, &[entry])?;
Ok(fork_id)
}
@@ -415,8 +472,9 @@ pub fn fork_at(
/// it needs the same value for an in-memory mirror + broadcast).
pub fn append_entry(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
entry: LogEntry,
) -> Result<(), StoreError> {
store.append(segment_id, &entry)
store.append(session_id, segment_id, &entry)
}
+24 -2
View File
@@ -37,13 +37,19 @@ pub enum LogEntry {
/// For forked segments, `history` contains the seed state from the parent.
SegmentStart {
ts: u64,
/// Session this segment belongs to. Compaction / fork inherits
/// the source segment's session_id; only fresh "new conversation"
/// segments mint a new session_id.
session_id: crate::SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedItem>,
/// Origin: forked from another segment at a specific turn boundary.
/// Origin: forked from a sibling segment at a specific turn boundary.
/// The referenced segment is guaranteed to share `session_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SegmentOrigin>,
/// Origin: compacted from another segment at a specific turn boundary.
/// Origin: compacted from a sibling segment at a specific turn boundary.
/// The referenced segment is guaranteed to share `session_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_from: Option<SegmentOrigin>,
},
@@ -190,6 +196,10 @@ pub struct PodScopeSnapshot {
/// State collected from log entries.
#[derive(Debug, Clone)]
pub struct RestoredState {
/// Session the replayed segment belongs to. Sourced from the
/// `SegmentStart` entry; `None` only if the log was empty (in which
/// case `entries_count == 0`).
pub session_id: Option<crate::SessionId>,
pub system_prompt: Option<String>,
pub config: RequestConfig,
pub history: Vec<Item>,
@@ -221,6 +231,7 @@ pub struct RestoredState {
/// Replay a sequence of log entries to reconstruct worker state.
pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
let mut state = RestoredState {
session_id: None,
system_prompt: None,
config: RequestConfig::default(),
history: Vec::new(),
@@ -238,11 +249,13 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
match entry {
LogEntry::SegmentStart {
session_id,
system_prompt,
config,
history,
..
} => {
state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone();
state.config = config.clone();
state.history = history.iter().cloned().map(Item::from).collect();
@@ -354,6 +367,7 @@ mod tests {
fn replay_segment_start_sets_initial_state() {
let state = collect_state(&[LogEntry::SegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: Some("You are helpful.".into()),
config: RequestConfig::default().with_max_tokens(1024),
history: vec![Item::user_message("seed").into()],
@@ -371,6 +385,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
@@ -405,6 +420,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
@@ -442,6 +458,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
@@ -461,6 +478,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
@@ -507,6 +525,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
@@ -578,6 +597,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
@@ -610,6 +630,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
@@ -693,6 +714,7 @@ mod tests {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 1,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
+48 -13
View File
@@ -1,7 +1,8 @@
//! Persistence backend abstraction.
//!
//! [`Store`] defines the sync interface for reading and writing segment logs.
//! Implementations handle the physical storage (filesystem, database, etc.).
//! [`Store`] defines the sync interface for reading and writing segment logs
//! within a [`Session`](crate::SessionId). Implementations handle the
//! physical storage (filesystem, database, etc.).
//!
//! Sync (rather than async) is intentional: a segment log append is a single
//! `< 1 KiB` line on local fs and completes well below a millisecond. Going
@@ -10,9 +11,9 @@
//! drain task. Keeping the store sync lets the worker callback, Pod commit
//! paths, and `PodInterceptor` all share one direct `append_entry` call.
use crate::SegmentId;
use crate::event_trace::TraceEntry;
use crate::segment_log::LogEntry;
use crate::{SegmentId, SessionId};
/// Errors from the persistence store.
#[derive(Debug, thiserror::Error)]
@@ -33,33 +34,67 @@ pub enum StoreError {
/// Sync persistence backend for segment logs.
///
/// All methods take `&self` — implementations should use interior mutability
/// (e.g., append-mode file handles) when needed.
/// (e.g., append-mode file handles) when needed. Most read/write methods
/// take `(SessionId, SegmentId)` so segments can be physically grouped
/// per Session on disk (or per session_id in a DB).
pub trait Store: Send + Sync {
/// Append a single log entry to the segment log.
///
/// One line per call. The kernel orders concurrent `O_APPEND` writes
/// for lines < `PIPE_BUF`, so user-space serialization is unnecessary.
fn append(&self, id: SegmentId, entry: &LogEntry) -> Result<(), StoreError>;
fn append(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &LogEntry,
) -> Result<(), StoreError>;
/// Read all log entries for a segment, in order.
fn read_all(&self, id: SegmentId) -> Result<Vec<LogEntry>, StoreError>;
fn read_all(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError>;
/// List all segment IDs, most recent first.
fn list_segments(&self) -> Result<Vec<SegmentId>, StoreError>;
/// List all session IDs, most recent first.
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError>;
/// Create a new segment with initial entries.
fn create_segment(&self, id: SegmentId, entries: &[LogEntry]) -> Result<(), StoreError>;
/// List segment IDs belonging to `session_id`, most recent first.
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, StoreError>;
/// Look up which session a given segment belongs to. Returns `None`
/// when the segment is not known to any session. Implementations
/// may scan storage; intended for shim entry points that receive a
/// segment ID without its session ID (e.g. legacy `--session <UUID>`).
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError>;
/// Create a new segment within `session_id`, with initial entries.
fn create_segment(
&self,
session_id: SessionId,
segment_id: SegmentId,
entries: &[LogEntry],
) -> Result<(), StoreError>;
/// Check if a segment exists.
fn exists(&self, id: SegmentId) -> Result<bool, StoreError>;
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError>;
/// Count entries currently stored for a segment.
///
/// Used by `ensure_head_or_fork` to detect concurrent writers:
/// if the on-disk count exceeds the writer's own append tally,
/// another process has extended the log.
fn read_entry_count(&self, id: SegmentId) -> Result<usize, StoreError>;
fn read_entry_count(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<usize, StoreError>;
/// Append a trace entry to the debug event trace file.
fn append_trace(&self, id: SegmentId, entry: &TraceEntry) -> Result<(), StoreError>;
fn append_trace(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &TraceEntry,
) -> Result<(), StoreError>;
}