Skip to main content

turnframe_runtime/
resume.rs

1//! What a card remembers, so an answer continues the work it interrupted
2//! (spec §13.3, §15.3).
3//!
4//! A card raised because an act could not finish carries that act as a [`DeferredAct`]
5//! in its payload metadata, under [`DEFERRED_ACT_KEY`], covered by the payload hash.
6//! When the card is answered, [`resume`] turns the stored option into the act to run,
7//! and the runtime feeds it through the normal reducer, policy and execution path.
8//! Nothing here executes, and nothing skips a confirmation.
9//!
10//! | Stored action | What resuming does |
11//! | --- | --- |
12//! | `SelectTarget` | rebinds the act to the case the user picked, and runs it |
13//! | `ResolveClarification`, `Custom` | runs the act against the case it was aimed at |
14//! | `Dismiss`, `DeclineCommands`, `DeclineAndRecord` | [`Resumption::Declined`] |
15//! | `ConfirmCommands`, `ApplyOperation` | nothing: both have their own paths |
16//!
17//! A confirmation card may also carry [`DependentAct`]s: acts of the same message that
18//! needed what the confirmed commands make. [`dependents`] returns them for the turn
19//! that answers the card, after those commands have committed.
20
21use serde::{Deserialize, Serialize};
22use turnframe_core::case::CaseRef;
23use turnframe_core::interaction::{AcceptedResponse, InteractionPayload, StoredInteractionAction};
24use turnframe_core::target::TargetTokenMap;
25use turnframe_core::understanding::{
26    ActAction, ActId, ActStatus, ActTarget, ArgumentValue, RecordValue, UnderstoodAct, UnitId,
27    WordRange,
28};
29
30/// Key the deferred act is stored under in a card's payload metadata.
31pub const DEFERRED_ACT_KEY: &str = "turnframe.deferred_act";
32
33/// The unit an act that came from a card belongs to: `u0`.
34pub const CARD_UNIT: UnitId = UnitId(0);
35
36/// The identifier of the act a card answer puts in the plan: `u0.a1`.
37#[must_use]
38pub const fn card_act_id() -> ActId {
39    ActId::new(CARD_UNIT, 1)
40}
41
42/// The act a card is guarding, persisted with the card.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct DeferredAct {
46    /// The act as it was understood. Its target is replaced when it is resumed.
47    pub act: UnderstoodAct,
48    /// The case the act was aimed at, when the turn already knew it; `None` for a
49    /// selection card, whose answer supplies it.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub case_ref: Option<CaseRef>,
52}
53
54impl DeferredAct {
55    /// An act whose target the answer will supply.
56    #[must_use]
57    pub const fn unbound(act: UnderstoodAct) -> Self {
58        Self {
59            act,
60            case_ref: None,
61        }
62    }
63
64    /// An act already aimed at a case, waiting only for a yes.
65    #[must_use]
66    pub const fn on(act: UnderstoodAct, case_ref: CaseRef) -> Self {
67        Self {
68            act,
69            case_ref: Some(case_ref),
70        }
71    }
72
73    /// Writes the deferred act into a card payload. Metadata that is neither absent
74    /// nor a JSON object is the application's own, and is left alone.
75    #[must_use]
76    pub fn attach_to(&self, mut payload: InteractionPayload) -> InteractionPayload {
77        let Ok(value) = serde_json::to_value(self) else {
78            return payload;
79        };
80        match &mut payload.metadata {
81            serde_json::Value::Null => {
82                let mut map = serde_json::Map::new();
83                map.insert(DEFERRED_ACT_KEY.to_owned(), value);
84                payload.metadata = serde_json::Value::Object(map);
85            }
86            serde_json::Value::Object(map) => {
87                map.insert(DEFERRED_ACT_KEY.to_owned(), value);
88            }
89            _ => {
90                tracing::warn!(
91                    target: "turnframe.resume",
92                    "card metadata is not an object; the card carries no deferred act"
93                );
94            }
95        }
96        payload
97    }
98
99    /// Reads the deferred act back, when the card carries one.
100    #[must_use]
101    pub fn of(payload: &InteractionPayload) -> Option<Self> {
102        let value = payload.metadata.get(DEFERRED_ACT_KEY)?;
103        serde_json::from_value(value.clone())
104            .inspect_err(|_| {
105                tracing::warn!(
106                    target: "turnframe.resume",
107                    "card metadata carries an unreadable deferred act; it is ignored"
108                );
109            })
110            .ok()
111    }
112}
113
114/// Key the acts waiting on a confirmation are stored under in its card's metadata.
115pub const DEPENDENT_ACTS_KEY: &str = "turnframe.dependent_acts";
116
117/// A case an act of an earlier turn opened, which a dependent act refers to.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct MadeCase {
121    /// The act that opened it.
122    pub act: ActId,
123    /// The case.
124    pub case_ref: CaseRef,
125}
126
127/// An act that waits for a confirmation card: once the card's commands commit, it
128/// runs under its own origin and policy (spec §6.7).
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct DependentAct {
132    /// The act as it was understood.
133    pub act: UnderstoodAct,
134    /// The cases its prerequisites open.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub made: Vec<MadeCase>,
137}
138
139impl DependentAct {
140    /// Appends this act to the card's dependents.
141    #[must_use]
142    pub fn attach_to(self, mut payload: InteractionPayload) -> InteractionPayload {
143        let mut dependents = Self::of(&payload);
144        dependents.push(self);
145        let Ok(value) = serde_json::to_value(dependents) else {
146            return payload;
147        };
148        match &mut payload.metadata {
149            serde_json::Value::Null => {
150                let mut map = serde_json::Map::new();
151                map.insert(DEPENDENT_ACTS_KEY.to_owned(), value);
152                payload.metadata = serde_json::Value::Object(map);
153            }
154            serde_json::Value::Object(map) => {
155                map.insert(DEPENDENT_ACTS_KEY.to_owned(), value);
156            }
157            _ => tracing::warn!(
158                target: "turnframe.resume",
159                "card metadata is not an object; the card carries no dependent act"
160            ),
161        }
162        payload
163    }
164
165    /// The dependents a card carries, in the order they were understood.
166    #[must_use]
167    pub fn of(payload: &InteractionPayload) -> Vec<Self> {
168        payload
169            .metadata
170            .get(DEPENDENT_ACTS_KEY)
171            .and_then(|value| serde_json::from_value(value.clone()).ok())
172            .unwrap_or_default()
173    }
174}
175
176/// The acts a confirmed card lets run, numbered after the card's own act.
177///
178/// A reference to a case a prerequisite opened becomes that record's token; a
179/// reference to another dependent keeps pointing at it under its new number.
180#[must_use]
181pub fn dependents(payload: &InteractionPayload, tokens: &TargetTokenMap) -> Vec<UnderstoodAct> {
182    let waiting = DependentAct::of(payload);
183    let renumbered: std::collections::BTreeMap<ActId, ActId> = waiting
184        .iter()
185        .zip(2..)
186        .map(|(dependent, position)| (dependent.act.id, ActId::new(CARD_UNIT, position)))
187        .collect();
188    waiting
189        .into_iter()
190        .map(|dependent| {
191            let made = |earlier: &ActId| {
192                let case_ref = &dependent.made.iter().find(|m| m.act == *earlier)?.case_ref;
193                tokens.token_for(&case_ref.key()).cloned()
194            };
195            let mut act = dependent.act.clone();
196            act.id = renumbered[&dependent.act.id];
197            if let ActTarget::SameTurn { act: earlier } = &act.target {
198                act.target = match (renumbered.get(earlier), made(earlier)) {
199                    (Some(renamed), _) => ActTarget::SameTurn { act: *renamed },
200                    (None, Some(token)) => ActTarget::Record { token },
201                    (None, None) => act.target.clone(),
202                };
203            }
204            for argument in act.arguments.values_mut() {
205                let ArgumentValue::Record(RecordValue::SameTurn { act: earlier }) = &argument.value
206                else {
207                    continue;
208                };
209                argument.value = match (renumbered.get(earlier), made(earlier)) {
210                    (Some(renamed), _) => {
211                        ArgumentValue::Record(RecordValue::SameTurn { act: *renamed })
212                    }
213                    (None, Some(token)) => ArgumentValue::Record(RecordValue::Record { token }),
214                    (None, None) => continue,
215                };
216            }
217            act.depends_on = act
218                .depends_on
219                .iter()
220                .filter_map(|earlier| renumbered.get(earlier).copied())
221                .collect();
222            act
223        })
224        .collect()
225}
226
227/// What answering a card means for the act it was guarding.
228#[derive(Debug, Clone, PartialEq, Eq)]
229#[non_exhaustive]
230pub enum Resumption {
231    /// The answer continues the work: this act goes through the normal path.
232    Continue(Box<UnderstoodAct>),
233    /// The answer refused it. `record` is the operation a `DeclineAndRecord` option
234    /// names, so the case can remember it was asked.
235    Declined {
236        /// The act that records the refusal, when the option named one.
237        record: Option<Box<UnderstoodAct>>,
238    },
239    /// Nothing to continue: no act on the card, an answer with its own path, or a
240    /// case this turn cannot address.
241    Nothing,
242}
243
244impl Resumption {
245    /// The act to run, when there is one.
246    #[must_use]
247    pub fn act(&self) -> Option<&UnderstoodAct> {
248        match self {
249            Self::Continue(act) => Some(act),
250            _ => None,
251        }
252    }
253
254    /// Returns `true` when the user refused the instruction the card guarded.
255    #[must_use]
256    pub const fn is_declined(&self) -> bool {
257        matches!(self, Self::Declined { .. })
258    }
259}
260
261/// An act a card answer applies to the card's own case, with the card's words.
262#[must_use]
263pub fn card_act(action: ActAction, target: ActTarget) -> UnderstoodAct {
264    UnderstoodAct {
265        id: card_act_id(),
266        action,
267        target,
268        arguments: std::collections::BTreeMap::new(),
269        words: WordRange {
270            first: 0,
271            last: 0,
272            start: 0,
273            end: 0,
274        },
275        depends_on: Vec::new(),
276        status: ActStatus::Ready,
277    }
278}
279
280/// Decides what an accepted card answer does to the act the card was guarding.
281///
282/// `tokens` is the answering turn's map: a case the actor may not address this turn
283/// has no token, and the answer resumes nothing.
284#[must_use]
285pub fn resume(
286    accepted: &AcceptedResponse,
287    payload: &InteractionPayload,
288    tokens: &TargetTokenMap,
289) -> Resumption {
290    // A refusal is a fact about the click, whatever the card carried.
291    if accepted.action.declines() {
292        let record = accepted.action.records().and_then(|operation| {
293            let token = tokens.token_for(&accepted.case_ref.key())?;
294            Some(Box::new(card_act(
295                ActAction::Apply {
296                    operation: operation.clone(),
297                },
298                ActTarget::Record {
299                    token: token.clone(),
300                },
301            )))
302        });
303        return Resumption::Declined { record };
304    }
305    let Some(deferred) = DeferredAct::of(payload) else {
306        return Resumption::Nothing;
307    };
308    let case_ref = match &accepted.action {
309        StoredInteractionAction::SelectTarget { case_ref } => Some(case_ref.clone()),
310        StoredInteractionAction::ResolveClarification { .. }
311        | StoredInteractionAction::Custom { .. } => deferred.case_ref.clone(),
312        _ => return Resumption::Nothing,
313    };
314    let mut act = deferred.act;
315    act.id = card_act_id();
316    act.depends_on.clear();
317    act.status = ActStatus::Ready;
318    if !matches!(act.action, ActAction::Start { .. }) {
319        let Some(token) = case_ref
320            .as_ref()
321            .and_then(|case_ref| tokens.token_for(&case_ref.key()))
322        else {
323            tracing::warn!(
324                target: "turnframe.resume",
325                "the case a card named is not addressable in this turn; nothing was resumed"
326            );
327            return Resumption::Nothing;
328        };
329        act.target = ActTarget::Record {
330            token: token.clone(),
331        };
332    }
333    Resumption::Continue(Box::new(act))
334}
335
336#[cfg(test)]
337mod tests {
338    use turnframe_core::hash::Digest;
339    use turnframe_core::ids::{
340        AccountId, CaseRevision, InteractionId, OperationKey, OptionId, TurnId,
341    };
342    use turnframe_core::interaction::InteractionKind;
343    use turnframe_core::understanding::UnderstoodArgument;
344
345    use super::*;
346
347    fn case(id: &str) -> CaseRef {
348        CaseRef::new("trip", id, CaseRevision(3))
349    }
350
351    fn act() -> UnderstoodAct {
352        let mut act = card_act(
353            ActAction::Apply {
354                operation: OperationKey::from("trip.set_name"),
355            },
356            ActTarget::Ambiguous {
357                candidates: Vec::new(),
358            },
359        );
360        act.id = ActId::new(UnitId(1), 1);
361        act.arguments.insert(
362            "value".to_owned(),
363            UnderstoodArgument {
364                value: ArgumentValue::Json(serde_json::json!("Lisbon")),
365                excerpt: None,
366            },
367        );
368        act
369    }
370
371    fn tokens() -> TargetTokenMap {
372        let mut map = TargetTokenMap::new(AccountId::from("acct"), TurnId::nil());
373        map.issue(case("trip-1"), "Rossi".to_owned());
374        map.issue(case("trip-2"), "Rossi".to_owned());
375        map
376    }
377
378    fn answer(action: StoredInteractionAction) -> AcceptedResponse {
379        AcceptedResponse {
380            interaction_id: InteractionId::nil(),
381            case_ref: case("trip-1"),
382            kind: InteractionKind::SelectTarget,
383            option_id: OptionId::from("pick"),
384            action,
385            channel: turnframe_core::command::ResolutionChannel::Click,
386            freeform_input: None,
387            payload_hash: Digest::of_bytes(b"p"),
388        }
389    }
390
391    fn payload_with(deferred: &DeferredAct) -> InteractionPayload {
392        deferred.attach_to(InteractionPayload::new("Which one?"))
393    }
394
395    fn token_of(tokens: &TargetTokenMap, id: &str) -> ActTarget {
396        ActTarget::Record {
397            token: tokens.token_for(&case(id).key()).unwrap().clone(),
398        }
399    }
400
401    #[test]
402    fn a_deferred_act_survives_the_payload_round_trip() {
403        let deferred = DeferredAct::unbound(act());
404        let payload = payload_with(&deferred);
405        assert_eq!(DeferredAct::of(&payload), Some(deferred.clone()));
406        let json = serde_json::to_value(&payload).unwrap();
407        let back: InteractionPayload = serde_json::from_value(json).unwrap();
408        assert_eq!(DeferredAct::of(&back), Some(deferred));
409        assert_eq!(DeferredAct::of(&InteractionPayload::new("bare")), None);
410    }
411
412    #[test]
413    fn attaching_keeps_the_applications_own_metadata() {
414        let payload = InteractionPayload::new("Which one?")
415            .with_metadata(serde_json::json!({"preview": "abc"}));
416        let attached = DeferredAct::unbound(act()).attach_to(payload);
417        assert_eq!(attached.metadata["preview"], "abc");
418        assert!(DeferredAct::of(&attached).is_some());
419        let odd = InteractionPayload::new("Which one?").with_metadata(serde_json::json!(7));
420        let untouched = DeferredAct::unbound(act()).attach_to(odd.clone());
421        assert_eq!(untouched.metadata, odd.metadata);
422    }
423
424    #[test]
425    fn a_selection_binds_the_act_to_the_case_the_user_picked() {
426        let tokens = tokens();
427        let payload = payload_with(&DeferredAct::unbound(act()));
428        let resumed = resume(
429            &answer(StoredInteractionAction::SelectTarget {
430                case_ref: case("trip-2"),
431            }),
432            &payload,
433            &tokens,
434        );
435        let act = resumed.act().expect("the act continues");
436        assert_eq!(act.target, token_of(&tokens, "trip-2"));
437        assert_eq!(act.id, card_act_id());
438        assert_eq!(
439            act.arguments["value"].value,
440            ArgumentValue::Json(serde_json::json!("Lisbon"))
441        );
442    }
443
444    #[test]
445    fn a_decline_may_record_that_the_card_was_answered() {
446        let tokens = tokens();
447        let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
448        assert_eq!(
449            resume(
450                &answer(StoredInteractionAction::DeclineCommands),
451                &payload,
452                &tokens
453            ),
454            Resumption::Declined { record: None }
455        );
456        let recording = resume(
457            &answer(StoredInteractionAction::DeclineAndRecord {
458                operation: OperationKey::from("trip.note_declined"),
459            }),
460            &payload,
461            &tokens,
462        );
463        let Resumption::Declined { record: Some(act) } = recording else {
464            panic!("the refusal is recorded: {recording:?}");
465        };
466        assert_eq!(
467            act.action,
468            ActAction::Apply {
469                operation: OperationKey::from("trip.note_declined")
470            }
471        );
472        assert_eq!(act.target, token_of(&tokens, "trip-1"));
473    }
474
475    #[test]
476    fn a_clarification_runs_the_act_against_the_case_it_was_aimed_at() {
477        let tokens = tokens();
478        let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
479        let resumed = resume(
480            &answer(StoredInteractionAction::ResolveClarification {
481                answer_key: crate::reduce::CONDITION_HOLDS_ANSWER.to_owned(),
482            }),
483            &payload,
484            &tokens,
485        );
486        assert_eq!(
487            resumed.act().map(|act| &act.target),
488            Some(&token_of(&tokens, "trip-1"))
489        );
490        assert!(!resumed.is_declined());
491    }
492
493    #[test]
494    fn refusing_is_recorded_and_never_silently_dropped() {
495        let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
496        for action in [
497            StoredInteractionAction::Dismiss,
498            StoredInteractionAction::DeclineCommands,
499        ] {
500            let resumed = resume(&answer(action), &payload, &tokens());
501            assert_eq!(resumed, Resumption::Declined { record: None });
502        }
503    }
504
505    #[test]
506    fn there_is_nothing_to_resume_without_a_card_that_remembers() {
507        let tokens = tokens();
508        let bare = InteractionPayload::new("Which one?");
509        let select = StoredInteractionAction::SelectTarget {
510            case_ref: case("trip-1"),
511        };
512        assert_eq!(resume(&answer(select), &bare, &tokens), Resumption::Nothing);
513        let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
514        let confirm = StoredInteractionAction::ConfirmCommands {
515            command_refs: Vec::new(),
516        };
517        assert_eq!(
518            resume(&answer(confirm), &payload, &tokens),
519            Resumption::Nothing
520        );
521        let elsewhere = payload_with(&DeferredAct::on(act(), case("trip-9")));
522        let clarify = StoredInteractionAction::ResolveClarification {
523            answer_key: "yes".to_owned(),
524        };
525        assert_eq!(
526            resume(&answer(clarify), &elsewhere, &tokens),
527            Resumption::Nothing
528        );
529    }
530
531    #[test]
532    fn a_start_is_resumed_as_it_stands() {
533        let start = card_act(
534            ActAction::Start {
535                workflow: "trip".into(),
536            },
537            ActTarget::New {
538                workflow: "trip".into(),
539            },
540        );
541        let resumed = resume(
542            &answer(StoredInteractionAction::Custom {
543                key: "app.yes".to_owned(),
544                payload: serde_json::Value::Null,
545            }),
546            &payload_with(&DeferredAct::unbound(start)),
547            &tokens(),
548        );
549        assert!(matches!(
550            resumed.act().map(|act| &act.action),
551            Some(ActAction::Start { .. })
552        ));
553    }
554
555    fn dependent(id: u16, target: ActTarget, depends_on: &[ActId]) -> UnderstoodAct {
556        let mut act = card_act(
557            ActAction::Apply {
558                operation: OperationKey::from("trip.set_traveler"),
559            },
560            target,
561        );
562        act.id = ActId::new(UnitId(1), id);
563        act.depends_on = depends_on.to_vec();
564        act
565    }
566
567    #[test]
568    fn dependents_point_at_the_case_their_prerequisite_made() {
569        let tokens = tokens();
570        let opener = ActId::new(UnitId(1), 1);
571        let mut first = dependent(2, ActTarget::SameTurn { act: opener }, &[opener]);
572        first.arguments.insert(
573            "traveler".to_owned(),
574            UnderstoodArgument {
575                value: ArgumentValue::Record(RecordValue::SameTurn { act: opener }),
576                excerpt: None,
577            },
578        );
579        let second_id = ActId::new(UnitId(1), 2);
580        let second = dependent(3, ActTarget::SameTurn { act: second_id }, &[second_id]);
581        let made = vec![MadeCase {
582            act: opener,
583            case_ref: case("trip-1"),
584        }];
585        let payload = DependentAct {
586            act: second,
587            made: Vec::new(),
588        }
589        .attach_to(DependentAct { act: first, made }.attach_to(InteractionPayload::new("Sure?")));
590        assert_eq!(DependentAct::of(&payload).len(), 2);
591
592        let acts = dependents(&payload, &tokens);
593        assert_eq!(acts[0].id, ActId::new(CARD_UNIT, 2));
594        assert_eq!(acts[0].target, token_of(&tokens, "trip-1"));
595        assert!(acts[0].depends_on.is_empty());
596        let ActTarget::Record { token } = token_of(&tokens, "trip-1") else {
597            unreachable!()
598        };
599        assert_eq!(
600            acts[0].arguments["traveler"].value,
601            ArgumentValue::Record(RecordValue::Record { token })
602        );
603        let renamed = ActId::new(CARD_UNIT, 2);
604        assert_eq!(acts[1].target, ActTarget::SameTurn { act: renamed });
605        assert_eq!(acts[1].depends_on, vec![renamed]);
606    }
607
608    #[test]
609    fn a_card_without_dependents_lets_nothing_run() {
610        assert!(dependents(&InteractionPayload::new("Sure?"), &tokens()).is_empty());
611    }
612}