1use crate::contract::backend::AgentStatus;
19use crate::contract::event::{AgentEvent, EventSender};
20
21use crate::contract::finding::Finding;
22use crate::contract::ids::{AgentId, PhaseId, RunId, TokenUsage};
23use crate::scheduler::{BackendRegistry, SchedulerConfig};
24use crate::session::{resolve_session, restore_session};
25use crate::state::{AgentResultCache, AgentSessionCheckpoint, CheckpointBackend, RunCheckpoint};
26use blake3::Hasher;
27use chrono::Utc;
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::path::Path;
31use std::sync::{Arc, RwLock};
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33use thiserror::Error;
34
35#[derive(Error, Debug)]
40pub enum JournalError {
41 #[error("run not found: {0}")]
42 RunNotFound(RunId),
43 #[error("run is not resumable (status: {status:?})")]
44 NotResumable { status: String },
45 #[error("I/O error: {0}")]
46 Io(#[from] std::io::Error),
47 #[error("serialization error: {0}")]
48 Serde(#[from] serde_json::Error),
49 #[error("journal corrupted: {0}")]
50 Corrupted(String),
51 #[error("backend error: {0}")]
52 Backend(String),
53}
54
55fn map_anyhow(e: anyhow::Error) -> JournalError {
56 JournalError::Backend(e.to_string())
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct AgentCacheKey {
67 pub hash: String,
68 pub prompt_preview: String,
70 pub phase_id: PhaseId,
71}
72
73impl AgentCacheKey {
74 pub fn new(prompt: &str, phase_id: PhaseId) -> Self {
77 let normalized = normalize_prompt(prompt);
78 let preview = if normalized.chars().count() > 80 {
79 format!("{}...", normalized.chars().take(80).collect::<String>())
80 } else {
81 normalized.clone()
82 };
83
84 let mut h = Hasher::new();
85 h.update(normalized.as_bytes());
86 h.update(b"\0");
87 h.update(&phase_id.to_le_bytes());
88
89 Self {
90 hash: h.finalize().to_hex().to_string(),
91 prompt_preview: preview,
92 phase_id,
93 }
94 }
95}
96
97fn normalize_prompt(prompt: &str) -> String {
98 prompt
99 .replace("\r\n", "\n")
100 .replace('\r', "\n")
101 .split_whitespace()
102 .collect::<Vec<_>>()
103 .join(" ")
104}
105
106pub struct JournalStore {
120 inner: Arc<dyn CheckpointBackend>,
122 cache_index: RwLock<HashMap<String, AgentResultCache>>,
125 event_tx: Option<EventSender>,
127}
128
129impl std::fmt::Debug for JournalStore {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.debug_struct("JournalStore")
132 .field("inner", &self.inner)
133 .field("cache_index_size", &self.cache_index.read().unwrap().len())
134 .field("has_event_tx", &self.event_tx.is_some())
135 .finish()
136 }
137}
138
139impl JournalStore {
140 pub fn with_backend(backend: Arc<dyn CheckpointBackend>) -> Self {
142 tracing::debug!(backend = ?backend, "creating journal store with backend");
143 Self {
144 inner: backend,
145 cache_index: RwLock::new(HashMap::new()),
146 event_tx: None,
147 }
148 }
149
150 #[deprecated(note = "use JournalStore::with_backend instead")]
154 pub fn new(_run_dir: &Path) -> Result<Self, JournalError> {
155 Err(JournalError::Corrupted(
156 "JournalStore::new(path) is no longer supported. Use JournalStore::with_backend(backend).".into()
157 ))
158 }
159
160 pub fn init_run(&self, run_id: RunId, task: &str, run_dir: &str) -> Result<(), JournalError> {
162 tracing::info!(%run_id, %task, "initializing run in journal");
163 self.inner.init_run(run_id, task, run_dir).map_err(map_anyhow)?;
164 Ok(())
165 }
166
167 pub fn init_run_with_meta(
169 &self,
170 run_id: RunId,
171 task: &str,
172 run_dir: &str,
173 workflow_meta: serde_json::Value,
174 ) -> Result<(), JournalError> {
175 tracing::info!(
176 %run_id, %task,
177 "initializing run in journal with meta"
178 );
179 self.inner.init_run_with_meta(run_id, task, run_dir, workflow_meta).map_err(map_anyhow)?;
180 Ok(())
181 }
182
183 pub fn open(&self, run_id: RunId) -> Result<RunCheckpoint, JournalError> {
190 tracing::info!(%run_id, "opening journal for resume");
191 let checkpoint = self
192 .inner
193 .open_run(run_id).map_err(map_anyhow)?
194 .ok_or(JournalError::RunNotFound(run_id))?;
195
196 if matches!(
197 checkpoint.status,
198 crate::state::CheckpointStatus::Completed
199 ) {
200 return Err(JournalError::NotResumable {
201 status: format!("{:?}", checkpoint.status),
202 });
203 }
204
205 let mut index = HashMap::new();
208 for (agent_id, cache) in &checkpoint.agent_results {
209 index.insert(agent_id.to_string(), cache.clone());
210 if let Some(ref hash) = cache.cache_key_hash {
211 index.insert(hash.clone(), cache.clone());
212 }
213 }
214 *self.cache_index.write().unwrap() = index;
215
216 Ok(checkpoint)
217 }
218
219 #[allow(clippy::too_many_arguments)]
225 pub fn cache_agent(
226 &self,
227 cache_key: &AgentCacheKey,
228 agent_id: AgentId,
229 phase_id: PhaseId,
230 status: AgentStatus,
231 output: serde_json::Value,
232 findings: Vec<Finding>,
233 tokens: TokenUsage,
234 ) -> Result<AgentCacheKey, JournalError> {
235 let ts = current_timestamp();
236 let cache = AgentResultCache {
237 agent_id,
238 phase_id,
239 status: status.as_str().to_string(),
240 output,
241 findings,
242 tokens: tokens.total(),
243 completed_at: ts,
244 cache_key_hash: Some(cache_key.hash.clone()),
245 description: None,
246 role: None,
247 };
248
249 {
251 let mut index = self.cache_index.write().unwrap();
252 index.insert(cache_key.hash.clone(), cache.clone());
253 index.insert(agent_id.to_string(), cache.clone());
255 }
256
257 if let Err(e) = self.inner.upsert_agent_result(&cache) {
259 tracing::warn!(%agent_id, error = %e, "failed to persist agent cache");
260 }
261
262 let event = AgentEvent::AgentDone {
264 run_id: self
265 .inner
266 .get_checkpoint()
267 .map(|c| c.run_id)
268 .unwrap_or_else(uuid::Uuid::nil),
269 agent_id,
270 status,
271 tokens,
272 elapsed_ms: 0,
273 name: None,
274 agent_seq: 0,
275 output: serde_json::Value::Null,
276 findings: Vec::new(),
277 prompt: String::new(),
278 retry_count: 0,
279 ts: Utc::now(),
280 };
281 self.inner.append_event(&event).map_err(map_anyhow)?;
282
283 if let Some(ref tx) = self.event_tx {
285 let _ = tx.send(event);
286 }
287
288 Ok(cache_key.clone())
289 }
290
291 #[allow(clippy::too_many_arguments)]
299 pub fn record_result(
300 &self,
301 cache_key: &AgentCacheKey,
302 agent_id: AgentId,
303 phase_id: PhaseId,
304 status: AgentStatus,
305 output: serde_json::Value,
306 findings: Vec<Finding>,
307 tokens: TokenUsage,
308 ) {
309 let cache = AgentResultCache {
310 agent_id,
311 phase_id,
312 status: status.as_str().to_string(),
313 output,
314 findings,
315 tokens: tokens.total(),
316 completed_at: current_timestamp(),
317 cache_key_hash: Some(cache_key.hash.clone()),
318 description: None,
319 role: None,
320 };
321
322 {
323 let mut index = self.cache_index.write().unwrap();
324 index.insert(cache_key.hash.clone(), cache.clone());
325 index.insert(agent_id.to_string(), cache.clone());
326 }
327
328 if let Err(e) = self.inner.upsert_agent_result(&cache) {
329 tracing::warn!(%agent_id, error = %e, "failed to persist agent result");
330 }
331 }
332
333 pub fn record_session(
337 &self,
338 agent_id: AgentId,
339 session_id: String,
340 status: &str,
341 resumable: bool,
342 ) {
343 let backend_id = crate::contract::current_backend().map(|backend| backend.id);
344 let protocol_session_id = backend_id
345 .as_deref()
346 .and_then(|backend| resolve_session(&session_id, backend))
347 .map(|record| record.protocol_session_id)
348 .or_else(|| Some(session_id.clone()));
349 let session = AgentSessionCheckpoint {
350 agent_id,
351 backend_id,
352 protocol_session_id,
353 session_id,
354 status: status.to_string(),
355 updated_at: current_timestamp(),
356 resumable,
357 };
358 if let Err(e) = self.inner.upsert_agent_session(&session) {
359 tracing::warn!(%agent_id, error = %e, "failed to persist agent session");
360 }
361 }
362
363 pub fn get_session(&self, agent_id: AgentId) -> Option<AgentSessionCheckpoint> {
365 let session = self
366 .inner
367 .get_checkpoint()
368 .and_then(|checkpoint| checkpoint.agent_sessions.get(&agent_id).cloned());
369 if let Some(ref session) = session {
370 if let (Some(backend_id), Some(protocol_id)) =
371 (session.backend_id.as_deref(), session.protocol_session_id.as_deref())
372 {
373 restore_session(&session.session_id, backend_id, protocol_id);
374 }
375 }
376 session
377 }
378
379 pub fn store(&self) -> Arc<dyn CheckpointBackend> {
383 self.inner.clone()
384 }
385
386 pub fn append_event(&self, event: &AgentEvent) -> Result<(), JournalError> {
388 self.inner.append_event(event).map_err(map_anyhow)?;
389 Ok(())
390 }
391
392 pub fn has_completed(&self, cache_key: &AgentCacheKey) -> bool {
395 let index = self.cache_index.read().unwrap();
396 index.contains_key(&cache_key.hash)
397 }
398
399 pub fn get_cached(&self, cache_key: &AgentCacheKey) -> Option<AgentResultCache> {
402 let index = self.cache_index.read().unwrap();
403 index.get(&cache_key.hash).cloned()
404 }
405
406 pub fn completed_keys(&self) -> Vec<AgentCacheKey> {
409 let index = self.cache_index.read().unwrap();
410 index
411 .keys()
412 .map(|k| AgentCacheKey {
413 hash: k.clone(),
414 prompt_preview: String::new(),
415 phase_id: 0,
416 })
417 .collect()
418 }
419
420 pub fn get_checkpoint(&self) -> Option<RunCheckpoint> {
422 self.inner.get_checkpoint()
423 }
424
425 pub fn flush(&self) -> Result<(), JournalError> {
427 Ok(())
429 }
430
431 pub fn cancel(&self) -> Result<(), JournalError> {
433 self.inner.cancel().map_err(map_anyhow)?;
434 Ok(())
435 }
436
437 pub fn reset_status_to_running(&self) -> Result<(), JournalError> {
440 self.inner.reset_status_to_running().map_err(map_anyhow)?;
441 Ok(())
442 }
443}
444
445pub struct CompositeJournalCallback {
451 callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>,
452}
453
454impl CompositeJournalCallback {
455 pub fn new(callbacks: Vec<Arc<dyn crate::scheduler::JournalCallback>>) -> Self {
456 Self { callbacks }
457 }
458}
459
460#[async_trait::async_trait]
461impl crate::scheduler::JournalCallback for CompositeJournalCallback {
462 async fn on_agent_done(
463 &self,
464 agent_id: AgentId,
465 phase_id: PhaseId,
466 status: AgentStatus,
467 output: serde_json::Value,
468 tokens: TokenUsage,
469 ) {
470 for cb in &self.callbacks {
471 cb.on_agent_done(agent_id, phase_id, status.clone(), output.clone(), tokens)
472 .await;
473 }
474 }
475}
476
477#[async_trait::async_trait]
478impl crate::scheduler::JournalCallback for JournalStore {
479 async fn on_agent_done(
480 &self,
481 agent_id: AgentId,
482 phase_id: PhaseId,
483 status: AgentStatus,
484 output: serde_json::Value,
485 tokens: TokenUsage,
486 ) {
487 let ts = current_timestamp();
488
489 let existing = {
494 let index = self.cache_index.read().unwrap();
495 index.get(&agent_id.to_string()).cloned()
496 };
497
498 let cache = AgentResultCache {
499 agent_id,
500 phase_id: existing.as_ref().map(|c| c.phase_id).unwrap_or(phase_id),
501 status: status.as_str().to_string(),
502 output: existing
503 .as_ref()
504 .filter(|c| !c.output.is_null())
505 .map(|c| c.output.clone())
506 .unwrap_or(output),
507 findings: existing
508 .as_ref()
509 .filter(|c| !c.findings.is_empty())
510 .map(|c| c.findings.clone())
511 .unwrap_or_default(),
512 tokens: tokens.total(),
513 completed_at: ts,
514 cache_key_hash: existing.as_ref().and_then(|c| c.cache_key_hash.clone()),
515 description: existing.as_ref().and_then(|c| c.description.clone()),
516 role: existing.as_ref().and_then(|c| c.role.clone()),
517 };
518
519 {
522 let mut index = self.cache_index.write().unwrap();
523 index.insert(agent_id.to_string(), cache.clone());
524 if let Some(ref hash) = cache.cache_key_hash {
525 index.insert(hash.clone(), cache.clone());
526 }
527 }
528
529 if let Err(e) = self.inner.upsert_agent_result(&cache) {
531 tracing::warn!(%agent_id, error = %e, "failed to persist agent result from callback");
532 }
533 }
534}
535
536#[derive(Debug)]
542pub struct ResumeContext {
543 pub run_id: RunId,
544 pub checkpoint: RunCheckpoint,
545 pub journal: Arc<JournalStore>,
546 pub scheduler_config: SchedulerConfig,
547 pub backend_registry: BackendRegistry,
548}
549
550#[derive(Debug, Clone)]
552pub enum RunCreationMode {
553 New { task: String },
555 Resume { run_id: RunId, run_dir_name: String },
557 Auto { task: String },
559}
560
561impl RunCreationMode {
562 pub fn resolve(
565 self,
566 journal_dir: &Path,
567 backend_factory: &dyn Fn(&Path) -> Arc<dyn CheckpointBackend>,
568 ) -> Result<(RunId, Option<RunCheckpoint>), JournalError> {
569 match self {
570 RunCreationMode::New { task: _ } => {
571 let run_id = uuid::Uuid::now_v7();
572 Ok((run_id, None))
573 }
574 RunCreationMode::Resume {
575 run_id,
576 run_dir_name,
577 } => {
578 let backend = backend_factory(&journal_dir.join(&run_dir_name));
579 let store = JournalStore::with_backend(backend);
580 let checkpoint = store.open(run_id)?;
581 Ok((run_id, Some(checkpoint)))
582 }
583 RunCreationMode::Auto { task: _ } => {
584 let run_dirs = crate::state::list_run_dirs(journal_dir).map_err(map_anyhow)?;
585 for dir_name in run_dirs.iter().rev() {
586 let run_dir = journal_dir.join(dir_name);
587 let backend = backend_factory(&run_dir);
588 if let Ok(Some(checkpoint)) = backend.open_run(uuid::Uuid::nil()) {
589 if matches!(checkpoint.status, crate::state::CheckpointStatus::Running)
590 || matches!(checkpoint.status, crate::state::CheckpointStatus::Failed)
591 || matches!(checkpoint.status, crate::state::CheckpointStatus::Cancelled)
592 {
593 let run_id = checkpoint.run_id;
594 return Ok((run_id, Some(checkpoint)));
595 }
596 }
597 }
598 let run_id = uuid::Uuid::now_v7();
599 Ok((run_id, None))
600 }
601 }
602 }
603}
604
605pub fn gc_runs(journal_dir: &Path, older_than: Duration) -> Result<usize, JournalError> {
617 let run_dirs = crate::state::list_run_dirs(journal_dir).map_err(map_anyhow)?;
618 let cutoff = current_timestamp().saturating_sub(older_than.as_secs());
619
620 tracing::debug!("GC: scanning {} runs", run_dirs.len());
621 let mut cleaned = 0;
622 for dir_name in &run_dirs {
623 let run_dir = journal_dir.join(dir_name);
624 let checkpoint_path = run_dir.join("checkpoint.json");
626 if !checkpoint_path.exists() {
627 continue;
628 }
629
630 let content = std::fs::read_to_string(&checkpoint_path)?;
631 let checkpoint: RunCheckpoint = serde_json::from_str(&content)?;
632
633 let is_old = checkpoint.updated_at < cutoff;
634 let is_terminal = matches!(
635 checkpoint.status,
636 crate::state::CheckpointStatus::Completed
637 | crate::state::CheckpointStatus::Cancelled
638 | crate::state::CheckpointStatus::Failed
639 );
640
641 if is_old && is_terminal {
642 tracing::info!(dir = %dir_name, "GC: removing old terminal run");
643 std::fs::remove_dir_all(&run_dir)?;
644 cleaned += 1;
645 }
646 }
647
648 Ok(cleaned)
649}
650
651fn current_timestamp() -> u64 {
652 SystemTime::now()
653 .duration_since(UNIX_EPOCH)
654 .map(|d| d.as_secs())
655 .unwrap_or(0)
656}
657
658#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn test_cache_key_uniqueness() {
668 let k1 = AgentCacheKey::new("prompt A", 1);
669 let k2 = AgentCacheKey::new("prompt B", 1);
670 assert_ne!(k1.hash, k2.hash);
671
672 let k4 = AgentCacheKey::new("prompt A", 2);
674 assert_ne!(k1.hash, k4.hash);
675
676 let k5 = AgentCacheKey::new(" prompt \r\nA ", 1);
678 assert_eq!(k1.hash, k5.hash);
679 }
680}