Skip to main content

headless_lms_models/library/agent_changesets/
reverting.rs

1//! Planning the revert of an agent write call.
2
3use std::collections::HashMap;
4
5use crate::{
6    agent_changeset_calls, agent_changesets,
7    library::{
8        agent_changesets::course_state::{CourseView, StagedEntity},
9        entity_snapshots::{ContentEntityType, EntityKey, EntityRef, EntitySnapshot},
10    },
11    prelude::*,
12};
13
14/// How a call can be reverted.
15#[derive(Debug, Clone)]
16pub enum RevertPlan {
17    /// Stage these with [`super::stage_call`] (with `revert_of_call_id` set) to revert the call.
18    Ready {
19        /// The tool the reverted call was made with.
20        tool_name: String,
21        entities: Vec<StagedEntity>,
22        /// The state each entity has now, staged or live, which the revert builds on.
23        current: HashMap<EntityKey, Option<EntitySnapshot>>,
24    },
25    /// Entities that changed again after the call; nothing can be reverted.
26    Conflicts(Vec<EntityRef>),
27}
28
29/// Works out how to revert one of the user's agent write calls in the course: every entity it
30/// touched goes back to its state just before the call. The revert is a new change staged in the
31/// user's open changeset, so it goes through the normal risk rules and publishing.
32///
33/// The current state is the user's staged state in `view`, which must hold every part of the
34/// course. If any entity's current state is not what the call left behind, the plan lists those
35/// entities instead. Errors with `NotFound` if the call is not the user's or not in the course.
36pub async fn plan_revert(
37    conn: &mut PgConnection,
38    view: &CourseView,
39    call_id: Uuid,
40    user_id: Uuid,
41) -> ModelResult<RevertPlan> {
42    let call = agent_changeset_calls::get_by_id(conn, call_id).await?;
43    let changeset = agent_changesets::get_by_id_for_user(conn, call.changeset_id, user_id).await?;
44    if changeset.course_id != view.course.id {
45        return Err(model_err!(NotFound, "No such call".to_string()));
46    }
47    let page_ids: Vec<Uuid> = call
48        .entity_changes
49        .iter()
50        .filter(|change| change.entity_type == ContentEntityType::Page)
51        .map(|change| change.entity_id)
52        .collect();
53    let mut current_pages = view.current_page_snapshots(conn, &page_ids).await?;
54    let mut current_states: HashMap<EntityKey, Option<EntitySnapshot>> = HashMap::new();
55    for change in &call.entity_changes {
56        let key = EntityKey::new(change.entity_type, change.entity_id);
57        let current = match key.entity_type {
58            ContentEntityType::Page => current_pages
59                .remove(&key.entity_id)
60                .map(EntitySnapshot::Page),
61            _ => view.staged.entity_snapshot(key),
62        };
63        current_states.entry(key).or_insert(current);
64    }
65
66    let mut conflicts = Vec::new();
67    let mut entities = Vec::with_capacity(call.entity_changes.len());
68    for change in call.entity_changes {
69        let key = EntityKey::new(change.entity_type, change.entity_id);
70        let current = current_states.get(&key).and_then(Option::as_ref);
71        let current_digest = current.map(EntitySnapshot::digest).transpose()?;
72        if current_digest != change.after_digest {
73            conflicts.push(EntityRef {
74                entity_type: change.entity_type,
75                entity_id: change.entity_id,
76                label: current.map_or(change.label, |current| current.label().to_string()),
77            });
78            continue;
79        }
80        entities.push(StagedEntity::new(key, change.before));
81    }
82    if conflicts.is_empty() {
83        Ok(RevertPlan::Ready {
84            tool_name: call.tool_name,
85            entities,
86            current: current_states,
87        })
88    } else {
89        Ok(RevertPlan::Conflicts(conflicts))
90    }
91}