huskies: merge 1236 story Ask "what happened with X" and get a paged, subject-scoped history

This commit is contained in:
Huskies Agent
2026-07-20 14:44:32 +00:00
parent 30ca3ad463
commit c9804ecfdf
18 changed files with 911 additions and 25 deletions
@@ -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,
})
}
+2
View File
@@ -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};
+9 -8
View File
@@ -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::{
+31
View File
@@ -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