update: entry hash chain と session_head mutex を撤廃
- HashedEntry / EntryHash / compute_hash / build_chain 撤去、JSONL は 1 行 1 LogEntry - SessionOrigin.at_hash → at_turn_index (TurnEnd 由来) に置換 - Pod 側 SessionHead mutex を ArcSwap<SessionId> + AtomicUsize の SessionState に置換 - ensure_head_or_fork は store の entry count と writer の append tally で判定 - session-store から sha2 / hex 依存、pod から parking_lot 依存を削除
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
use crate::SessionId;
|
||||
use crate::event_trace::TraceEntry;
|
||||
use crate::session_log::{EntryHash, HashedEntry};
|
||||
use crate::session_log::LogEntry;
|
||||
use crate::store::{Store, StoreError};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
@@ -65,12 +65,12 @@ impl FsStore {
|
||||
}
|
||||
|
||||
impl Store for FsStore {
|
||||
fn append(&self, id: SessionId, entry: &HashedEntry) -> Result<(), StoreError> {
|
||||
fn append(&self, id: SessionId, entry: &LogEntry) -> Result<(), StoreError> {
|
||||
let line = serde_json::to_string(entry)?;
|
||||
self.append_line(&self.log_path(id), &line)
|
||||
}
|
||||
|
||||
fn read_all(&self, id: SessionId) -> Result<Vec<HashedEntry>, StoreError> {
|
||||
fn read_all(&self, id: SessionId) -> Result<Vec<LogEntry>, StoreError> {
|
||||
let path = self.log_path(id);
|
||||
if !path.exists() {
|
||||
return Err(StoreError::NotFound(id));
|
||||
@@ -98,7 +98,7 @@ impl Store for FsStore {
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn create_session(&self, id: SessionId, entries: &[HashedEntry]) -> Result<(), StoreError> {
|
||||
fn create_session(&self, id: SessionId, entries: &[LogEntry]) -> Result<(), StoreError> {
|
||||
let path = self.log_path(id);
|
||||
let mut content = String::new();
|
||||
for entry in entries {
|
||||
@@ -113,24 +113,13 @@ impl Store for FsStore {
|
||||
Ok(self.log_path(id).exists())
|
||||
}
|
||||
|
||||
fn read_head_hash(&self, id: SessionId) -> Result<Option<EntryHash>, StoreError> {
|
||||
fn read_entry_count(&self, id: SessionId) -> Result<usize, StoreError> {
|
||||
let path = self.log_path(id);
|
||||
if !path.exists() {
|
||||
return Err(StoreError::NotFound(id));
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let last_line = content.lines().rev().find(|l| !l.trim().is_empty());
|
||||
match last_line {
|
||||
Some(line) => {
|
||||
let entry: HashedEntry =
|
||||
serde_json::from_str(line).map_err(|e| StoreError::Corrupt {
|
||||
line: content.lines().count(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
Ok(Some(entry.hash))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
Ok(content.lines().filter(|l| !l.trim().is_empty()).count())
|
||||
}
|
||||
|
||||
fn append_trace(&self, id: SessionId, entry: &TraceEntry) -> Result<(), StoreError> {
|
||||
|
||||
@@ -40,15 +40,14 @@ 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, 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,
|
||||
SessionStartState, append_entry, 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,
|
||||
SessionOrigin, build_chain, collect_state, compute_hash,
|
||||
LogEntry, POD_SCOPE_EXTENSION_DOMAIN, PodScopeSnapshot, RestoredState, SessionOrigin,
|
||||
collect_state,
|
||||
};
|
||||
pub use system_item::{SystemItem, render_pod_event};
|
||||
pub use store::{Store, StoreError};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use crate::SessionId;
|
||||
use crate::logged_item::{LoggedItem, to_logged};
|
||||
use crate::session_log::{self, EntryHash, HashedEntry, LogEntry, PodScopeSnapshot, SessionOrigin};
|
||||
use crate::session_log::{self, LogEntry, PodScopeSnapshot, SessionOrigin};
|
||||
use crate::store::{Store, StoreError};
|
||||
use crate::system_item::SystemItem;
|
||||
use llm_worker::WorkerResult;
|
||||
@@ -22,27 +22,25 @@ pub struct SessionStartState<'a> {
|
||||
}
|
||||
|
||||
/// Create a new session, writing the initial `SessionStart` entry.
|
||||
///
|
||||
/// Returns the new session ID and head hash.
|
||||
pub fn create_session(
|
||||
store: &impl Store,
|
||||
state: SessionStartState<'_>,
|
||||
) -> Result<(SessionId, EntryHash), StoreError> {
|
||||
) -> Result<SessionId, StoreError> {
|
||||
let session_id = crate::new_session_id();
|
||||
let hash = create_session_with_id(store, session_id, state)?;
|
||||
Ok((session_id, hash))
|
||||
create_session_with_id(store, session_id, state)?;
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Write a fresh `SessionStart` entry using a pre-generated session ID.
|
||||
///
|
||||
/// 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.
|
||||
/// system prompt only at first turn).
|
||||
pub fn create_session_with_id(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
state: SessionStartState<'_>,
|
||||
) -> Result<EntryHash, StoreError> {
|
||||
) -> Result<(), StoreError> {
|
||||
let entry = LogEntry::SessionStart {
|
||||
ts: session_log::now_millis(),
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
@@ -51,26 +49,20 @@ pub fn create_session_with_id(
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let hash = session_log::compute_hash(None, &entry);
|
||||
let hashed_entry = HashedEntry {
|
||||
hash: hash.clone(),
|
||||
prev_hash: None,
|
||||
entry,
|
||||
};
|
||||
store.append(session_id, &hashed_entry)?;
|
||||
Ok(hash)
|
||||
store.append(session_id, &entry)
|
||||
}
|
||||
|
||||
/// Create a compacted session from an existing one.
|
||||
///
|
||||
/// Records `compacted_from` provenance linking back to the source session.
|
||||
/// Returns the new session ID and head hash.
|
||||
/// Records `compacted_from` provenance linking back to the source session
|
||||
/// at the turn boundary captured by `source_turn_count` (the most recent
|
||||
/// completed turn in the source).
|
||||
pub fn create_compacted_session(
|
||||
store: &impl Store,
|
||||
state: SessionStartState<'_>,
|
||||
source_session_id: SessionId,
|
||||
source_head_hash: EntryHash,
|
||||
) -> Result<(SessionId, EntryHash), StoreError> {
|
||||
source_turn_count: usize,
|
||||
) -> Result<SessionId, StoreError> {
|
||||
let session_id = crate::new_session_id();
|
||||
let entry = LogEntry::SessionStart {
|
||||
ts: session_log::now_millis(),
|
||||
@@ -80,17 +72,11 @@ pub fn create_compacted_session(
|
||||
forked_from: None,
|
||||
compacted_from: Some(SessionOrigin {
|
||||
session_id: source_session_id,
|
||||
at_hash: source_head_hash,
|
||||
at_turn_index: source_turn_count,
|
||||
}),
|
||||
};
|
||||
let hash = session_log::compute_hash(None, &entry);
|
||||
let hashed_entry = HashedEntry {
|
||||
hash: hash.clone(),
|
||||
prev_hash: None,
|
||||
entry,
|
||||
};
|
||||
store.append(session_id, &hashed_entry)?;
|
||||
Ok((session_id, hash))
|
||||
store.append(session_id, &entry)?;
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Restore session state from a stored log.
|
||||
@@ -105,18 +91,18 @@ pub fn restore(
|
||||
Ok(session_log::collect_state(&entries))
|
||||
}
|
||||
|
||||
/// Check if the store's head still matches the expected head hash.
|
||||
/// Check if the store's entry count still matches the writer's tally.
|
||||
/// If not, auto-fork into a new session.
|
||||
///
|
||||
/// Updates `session_id` and `head_hash` in place when a fork occurs.
|
||||
/// Updates `session_id` and `entries_written` in place when a fork occurs.
|
||||
pub fn ensure_head_or_fork(
|
||||
store: &impl Store,
|
||||
session_id: &mut SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
entries_written: &mut usize,
|
||||
state: SessionStartState<'_>,
|
||||
) -> Result<(), StoreError> {
|
||||
let store_head = store.read_head_hash(*session_id)?;
|
||||
if store_head == *head_hash {
|
||||
let store_count = store.read_entry_count(*session_id)?;
|
||||
if store_count == *entries_written {
|
||||
return Ok(());
|
||||
}
|
||||
let fork_id = crate::new_session_id();
|
||||
@@ -128,15 +114,9 @@ pub fn ensure_head_or_fork(
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let hash = session_log::compute_hash(None, &entry);
|
||||
let hashed_entry = HashedEntry {
|
||||
hash: hash.clone(),
|
||||
prev_hash: None,
|
||||
entry,
|
||||
};
|
||||
store.create_session(fork_id, &[hashed_entry])?;
|
||||
store.create_session(fork_id, &[entry])?;
|
||||
*session_id = fork_id;
|
||||
*head_hash = Some(hash);
|
||||
*entries_written = 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -149,13 +129,11 @@ pub fn ensure_head_or_fork(
|
||||
pub fn save_user_input(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
segments: Vec<Segment>,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::UserInput {
|
||||
ts: session_log::now_millis(),
|
||||
segments,
|
||||
@@ -174,7 +152,6 @@ pub fn save_user_input(
|
||||
pub fn save_delta(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
new_items: &[Item],
|
||||
) -> Result<(), StoreError> {
|
||||
if new_items.is_empty() {
|
||||
@@ -188,7 +165,7 @@ pub fn save_delta(
|
||||
continue;
|
||||
}
|
||||
let entry = classify_history_item(item, ts);
|
||||
append_entry(store, session_id, head_hash, entry)?;
|
||||
append_entry(store, session_id, entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -223,13 +200,11 @@ pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
|
||||
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(
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::SystemItem {
|
||||
ts: session_log::now_millis(),
|
||||
item,
|
||||
@@ -241,13 +216,11 @@ pub fn append_system_item(
|
||||
pub fn save_turn_end(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
turn_count: usize,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::TurnEnd {
|
||||
ts: session_log::now_millis(),
|
||||
turn_count,
|
||||
@@ -259,14 +232,12 @@ pub fn save_turn_end(
|
||||
pub fn save_run_completed(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
result: WorkerResult,
|
||||
interrupted: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::RunCompleted {
|
||||
ts: session_log::now_millis(),
|
||||
interrupted,
|
||||
@@ -282,14 +253,12 @@ pub fn save_run_completed(
|
||||
pub fn save_run_errored(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
message: String,
|
||||
interrupted: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::RunErrored {
|
||||
ts: session_log::now_millis(),
|
||||
interrupted,
|
||||
@@ -307,7 +276,6 @@ pub fn save_run_errored(
|
||||
pub fn save_usage(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
history_len: usize,
|
||||
input_total_tokens: u64,
|
||||
cache_read_tokens: u64,
|
||||
@@ -317,7 +285,6 @@ pub fn save_usage(
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::LlmUsage {
|
||||
ts: session_log::now_millis(),
|
||||
history_len,
|
||||
@@ -337,14 +304,12 @@ pub fn save_usage(
|
||||
pub fn save_extension(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
domain: impl Into<String>,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::Extension {
|
||||
ts: session_log::now_millis(),
|
||||
domain: domain.into(),
|
||||
@@ -357,14 +322,12 @@ pub fn save_extension(
|
||||
pub fn save_pod_scope(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
snapshot: &PodScopeSnapshot,
|
||||
) -> Result<(), StoreError> {
|
||||
let payload = serde_json::to_value(snapshot)?;
|
||||
save_extension(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
session_log::POD_SCOPE_EXTENSION_DOMAIN,
|
||||
payload,
|
||||
)
|
||||
@@ -374,13 +337,11 @@ pub fn save_pod_scope(
|
||||
pub fn save_config_changed(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
config: &RequestConfig,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
head_hash,
|
||||
LogEntry::ConfigChanged {
|
||||
ts: session_log::now_millis(),
|
||||
config: config.clone(),
|
||||
@@ -399,28 +360,36 @@ pub fn fork(store: &impl Store, state: SessionStartState<'_>) -> Result<SessionI
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let hash = session_log::compute_hash(None, &entry);
|
||||
let hashed_entry = HashedEntry {
|
||||
hash,
|
||||
prev_hash: None,
|
||||
entry,
|
||||
};
|
||||
store.create_session(fork_id, &[hashed_entry])?;
|
||||
store.create_session(fork_id, &[entry])?;
|
||||
Ok(fork_id)
|
||||
}
|
||||
|
||||
/// Fork from an arbitrary point in a stored session's log.
|
||||
/// Fork from a turn boundary in a stored session's log.
|
||||
///
|
||||
/// `at_turn_index` is the `turn_count` of the most recent completed
|
||||
/// `TurnEnd` in the source segment that the fork should branch from.
|
||||
/// Replay collects state up to and including that `TurnEnd`; entries
|
||||
/// after it are not carried into the new segment.
|
||||
pub fn fork_at(
|
||||
store: &impl Store,
|
||||
source_id: SessionId,
|
||||
at_hash: &EntryHash,
|
||||
at_turn_index: usize,
|
||||
) -> Result<SessionId, StoreError> {
|
||||
let entries = store.read_all(source_id)?;
|
||||
let cut = entries
|
||||
.iter()
|
||||
.position(|e| &e.hash == at_hash)
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(entries.len());
|
||||
let cut = if at_turn_index == 0 {
|
||||
// Branch directly after the SessionStart (or whatever opens the
|
||||
// segment), before any turn completes.
|
||||
entries
|
||||
.iter()
|
||||
.position(|e| !matches!(e, LogEntry::SessionStart { .. }))
|
||||
.unwrap_or(entries.len())
|
||||
} else {
|
||||
entries
|
||||
.iter()
|
||||
.position(|e| matches!(e, LogEntry::TurnEnd { turn_count, .. } if *turn_count == at_turn_index))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(entries.len())
|
||||
};
|
||||
let state = session_log::collect_state(&entries[..cut]);
|
||||
|
||||
let fork_id = crate::new_session_id();
|
||||
@@ -429,23 +398,17 @@ pub fn fork_at(
|
||||
system_prompt: state.system_prompt,
|
||||
config: state.config,
|
||||
history: to_logged(&state.history),
|
||||
forked_from: Some(session_log::SessionOrigin {
|
||||
forked_from: Some(SessionOrigin {
|
||||
session_id: source_id,
|
||||
at_hash: at_hash.clone(),
|
||||
at_turn_index,
|
||||
}),
|
||||
compacted_from: None,
|
||||
};
|
||||
let hash = session_log::compute_hash(None, &entry);
|
||||
let hashed_entry = HashedEntry {
|
||||
hash,
|
||||
prev_hash: None,
|
||||
entry,
|
||||
};
|
||||
store.create_session(fork_id, &[hashed_entry])?;
|
||||
store.create_session(fork_id, &[entry])?;
|
||||
Ok(fork_id)
|
||||
}
|
||||
|
||||
/// Append a single `LogEntry`, chaining the hash and updating `head_hash`.
|
||||
/// Append a single `LogEntry`.
|
||||
///
|
||||
/// Lower-level dual of the `save_*` convenience wrappers in this module.
|
||||
/// Use when the caller already builds the typed entry itself (e.g. when
|
||||
@@ -453,30 +416,7 @@ pub fn fork_at(
|
||||
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)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Same as [`append_entry`] but returns the freshly computed entry hash.
|
||||
///
|
||||
/// Used by paths that need the hash for downstream broadcast or mirror
|
||||
/// updates (e.g. the Pod's `SessionLogSink`).
|
||||
pub fn append_entry_with_hash(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
head_hash: &mut Option<EntryHash>,
|
||||
entry: LogEntry,
|
||||
) -> Result<EntryHash, StoreError> {
|
||||
let hash = session_log::compute_hash(head_hash.as_ref(), &entry);
|
||||
let hashed_entry = HashedEntry {
|
||||
hash: hash.clone(),
|
||||
prev_hash: head_hash.clone(),
|
||||
entry,
|
||||
};
|
||||
store.append(session_id, &hashed_entry)?;
|
||||
*head_hash = Some(hash.clone());
|
||||
Ok(hash)
|
||||
store.append(session_id, &entry)
|
||||
}
|
||||
|
||||
@@ -4,89 +4,18 @@
|
||||
//! serialized as one line in a `.jsonl` file. Reading all entries and
|
||||
//! collecting them via [`collect_state`] reconstructs the full [`Worker`] state.
|
||||
//!
|
||||
//! Entries are chained via [`EntryHash`]: each [`HashedEntry`] records the hash
|
||||
//! of the previous entry, forming a tamper-evident append-only chain. This
|
||||
//! enables safe fork detection when multiple writers share a session.
|
||||
//! The on-disk format is one `LogEntry` per line — entries are positionally
|
||||
//! ordered. Fork lineage references between segments use turn-number indices
|
||||
//! (`SessionOrigin.at_turn_index`) rather than per-entry hashes.
|
||||
|
||||
use llm_worker::llm_client::types::{Item, RequestConfig};
|
||||
use llm_worker::{UsageRecord, WorkerResult};
|
||||
use protocol::{InvokeKind, ScopeRule, Segment};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::logged_item::LoggedItem;
|
||||
use crate::system_item::SystemItem;
|
||||
|
||||
/// SHA-256 hash identifying a specific log entry in the chain.
|
||||
///
|
||||
/// Computed as `sha256(prev_hash_bytes || canonical_json(entry))`.
|
||||
/// Displayed and serialized as a lowercase hex string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct EntryHash([u8; 32]);
|
||||
|
||||
impl EntryHash {
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
|
||||
pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
|
||||
let mut buf = [0u8; 32];
|
||||
hex::decode_to_slice(s, &mut buf)?;
|
||||
Ok(Self(buf))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EntryHash {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for EntryHash {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EntryHash {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::from_hex(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the hash for a log entry given its predecessor's hash.
|
||||
pub fn compute_hash(prev: Option<&EntryHash>, entry: &LogEntry) -> EntryHash {
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
// Feed prev_hash bytes (32 zero bytes if None).
|
||||
match prev {
|
||||
Some(h) => hasher.update(h.as_bytes()),
|
||||
None => hasher.update([0u8; 32]),
|
||||
}
|
||||
|
||||
// Canonical JSON of the entry.
|
||||
let json = serde_json::to_string(entry).expect("LogEntry serialization cannot fail");
|
||||
hasher.update(json.as_bytes());
|
||||
|
||||
EntryHash(hasher.finalize().into())
|
||||
}
|
||||
|
||||
/// A [`LogEntry`] with hash-chain metadata.
|
||||
///
|
||||
/// This is the unit persisted to JSONL — one line per `HashedEntry`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HashedEntry {
|
||||
pub hash: EntryHash,
|
||||
pub prev_hash: Option<EntryHash>,
|
||||
#[serde(flatten)]
|
||||
pub entry: LogEntry,
|
||||
}
|
||||
|
||||
/// A single session log entry, serialized as one JSONL line.
|
||||
///
|
||||
/// Variants correspond to specific mutation points in `Worker`:
|
||||
@@ -110,10 +39,10 @@ pub enum LogEntry {
|
||||
system_prompt: Option<String>,
|
||||
config: RequestConfig,
|
||||
history: Vec<LoggedItem>,
|
||||
/// Origin: forked from another session at a specific entry.
|
||||
/// Origin: forked from another session at a specific turn boundary.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
forked_from: Option<SessionOrigin>,
|
||||
/// Origin: compacted from another session at a specific entry.
|
||||
/// Origin: compacted from another session at a specific turn boundary.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
compacted_from: Option<SessionOrigin>,
|
||||
},
|
||||
@@ -235,13 +164,16 @@ pub enum LogEntry {
|
||||
},
|
||||
}
|
||||
|
||||
/// Provenance reference to a parent session.
|
||||
/// Provenance reference to a parent segment.
|
||||
///
|
||||
/// `at_turn_index` is the `turn_count` value of the most recent
|
||||
/// `TurnEnd` entry preceding the split point in the source segment.
|
||||
/// A value of `0` means the split happened before any turn completed
|
||||
/// (e.g. immediately after `SessionStart`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionOrigin {
|
||||
/// Session ID of the source session.
|
||||
pub session_id: crate::SessionId,
|
||||
/// Hash of the entry in the source session at the point of fork/compact.
|
||||
pub at_hash: EntryHash,
|
||||
pub at_turn_index: usize,
|
||||
}
|
||||
|
||||
/// Domain used by Pod to persist its latest effective runtime scope.
|
||||
@@ -262,8 +194,10 @@ pub struct RestoredState {
|
||||
pub history: Vec<Item>,
|
||||
pub turn_count: usize,
|
||||
pub last_run_interrupted: bool,
|
||||
/// Hash of the last entry in the chain (None if empty).
|
||||
pub head_hash: Option<EntryHash>,
|
||||
/// Number of entries replayed. `0` means the session log was empty.
|
||||
/// Writers track their own append count via the same counter so
|
||||
/// `ensure_head_or_fork` can compare it with the on-disk count.
|
||||
pub entries_count: usize,
|
||||
/// LLM リクエストごとの Usage スナップショット時系列。
|
||||
/// `LogEntry::LlmUsage` を replay して時系列順に積まれる。
|
||||
/// 任意位置のトークン数推定に使う。
|
||||
@@ -283,25 +217,25 @@ pub struct RestoredState {
|
||||
pub user_segments: Vec<Vec<Segment>>,
|
||||
}
|
||||
|
||||
/// Replay a sequence of hashed entries to reconstruct worker state.
|
||||
pub fn collect_state(entries: &[HashedEntry]) -> RestoredState {
|
||||
/// Replay a sequence of log entries to reconstruct worker state.
|
||||
pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
let mut state = RestoredState {
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: Vec::new(),
|
||||
turn_count: 0,
|
||||
last_run_interrupted: false,
|
||||
head_hash: None,
|
||||
entries_count: 0,
|
||||
usage_history: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
pod_scope: None,
|
||||
user_segments: Vec::new(),
|
||||
};
|
||||
|
||||
for hashed in entries {
|
||||
state.head_hash = Some(hashed.hash.clone());
|
||||
for entry in entries {
|
||||
state.entries_count += 1;
|
||||
|
||||
match &hashed.entry {
|
||||
match entry {
|
||||
LogEntry::SessionStart {
|
||||
system_prompt,
|
||||
config,
|
||||
@@ -403,26 +337,6 @@ pub fn now_millis() -> u64 {
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Build a hash chain from plain `LogEntry` values.
|
||||
///
|
||||
/// Useful for tests and for seeding new sessions from a list of entries.
|
||||
pub fn build_chain(entries: &[LogEntry]) -> Vec<HashedEntry> {
|
||||
let mut chain = Vec::with_capacity(entries.len());
|
||||
let mut prev: Option<EntryHash> = None;
|
||||
|
||||
for entry in entries {
|
||||
let hash = compute_hash(prev.as_ref(), entry);
|
||||
chain.push(HashedEntry {
|
||||
hash: hash.clone(),
|
||||
prev_hash: prev,
|
||||
entry: entry.clone(),
|
||||
});
|
||||
prev = Some(hash);
|
||||
}
|
||||
|
||||
chain
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -432,12 +346,12 @@ mod tests {
|
||||
let state = collect_state(&[]);
|
||||
assert!(state.history.is_empty());
|
||||
assert_eq!(state.turn_count, 0);
|
||||
assert!(state.head_hash.is_none());
|
||||
assert_eq!(state.entries_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_session_start_sets_initial_state() {
|
||||
let entries = build_chain(&[LogEntry::SessionStart {
|
||||
let state = collect_state(&[LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
config: RequestConfig::default().with_max_tokens(1024),
|
||||
@@ -445,16 +359,15 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}]);
|
||||
let state = collect_state(&entries);
|
||||
assert_eq!(state.system_prompt.as_deref(), Some("You are helpful."));
|
||||
assert_eq!(state.config.max_tokens, Some(1024));
|
||||
assert_eq!(state.history.len(), 1);
|
||||
assert!(state.head_hash.is_some());
|
||||
assert_eq!(state.entries_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_full_turn() {
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
@@ -481,7 +394,6 @@ mod tests {
|
||||
result: WorkerResult::Finished,
|
||||
},
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
assert_eq!(state.history.len(), 2);
|
||||
assert_eq!(state.turn_count, 1);
|
||||
assert!(!state.last_run_interrupted);
|
||||
@@ -489,7 +401,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_with_tool_calls() {
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
@@ -519,7 +431,6 @@ mod tests {
|
||||
turn_count: 1,
|
||||
},
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
assert_eq!(state.history.len(), 4);
|
||||
assert!(state.history[1].is_tool_call());
|
||||
assert!(state.history[2].is_tool_result());
|
||||
@@ -527,7 +438,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_config_changed() {
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
@@ -541,50 +452,12 @@ mod tests {
|
||||
config: RequestConfig::default().with_temperature(0.5),
|
||||
},
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
assert_eq!(state.config.temperature, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_chain_is_deterministic() {
|
||||
let raw = vec![
|
||||
LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
ts: 2000,
|
||||
segments: vec![Segment::text("Hello")],
|
||||
},
|
||||
];
|
||||
let chain_a = build_chain(&raw);
|
||||
let chain_b = build_chain(&raw);
|
||||
assert_eq!(chain_a[0].hash, chain_b[0].hash);
|
||||
assert_eq!(chain_a[1].hash, chain_b[1].hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_content_produces_different_hash() {
|
||||
let entry_a = LogEntry::UserInput {
|
||||
ts: 1000,
|
||||
segments: vec![Segment::text("Hello")],
|
||||
};
|
||||
let entry_b = LogEntry::UserInput {
|
||||
ts: 1000,
|
||||
segments: vec![Segment::text("World")],
|
||||
};
|
||||
let hash_a = compute_hash(None, &entry_a);
|
||||
let hash_b = compute_hash(None, &entry_b);
|
||||
assert_ne!(hash_a, hash_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_llm_usage_appends_to_usage_history() {
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
@@ -618,7 +491,6 @@ mod tests {
|
||||
output_tokens: 5,
|
||||
},
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
// history は LlmUsage で変化しない
|
||||
assert_eq!(state.history.len(), 2);
|
||||
// usage_history は時系列順
|
||||
@@ -631,8 +503,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_without_llm_usage_keeps_usage_history_empty() {
|
||||
// 既存ログ互換: LlmUsage entry が無くても collect_state は壊れない
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
@@ -646,7 +517,6 @@ mod tests {
|
||||
segments: vec![Segment::text("hi")],
|
||||
},
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
assert!(state.usage_history.is_empty());
|
||||
}
|
||||
|
||||
@@ -704,7 +574,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_invoke_marker_does_not_mutate_state() {
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 0,
|
||||
system_prompt: None,
|
||||
@@ -730,14 +600,13 @@ mod tests {
|
||||
trigger: InvokeKind::Notify,
|
||||
},
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
assert_eq!(state.history.len(), 1);
|
||||
assert_eq!(state.turn_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_extension_collects_domain_payload_pairs() {
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
@@ -762,7 +631,6 @@ mod tests {
|
||||
payload: serde_json::json!({ "x": 1 }),
|
||||
},
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
// 順序保持で全件積まれる。fold は呼び出し側の責務。
|
||||
assert_eq!(state.extensions.len(), 3);
|
||||
assert_eq!(state.extensions[0].0, "memory.extract");
|
||||
@@ -794,22 +662,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_hex_round_trip() {
|
||||
let entry = LogEntry::SessionStart {
|
||||
ts: 1000,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let hash = compute_hash(None, &entry);
|
||||
let hex = hash.to_hex();
|
||||
let parsed = EntryHash::from_hex(&hex).unwrap();
|
||||
assert_eq!(hash, parsed);
|
||||
}
|
||||
|
||||
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`,
|
||||
/// and `collect_state` derives `Item::user_message` from the flattened
|
||||
/// text while preserving the original segments separately. This covers
|
||||
@@ -834,10 +686,10 @@ mod tests {
|
||||
ts: 4242,
|
||||
segments: segments.clone(),
|
||||
};
|
||||
// Hash + JSON round-trip preserves the variant byte-for-byte.
|
||||
// JSON round-trip preserves the variant byte-for-byte.
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
|
||||
let entries = build_chain(&[
|
||||
let state = collect_state(&[
|
||||
LogEntry::SessionStart {
|
||||
ts: 1,
|
||||
system_prompt: None,
|
||||
@@ -848,7 +700,6 @@ mod tests {
|
||||
},
|
||||
parsed,
|
||||
]);
|
||||
let state = collect_state(&entries);
|
||||
// Worker history gets a flattened user_message item.
|
||||
assert_eq!(state.history.len(), 1);
|
||||
match &state.history[0] {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use crate::SessionId;
|
||||
use crate::event_trace::TraceEntry;
|
||||
use crate::session_log::{EntryHash, HashedEntry};
|
||||
use crate::session_log::LogEntry;
|
||||
|
||||
/// Errors from the persistence store.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -35,25 +35,30 @@ pub enum StoreError {
|
||||
/// 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) -> Result<(), StoreError>;
|
||||
/// Append a single log entry to the session 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: SessionId, entry: &LogEntry) -> Result<(), StoreError>;
|
||||
|
||||
/// Read all hashed entries for a session, in order.
|
||||
fn read_all(&self, id: SessionId) -> Result<Vec<HashedEntry>, StoreError>;
|
||||
/// Read all log entries for a session, in order.
|
||||
fn read_all(&self, id: SessionId) -> Result<Vec<LogEntry>, StoreError>;
|
||||
|
||||
/// List all session IDs, most recent first.
|
||||
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError>;
|
||||
|
||||
/// Create a new session with initial entries.
|
||||
fn create_session(&self, id: SessionId, entries: &[HashedEntry]) -> Result<(), StoreError>;
|
||||
fn create_session(&self, id: SessionId, entries: &[LogEntry]) -> Result<(), StoreError>;
|
||||
|
||||
/// Check if a session exists.
|
||||
fn exists(&self, id: SessionId) -> Result<bool, StoreError>;
|
||||
|
||||
/// Read the hash of the last entry in a session (the head).
|
||||
/// Count entries currently stored for a session.
|
||||
///
|
||||
/// Returns `None` if the session is empty.
|
||||
fn read_head_hash(&self, id: SessionId) -> Result<Option<EntryHash>, StoreError>;
|
||||
/// 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: SessionId) -> Result<usize, StoreError>;
|
||||
|
||||
/// Append a trace entry to the debug event trace file.
|
||||
fn append_trace(&self, id: SessionId, entry: &TraceEntry) -> Result<(), StoreError>;
|
||||
|
||||
Reference in New Issue
Block a user