1use chrono::{DateTime, Utc};
18use meerkat_core::lifecycle::{InputId, RunId};
19
20use crate::identifiers::IdempotencyKey;
21use crate::input_state::{
22 InputAbandonReason, InputLifecycleState, InputTerminalOutcome, StoredInputState,
23};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum InteractionSelector {
28 InputId(InputId),
30 IdempotencyKey(String),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum TerminalWitnessSource {
37 LiveRuntime,
39 DurableStore,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub struct InteractionTerminalReport {
47 pub input_id: InputId,
48 pub phase: InputLifecycleState,
50 pub terminal: Option<InputTerminalOutcome>,
52 pub resolving_run_id: Option<RunId>,
54 pub attempt_count: u32,
55 pub idempotency_key: Option<IdempotencyKey>,
56 pub updated_at: DateTime<Utc>,
57}
58
59#[derive(Debug, Clone, PartialEq)]
61pub enum RunResolutionClass {
62 Consumed,
64 Abandoned { reason: InputAbandonReason },
67 Displaced,
69}
70
71#[derive(Debug, Clone, PartialEq)]
73pub enum RunTerminalStatus {
74 InFlight,
76 Resolved { outcome: RunResolutionClass },
78 NoDurableWitness,
81}
82
83#[derive(Debug, Clone, PartialEq)]
85pub struct RunTerminalReport {
86 pub run_id: RunId,
87 pub status: RunTerminalStatus,
88 pub witnesses: Vec<InteractionTerminalReport>,
90}
91
92#[derive(Debug, Clone, PartialEq)]
94pub struct Sourced<T> {
95 pub source: TerminalWitnessSource,
96 pub report: T,
97}
98
99#[must_use]
104pub fn interaction_report(bundle: &StoredInputState) -> InteractionTerminalReport {
105 InteractionTerminalReport {
106 input_id: bundle.state.input_id.clone(),
107 phase: bundle.seed.phase,
108 terminal: bundle.seed.terminal_outcome.clone(),
109 resolving_run_id: bundle.seed.last_run_id.clone(),
110 attempt_count: bundle.seed.attempt_count,
111 idempotency_key: bundle.state.idempotency_key.clone(),
112 updated_at: bundle.state.updated_at,
113 }
114}
115
116#[must_use]
123pub fn find_by_idempotency_key<'a>(
124 inputs: &'a [StoredInputState],
125 key: &str,
126) -> Option<&'a StoredInputState> {
127 inputs.iter().find(|stored| {
128 stored
129 .state
130 .idempotency_key
131 .as_ref()
132 .is_some_and(|stored_key| stored_key.0 == key)
133 })
134}
135
136#[must_use]
143pub fn evaluate_run(run_id: &RunId, inputs: &[StoredInputState]) -> RunTerminalReport {
144 let mut witnesses: Vec<&StoredInputState> = inputs
145 .iter()
146 .filter(|stored| stored.seed.last_run_id.as_ref() == Some(run_id))
147 .collect();
148 witnesses.sort_by(|a, b| {
151 let a_key = (
152 a.seed.admission_sequence.is_none(),
153 a.seed.admission_sequence,
154 );
155 let b_key = (
156 b.seed.admission_sequence.is_none(),
157 b.seed.admission_sequence,
158 );
159 a_key
160 .cmp(&b_key)
161 .then_with(|| a.state.input_id.0.cmp(&b.state.input_id.0))
162 });
163
164 let status = if witnesses.is_empty() {
165 RunTerminalStatus::NoDurableWitness
166 } else if witnesses
167 .iter()
168 .any(|stored| stored.seed.terminal_outcome.is_none())
169 {
170 RunTerminalStatus::InFlight
171 } else if witnesses.iter().any(|stored| {
172 matches!(
173 stored.seed.terminal_outcome,
174 Some(InputTerminalOutcome::Consumed)
175 )
176 }) {
177 RunTerminalStatus::Resolved {
178 outcome: RunResolutionClass::Consumed,
179 }
180 } else if let Some(reason) =
181 witnesses
182 .iter()
183 .find_map(|stored| match &stored.seed.terminal_outcome {
184 Some(InputTerminalOutcome::Abandoned { reason }) => Some(reason.clone()),
185 _ => None,
186 })
187 {
188 RunTerminalStatus::Resolved {
189 outcome: RunResolutionClass::Abandoned { reason },
190 }
191 } else {
192 RunTerminalStatus::Resolved {
193 outcome: RunResolutionClass::Displaced,
194 }
195 };
196
197 RunTerminalReport {
198 run_id: run_id.clone(),
199 status,
200 witnesses: witnesses.into_iter().map(interaction_report).collect(),
201 }
202}
203
204#[cfg(test)]
205#[allow(clippy::unwrap_used, clippy::expect_used)]
206mod tests {
207 use super::*;
208 use crate::input_state::{InputState, InputStateSeed};
209
210 fn witness(
211 run_id: Option<&RunId>,
212 terminal: Option<InputTerminalOutcome>,
213 admission_sequence: Option<u64>,
214 idempotency_key: Option<&str>,
215 ) -> StoredInputState {
216 let input_id = InputId::new();
217 let mut state = InputState::new_accepted(input_id.clone());
218 state.idempotency_key = idempotency_key.map(IdempotencyKey::new);
219 let phase = match &terminal {
220 None => InputLifecycleState::Staged,
221 Some(InputTerminalOutcome::Consumed) => InputLifecycleState::Consumed,
222 Some(InputTerminalOutcome::Superseded { .. }) => InputLifecycleState::Superseded,
223 Some(InputTerminalOutcome::Coalesced { .. }) => InputLifecycleState::Coalesced,
224 Some(InputTerminalOutcome::Abandoned { .. }) => InputLifecycleState::Abandoned,
225 };
226 StoredInputState {
227 state,
228 seed: InputStateSeed {
229 phase,
230 last_run_id: run_id.cloned(),
231 last_boundary_sequence: None,
232 admission_sequence,
233 terminal_outcome: terminal,
234 attempt_count: 1,
235 recovery_lane: None,
236 },
237 }
238 }
239
240 fn abandoned(reason: InputAbandonReason) -> Option<InputTerminalOutcome> {
241 Some(InputTerminalOutcome::Abandoned { reason })
242 }
243
244 #[test]
245 fn consumed_takes_precedence_over_abandoned_and_displaced() {
246 let run_id = RunId::new();
247 let inputs = vec![
248 witness(
249 Some(&run_id),
250 abandoned(InputAbandonReason::Cancelled),
251 Some(1),
252 None,
253 ),
254 witness(
255 Some(&run_id),
256 Some(InputTerminalOutcome::Consumed),
257 Some(2),
258 None,
259 ),
260 witness(
261 Some(&run_id),
262 Some(InputTerminalOutcome::Superseded {
263 superseded_by: InputId::new(),
264 }),
265 Some(3),
266 None,
267 ),
268 ];
269 let report = evaluate_run(&run_id, &inputs);
270 assert_eq!(
271 report.status,
272 RunTerminalStatus::Resolved {
273 outcome: RunResolutionClass::Consumed
274 }
275 );
276 assert_eq!(report.witnesses.len(), 3);
277 }
278
279 #[test]
280 fn abandoned_cause_is_first_abandoned_witness_in_admission_order() {
281 let run_id = RunId::new();
282 let inputs = vec![
283 witness(
284 Some(&run_id),
285 abandoned(InputAbandonReason::Stopped),
286 Some(9),
287 None,
288 ),
289 witness(
290 Some(&run_id),
291 abandoned(InputAbandonReason::Cancelled),
292 Some(2),
293 None,
294 ),
295 ];
296 let report = evaluate_run(&run_id, &inputs);
297 assert_eq!(
298 report.status,
299 RunTerminalStatus::Resolved {
300 outcome: RunResolutionClass::Abandoned {
301 reason: InputAbandonReason::Cancelled
302 }
303 },
304 "the FIRST abandoned witness in admission order supplies the cause"
305 );
306 }
307
308 #[test]
309 fn all_superseded_or_coalesced_is_displaced() {
310 let run_id = RunId::new();
311 let inputs = vec![
312 witness(
313 Some(&run_id),
314 Some(InputTerminalOutcome::Superseded {
315 superseded_by: InputId::new(),
316 }),
317 Some(1),
318 None,
319 ),
320 witness(
321 Some(&run_id),
322 Some(InputTerminalOutcome::Coalesced {
323 aggregate_id: InputId::new(),
324 }),
325 Some(2),
326 None,
327 ),
328 ];
329 let report = evaluate_run(&run_id, &inputs);
330 assert_eq!(
331 report.status,
332 RunTerminalStatus::Resolved {
333 outcome: RunResolutionClass::Displaced
334 }
335 );
336 }
337
338 #[test]
339 fn one_non_terminal_witness_is_in_flight() {
340 let run_id = RunId::new();
341 let inputs = vec![
342 witness(
343 Some(&run_id),
344 Some(InputTerminalOutcome::Consumed),
345 Some(1),
346 None,
347 ),
348 witness(Some(&run_id), None, Some(2), None),
349 ];
350 let report = evaluate_run(&run_id, &inputs);
351 assert_eq!(report.status, RunTerminalStatus::InFlight);
352 }
353
354 #[test]
355 fn empty_witness_set_is_no_durable_witness() {
356 let run_id = RunId::new();
357 let other_run = RunId::new();
358 let inputs = vec![witness(
359 Some(&other_run),
360 Some(InputTerminalOutcome::Consumed),
361 Some(1),
362 None,
363 )];
364 let report = evaluate_run(&run_id, &inputs);
365 assert_eq!(report.status, RunTerminalStatus::NoDurableWitness);
366 assert!(report.witnesses.is_empty());
367 }
368
369 #[test]
370 fn find_by_idempotency_key_is_exact_match_only() {
371 let run_id = RunId::new();
372 let inputs = vec![
373 witness(
374 Some(&run_id),
375 Some(InputTerminalOutcome::Consumed),
376 Some(1),
377 Some("interaction-1"),
378 ),
379 witness(Some(&run_id), None, Some(2), None),
380 ];
381 assert!(find_by_idempotency_key(&inputs, "interaction-1").is_some());
382 assert!(
383 find_by_idempotency_key(&inputs, "interaction").is_none(),
384 "prefix must not match"
385 );
386 assert!(
387 find_by_idempotency_key(&inputs, "interaction-12").is_none(),
388 "superstring must not match"
389 );
390 }
391
392 #[test]
393 fn interaction_report_projects_seed_and_shell_facts() {
394 let run_id = RunId::new();
395 let stored = witness(
396 Some(&run_id),
397 Some(InputTerminalOutcome::Consumed),
398 Some(7),
399 Some("key-7"),
400 );
401 let report = interaction_report(&stored);
402 assert_eq!(report.input_id, stored.state.input_id);
403 assert_eq!(report.phase, InputLifecycleState::Consumed);
404 assert_eq!(report.terminal, Some(InputTerminalOutcome::Consumed));
405 assert_eq!(report.resolving_run_id, Some(run_id));
406 assert_eq!(report.attempt_count, 1);
407 assert_eq!(report.idempotency_key, Some(IdempotencyKey::new("key-7")));
408 }
409}