#![allow(dead_code)]
#![allow(clippy::result_large_err)]
use std::sync::Arc;
use chrono::{DateTime, Utc};
use turnframe_core::case::{CaseKey, CaseRef, Versioned};
use turnframe_core::command::{CommandBatch, IdempotencyKey};
use turnframe_core::error::{ExecutionError, StoreError};
use turnframe_core::event::Commit;
use turnframe_core::flow::{WorkflowExecutor, WorkflowRegistry};
use turnframe_core::ids::{
AccountId, CaseId, CaseRevision, ConversationId, InteractionId, OptionId, TargetToken, TurnId,
};
use turnframe_core::interaction::Interaction;
use turnframe_core::knowledge::{
KnowledgeChunk, KnowledgeError, KnowledgeProvider, KnowledgeRequest,
};
use turnframe_core::locale::Locale;
use turnframe_core::observe::{Observer, Signal, SignalLabels};
use turnframe_core::policy::PolicySnapshot;
use turnframe_core::replay::TurnPhase;
use turnframe_core::response::{AssistantTurn, ResponseBlock};
use turnframe_core::turn::{ActorContext, InteractionResponse, TurnInput};
use turnframe_core::understanding::Understanding;
use turnframe_provider::error::ProviderError;
use turnframe_provider::router::ProviderPool;
use turnframe_runtime::config::{NarrationConfig, OrchestratorConfig};
use turnframe_runtime::orchestrator::{
CaseCandidate, CaseDirectory, FixedTurnClock, Orchestrator, StaticCaseDirectory, TurnClock,
};
use turnframe_runtime::resolve::{AuthorizedCase, TargetResolver};
use turnframe_test::providers::{ScriptedProvider, ScriptedProviderBuilder, ScriptedUnderstanding};
use turnframe_test::stores::{FailurePoint, FakeStores};
use turnframe_test::workflows::InMemoryExecutor;
use turnframe_test::workflows::traveler::{TravelerState, TravelerWorkflow};
use turnframe_test::workflows::trip::{TripCommand, TripEvent, TripState, TripWorkflow};
pub const ACCOUNT: &str = "aurora";
pub const OTHER_ACCOUNT: &str = "other";
pub const TRIP: &str = "trip";
pub const TRAVELER: &str = "traveler";
#[must_use]
pub fn now() -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000, 0).expect("a valid fixed instant")
}
#[must_use]
pub fn account() -> AccountId {
AccountId::from(ACCOUNT)
}
pub struct SharedExecutor<W: turnframe_test::workflows::PureWorkflow>(pub Arc<InMemoryExecutor<W>>);
impl<W: turnframe_test::workflows::PureWorkflow> Clone for SharedExecutor<W> {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
#[async_trait::async_trait]
impl<W: turnframe_test::workflows::PureWorkflow> WorkflowExecutor<W> for SharedExecutor<W> {
async fn load(
&self,
account: &AccountId,
case_id: &CaseId,
) -> Result<Versioned<Option<W::State>>, StoreError> {
self.0.load(account, case_id).await
}
async fn execute(
&self,
batch: CommandBatch<W::Command>,
) -> Result<Commit<W::State, W::Event>, ExecutionError> {
self.0.execute(batch).await
}
}
pub struct FailingExecutor<W: turnframe_test::workflows::PureWorkflow> {
inner: SharedExecutor<W>,
error: ExecutionError,
armed: Arc<std::sync::atomic::AtomicBool>,
refresh_after_failure: Option<(W::State, CaseRevision, bool)>,
failed: std::sync::atomic::AtomicBool,
failed_cases: std::sync::Mutex<std::collections::BTreeSet<CaseId>>,
load_counts: Arc<std::sync::Mutex<std::collections::BTreeMap<CaseId, usize>>>,
}
impl<W: turnframe_test::workflows::PureWorkflow> FailingExecutor<W> {
#[must_use]
pub fn new(inner: SharedExecutor<W>, error: ExecutionError) -> Self {
Self::armable(
inner,
error,
Arc::new(std::sync::atomic::AtomicBool::new(true)),
)
}
#[must_use]
pub fn armable(
inner: SharedExecutor<W>,
error: ExecutionError,
armed: Arc<std::sync::atomic::AtomicBool>,
) -> Self {
Self {
inner,
error,
armed,
refresh_after_failure: None,
failed: std::sync::atomic::AtomicBool::new(false),
failed_cases: std::sync::Mutex::new(std::collections::BTreeSet::new()),
load_counts: Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new())),
}
}
#[must_use]
pub fn with_refresh_after_failure(
mut self,
refresh: Option<(W::State, CaseRevision, bool)>,
) -> Self {
self.refresh_after_failure = refresh;
self
}
#[must_use]
fn with_load_counts(
mut self,
counts: Arc<std::sync::Mutex<std::collections::BTreeMap<CaseId, usize>>>,
) -> Self {
self.load_counts = counts;
self
}
}
#[async_trait::async_trait]
impl<W: turnframe_test::workflows::PureWorkflow> WorkflowExecutor<W> for FailingExecutor<W> {
async fn load(
&self,
account: &AccountId,
case_id: &CaseId,
) -> Result<Versioned<Option<W::State>>, StoreError> {
*self
.load_counts
.lock()
.expect("load counts are not poisoned")
.entry(case_id.clone())
.or_default() += 1;
if self.failed.load(std::sync::atomic::Ordering::SeqCst)
&& self
.refresh_after_failure
.as_ref()
.is_some_and(|(_, _, fail)| *fail)
&& self
.failed_cases
.lock()
.expect("failed cases are not poisoned")
.contains(case_id)
{
return Err(StoreError::Unavailable);
}
self.inner.load(account, case_id).await
}
async fn execute(
&self,
batch: CommandBatch<W::Command>,
) -> Result<Commit<W::State, W::Event>, ExecutionError> {
if self.armed.load(std::sync::atomic::Ordering::SeqCst) {
let envelope = batch.envelopes.first().expect("nonempty batch");
if let Some((state, revision, _)) = &self.refresh_after_failure {
self.inner.0.seed(
&envelope.actor.account_id,
&envelope.case_ref.case_id,
state.clone(),
*revision,
);
}
self.failed_cases
.lock()
.expect("failed cases are not poisoned")
.insert(envelope.case_ref.case_id.clone());
self.failed.store(true, std::sync::atomic::Ordering::SeqCst);
return Err(self.error.clone());
}
self.inner.execute(batch).await
}
}
pub struct PanicOnWriteExecutor<W: turnframe_test::workflows::PureWorkflow> {
inner: SharedExecutor<W>,
}
impl<W: turnframe_test::workflows::PureWorkflow> PanicOnWriteExecutor<W> {
#[must_use]
pub fn new(inner: SharedExecutor<W>) -> Self {
Self { inner }
}
}
#[async_trait::async_trait]
impl<W: turnframe_test::workflows::PureWorkflow> WorkflowExecutor<W> for PanicOnWriteExecutor<W> {
async fn load(
&self,
account: &AccountId,
case_id: &CaseId,
) -> Result<Versioned<Option<W::State>>, StoreError> {
self.inner.load(account, case_id).await
}
async fn execute(
&self,
_batch: CommandBatch<W::Command>,
) -> Result<Commit<W::State, W::Event>, ExecutionError> {
panic!("a path that must not write reached the workflow executor");
}
}
#[derive(Debug, Clone, Default)]
pub struct FixedKnowledge {
chunks: Vec<KnowledgeChunk>,
unavailable: bool,
}
impl FixedKnowledge {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub fn saying(text: &str) -> Self {
Self {
chunks: vec![KnowledgeChunk {
chunk_id: "chunk-1".to_owned(),
source_id: "account_records".to_owned(),
source_version: None,
effective_from: None,
effective_to: None,
permissions: Vec::new(),
citation: turnframe_core::knowledge::Citation {
source_id: "account_records".to_owned(),
label: "The account's own records".to_owned(),
uri: None,
locator: None,
version: None,
},
text: text.to_owned(),
trust: turnframe_core::read::TrustLevel::Authoritative,
sensitivity: turnframe_core::read::DataSensitivity::Internal,
}],
unavailable: false,
}
}
#[must_use]
pub fn unavailable() -> Self {
Self {
chunks: Vec::new(),
unavailable: true,
}
}
}
#[async_trait::async_trait]
impl KnowledgeProvider for FixedKnowledge {
async fn retrieve(
&self,
_request: KnowledgeRequest,
) -> Result<Vec<KnowledgeChunk>, KnowledgeError> {
if self.unavailable {
return Err(KnowledgeError::Unavailable {
source_id: "fixture".to_owned(),
});
}
Ok(self.chunks.clone())
}
}
#[derive(Debug)]
pub struct SteppingClock {
start: DateTime<Utc>,
step: chrono::TimeDelta,
reads: std::sync::atomic::AtomicI64,
}
impl SteppingClock {
#[must_use]
pub fn new(start: DateTime<Utc>, step: chrono::TimeDelta) -> Self {
Self {
start,
step,
reads: std::sync::atomic::AtomicI64::new(0),
}
}
#[must_use]
pub fn every(seconds: i64) -> Self {
Self::new(now(), chrono::TimeDelta::seconds(seconds))
}
}
impl TurnClock for SteppingClock {
fn now(&self) -> DateTime<Utc> {
let reads = self
.reads
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.start + self.step * i32::try_from(reads).unwrap_or(i32::MAX)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Observed {
pub signal: Signal,
pub labels: SignalLabels,
pub duration: Option<std::time::Duration>,
}
#[derive(Debug, Default)]
pub struct RecordingObserver {
seen: std::sync::Mutex<Vec<Observed>>,
}
impl RecordingObserver {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn all(&self) -> Vec<Observed> {
self.seen
.lock()
.map(|seen| seen.clone())
.unwrap_or_default()
}
#[must_use]
pub fn distinct(&self) -> std::collections::BTreeSet<&'static str> {
self.all()
.into_iter()
.map(|observed| observed.signal.name())
.collect()
}
#[must_use]
pub fn count(&self, signal: Signal) -> usize {
self.all()
.iter()
.filter(|observed| observed.signal == signal)
.count()
}
#[must_use]
pub fn occurrences(&self, signal: Signal) -> Vec<Observed> {
self.all()
.into_iter()
.filter(|observed| observed.signal == signal)
.collect()
}
#[must_use]
pub fn once(&self, signal: Signal) -> Observed {
let found = self.occurrences(signal);
assert_eq!(
found.len(),
1,
"{} fired {} times, not once; the turn saw {:?}",
signal.name(),
found.len(),
self.distinct()
);
found.into_iter().next().expect("exactly one")
}
}
impl Observer for RecordingObserver {
fn observe(&self, signal: &Signal) {
self.observe_labeled(signal, &SignalLabels::none());
}
fn observe_labeled(&self, signal: &Signal, labels: &SignalLabels) {
self.record(*signal, labels.clone(), None);
}
fn observe_duration(
&self,
signal: &Signal,
duration: std::time::Duration,
labels: &SignalLabels,
) {
self.record(*signal, labels.clone(), Some(duration));
}
}
impl RecordingObserver {
fn record(&self, signal: Signal, labels: SignalLabels, duration: Option<std::time::Duration>) {
if let Ok(mut seen) = self.seen.lock() {
seen.push(Observed {
signal,
labels,
duration,
});
}
}
}
pub fn assert_labels(observed: &Observed, expected: &SignalLabels) {
assert_eq!(
&observed.labels,
expected,
"{} carried the wrong labels",
observed.signal.name()
);
}
#[must_use]
pub fn narrating() -> ScriptedProviderBuilder {
ScriptedProvider::builder("scripted", "model-1")
.acknowledging("Right, here is where that leaves things.")
}
#[must_use]
pub fn silent() -> ScriptedProviderBuilder {
ScriptedProvider::builder("scripted", "model-1")
}
pub struct HarnessBuilder {
consequences: Option<Arc<dyn turnframe_runtime::orchestrator::TurnConsequences>>,
locales: Vec<turnframe_core::locale::Locale>,
cases: Vec<(CaseCandidate, Option<TripState>, CaseRevision)>,
travelers: Vec<(CaseCandidate, Option<TravelerState>, CaseRevision)>,
broken: Vec<CaseCandidate>,
unwritten: bool,
understander: Arc<ScriptedUnderstanding>,
tasks: Option<Arc<turnframe_tasks::testing::ScriptedTasks>>,
trace: Option<Arc<turnframe_runtime::trace::JsonlTrace>>,
attachment_source: Option<Arc<dyn turnframe_core::turn::AttachmentSource>>,
providers: Vec<Arc<ScriptedProvider>>,
config: OrchestratorConfig,
knowledge: Option<Arc<dyn KnowledgeProvider>>,
observer: Option<Arc<RecordingObserver>>,
trip_failure: Option<ExecutionError>,
trip_refresh_after_failure: Option<(TripState, CaseRevision, bool)>,
trip_failure_armed: Arc<std::sync::atomic::AtomicBool>,
trip_panics_on_write: bool,
origins: Vec<(turnframe_core::ids::OriginToken, CaseKey)>,
clock: Option<Arc<dyn TurnClock>>,
prompts: Option<Arc<dyn turnframe_core::prompt::PromptSource>>,
notice_copy: Option<turnframe_runtime::reduce::NoticeCopy>,
confirmation_copy: Option<turnframe_runtime::policy::ConfirmationCopy>,
directory: Option<Arc<dyn CaseDirectory>>,
}
impl Default for HarnessBuilder {
fn default() -> Self {
Self::new()
}
}
impl HarnessBuilder {
#[must_use]
pub fn new() -> Self {
Self {
consequences: None,
locales: Vec::new(),
cases: Vec::new(),
travelers: Vec::new(),
broken: Vec::new(),
unwritten: false,
understander: Arc::new(ScriptedUnderstanding::new()),
tasks: None,
trace: None,
attachment_source: None,
providers: Vec::new(),
notice_copy: None,
confirmation_copy: None,
config: OrchestratorConfig::conservative(),
knowledge: None,
observer: None,
trip_failure: None,
trip_refresh_after_failure: None,
trip_failure_armed: Arc::new(std::sync::atomic::AtomicBool::new(true)),
trip_panics_on_write: false,
origins: Vec::new(),
clock: None,
prompts: None,
directory: None,
}
}
#[must_use]
pub fn prompts(mut self, source: Arc<dyn turnframe_core::prompt::PromptSource>) -> Self {
self.prompts = Some(source);
self
}
#[must_use]
pub fn clock(mut self, clock: Arc<dyn TurnClock>) -> Self {
self.clock = Some(clock);
self
}
#[must_use]
pub fn case_directory(mut self, directory: Arc<dyn CaseDirectory>) -> Self {
self.directory = Some(directory);
self
}
#[must_use]
pub fn origin(mut self, token: &str, workflow: &str, case_id: &str) -> Self {
self.origins.push((
turnframe_core::ids::OriginToken::from(token),
CaseKey::new(workflow, case_id),
));
self
}
#[must_use]
pub fn understanding_tasks(
mut self,
tasks: Arc<turnframe_tasks::testing::ScriptedTasks>,
) -> Self {
self.tasks = Some(tasks);
self
}
#[must_use]
pub fn trace(mut self, trace: Arc<turnframe_runtime::trace::JsonlTrace>) -> Self {
self.trace = Some(trace);
self
}
#[must_use]
pub fn locales(mut self, locales: &[&str]) -> Self {
self.locales = locales.iter().map(|locale| (*locale).into()).collect();
self
}
#[must_use]
pub fn understands(self, understanding: Understanding) -> Self {
self.understander.push(understanding);
self
}
#[must_use]
pub fn trip(mut self, case_id: &str, label: &str, revision: u64, state: TripState) -> Self {
self.cases.push((
CaseCandidate::new(CaseKey::new(TRIP, case_id), label),
Some(state),
CaseRevision(revision),
));
self
}
#[must_use]
pub fn protected(mut self) -> Self {
if let Some((candidate, _, _)) = self.cases.last_mut() {
*candidate = candidate.clone().confirming_every_write();
}
self
}
#[must_use]
pub fn reachable_only(mut self) -> Self {
if let Some((candidate, _, _)) = self.cases.last_mut() {
*candidate = candidate.clone().subject_only_when_named();
}
self
}
#[must_use]
pub fn traveler(
mut self,
case_id: &str,
label: &str,
revision: u64,
state: TravelerState,
) -> Self {
self.travelers.push((
CaseCandidate::new(CaseKey::new(TRAVELER, case_id), label),
Some(state),
CaseRevision(revision),
));
self
}
#[must_use]
pub fn attachments(mut self, source: Arc<dyn turnframe_core::turn::AttachmentSource>) -> Self {
self.attachment_source = Some(source);
self
}
pub fn unwritten(mut self) -> Self {
self.unwritten = true;
self
}
#[must_use]
pub fn broken_case(mut self, case_id: &str, label: &str) -> Self {
self.broken
.push(CaseCandidate::new(CaseKey::new(BROKEN, case_id), label));
self
}
#[must_use]
pub fn provider(mut self, provider: Arc<ScriptedProvider>) -> Self {
self.providers.push(provider);
self
}
#[must_use]
pub fn consequences(
mut self,
consequences: Arc<dyn turnframe_runtime::orchestrator::TurnConsequences>,
) -> Self {
self.consequences = Some(consequences);
self
}
#[must_use]
pub fn notice_copy(mut self, copy: turnframe_runtime::reduce::NoticeCopy) -> Self {
self.notice_copy = Some(copy);
self
}
#[must_use]
pub fn confirmation_copy(mut self, copy: turnframe_runtime::policy::ConfirmationCopy) -> Self {
self.confirmation_copy = Some(copy);
self
}
#[must_use]
pub fn config(mut self, config: OrchestratorConfig) -> Self {
self.config = config;
self
}
#[must_use]
pub fn narration(mut self, narration: NarrationConfig) -> Self {
self.config.narration = narration;
self
}
#[must_use]
pub fn without_narration(mut self) -> Self {
self.config.narration = NarrationConfig::conservative().with_enabled(false);
self
}
#[must_use]
pub fn knowledge(mut self, knowledge: Arc<dyn KnowledgeProvider>) -> Self {
self.knowledge = Some(knowledge);
self
}
#[must_use]
pub fn observing(mut self) -> Self {
self.observer = Some(Arc::new(RecordingObserver::new()));
self
}
#[must_use]
pub fn trip_fails(mut self, error: ExecutionError) -> Self {
self.trip_failure = Some(error);
self
}
#[must_use]
pub fn trip_fails_once_armed(mut self, error: ExecutionError) -> Self {
self.trip_failure = Some(error);
self.trip_failure_armed = Arc::new(std::sync::atomic::AtomicBool::new(false));
self
}
#[must_use]
pub fn trip_refresh_after_failure(
mut self,
state: TripState,
revision: CaseRevision,
fail_read: bool,
) -> Self {
self.trip_refresh_after_failure = Some((state, revision, fail_read));
self
}
#[must_use]
pub fn trip_panics_on_write(mut self) -> Self {
self.trip_panics_on_write = true;
self
}
pub async fn build(self) -> Harness {
self.try_build().await.expect("a complete orchestrator")
}
pub async fn try_build(self) -> Result<Harness, turnframe_runtime::orchestrator::BuildError> {
let stores = FakeStores::at(now());
let trip = Arc::new(InMemoryExecutor::new(TripWorkflow::new().with_cards()));
let traveler = Arc::new(InMemoryExecutor::new(
TravelerWorkflow::only_while_a_trip_is_open().with_cards(),
));
let broken = Arc::new(InMemoryExecutor::new(BrokenWorkflow::default()));
let unwritten = Arc::new(InMemoryExecutor::new(StartsWithoutWriting::default()));
let trip_load_counts = Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::<
CaseId,
usize,
>::new()));
let account = account();
for (candidate, state, revision) in &self.cases {
if let Some(state) = state {
trip.seed(&account, &candidate.key.case_id, state.clone(), *revision);
}
}
for (candidate, state, revision) in &self.travelers {
if let Some(state) = state {
traveler.seed(&account, &candidate.key.case_id, state.clone(), *revision);
}
}
let trip_shared = SharedExecutor(Arc::clone(&trip));
let with_trip = if self.trip_panics_on_write {
WorkflowRegistry::builder().register(
TripWorkflow::new().with_cards(),
PanicOnWriteExecutor::new(trip_shared),
)
} else {
match self.trip_failure {
Some(error) => WorkflowRegistry::builder().register(
TripWorkflow::new().with_cards(),
FailingExecutor::armable(
trip_shared,
error,
Arc::clone(&self.trip_failure_armed),
)
.with_refresh_after_failure(self.trip_refresh_after_failure)
.with_load_counts(Arc::clone(&trip_load_counts)),
),
None => WorkflowRegistry::builder()
.register(TripWorkflow::new().with_cards(), trip_shared),
}
};
let mut builder = with_trip.register(
TravelerWorkflow::only_while_a_trip_is_open().with_cards(),
SharedExecutor(Arc::clone(&traveler)),
);
if !self.broken.is_empty() {
builder = builder.register(
BrokenWorkflow::default(),
SharedExecutor(Arc::clone(&broken)),
);
}
if self.unwritten {
builder = builder.register(
StartsWithoutWriting::default(),
SharedExecutor(Arc::clone(&unwritten)),
);
}
let registry = builder.build().expect("distinct workflow keys");
let traced = |provider: Arc<dyn turnframe_provider::provider::ModelProvider>| match &self
.trace
{
Some(trace) => Arc::new(turnframe_provider::trace::TracedProvider::new(
provider,
Arc::clone(trace) as Arc<dyn turnframe_provider::trace::CallTrace>,
)) as Arc<dyn turnframe_provider::provider::ModelProvider>,
None => provider,
};
let mut pool = ProviderPool::builder();
if let Some(tasks) = &self.tasks {
pool = pool.provider(traced(Arc::clone(tasks) as _));
}
for provider in &self.providers {
pool = pool.provider(traced(Arc::clone(provider) as _));
}
let pool = Arc::new(pool.build().expect("the pool has distinct model keys"));
let directory: Arc<dyn CaseDirectory> = match self.directory.clone() {
Some(directory) => directory,
None => {
let mut directory = StaticCaseDirectory::new();
for (candidate, _, _) in &self.cases {
directory = directory.with_case(candidate.clone());
}
for (candidate, _, _) in &self.travelers {
directory = directory.with_case(candidate.clone());
}
for candidate in &self.broken {
directory = directory.with_case(candidate.clone());
}
for (token, key) in &self.origins {
directory = directory.with_origin(
token.clone(),
CaseCandidate::new(key.clone(), "the record you have open"),
);
}
Arc::new(directory)
}
};
let observer = self.observer.clone();
let mut builder = Orchestrator::builder()
.workflows(Arc::new(registry))
.providers(Arc::clone(&pool))
.stores(stores.stores().clone())
.case_directory(directory)
.policy(PolicySnapshot::conservative())
.clock(
self.clock
.clone()
.unwrap_or_else(|| Arc::new(FixedTurnClock(now()))),
)
.config(read_once(self.config.clone()));
if self.tasks.is_none() {
builder = builder.understander(Arc::clone(&self.understander) as _);
}
if let Some(consequences) = self.consequences {
builder = builder.consequences(consequences);
}
if let Some(knowledge) = self.knowledge {
builder = builder.knowledge(knowledge);
}
if let Some(observer) = observer.clone() {
builder = builder.observer(observer);
}
if let Some(prompts) = self.prompts {
builder = builder.prompt_source(prompts);
}
if let Some(copy) = self.notice_copy {
builder = builder.notice_copy(copy);
}
if let Some(source) = self.attachment_source {
builder = builder.attachments(source);
}
if let Some(copy) = self.confirmation_copy {
builder = builder.confirmation_copy(copy);
}
if let Some(trace) = self.trace.clone() {
builder = builder.trace(trace);
}
if !self.locales.is_empty() {
builder = builder.locales(self.locales.clone());
}
let orchestrator = Arc::new(builder.build()?);
let conversation = ConversationId::nil();
stores
.stores()
.conversations()
.create_conversation(turnframe_store::conversation::ConversationRecord::new(
conversation,
account.clone(),
now(),
))
.await
.expect("the conversation is new");
Ok(Harness {
stores,
trip,
traveler,
orchestrator,
providers: self.providers,
understander: self.understander,
observer,
conversation,
trip_failure_armed: self.trip_failure_armed,
trip_load_counts,
})
}
}
pub struct Harness {
pub stores: FakeStores,
pub trip: Arc<InMemoryExecutor<TripWorkflow>>,
pub traveler: Arc<InMemoryExecutor<TravelerWorkflow>>,
pub orchestrator: Arc<Orchestrator>,
pub providers: Vec<Arc<ScriptedProvider>>,
pub understander: Arc<ScriptedUnderstanding>,
pub observer: Option<Arc<RecordingObserver>>,
pub conversation: ConversationId,
trip_failure_armed: Arc<std::sync::atomic::AtomicBool>,
trip_load_counts: Arc<std::sync::Mutex<std::collections::BTreeMap<CaseId, usize>>>,
}
impl Harness {
#[must_use]
pub fn builder() -> HarnessBuilder {
HarnessBuilder::new()
}
#[must_use]
pub fn account(&self) -> AccountId {
account()
}
#[must_use]
pub fn observed(&self) -> Arc<RecordingObserver> {
Arc::clone(
self.observer
.as_ref()
.expect("the harness was built with `observing()`"),
)
}
pub fn understands(&self, understanding: Understanding) {
self.understander.push(understanding);
}
pub fn arm_trip_failure(&self) {
self.trip_failure_armed
.store(true, std::sync::atomic::Ordering::SeqCst);
}
#[must_use]
pub fn turn_from(&self, turn_id: TurnId, text: &str, origin: &str) -> TurnInput {
TurnInput {
origin: Some(turnframe_core::turn::OriginRef {
origin_token: turnframe_core::ids::OriginToken::from(origin),
signature: None,
surface: Some("trip_detail".to_owned()),
}),
..self.turn(turn_id, text)
}
}
#[must_use]
pub fn turn(&self, turn_id: TurnId, text: &str) -> TurnInput {
TurnInput {
turn_id,
conversation_id: self.conversation,
actor: ActorContext::new(account(), "u1"),
text: Some(text.to_owned()),
interaction_response: None,
attachments: Vec::new(),
origin: None,
locale: Locale::from("en-GB"),
effort: None,
}
}
#[must_use]
pub fn click(
&self,
turn_id: TurnId,
interaction_id: InteractionId,
option: &str,
revision: u64,
) -> TurnInput {
TurnInput {
interaction_response: Some(InteractionResponse {
interaction_id,
option_id: OptionId::from(option),
expected_case_revision: CaseRevision(revision),
freeform_input: None,
}),
text: None,
..self.turn(turn_id, "")
}
}
#[must_use]
pub fn click_and_say(
&self,
turn_id: TurnId,
interaction_id: InteractionId,
option: &str,
revision: u64,
text: &str,
) -> TurnInput {
TurnInput {
text: Some(text.to_owned()),
..self.click(turn_id, interaction_id, option, revision)
}
}
#[must_use]
pub fn token(&self, turn_id: TurnId, workflow: &str, case_id: &str) -> TargetToken {
token_for(turn_id, workflow, case_id)
}
pub async fn blocking_card(&self, workflow: &str, case_id: &str) -> Interaction {
let open = self.open_cards(workflow, case_id).await;
open.into_iter()
.find(|card| card.blocking)
.expect("the case has a blocking card")
}
pub async fn handle(
&self,
input: TurnInput,
) -> Result<AssistantTurn, turnframe_core::error::OrchestratorError> {
self.orchestrator.handle_turn(input).await
}
pub async fn replay(&self, turn_id: TurnId) -> turnframe_core::replay::ReplayRecord {
self.stores
.stores()
.replay()
.get(&account(), &turn_id)
.await
.expect("the turn recorded a replay record")
}
pub async fn open_cards(&self, workflow: &str, case_id: &str) -> Vec<Interaction> {
self.stores
.open_interactions(&account(), &CaseKey::new(workflow, case_id))
.await
.expect("the store answers")
}
pub async fn events(&self, workflow: &str, case_id: &str) -> Vec<String> {
self.stores
.event_types(&account(), &CaseKey::new(workflow, case_id))
.await
.expect("the store answers")
}
pub async fn journal(
&self,
turn_id: TurnId,
) -> Vec<turnframe_store::journal::CommandJournalEntry> {
self.stores
.journal_for_turn(&account(), &turn_id)
.await
.expect("the store answers")
}
pub async fn phase(&self, turn_id: TurnId) -> TurnPhase {
self.stores
.turn_phase(&account(), &turn_id)
.await
.expect("the turn exists")
.phase
}
pub async fn stored_turn(&self, turn_id: TurnId) -> Option<AssistantTurn> {
self.stores
.turn(&account(), &turn_id)
.await
.ok()
.and_then(|stored| stored.assistant)
}
#[must_use]
pub fn trip_revision(&self, case_id: &str) -> CaseRevision {
self.trip.revision_of(&account(), &CaseId::from(case_id))
}
#[must_use]
pub fn trip_load_count(&self, case_id: &str) -> usize {
self.trip_load_counts
.lock()
.expect("load counts are not poisoned")
.get(&CaseId::from(case_id))
.copied()
.unwrap_or_default()
}
#[must_use]
pub fn trip_name(&self, case_id: &str) -> Option<String> {
self.trip
.state_of(&account(), &CaseId::from(case_id))
.and_then(|state| state.name)
}
pub async fn outside(
&self,
case_id: &str,
key: &str,
command: TripCommand,
) -> Result<Commit<TripState, TripEvent>, ExecutionError> {
self.trip
.execute(self.outside_batch(case_id, key, command))
.await
}
#[must_use]
pub fn outside_batch(
&self,
case_id: &str,
key: &str,
command: TripCommand,
) -> CommandBatch<TripCommand> {
use turnframe_core::command::{AtomicityScope, CommandEnvelope, CommandOrigin};
use turnframe_core::ids::{BatchId, CommandId};
use turnframe_core::understanding::{ActId, UnitId};
let case_id = CaseId::from(case_id);
let revision = self.trip.revision_of(&account(), &case_id);
let seed = key.bytes().fold(0xa1_u128, |hash, byte| {
hash.wrapping_mul(131).wrapping_add(u128::from(byte))
});
let turn_id = TurnId::from(uuid::Uuid::from_u128(seed));
let case_ref = CaseRef::new(TRIP, case_id, revision);
CommandBatch {
batch_id: BatchId::derive(&turn_id, &case_ref.key(), &AtomicityScope::PerCase),
scope: AtomicityScope::PerCase,
envelopes: vec![CommandEnvelope {
command_id: CommandId::derive(&turn_id, ActId::new(UnitId(1), 1), 0),
turn_id,
actor: ActorContext::new(account(), "airline"),
case_ref,
idempotency_key: IdempotencyKey::new(format!("airline:{key}")),
origin: CommandOrigin::ExternalCallback {
callback_id: key.to_owned(),
signature_verified: true,
},
command,
}],
}
}
#[must_use]
pub fn trip_state(&self, case_id: &str) -> Option<TripState> {
self.trip.state_of(&account(), &CaseId::from(case_id))
}
#[must_use]
pub fn trip_traveler(&self, case_id: &str) -> Option<String> {
self.trip
.state_of(&account(), &CaseId::from(case_id))
.and_then(|state| state.traveler)
.map(|traveler| traveler.display_name)
}
pub fn fail_at(&self, point: FailurePoint, error: StoreError) {
self.stores
.fail_at(point, error)
.expect("the store is not poisoned");
}
pub async fn idempotency_keys(&self, turn_id: TurnId) -> Vec<IdempotencyKey> {
self.journal(turn_id)
.await
.into_iter()
.map(|entry| entry.idempotency_key)
.collect()
}
}
#[must_use]
pub fn token_for(turn_id: TurnId, workflow: &str, case_id: &str) -> TargetToken {
let key = CaseKey::new(workflow, case_id);
TargetResolver::builder(account(), turn_id)
.candidate(AuthorizedCase::new(
CaseRef::new(workflow, case_id, CaseRevision::ZERO),
"label",
))
.build()
.token_map()
.token_for(&key)
.cloned()
.expect("a token was issued for the case")
}
#[must_use]
pub fn receipt_codes(turn: &AssistantTurn) -> Vec<String> {
turn.receipts()
.map(|receipt| receipt.status_code.clone())
.collect()
}
#[must_use]
pub fn notice_codes(turn: &AssistantTurn) -> Vec<String> {
turn.blocks
.iter()
.filter_map(|block| match block {
ResponseBlock::Notice(notice) => Some(notice.code.clone()),
_ => None,
})
.collect()
}
#[must_use]
pub fn block_kinds(turn: &AssistantTurn) -> Vec<&'static str> {
turn.blocks
.iter()
.map(|block| match block {
ResponseBlock::Answer(_) => "answer",
ResponseBlock::Transition(_) => "transition",
ResponseBlock::Receipt(_) => "receipt",
ResponseBlock::Notice(_) => "notice",
ResponseBlock::Interaction(_) => "interaction",
ResponseBlock::Artifact(_) => "artifact",
_ => "other",
})
.collect()
}
#[must_use]
pub fn narration(turn: &AssistantTurn) -> String {
turn.blocks
.iter()
.filter_map(|block| match block {
ResponseBlock::Answer(answer) => Some(answer.text.as_str()),
ResponseBlock::Transition(transition) => Some(transition.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join(" ")
}
#[must_use]
pub fn transient_failure() -> ProviderError {
ProviderError::server(Some(503))
}
#[derive(Debug, Default, Clone, Copy)]
pub struct BrokenWorkflow(TravelerWorkflow);
pub const BROKEN: &str = "broken";
#[derive(Debug, Default, Clone, Copy)]
pub struct StartsWithoutWriting(turnframe_test::workflows::trip::TripWorkflow);
pub const UNWRITTEN: &str = "unwritten";
impl turnframe_core::flow::WorkflowDefinition for StartsWithoutWriting {
type State = <turnframe_test::workflows::trip::TripWorkflow as turnframe_core::flow::WorkflowDefinition>::State;
type Phase = <turnframe_test::workflows::trip::TripWorkflow as turnframe_core::flow::WorkflowDefinition>::Phase;
type Obligation = <turnframe_test::workflows::trip::TripWorkflow as turnframe_core::flow::WorkflowDefinition>::Obligation;
type Command = <turnframe_test::workflows::trip::TripWorkflow as turnframe_core::flow::WorkflowDefinition>::Command;
type Event = <turnframe_test::workflows::trip::TripWorkflow as turnframe_core::flow::WorkflowDefinition>::Event;
type Outcome = <turnframe_test::workflows::trip::TripWorkflow as turnframe_core::flow::WorkflowDefinition>::Outcome;
fn key(&self) -> turnframe_core::ids::WorkflowKey {
turnframe_core::ids::WorkflowKey::from(UNWRITTEN)
}
fn version(&self) -> turnframe_core::ids::WorkflowVersion {
self.0.version()
}
fn start_preconditions(&self) -> Vec<turnframe_core::flow::StartPrecondition> {
vec![turnframe_core::flow::StartPrecondition::new(
"traveler",
[serde_json::json!("collecting")],
turnframe_core::locale::LocalizedText::new("There is no traveler being filled in."),
)]
}
fn compile_act(
&self,
state: Option<&Self::State>,
view: &turnframe_core::flow::ViewOf<Self>,
act: &turnframe_core::target::ResolvedAct,
) -> Result<Vec<Self::Command>, turnframe_core::error::DomainRejection> {
if matches!(
act.kind,
turnframe_core::target::ResolvedActKind::StartWorkflow
) {
return Ok(Vec::new());
}
self.0.compile_act(state, view, act)
}
fn phase_ownership(&self, phase: &Self::Phase) -> turnframe_core::flow::PhaseOwnership {
self.0.phase_ownership(phase)
}
fn project(
&self,
case_ref: CaseRef,
state: Option<&Self::State>,
) -> turnframe_core::flow::ViewOf<Self> {
self.0.project(case_ref, state)
}
fn operations(
&self,
view: &turnframe_core::flow::ViewOf<Self>,
) -> Vec<turnframe_core::operation::OperationSpec> {
self.0.operations(view)
}
fn transition_briefing(&self, view: &turnframe_core::flow::ViewOf<Self>) -> Option<String> {
self.0.transition_briefing(view)
}
fn command_policy(
&self,
state: Option<&Self::State>,
command: &Self::Command,
) -> turnframe_core::command::CommandPolicy {
self.0.command_policy(state, command)
}
fn validate_command(
&self,
state: Option<&Self::State>,
command: &Self::Command,
) -> Result<(), turnframe_core::error::DomainRejection> {
self.0.validate_command(state, command)
}
fn receipts(
&self,
events: &[turnframe_core::event::ReceiptEvent<Self::Event>],
locale: &Locale,
) -> Vec<turnframe_core::event::OperationalReceipt> {
self.0.receipts(events, locale)
}
}
impl turnframe_test::workflows::PureWorkflow for StartsWithoutWriting {
fn apply(
&self,
state: Option<&Self::State>,
command: &Self::Command,
) -> Result<
turnframe_test::workflows::Applied<Self::State, Self::Event>,
turnframe_core::error::DomainRejection,
> {
self.0.apply(state, command)
}
fn event_type(&self, event: &Self::Event) -> String {
self.0.event_type(event)
}
}
impl turnframe_core::flow::WorkflowDefinition for BrokenWorkflow {
type State = <TravelerWorkflow as turnframe_core::flow::WorkflowDefinition>::State;
type Phase = <TravelerWorkflow as turnframe_core::flow::WorkflowDefinition>::Phase;
type Obligation = <TravelerWorkflow as turnframe_core::flow::WorkflowDefinition>::Obligation;
type Command = <TravelerWorkflow as turnframe_core::flow::WorkflowDefinition>::Command;
type Event = <TravelerWorkflow as turnframe_core::flow::WorkflowDefinition>::Event;
type Outcome = <TravelerWorkflow as turnframe_core::flow::WorkflowDefinition>::Outcome;
fn key(&self) -> turnframe_core::ids::WorkflowKey {
turnframe_core::ids::WorkflowKey::from(BROKEN)
}
fn version(&self) -> turnframe_core::ids::WorkflowVersion {
self.0.version()
}
fn phase_ownership(&self, _phase: &Self::Phase) -> turnframe_core::flow::PhaseOwnership {
turnframe_core::flow::PhaseOwnership::User
}
fn project(
&self,
case_ref: CaseRef,
state: Option<&Self::State>,
) -> turnframe_core::flow::ViewOf<Self> {
self.0.project(case_ref, state)
}
fn operations(
&self,
view: &turnframe_core::flow::ViewOf<Self>,
) -> Vec<turnframe_core::operation::OperationSpec> {
self.0.operations(view)
}
fn compile_act(
&self,
state: Option<&Self::State>,
view: &turnframe_core::flow::ViewOf<Self>,
act: &turnframe_core::target::ResolvedAct,
) -> Result<Vec<Self::Command>, turnframe_core::error::DomainRejection> {
self.0.compile_act(state, view, act)
}
fn command_policy(
&self,
state: Option<&Self::State>,
command: &Self::Command,
) -> turnframe_core::command::CommandPolicy {
self.0.command_policy(state, command)
}
fn validate_command(
&self,
state: Option<&Self::State>,
command: &Self::Command,
) -> Result<(), turnframe_core::error::DomainRejection> {
self.0.validate_command(state, command)
}
fn receipts(
&self,
events: &[turnframe_core::event::ReceiptEvent<Self::Event>],
locale: &Locale,
) -> Vec<turnframe_core::event::OperationalReceipt> {
self.0.receipts(events, locale)
}
}
impl turnframe_test::workflows::PureWorkflow for BrokenWorkflow {
fn apply(
&self,
state: Option<&Self::State>,
command: &Self::Command,
) -> Result<
turnframe_test::workflows::Applied<Self::State, Self::Event>,
turnframe_core::error::DomainRejection,
> {
self.0.apply(state, command)
}
fn event_type(&self, event: &Self::Event) -> String {
self.0.event_type(event)
}
}
fn read_once(mut config: OrchestratorConfig) -> OrchestratorConfig {
if config.effort.medium.settings.is_none() {
config.effort.medium.settings = Some(config.understanding.settings);
}
for kind in [
turnframe_tasks::TaskKind::Segment,
turnframe_tasks::TaskKind::Route,
] {
let configured = config.understanding.tasks.get(kind);
let mut change = turnframe_tasks::ProfileChange::default();
change.votes = Some(configured.votes);
change.on_disagreement = Some(configured.on_disagreement);
config.effort.medium.tasks = config.effort.medium.tasks.with(kind, change);
}
config
}
#[must_use]
pub fn rebooking_requested(turn: TurnId) -> (&'static str, Understanding) {
let text = "show me the rebooking card";
let understanding = turnframe_test::providers::UnderstandingBuilder::of(text)
.apply(
turnframe_test::workflows::trip::operations::REQUEST_REBOOKING,
token_for(turn, TRIP, "trip-1"),
serde_json::json!({"leg": 1}),
text,
)
.build()
.expect("the words are in the message");
(text, understanding)
}