use std::fmt;
use std::sync::Arc;
use turnframe_core::error::OrchestratorError;
use turnframe_core::ids::{AccountId, AttemptId, EventId, TurnId};
use turnframe_core::plan::AnswerBasis;
use turnframe_core::reduce::{AnswerTask, SourcePolicy};
use turnframe_core::replay::{ReplayRecord, TurnPhase};
use turnframe_store::conversation::{
ConversationStore, RecoveryScope, StoredTurn, TurnPhaseMarker,
};
use turnframe_store::error::StoreError;
use turnframe_store::events::{EventJournal, StoredEvent};
use turnframe_store::journal::{CommandJournal, CommandJournalEntry};
use turnframe_store::replay::ReplayStore;
const MAX_REPLAYED_EVENTS: usize = 1024;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum RecoveryAction {
Nothing {
phase: TurnPhase,
},
RestartInterpretation,
ResumeCommands {
entries: Vec<CommandJournalEntry>,
},
RegenerateResponse {
events: Vec<StoredEvent>,
answer_tasks: Vec<AnswerTask>,
},
ReconcileExternal {
attempts: Vec<AttemptId>,
entries: Vec<CommandJournalEntry>,
},
}
impl RecoveryAction {
#[must_use]
pub const fn may_cause_effects(&self) -> bool {
matches!(self, Self::ResumeCommands { .. })
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Nothing { .. } => "nothing",
Self::RestartInterpretation => "restart_interpretation",
Self::ResumeCommands { .. } => "resume_commands",
Self::RegenerateResponse { .. } => "regenerate_response",
Self::ReconcileExternal { .. } => "reconcile_external",
}
}
}
#[derive(Clone)]
pub struct Recovery {
conversations: Arc<dyn ConversationStore>,
journal: Arc<dyn CommandJournal>,
events: Arc<dyn EventJournal>,
replay: Arc<dyn ReplayStore>,
}
impl fmt::Debug for Recovery {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Recovery").finish_non_exhaustive()
}
}
impl Recovery {
#[must_use]
pub fn new(
conversations: Arc<dyn ConversationStore>,
journal: Arc<dyn CommandJournal>,
events: Arc<dyn EventJournal>,
replay: Arc<dyn ReplayStore>,
) -> Self {
Self {
conversations,
journal,
events,
replay,
}
}
pub async fn unfinished(
&self,
scope: RecoveryScope,
limit: usize,
) -> Result<Vec<TurnPhaseMarker>, OrchestratorError> {
self.conversations
.list_unfinished_turns(scope, limit)
.await
.map_err(OrchestratorError::Store)
}
pub async fn decide(
&self,
account: &AccountId,
turn_id: &TurnId,
) -> Result<RecoveryAction, OrchestratorError> {
let marker = self
.conversations
.turn_phase(account, turn_id)
.await
.map_err(OrchestratorError::Store)?;
let entries = self
.journal
.for_turn(account, turn_id)
.await
.map_err(OrchestratorError::Store)?;
let unknown: Vec<CommandJournalEntry> = entries
.iter()
.filter(|entry| {
entry.status == turnframe_store::journal::CommandJournalStatus::OutcomeUnknown
})
.cloned()
.collect();
if !unknown.is_empty() {
let attempts = unknown
.iter()
.filter_map(|entry| match entry.result.as_ref() {
Some(turnframe_store::journal::JournalOutcome::OutcomeUnknown {
attempt_id,
..
}) => Some(attempt_id.clone()),
_ => None,
})
.collect();
return Ok(RecoveryAction::ReconcileExternal {
attempts,
entries: unknown,
});
}
if marker.phase.is_terminal() {
return Ok(RecoveryAction::Nothing {
phase: marker.phase,
});
}
let pending: Vec<CommandJournalEntry> = entries
.iter()
.filter(|entry| entry.status.is_pending())
.cloned()
.collect();
if !pending.is_empty() {
return Ok(RecoveryAction::ResumeCommands { entries: pending });
}
if !entries.is_empty() {
let record = self.replay.get(account, turn_id).await.ok();
let text = self
.conversations
.load_turn(account, turn_id)
.await
.ok()
.and_then(|turn| turn.user.input.text);
let event_ids = committed_event_ids(&entries);
let events = self
.events
.get_by_ids(account, &event_ids)
.await
.map_err(OrchestratorError::Store)?;
return Ok(RecoveryAction::RegenerateResponse {
events,
answer_tasks: answer_tasks_of(record.as_ref(), text.as_deref()),
});
}
Ok(RecoveryAction::RestartInterpretation)
}
pub async fn stored_turn(
&self,
account: &AccountId,
turn_id: &TurnId,
) -> Result<StoredTurn, OrchestratorError> {
self.conversations
.load_turn(account, turn_id)
.await
.map_err(OrchestratorError::Store)
}
pub async fn record(
&self,
account: &AccountId,
turn_id: &TurnId,
) -> Result<Option<ReplayRecord>, OrchestratorError> {
match self.replay.get(account, turn_id).await {
Ok(record) => Ok(Some(record)),
Err(StoreError::NotFound) => Ok(None),
Err(error) => Err(OrchestratorError::Store(error)),
}
}
pub async fn case_events(
&self,
account: &AccountId,
case_key: &turnframe_core::case::CaseKey,
since: turnframe_core::ids::CaseRevision,
) -> Result<Vec<StoredEvent>, OrchestratorError> {
self.events
.list_since(account, case_key, since, MAX_REPLAYED_EVENTS)
.await
.map_err(OrchestratorError::Store)
}
}
#[must_use]
pub fn committed_event_ids(entries: &[CommandJournalEntry]) -> Vec<EventId> {
entries
.iter()
.filter_map(|entry| match entry.result.as_ref() {
Some(turnframe_store::journal::JournalOutcome::Committed { event_ids, .. }) => {
Some(event_ids.clone())
}
_ => None,
})
.flatten()
.collect()
}
#[must_use]
pub fn answer_tasks_of(record: Option<&ReplayRecord>, text: Option<&str>) -> Vec<AnswerTask> {
let (Some(record), Some(text)) = (record, text) else {
return Vec::new();
};
let Some(understanding) = record.understanding.as_ref() else {
return Vec::new();
};
understanding
.questions
.iter()
.map(|question| AnswerTask {
question_id: turnframe_core::ids::QuestionId::from(question.unit.to_string()),
question: text
.get(question.words.start..question.words.end)
.unwrap_or_default()
.to_owned(),
basis: match question.basis {
AnswerBasis::GeneralDomainKnowledge => AnswerBasis::GeneralDomainKnowledge,
_ => AnswerBasis::CurrentCommittedState,
},
case_refs: record.loaded_cases.clone(),
proposed_diff_ref: None,
required_sources: if question.basis == AnswerBasis::GeneralDomainKnowledge {
SourcePolicy::AnySource
} else {
SourcePolicy::AuthoritativeOnly
},
enumerations: Vec::new(),
capabilities: Vec::new(),
continues_previous: question.continues_previous,
asked_at: Some(turnframe_core::reduce::TextSpan {
start_byte: question.words.start,
end_byte: question.words.end,
}),
})
.collect()
}
#[cfg(test)]
mod tests {
use turnframe_core::ids::{AccountId, ConversationId};
use super::*;
#[test]
fn answer_tasks_come_back_from_the_stored_plan() {
let mut record = ReplayRecord::received(
TurnId::nil(),
ConversationId::nil(),
AccountId::from("acct"),
chrono::Utc::now(),
);
let text = "done. why the loyalty number?";
record.understanding = Some(turnframe_core::understanding::Understanding {
questions: vec![turnframe_core::understanding::UnderstoodQuestion {
unit: turnframe_core::understanding::UnitId(2),
words: turnframe_core::understanding::WordRange {
first: 1,
last: 4,
start: 6,
end: 29,
},
workflow: None,
record: None,
subjects: Vec::new(),
basis: AnswerBasis::CommittedStateAfterTurn,
topic: turnframe_core::understanding::QuestionTopic::default(),
continues_previous: false,
}],
..turnframe_core::understanding::Understanding::default()
});
let tasks = answer_tasks_of(Some(&record), Some(text));
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].question, "why the loyalty number?");
assert_eq!(
tasks[0].basis,
AnswerBasis::CurrentCommittedState,
"after commit, the state after the turn is the state now"
);
}
#[test]
fn a_record_that_was_never_written_implies_no_questions() {
assert!(answer_tasks_of(None, Some("x")).is_empty());
}
#[test]
fn only_resuming_commands_can_cause_an_effect() {
assert!(
RecoveryAction::ResumeCommands {
entries: Vec::new()
}
.may_cause_effects()
);
assert!(!RecoveryAction::RestartInterpretation.may_cause_effects());
assert!(
!RecoveryAction::RegenerateResponse {
events: Vec::new(),
answer_tasks: Vec::new(),
}
.may_cause_effects()
);
assert!(
!RecoveryAction::ReconcileExternal {
attempts: Vec::new(),
entries: Vec::new(),
}
.may_cause_effects()
);
}
}