huskies: merge 1236 story Ask "what happened with X" and get a paged, subject-scoped history
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
//! Read/write helpers for the `history_log` append-only list in the CRDT document.
|
||||
//!
|
||||
//! Every chat turn, agent run, and pipeline transition is appended as a
|
||||
//! [`HistoryEntryCrdt`][super::super::types::HistoryEntryCrdt] entry, scoped
|
||||
//! to a subject (story, sled, or project). Entries are never updated or
|
||||
//! tombstoned — the list is strictly grow-only, mirroring `event_log.rs`.
|
||||
|
||||
use bft_json_crdt::json_crdt::{JsonValue, *};
|
||||
use bft_json_crdt::op::ROOT_ID;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::state::{apply_and_persist, get_crdt};
|
||||
use super::super::types::HistoryEntryCrdt;
|
||||
|
||||
/// Raw history entry extracted from the CRDT document.
|
||||
pub struct HistoryEntryRaw {
|
||||
/// Monotonic sequence number for the recording sled (0-based).
|
||||
pub event_seq: u64,
|
||||
/// Hex-encoded Ed25519 public key of the sled that wrote this entry.
|
||||
pub sled_id: String,
|
||||
/// Unix timestamp (seconds) when the entry was recorded.
|
||||
pub timestamp: f64,
|
||||
/// Subject kind: `"story"`, `"sled"`, or `"project"`.
|
||||
pub subject_type: String,
|
||||
/// Subject identifier.
|
||||
pub subject_id: String,
|
||||
/// Entry kind: `"pipeline_transition"`, `"chat_turn"`, or `"agent_run"`.
|
||||
pub kind: String,
|
||||
/// Short human-readable summary.
|
||||
pub summary: String,
|
||||
/// JSON-encoded full payload.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Append a new history entry to the CRDT, computing the monotonic `event_seq`
|
||||
/// atomically while the CRDT lock is held. No-ops silently when the CRDT is
|
||||
/// not yet initialised.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn append_history_entry(
|
||||
sled_id: &str,
|
||||
timestamp: f64,
|
||||
subject_type: &str,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
summary: &str,
|
||||
detail: &str,
|
||||
) {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut state) = state_mutex.lock() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Count existing entries for this sled while holding the lock so the seq
|
||||
// is computed and used in the same critical section — no TOCTOU gap.
|
||||
let event_seq = state
|
||||
.crdt
|
||||
.doc
|
||||
.history_log
|
||||
.iter()
|
||||
.filter(|e| matches!(e.sled_id.view(), JsonValue::String(s) if s == sled_id))
|
||||
.count() as f64;
|
||||
|
||||
// Append after the last existing entry so the list stays in insertion order.
|
||||
let total_len = state.crdt.doc.history_log.view().len();
|
||||
let after = if total_len > 0 {
|
||||
super::list_id_at(&state.crdt.doc.history_log, total_len - 1).unwrap_or(ROOT_ID)
|
||||
} else {
|
||||
ROOT_ID
|
||||
};
|
||||
|
||||
let entry: JsonValue = json!({
|
||||
"event_seq": event_seq,
|
||||
"sled_id": sled_id,
|
||||
"timestamp": timestamp,
|
||||
"subject_type": subject_type,
|
||||
"subject_id": subject_id,
|
||||
"kind": kind,
|
||||
"summary": summary,
|
||||
"detail": detail,
|
||||
})
|
||||
.into();
|
||||
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.history_log.insert(after, entry));
|
||||
}
|
||||
|
||||
/// Read all history entries from the CRDT document.
|
||||
///
|
||||
/// Entries with a missing or empty `sled_id` are silently skipped. Order
|
||||
/// reflects CRDT insertion order (RGA list semantics) — callers that need a
|
||||
/// deterministic global order should sort by `(timestamp, sled_id, event_seq)`.
|
||||
pub fn read_all_history_entries() -> Vec<HistoryEntryRaw> {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(state) = state_mutex.lock() else {
|
||||
return Vec::new();
|
||||
};
|
||||
state
|
||||
.crdt
|
||||
.doc
|
||||
.history_log
|
||||
.iter()
|
||||
.filter_map(extract_entry)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert a CRDT history entry to its read-side representation.
|
||||
fn extract_entry(e: &HistoryEntryCrdt) -> Option<HistoryEntryRaw> {
|
||||
let event_seq = match e.event_seq.view() {
|
||||
JsonValue::Number(n) => n as u64,
|
||||
_ => return None,
|
||||
};
|
||||
let sled_id = match e.sled_id.view() {
|
||||
JsonValue::String(s) if !s.is_empty() => s,
|
||||
_ => return None,
|
||||
};
|
||||
let timestamp = match e.timestamp.view() {
|
||||
JsonValue::Number(n) => n,
|
||||
_ => 0.0,
|
||||
};
|
||||
let subject_type = match e.subject_type.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let subject_id = match e.subject_id.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let kind = match e.kind.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let summary = match e.summary.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let detail = match e.detail.view() {
|
||||
JsonValue::String(s) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(HistoryEntryRaw {
|
||||
event_seq,
|
||||
sled_id,
|
||||
timestamp,
|
||||
subject_type,
|
||||
subject_id,
|
||||
kind,
|
||||
summary,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod active_agents;
|
||||
mod agent_throttle;
|
||||
mod event_log;
|
||||
mod gateway_projects;
|
||||
mod history_log;
|
||||
mod llm_sessions;
|
||||
mod merge_jobs;
|
||||
mod test_jobs;
|
||||
@@ -37,6 +38,7 @@ pub use event_log::{
|
||||
pub use gateway_projects::{
|
||||
delete_gateway_project, read_all_gateway_projects, read_gateway_project, write_gateway_project,
|
||||
};
|
||||
pub use history_log::{HistoryEntryRaw, append_history_entry, read_all_history_entries};
|
||||
pub use llm_sessions::{assemble_and_advance_session, read_llm_session, write_llm_session};
|
||||
pub use merge_jobs::{delete_merge_job, read_all_merge_jobs, read_merge_job, write_merge_job};
|
||||
pub use test_jobs::{delete_test_job, read_all_test_jobs, read_test_job, write_test_job};
|
||||
|
||||
@@ -28,14 +28,15 @@ mod write;
|
||||
|
||||
pub use gateway_config::{read_gateway_active_project, write_gateway_active_project};
|
||||
pub use lww_maps::{
|
||||
EventLogEntryRaw, GAP_PIPELINE_EVENT, append_event_log_entry, append_gap_log_entry,
|
||||
assemble_and_advance_session, delete_active_agent, delete_agent_throttle,
|
||||
delete_gateway_project, delete_merge_job, delete_test_job, delete_token_usage,
|
||||
read_active_agent, read_agent_throttle, read_all_active_agents, read_all_agent_throttles,
|
||||
read_all_event_log_entries, read_all_gateway_projects, read_all_merge_jobs, read_all_test_jobs,
|
||||
read_all_token_usage, read_gateway_project, read_llm_session, read_merge_job, read_test_job,
|
||||
read_token_usage, write_active_agent, write_agent_throttle, write_gateway_project,
|
||||
write_llm_session, write_merge_job, write_test_job, write_token_usage,
|
||||
EventLogEntryRaw, GAP_PIPELINE_EVENT, HistoryEntryRaw, append_event_log_entry,
|
||||
append_gap_log_entry, append_history_entry, assemble_and_advance_session, delete_active_agent,
|
||||
delete_agent_throttle, delete_gateway_project, delete_merge_job, delete_test_job,
|
||||
delete_token_usage, read_active_agent, read_agent_throttle, read_all_active_agents,
|
||||
read_all_agent_throttles, read_all_event_log_entries, read_all_gateway_projects,
|
||||
read_all_history_entries, read_all_merge_jobs, read_all_test_jobs, read_all_token_usage,
|
||||
read_gateway_project, read_llm_session, read_merge_job, read_test_job, read_token_usage,
|
||||
write_active_agent, write_agent_throttle, write_gateway_project, write_llm_session,
|
||||
write_merge_job, write_test_job, write_token_usage,
|
||||
};
|
||||
pub use ops::{all_ops_json, apply_remote_op, ops_since, our_vector_clock, subscribe_ops};
|
||||
pub use presence::{
|
||||
|
||||
@@ -51,6 +51,9 @@ pub struct PipelineDoc {
|
||||
pub event_log: ListCrdt<EventLogEntryCrdt>,
|
||||
/// Per-session LLM context state (high-water marks for event log injection).
|
||||
pub llm_sessions: ListCrdt<LlmSessionCrdt>,
|
||||
/// Append-only, subject-scoped log of chat turns, agent runs, and
|
||||
/// pipeline transitions, persisted as CRDT ops (story 1236).
|
||||
pub history_log: ListCrdt<HistoryEntryCrdt>,
|
||||
}
|
||||
|
||||
/// CRDT entry representing a single persisted pipeline stage-transition event.
|
||||
@@ -79,6 +82,34 @@ pub struct EventLogEntryCrdt {
|
||||
pub pipeline_event: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
/// CRDT entry representing a single persisted history entry — a chat turn,
|
||||
/// agent run, or pipeline transition — scoped to a subject (story, sled, or
|
||||
/// project) for the `get_history` / `get_history_entry` MCP tools (story 1236).
|
||||
///
|
||||
/// Entries are append-only, mirroring [`EventLogEntryCrdt`]'s per-sled
|
||||
/// monotonic `event_seq` scheme so `"{sled_id}:{event_seq}"` is a stable,
|
||||
/// re-readable pagination cursor and payload ref.
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
|
||||
pub struct HistoryEntryCrdt {
|
||||
/// Monotonic sequence number for this sled (0, 1, 2, …).
|
||||
pub event_seq: LwwRegisterCrdt<f64>,
|
||||
/// Hex-encoded Ed25519 public key of the sled that recorded this entry.
|
||||
pub sled_id: LwwRegisterCrdt<String>,
|
||||
/// Unix timestamp (seconds) when the entry was recorded.
|
||||
pub timestamp: LwwRegisterCrdt<f64>,
|
||||
/// Subject kind: `"story"`, `"sled"`, or `"project"`.
|
||||
pub subject_type: LwwRegisterCrdt<String>,
|
||||
/// Subject identifier (story ID, sled hex ID, or project name/persona).
|
||||
pub subject_id: LwwRegisterCrdt<String>,
|
||||
/// Entry kind: `"pipeline_transition"`, `"chat_turn"`, or `"agent_run"`.
|
||||
pub kind: LwwRegisterCrdt<String>,
|
||||
/// Short human-readable summary shown in a paged listing.
|
||||
pub summary: LwwRegisterCrdt<String>,
|
||||
/// JSON-encoded full payload returned by `get_history_entry`.
|
||||
pub detail: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
/// CRDT entry tracking an LLM session's event-log injection state.
|
||||
///
|
||||
/// Each session (keyed by `session_id`, typically a Matrix room ID) records the
|
||||
|
||||
Reference in New Issue
Block a user