Skip to main content

headless_lms_mcp/handlers/
changesets.rs

1//! Changeset tools: listing, publishing, discarding and previewing staged changes.
2
3use async_trait::async_trait;
4use headless_lms_authorization::{fetch_user_roles, is_user_global_admin};
5use headless_lms_base::error::backend_error::BackendError;
6use headless_lms_models::{
7    ModelErrorType,
8    agent_changeset_entities::{self, AgentChangesetEntityOperation},
9    agent_changesets::{self, AgentChangeset, AgentChangesetStatus},
10    courses::{self, Course},
11    error::TryToOptional,
12    library::agent_changesets::{
13        ChangedSinceReview, ChangesetSummary, DiscardOutcome, DiscardRejection, PublishOptions,
14        PublishOutcome, PublishRejection, RiskBaseline, changed_page_links, discard,
15        discard_entities, get_summaries, mint_preview_token, preview_path, publish,
16    },
17    library::entity_snapshots::{ContentEntityType, EntityKey},
18    pages::{self, Page},
19};
20use serde_json::{Value, json};
21use sqlx::PgConnection;
22use uuid::Uuid;
23
24use crate::access::{authorize_course, connection, deleted_in_staged_changes, editable_courses};
25use crate::explanations::{ExplainedEntity, explain_blockers};
26use crate::hints;
27use crate::page_readiness;
28use crate::state::McpState;
29use crate::tools::args::{
30    DiscardChangesetArgs, GetPreviewLinkArgs, IdArgument, ListChangesetsArgs, PublishChangesetArgs,
31};
32use crate::tools::{
33    ConfirmationRequest, RequestContext, ToolError, ToolErrorCode, ToolHandler, ToolOutcome,
34    ToolResult,
35};
36use headless_lms_models::library::agent_changesets::course_state::{
37    CourseParts, CourseView, load_view,
38};
39
40/// Handler of the `list_changesets` tool.
41pub struct ListChangesets;
42/// Handler of the `publish_changeset` tool.
43pub struct PublishChangeset;
44/// Handler of the `discard_changeset` tool.
45pub struct DiscardChangeset;
46/// Handler of the `get_preview_link` tool.
47pub struct GetPreviewLink;
48
49/// Published, discarded and expired changesets listed besides the open ones.
50const RECENT_CLOSED_CHANGESETS: i64 = 10;
51
52fn explained(entities: Vec<impl Into<ExplainedEntity>>) -> Vec<ExplainedEntity> {
53    entities.into_iter().map(Into::into).collect()
54}
55
56fn summary_json(state: &McpState, summary: ChangesetSummary) -> Value {
57    let changeset = &summary.changeset;
58    json!({
59        "id": changeset.id,
60        "course": changeset.course_id,
61        "status": changeset.status,
62        "revision": changeset.revision,
63        "created_at": changeset.created_at,
64        "expires_at": changeset.is_open().then_some(changeset.expires_at),
65        "published_at": changeset.published_at,
66        "is_expiring_soon": summary.is_expiring_soon,
67        "has_risky_changes": summary.has_risky_changes,
68        "review_url": state.review_url(changeset.id),
69        "entities": explained(summary.entities),
70    })
71}
72
73#[async_trait(?Send)]
74impl ToolHandler<McpState> for ListChangesets {
75    type Args = ListChangesetsArgs;
76
77    async fn call(
78        &self,
79        state: &McpState,
80        ctx: &RequestContext,
81        args: ListChangesetsArgs,
82    ) -> Result<ToolOutcome, ToolError> {
83        let user_id = ctx.principal.actor.user_id;
84        let mut conn = connection(state).await?;
85        let course_ids = match &args.course {
86            Some(course) => {
87                let course_id = course.id();
88                authorize_course(&mut conn, user_id, course_id).await?;
89                vec![course_id]
90            }
91            None => {
92                let roles = fetch_user_roles(&mut conn, Some(user_id)).await?;
93                let course_ids =
94                    agent_changesets::get_course_ids_by_user(&mut conn, user_id).await?;
95                let courses = courses::get_by_ids(&mut conn, &course_ids).await?;
96                editable_courses(&roles, courses)
97                    .into_iter()
98                    .map(|course| course.id)
99                    .collect()
100            }
101        };
102        let listed = agent_changesets::get_open_and_recent_by_user(
103            &mut conn,
104            user_id,
105            &course_ids,
106            RECENT_CLOSED_CHANGESETS,
107        )
108        .await?;
109        let (open, closed): (Vec<ChangesetSummary>, Vec<ChangesetSummary>) =
110            get_summaries(&mut conn, listed)
111                .await?
112                .into_iter()
113                .partition(|summary| summary.changeset.is_open());
114        let as_json = |summaries: Vec<ChangesetSummary>| -> Vec<Value> {
115            summaries
116                .into_iter()
117                .map(|summary| summary_json(state, summary))
118                .collect()
119        };
120        Ok(ToolResult::success(json!({"open": as_json(open), "recent": as_json(closed)})).into())
121    }
122}
123
124/// The course and the user's own changeset in it, or not found.
125async fn own_changeset(
126    conn: &mut PgConnection,
127    ctx: &RequestContext,
128    course_id: Uuid,
129    changeset_id: Uuid,
130) -> Result<(Course, AgentChangeset), ToolError> {
131    let course = authorize_course(conn, ctx.principal.actor.user_id, course_id).await?;
132    let not_found = || ToolError::NotFound {
133        message: format!(
134            "you have no changeset {changeset_id} in course {course_id}; list_changesets lists yours"
135        ),
136    };
137    let changeset =
138        agent_changesets::get_by_id_for_user(conn, changeset_id, ctx.principal.actor.user_id)
139            .await
140            .map_err(|error| match error.error_type() {
141                ModelErrorType::NotFound | ModelErrorType::RecordNotFound => not_found(),
142                _ => error.into(),
143            })?;
144    if changeset.course_id != course_id {
145        return Err(not_found());
146    }
147    Ok((course, changeset))
148}
149
150/// The result for a changeset of `course` that is no longer open.
151fn not_open_result(
152    course: &Course,
153    changeset_id: Uuid,
154    status: AgentChangesetStatus,
155) -> ToolResult {
156    let mut result = ToolResult::success(json!({
157        "changeset": changeset_id,
158        "status": status,
159        "published": status == AgentChangesetStatus::Published,
160        "message": match status {
161            AgentChangesetStatus::Published => "The changeset is already published.",
162            AgentChangesetStatus::Discarded => "The changeset was discarded; nothing of it was published.",
163            AgentChangesetStatus::Expired => "The changeset expired; nothing of it was published.",
164            AgentChangesetStatus::Open => "The changeset is still open.",
165        },
166    }));
167    if status == AgentChangesetStatus::Published {
168        result = result.with_hint(hints::published(changeset_id, course.is_draft));
169    }
170    result
171}
172
173/// Refuses publishing while a page whose blocks the changeset changes has no page title block or
174/// still holds template placeholder text.
175fn check_pages_ready(view: &CourseView) -> Result<(), ToolError> {
176    let mut untitled = Vec::new();
177    let mut with_placeholders = Vec::new();
178    for page in page_readiness::pages_with_content_changes(&view.staged, &view.live) {
179        if !page_readiness::has_page_title(&page.content) {
180            untitled.push(page);
181        }
182        let placeholders = page_readiness::placeholder_texts(&page.content);
183        if !placeholders.is_empty() {
184            with_placeholders.push((page, placeholders));
185        }
186    }
187    if untitled.is_empty() && with_placeholders.is_empty() {
188        return Ok(());
189    }
190    let mut problems = Vec::new();
191    if !untitled.is_empty() {
192        let pages: Vec<String> = untitled
193            .iter()
194            .map(|page| format!("\"{}\" ({})", page.title, page.id))
195            .collect();
196        problems.push(format!(
197            "these pages have no page title: {}. Add a moocfi/hero-section with the page title as the first block (moocfi/landing-page-hero-section on the course front page); it is the only h1 course pages allow",
198            pages.join(", ")
199        ));
200    }
201    if !with_placeholders.is_empty() {
202        let pages: Vec<String> = with_placeholders
203            .iter()
204            .map(|(page, placeholders)| {
205                let blocks: Vec<String> = placeholders
206                    .iter()
207                    .map(|p| format!("{} {} \"{}\"", p.block_name, p.block, p.text))
208                    .collect();
209                format!("\"{}\" ({}): {}", page.title, page.id, blocks.join(", "))
210            })
211            .collect();
212        problems.push(format!(
213            "these pages still hold placeholder text from the default page template: {}. Replace it with the course's own text or remove those blocks",
214            pages.join("; ")
215        ));
216    }
217    Err(ToolError::refused(
218        ToolErrorCode::PagesNotReady,
219        format!(
220            "Nothing was published: {}. Fix the pages with patch_page_content, then publish again.",
221            problems.join("; and ")
222        ),
223        json!({
224            "pages_without_title": untitled.iter().map(|page| page.id).collect::<Vec<_>>(),
225            "pages_with_placeholders": with_placeholders
226                .iter()
227                .map(|(page, placeholders)| json!({"page": page.id, "placeholders": placeholders}))
228                .collect::<Vec<_>>(),
229        }),
230    ))
231}
232
233#[async_trait(?Send)]
234impl ToolHandler<McpState> for PublishChangeset {
235    type Args = PublishChangesetArgs;
236    const ASKS_FOR_CONFIRMATION: bool = true;
237
238    async fn call(
239        &self,
240        state: &McpState,
241        ctx: &RequestContext,
242        args: PublishChangesetArgs,
243    ) -> Result<ToolOutcome, ToolError> {
244        let mut conn = connection(state).await?;
245        let (course, changeset) =
246            own_changeset(&mut conn, ctx, args.course.id(), args.changeset.id()).await?;
247        if !changeset.is_open() {
248            return Ok(not_open_result(&course, changeset.id, changeset.status).into());
249        }
250        let view = load_view(
251            &mut conn,
252            ctx.principal.actor.user_id,
253            course,
254            CourseParts::All,
255        )
256        .await?;
257        check_pages_ready(&view)?;
258        let rendering_change_count =
259            page_readiness::pages_with_content_changes(&view.staged, &view.live).count();
260        let review_url = state.review_url(changeset.id);
261        let can_add_global_admin_only_exercise_services =
262            is_user_global_admin(&mut conn, ctx.principal.actor.user_id).await?;
263        let spec_fetcher = (state.exercise_services.spec_fetcher)();
264        let outcome = publish(
265            &mut conn,
266            changeset.id,
267            PublishOptions {
268                can_add_global_admin_only_exercise_services,
269                reviewed_revision: None,
270                // Refusing for confirmation can bump the revision, and on the retry the teacher has
271                // already seen the changeset on the review page.
272                seen_revision: ctx.retry.is_none().then_some(args.revision),
273            },
274            &state.app_conf,
275            &*spec_fetcher,
276            &*state.exercise_services.fetch_service_info,
277        )
278        .await?;
279        let rejection = match outcome {
280            PublishOutcome::Published {
281                changeset,
282                redirects,
283            } => {
284                let mut result = ToolResult::success(json!({
285                    "changeset": changeset.id,
286                    "published": true,
287                    "published_at": changeset.published_at,
288                    "redirects": redirects,
289                }))
290                .with_hint(hints::published(changeset.id, view.course.is_draft));
291                if rendering_change_count > 0 {
292                    result = result
293                        .with_hint(hints::published_rendering_changes(rendering_change_count));
294                }
295                return Ok(result.into());
296            }
297            PublishOutcome::Rejected { rejection } => rejection,
298        };
299        match rejection {
300            PublishRejection::NeedsConfirmation { risky_entities } => {
301                let reason_count: usize = risky_entities.iter().map(|e| e.risk_reasons.len()).sum();
302                let mut fallback = ToolResult::success(json!({
303                    "changeset": changeset.id,
304                    "published": false,
305                    "reason": "needs_teacher_review",
306                    "risky_entities": explained(risky_entities),
307                    "review_url": review_url,
308                }))
309                .with_hint(hints::needs_teacher_review(&review_url, reason_count));
310                if rendering_change_count > 0 {
311                    fallback = fallback.with_hint(hints::rendering_changes(
312                        rendering_change_count,
313                        ctx.client.client_kind(),
314                    ));
315                }
316                if let Some(retry) = &ctx.retry {
317                    // The teacher answered on the review page without publishing everything; asking
318                    // again would loop.
319                    let mut result = fallback;
320                    result
321                        .structured
322                        .insert("confirmation".into(), json!(retry.confirmation_action()));
323                    return Ok(result.into());
324                }
325                Ok(ToolOutcome::NeedsConfirmation(ConfirmationRequest {
326                    message: format!(
327                        "{reason_count} staged change(s) could affect students. Review them and publish or discard the changeset on this page."
328                    ),
329                    url: review_url,
330                    elicitation_id: changeset.id.to_string(),
331                    fallback,
332                }))
333            }
334            PublishRejection::Conflicts { conflicts } => Err(ToolError::refused(
335                ToolErrorCode::PublishConflict,
336                "Nothing was published: these staged pages or entities changed live after they were staged. Drop them with discard_changeset and entities set to their ids, read their published state again, redo your edits to them on top of it, then publish.",
337                json!({"conflicts": conflicts}),
338            )),
339            PublishRejection::ChangedSinceReview(ChangedSinceReview { revision }) => {
340                Err(ToolError::refused(
341                    ToolErrorCode::ChangesetChanged,
342                    format!(
343                        "Nothing was published: the changeset is at revision {revision}, not the one you gave, so it holds changes you haven't seen, maybe from another session. list_changesets shows what it holds now; publish only if the teacher asked for all of it."
344                    ),
345                    json!({"revision": revision}),
346                ))
347            }
348            PublishRejection::GlobalAdminOnlyExerciseServices { service_names } => {
349                Err(ToolError::refused(
350                    ToolErrorCode::AdminOnlyExerciseServices,
351                    "Nothing was published: the staged pages add exercises of services only administrators may add.",
352                    json!({"services": service_names}),
353                ))
354            }
355            PublishRejection::Blockers { blockers } => Err(ToolError::refused(
356                ToolErrorCode::PublishFailed,
357                "Nothing was published: with the staged changes the course would break rules publishing relies on. Fix the staged entities named here with the patch tools, or drop them with discard_changeset, then publish again.",
358                json!({"blockers": explain_blockers(blockers)}),
359            )),
360            PublishRejection::NotOpen { status } => {
361                Ok(not_open_result(&view.course, changeset.id, status).into())
362            }
363            PublishRejection::SaveFailed { entity, message } => Err(ToolError::refused(
364                ToolErrorCode::PublishFailed,
365                format!(
366                    "Nothing was published: saving the staged changes failed: {message}. Fix the staged entities with the patch tools, or drop them with discard_changeset, then publish again."
367                ),
368                json!({"entity": entity}),
369            )),
370        }
371    }
372}
373
374#[async_trait(?Send)]
375impl ToolHandler<McpState> for DiscardChangeset {
376    type Args = DiscardChangesetArgs;
377
378    async fn call(
379        &self,
380        state: &McpState,
381        ctx: &RequestContext,
382        args: DiscardChangesetArgs,
383    ) -> Result<ToolOutcome, ToolError> {
384        let mut conn = connection(state).await?;
385        let (course, changeset) =
386            own_changeset(&mut conn, ctx, args.course.id(), args.changeset.id()).await?;
387        if !changeset.is_open() {
388            return Ok(not_open_result(&course, changeset.id, changeset.status).into());
389        }
390        let Some(entity_ids) = &args.entities else {
391            return match discard(&mut conn, changeset.id, args.revision).await? {
392                DiscardOutcome::Discarded { changeset } => Ok(ToolResult::success(json!({
393                    "changeset": changeset.id,
394                    "status": changeset.status,
395                    "message": "Every staged change in the changeset was dropped; the published course is unchanged.",
396                }))
397                .into()),
398                DiscardOutcome::Rejected { rejection } => {
399                    discard_rejected(&course, changeset.id, rejection)
400                }
401            };
402        };
403        discard_some(
404            &mut conn,
405            ctx,
406            course,
407            &changeset,
408            entity_ids,
409            args.revision,
410        )
411        .await
412    }
413}
414
415fn discard_rejected(
416    course: &Course,
417    changeset_id: Uuid,
418    rejection: DiscardRejection,
419) -> Result<ToolOutcome, ToolError> {
420    match rejection {
421        DiscardRejection::NotOpen { status } => {
422            Ok(not_open_result(course, changeset_id, status).into())
423        }
424        DiscardRejection::ChangedSinceReview(ChangedSinceReview { revision }) => {
425            Err(ToolError::refused(
426                ToolErrorCode::ChangesetChanged,
427                format!(
428                    "Nothing was dropped: the changeset is at revision {revision}, not the one you gave, so it holds changes you haven't seen. list_changesets shows what it holds now."
429                ),
430                json!({"revision": revision}),
431            ))
432        }
433        DiscardRejection::Blockers { blockers } => Err(ToolError::refused(
434            ToolErrorCode::DiscardConflict,
435            "Nothing was dropped: without these entities the rest of the changeset couldn't be published. Include the entities those changes depend on, or discard the whole changeset.",
436            json!({"blockers": explain_blockers(blockers)}),
437        )),
438    }
439}
440
441/// Drops only the listed entities, refusing if the rest would no longer publish.
442async fn discard_some(
443    conn: &mut PgConnection,
444    ctx: &RequestContext,
445    course: Course,
446    changeset: &AgentChangeset,
447    entity_ids: &[IdArgument],
448    revision: Option<i32>,
449) -> Result<ToolOutcome, ToolError> {
450    if entity_ids.is_empty() {
451        return Err(ToolError::InvalidArguments {
452            message: "entities is empty; leave it out to discard the whole changeset".into(),
453        });
454    }
455    let ids: Vec<Uuid> = entity_ids.iter().map(|id| id.id()).collect();
456    let view = load_view(conn, ctx.principal.actor.user_id, course, CourseParts::All).await?;
457    let mut keys = Vec::with_capacity(ids.len());
458    let mut unknown = Vec::new();
459    for id in ids {
460        match view.staged_entities.keys().find(|key| key.entity_id == id) {
461            Some(key) => keys.push(*key),
462            None => unknown.push(id),
463        }
464    }
465    if !unknown.is_empty() {
466        return Err(ToolError::NotFound {
467            message: format!(
468                "the changeset has nothing staged for {unknown:?}; list_changesets lists its entities"
469            ),
470        });
471    }
472    let discarded = match discard_entities(
473        conn,
474        changeset.id,
475        &keys,
476        revision,
477        &RiskBaseline::View(&view),
478    )
479    .await?
480    {
481        Ok(discarded) => discarded,
482        Err(rejection) => return discard_rejected(&view.course, changeset.id, rejection),
483    };
484    Ok(ToolResult::success(json!({
485        "changeset": changeset.id,
486        "status": AgentChangesetStatus::Open,
487        "discarded": discarded,
488        "message": "These entities were dropped from the changeset and show their published state again; the rest stays staged.",
489    }))
490    .with_hint(hints::staged(changeset.id, view.course.is_draft))
491    .into())
492}
493
494/// The page as published, if the course has it.
495async fn live_page(
496    conn: &mut PgConnection,
497    course: &Course,
498    page_id: Uuid,
499) -> Result<Option<Page>, ToolError> {
500    Ok(pages::get_page(conn, page_id)
501        .await
502        .optional()?
503        .filter(|page| {
504            page.course_id == Some(course.id) && page.deleted_at.is_none() && page.exam_id.is_none()
505        }))
506}
507
508/// The preview link of one page.
509#[derive(serde::Serialize)]
510struct PreviewLink {
511    page: Uuid,
512    url: String,
513}
514
515#[async_trait(?Send)]
516impl ToolHandler<McpState> for GetPreviewLink {
517    type Args = GetPreviewLinkArgs;
518
519    async fn call(
520        &self,
521        state: &McpState,
522        ctx: &RequestContext,
523        args: GetPreviewLinkArgs,
524    ) -> Result<ToolOutcome, ToolError> {
525        let page_ids: Vec<Uuid> = match (&args.page, &args.pages) {
526            (Some(page), None) => vec![page.id()],
527            (None, Some(pages)) if !pages.is_empty() => {
528                pages.iter().map(|page| page.id()).collect()
529            }
530            (None, None) => Vec::new(),
531            _ => {
532                return Err(ToolError::InvalidArguments {
533                    message:
534                        "give page, a non-empty pages list, or neither for the whole changeset"
535                            .into(),
536                });
537            }
538        };
539        let course_id = args.course.id();
540        let user_id = ctx.principal.actor.user_id;
541        let mut conn = connection(state).await?;
542        let course = authorize_course(&mut conn, user_id, course_id).await?;
543        let not_found = |page_id: Uuid| ToolError::NotFound {
544            message: format!(
545                "course {course_id} has no page {page_id}; get_course_structure lists its pages"
546            ),
547        };
548        let mut links = Vec::with_capacity(page_ids.len());
549        let Some(changeset) = agent_changesets::get_open(&mut conn, user_id, course.id).await?
550        else {
551            if page_ids.is_empty() {
552                let preview = mint_preview_token(&mut conn, course.id, None, None, user_id).await?;
553                return Ok(ToolResult::success(json!({
554                    "is_staged": false,
555                    "expires_at": preview.expires_at,
556                    "url": state.app_conf.public_url(&preview_path(&preview.token)),
557                    "message": "You have nothing staged in this course, so this link shows the published pages.",
558                }))
559                .into());
560            }
561            let mut expires_at = None;
562            for page_id in page_ids {
563                live_page(&mut conn, &course, page_id)
564                    .await?
565                    .ok_or_else(|| not_found(page_id))?;
566                let preview =
567                    mint_preview_token(&mut conn, course.id, None, Some(page_id), user_id).await?;
568                expires_at = Some(preview.expires_at);
569                links.push(PreviewLink {
570                    page: page_id,
571                    url: state.app_conf.public_url(&preview_path(&preview.token)),
572                });
573            }
574            let mut result = json!({
575                "is_staged": false,
576                "expires_at": expires_at,
577                "message": "You have nothing staged in this course, so these links show the published pages.",
578            });
579            insert_links(&mut result, args.page.is_some(), links);
580            return Ok(ToolResult::success(result).into());
581        };
582        let mut expires_at = None;
583        if page_ids.is_empty() {
584            let changed_pages = changed_page_links(&mut conn, &changeset).await?;
585            if changed_pages.is_empty() {
586                return Ok(ToolResult::success(json!({
587                    "is_staged": true,
588                    "changeset": changeset.id,
589                    "message": "The changeset adds or changes no pages, so there is nothing to preview.",
590                }))
591                .into());
592            }
593            let preview =
594                mint_preview_token(&mut conn, course.id, Some(changeset.id), None, user_id).await?;
595            return Ok(ToolResult::success(json!({
596                "is_staged": true,
597                "changeset": changeset.id,
598                "expires_at": preview.expires_at,
599                "url": state.app_conf.public_url(&preview_path(&preview.token)),
600                "pages": changed_pages.len(),
601            }))
602            .into());
603        }
604        for page_id in page_ids {
605            let key = EntityKey::new(ContentEntityType::Page, page_id);
606            match agent_changeset_entities::get_operation(&mut conn, changeset.id, key).await? {
607                Some(AgentChangesetEntityOperation::Delete) => {
608                    return Err(deleted_in_staged_changes(page_id));
609                }
610                Some(_) => {}
611                None => {
612                    live_page(&mut conn, &course, page_id)
613                        .await?
614                        .ok_or_else(|| not_found(page_id))?;
615                }
616            }
617            let preview = mint_preview_token(
618                &mut conn,
619                course.id,
620                Some(changeset.id),
621                Some(page_id),
622                user_id,
623            )
624            .await?;
625            expires_at = Some(preview.expires_at);
626            links.push(PreviewLink {
627                page: page_id,
628                url: state.app_conf.public_url(&preview_path(&preview.token)),
629            });
630        }
631        let mut result = json!({
632            "is_staged": true,
633            "changeset": changeset.id,
634            "expires_at": expires_at,
635        });
636        insert_links(&mut result, args.page.is_some(), links);
637        Ok(ToolResult::success(result).into())
638    }
639}
640
641/// Puts `links` into `result`: the one link as `url` when a single `page` was asked for, else
642/// `previews`.
643fn insert_links(result: &mut Value, is_single_page: bool, links: Vec<PreviewLink>) {
644    let (field, value) = match (is_single_page, links.as_slice()) {
645        (true, [link]) => ("url", json!(link.url)),
646        _ => ("previews", json!(links)),
647    };
648    if let Value::Object(fields) = result {
649        fields.insert(field.into(), value);
650    }
651}