1use blake3::Hasher;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Block {
11 pub hash: BlockHash,
13
14 pub prev_hash: BlockHash,
16
17 pub sequence: u64,
19
20 pub timestamp: DateTime<Utc>,
22
23 pub block_type: BlockType,
25
26 pub content: BlockContent,
28
29 pub size_bytes: u32,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct BlockHash(pub [u8; 32]);
36
37impl BlockHash {
38 pub fn compute(data: &[u8]) -> Self {
39 Self(*blake3::hash(data).as_bytes())
40 }
41
42 pub fn zero() -> Self {
43 Self([0u8; 32])
44 }
45
46 pub fn to_hex(&self) -> String {
47 hex::encode(self.0)
48 }
49
50 pub fn from_hex(s: &str) -> Option<Self> {
51 let bytes = hex::decode(s).ok()?;
52 if bytes.len() != 32 {
53 return None;
54 }
55 let mut arr = [0u8; 32];
56 arr.copy_from_slice(&bytes);
57 Some(Self(arr))
58 }
59}
60
61impl std::fmt::Display for BlockHash {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 write!(f, "{}", self.to_hex())
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum BlockType {
71 UserMessage,
72 AssistantMessage,
73 SystemMessage,
74 ToolCall,
75 ToolResult,
76 FileOperation,
77 Decision,
78 SessionBoundary,
79 Error,
80 Checkpoint,
81 Custom,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum BlockContent {
88 Text {
90 text: String,
91 role: Option<String>,
92 tokens: Option<u32>,
93 },
94
95 Tool {
97 tool_name: String,
98 input: serde_json::Value,
99 output: Option<serde_json::Value>,
100 duration_ms: Option<u64>,
101 success: bool,
102 },
103
104 File {
106 path: String,
107 operation: FileOperation,
108 content_hash: Option<BlockHash>,
109 lines: Option<u32>,
110 diff: Option<String>,
111 },
112
113 Decision {
115 decision: String,
116 reasoning: Option<String>,
117 evidence_blocks: Vec<BlockHash>,
118 confidence: Option<f32>,
119 },
120
121 Boundary {
123 boundary_type: BoundaryType,
124 context_tokens_before: u32,
125 context_tokens_after: u32,
126 summary: String,
127 continuation_hint: Option<String>,
128 },
129
130 Error {
132 error_type: String,
133 message: String,
134 resolution: Option<String>,
135 resolved: bool,
136 },
137
138 Checkpoint {
140 active_files: Vec<String>,
141 working_context: String,
142 pending_tasks: Vec<String>,
143 },
144
145 Binary {
147 #[serde(with = "base64_serde")]
148 data: Vec<u8>,
149 mime_type: String,
150 },
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum FileOperation {
156 Create,
157 Read,
158 Update,
159 Delete,
160 Rename,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum BoundaryType {
166 SessionStart,
167 SessionEnd,
168 Compaction,
169 ContextPressure,
170 UserRequested,
171 Checkpoint,
172}
173
174impl Block {
175 pub fn new(
177 prev_hash: BlockHash,
178 sequence: u64,
179 block_type: BlockType,
180 content: BlockContent,
181 ) -> Self {
182 let timestamp = Utc::now();
183 let content_bytes = serde_json::to_vec(&content).unwrap_or_default();
184 let size_bytes = content_bytes.len() as u32;
185
186 let mut hasher = Hasher::new();
188 hasher.update(&prev_hash.0);
189 hasher.update(&sequence.to_le_bytes());
190 hasher.update(timestamp.to_rfc3339().as_bytes());
191 hasher.update(&content_bytes);
192 let hash = BlockHash(*hasher.finalize().as_bytes());
193
194 Self {
195 hash,
196 prev_hash,
197 sequence,
198 timestamp,
199 block_type,
200 content,
201 size_bytes,
202 }
203 }
204
205 pub fn verify(&self) -> bool {
207 let content_bytes = serde_json::to_vec(&self.content).unwrap_or_default();
208
209 let mut hasher = Hasher::new();
210 hasher.update(&self.prev_hash.0);
211 hasher.update(&self.sequence.to_le_bytes());
212 hasher.update(self.timestamp.to_rfc3339().as_bytes());
213 hasher.update(&content_bytes);
214 let computed = BlockHash(*hasher.finalize().as_bytes());
215
216 computed == self.hash
217 }
218
219 pub fn content_summary(&self) -> String {
221 let full = match &self.content {
222 BlockContent::Text { text, role, .. } => {
223 format!("[{}] {}", role.as_deref().unwrap_or("text"), text)
224 }
225 BlockContent::Tool {
226 tool_name, success, ..
227 } => {
228 format!(
229 "tool:{} ({})",
230 tool_name,
231 if *success { "ok" } else { "err" }
232 )
233 }
234 BlockContent::File {
235 path, operation, ..
236 } => {
237 format!("{:?} {}", operation, path)
238 }
239 BlockContent::Decision {
240 decision,
241 confidence,
242 ..
243 } => {
244 format!(
245 "Decision({:.0}%): {}",
246 confidence.unwrap_or(0.0) * 100.0,
247 decision
248 )
249 }
250 BlockContent::Boundary {
251 boundary_type,
252 summary,
253 ..
254 } => {
255 format!("{:?}: {}", boundary_type, summary)
256 }
257 BlockContent::Error {
258 error_type,
259 message,
260 resolved,
261 ..
262 } => {
263 format!(
264 "{}:{} [{}]",
265 error_type,
266 message,
267 if *resolved { "resolved" } else { "open" }
268 )
269 }
270 BlockContent::Checkpoint {
271 working_context, ..
272 } => {
273 format!("Checkpoint: {}", working_context)
274 }
275 BlockContent::Binary { mime_type, data } => {
276 format!("Binary({}, {} bytes)", mime_type, data.len())
277 }
278 };
279 if full.len() > 200 {
281 format!("{}...", &full[..200])
282 } else {
283 full
284 }
285 }
286
287 pub fn extract_text(&self) -> Option<String> {
289 match &self.content {
290 BlockContent::Text { text, .. } => Some(text.clone()),
291 BlockContent::Decision {
292 decision,
293 reasoning,
294 ..
295 } => Some(format!(
296 "{} {}",
297 decision,
298 reasoning.as_deref().unwrap_or("")
299 )),
300 BlockContent::Tool { tool_name, .. } => Some(tool_name.clone()),
301 BlockContent::File { path, .. } => Some(path.clone()),
302 BlockContent::Error { message, .. } => Some(message.clone()),
303 BlockContent::Boundary { summary, .. } => Some(summary.clone()),
304 BlockContent::Checkpoint {
305 working_context, ..
306 } => Some(working_context.clone()),
307 _ => None,
308 }
309 }
310}
311
312mod base64_serde {
314 use base64::{engine::general_purpose::STANDARD, Engine};
315 use serde::{Deserialize, Deserializer, Serializer};
316
317 pub fn serialize<S>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error>
318 where
319 S: Serializer,
320 {
321 serializer.serialize_str(&STANDARD.encode(data))
322 }
323
324 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
325 where
326 D: Deserializer<'de>,
327 {
328 let s = String::deserialize(deserializer)?;
329 STANDARD.decode(&s).map_err(serde::de::Error::custom)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn test_block_creation_and_verify() {
339 let block = Block::new(
340 BlockHash::zero(),
341 0,
342 BlockType::UserMessage,
343 BlockContent::Text {
344 text: "Hello world".to_string(),
345 role: Some("user".to_string()),
346 tokens: Some(3),
347 },
348 );
349
350 assert!(block.verify());
351 assert_eq!(block.sequence, 0);
352 assert_eq!(block.prev_hash, BlockHash::zero());
353 }
354
355 #[test]
356 fn test_block_hash_hex_roundtrip() {
357 let hash = BlockHash::compute(b"test data");
358 let hex = hash.to_hex();
359 let recovered = BlockHash::from_hex(&hex).unwrap();
360 assert_eq!(hash, recovered);
361 }
362
363 #[test]
364 fn test_block_integrity_chain() {
365 let b0 = Block::new(
366 BlockHash::zero(),
367 0,
368 BlockType::UserMessage,
369 BlockContent::Text {
370 text: "First".to_string(),
371 role: None,
372 tokens: None,
373 },
374 );
375
376 let b1 = Block::new(
377 b0.hash,
378 1,
379 BlockType::AssistantMessage,
380 BlockContent::Text {
381 text: "Second".to_string(),
382 role: None,
383 tokens: None,
384 },
385 );
386
387 assert!(b0.verify());
388 assert!(b1.verify());
389 assert_eq!(b1.prev_hash, b0.hash);
390 }
391
392 #[test]
393 fn test_block_serialization() {
394 let block = Block::new(
395 BlockHash::zero(),
396 0,
397 BlockType::Decision,
398 BlockContent::Decision {
399 decision: "Use V3 architecture".to_string(),
400 reasoning: Some("Better persistence".to_string()),
401 evidence_blocks: vec![],
402 confidence: Some(0.95),
403 },
404 );
405
406 let json = serde_json::to_string(&block).unwrap();
407 let recovered: Block = serde_json::from_str(&json).unwrap();
408 assert_eq!(recovered.hash, block.hash);
409 }
410}