1use std::fmt;
35use std::sync::Arc;
36
37use turnframe_core::error::OrchestratorError;
38use turnframe_core::ids::{AccountId, AttemptId, EventId, TurnId};
39use turnframe_core::plan::AnswerBasis;
40use turnframe_core::reduce::{AnswerTask, SourcePolicy};
41use turnframe_core::replay::{ReplayRecord, TurnPhase};
42use turnframe_store::conversation::{
43 ConversationStore, RecoveryScope, StoredTurn, TurnPhaseMarker,
44};
45use turnframe_store::error::StoreError;
46use turnframe_store::events::{EventJournal, StoredEvent};
47use turnframe_store::journal::{CommandJournal, CommandJournalEntry};
48use turnframe_store::replay::ReplayStore;
49
50const MAX_REPLAYED_EVENTS: usize = 1024;
52
53#[derive(Debug, Clone, PartialEq)]
61#[non_exhaustive]
62pub enum RecoveryAction {
63 Nothing {
65 phase: TurnPhase,
67 },
68 RestartInterpretation,
71 ResumeCommands {
75 entries: Vec<CommandJournalEntry>,
77 },
78 RegenerateResponse {
82 events: Vec<StoredEvent>,
84 answer_tasks: Vec<AnswerTask>,
86 },
87 ReconcileExternal {
90 attempts: Vec<AttemptId>,
92 entries: Vec<CommandJournalEntry>,
94 },
95}
96
97impl RecoveryAction {
98 #[must_use]
103 pub const fn may_cause_effects(&self) -> bool {
104 matches!(self, Self::ResumeCommands { .. })
105 }
106
107 #[must_use]
109 pub const fn as_str(&self) -> &'static str {
110 match self {
111 Self::Nothing { .. } => "nothing",
112 Self::RestartInterpretation => "restart_interpretation",
113 Self::ResumeCommands { .. } => "resume_commands",
114 Self::RegenerateResponse { .. } => "regenerate_response",
115 Self::ReconcileExternal { .. } => "reconcile_external",
116 }
117 }
118}
119
120#[derive(Clone)]
122pub struct Recovery {
123 conversations: Arc<dyn ConversationStore>,
124 journal: Arc<dyn CommandJournal>,
125 events: Arc<dyn EventJournal>,
126 replay: Arc<dyn ReplayStore>,
127}
128
129impl fmt::Debug for Recovery {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 f.debug_struct("Recovery").finish_non_exhaustive()
132 }
133}
134
135impl Recovery {
136 #[must_use]
138 pub fn new(
139 conversations: Arc<dyn ConversationStore>,
140 journal: Arc<dyn CommandJournal>,
141 events: Arc<dyn EventJournal>,
142 replay: Arc<dyn ReplayStore>,
143 ) -> Self {
144 Self {
145 conversations,
146 journal,
147 events,
148 replay,
149 }
150 }
151
152 pub async fn unfinished(
158 &self,
159 scope: RecoveryScope,
160 limit: usize,
161 ) -> Result<Vec<TurnPhaseMarker>, OrchestratorError> {
162 self.conversations
163 .list_unfinished_turns(scope, limit)
164 .await
165 .map_err(OrchestratorError::Store)
166 }
167
168 pub async fn decide(
176 &self,
177 account: &AccountId,
178 turn_id: &TurnId,
179 ) -> Result<RecoveryAction, OrchestratorError> {
180 let marker = self
181 .conversations
182 .turn_phase(account, turn_id)
183 .await
184 .map_err(OrchestratorError::Store)?;
185 let entries = self
186 .journal
187 .for_turn(account, turn_id)
188 .await
189 .map_err(OrchestratorError::Store)?;
190
191 let unknown: Vec<CommandJournalEntry> = entries
196 .iter()
197 .filter(|entry| {
198 entry.status == turnframe_store::journal::CommandJournalStatus::OutcomeUnknown
199 })
200 .cloned()
201 .collect();
202 if !unknown.is_empty() {
203 let attempts = unknown
204 .iter()
205 .filter_map(|entry| match entry.result.as_ref() {
206 Some(turnframe_store::journal::JournalOutcome::OutcomeUnknown {
207 attempt_id,
208 ..
209 }) => Some(attempt_id.clone()),
210 _ => None,
211 })
212 .collect();
213 return Ok(RecoveryAction::ReconcileExternal {
214 attempts,
215 entries: unknown,
216 });
217 }
218
219 if marker.phase.is_terminal() {
220 return Ok(RecoveryAction::Nothing {
222 phase: marker.phase,
223 });
224 }
225
226 let pending: Vec<CommandJournalEntry> = entries
228 .iter()
229 .filter(|entry| entry.status.is_pending())
230 .cloned()
231 .collect();
232 if !pending.is_empty() {
233 return Ok(RecoveryAction::ResumeCommands { entries: pending });
234 }
235
236 if !entries.is_empty() {
238 let record = self.replay.get(account, turn_id).await.ok();
239 let text = self
240 .conversations
241 .load_turn(account, turn_id)
242 .await
243 .ok()
244 .and_then(|turn| turn.user.input.text);
245 let event_ids = committed_event_ids(&entries);
246 let events = self
247 .events
248 .get_by_ids(account, &event_ids)
249 .await
250 .map_err(OrchestratorError::Store)?;
251 return Ok(RecoveryAction::RegenerateResponse {
252 events,
253 answer_tasks: answer_tasks_of(record.as_ref(), text.as_deref()),
254 });
255 }
256
257 Ok(RecoveryAction::RestartInterpretation)
259 }
260
261 pub async fn stored_turn(
268 &self,
269 account: &AccountId,
270 turn_id: &TurnId,
271 ) -> Result<StoredTurn, OrchestratorError> {
272 self.conversations
273 .load_turn(account, turn_id)
274 .await
275 .map_err(OrchestratorError::Store)
276 }
277
278 pub async fn record(
286 &self,
287 account: &AccountId,
288 turn_id: &TurnId,
289 ) -> Result<Option<ReplayRecord>, OrchestratorError> {
290 match self.replay.get(account, turn_id).await {
291 Ok(record) => Ok(Some(record)),
292 Err(StoreError::NotFound) => Ok(None),
293 Err(error) => Err(OrchestratorError::Store(error)),
294 }
295 }
296
297 pub async fn case_events(
304 &self,
305 account: &AccountId,
306 case_key: &turnframe_core::case::CaseKey,
307 since: turnframe_core::ids::CaseRevision,
308 ) -> Result<Vec<StoredEvent>, OrchestratorError> {
309 self.events
310 .list_since(account, case_key, since, MAX_REPLAYED_EVENTS)
311 .await
312 .map_err(OrchestratorError::Store)
313 }
314}
315
316#[must_use]
318pub fn committed_event_ids(entries: &[CommandJournalEntry]) -> Vec<EventId> {
319 entries
320 .iter()
321 .filter_map(|entry| match entry.result.as_ref() {
322 Some(turnframe_store::journal::JournalOutcome::Committed { event_ids, .. }) => {
323 Some(event_ids.clone())
324 }
325 _ => None,
326 })
327 .flatten()
328 .collect()
329}
330
331#[must_use]
335pub fn answer_tasks_of(record: Option<&ReplayRecord>, text: Option<&str>) -> Vec<AnswerTask> {
336 let (Some(record), Some(text)) = (record, text) else {
337 return Vec::new();
338 };
339 let Some(understanding) = record.understanding.as_ref() else {
340 return Vec::new();
341 };
342 understanding
343 .questions
344 .iter()
345 .map(|question| AnswerTask {
346 question_id: turnframe_core::ids::QuestionId::from(question.unit.to_string()),
347 question: text
348 .get(question.words.start..question.words.end)
349 .unwrap_or_default()
350 .to_owned(),
351 basis: match question.basis {
352 AnswerBasis::GeneralDomainKnowledge => AnswerBasis::GeneralDomainKnowledge,
353 _ => AnswerBasis::CurrentCommittedState,
354 },
355 case_refs: record.loaded_cases.clone(),
356 proposed_diff_ref: None,
357 required_sources: if question.basis == AnswerBasis::GeneralDomainKnowledge {
358 SourcePolicy::AnySource
359 } else {
360 SourcePolicy::AuthoritativeOnly
361 },
362 enumerations: Vec::new(),
364 capabilities: Vec::new(),
365 continues_previous: question.continues_previous,
366 asked_at: Some(turnframe_core::reduce::TextSpan {
367 start_byte: question.words.start,
368 end_byte: question.words.end,
369 }),
370 })
371 .collect()
372}
373
374#[cfg(test)]
375mod tests {
376 use turnframe_core::ids::{AccountId, ConversationId};
377
378 use super::*;
379
380 #[test]
381 fn answer_tasks_come_back_from_the_stored_plan() {
382 let mut record = ReplayRecord::received(
383 TurnId::nil(),
384 ConversationId::nil(),
385 AccountId::from("acct"),
386 chrono::Utc::now(),
387 );
388 let text = "done. why the loyalty number?";
389 record.understanding = Some(turnframe_core::understanding::Understanding {
390 questions: vec![turnframe_core::understanding::UnderstoodQuestion {
391 unit: turnframe_core::understanding::UnitId(2),
392 words: turnframe_core::understanding::WordRange {
393 first: 1,
394 last: 4,
395 start: 6,
396 end: 29,
397 },
398 workflow: None,
399 record: None,
400 subjects: Vec::new(),
401 basis: AnswerBasis::CommittedStateAfterTurn,
402 topic: turnframe_core::understanding::QuestionTopic::default(),
403 continues_previous: false,
404 }],
405 ..turnframe_core::understanding::Understanding::default()
406 });
407 let tasks = answer_tasks_of(Some(&record), Some(text));
408 assert_eq!(tasks.len(), 1);
409 assert_eq!(tasks[0].question, "why the loyalty number?");
410 assert_eq!(
411 tasks[0].basis,
412 AnswerBasis::CurrentCommittedState,
413 "after commit, the state after the turn is the state now"
414 );
415 }
416
417 #[test]
418 fn a_record_that_was_never_written_implies_no_questions() {
419 assert!(answer_tasks_of(None, Some("x")).is_empty());
420 }
421
422 #[test]
423 fn only_resuming_commands_can_cause_an_effect() {
424 assert!(
425 RecoveryAction::ResumeCommands {
426 entries: Vec::new()
427 }
428 .may_cause_effects()
429 );
430 assert!(!RecoveryAction::RestartInterpretation.may_cause_effects());
431 assert!(
432 !RecoveryAction::RegenerateResponse {
433 events: Vec::new(),
434 answer_tasks: Vec::new(),
435 }
436 .may_cause_effects()
437 );
438 assert!(
439 !RecoveryAction::ReconcileExternal {
440 attempts: Vec::new(),
441 entries: Vec::new(),
442 }
443 .may_cause_effects()
444 );
445 }
446}