@learnpack/learnpack 5.0.362 → 5.0.364

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/lib/commands/serve.js +444 -279
  2. package/lib/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  3. package/lib/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  4. package/lib/creatorDist/index.html +2 -2
  5. package/lib/models/creator.d.ts +1 -0
  6. package/lib/utils/api.d.ts +0 -9
  7. package/lib/utils/api.js +1 -65
  8. package/lib/utils/generationRetry.d.ts +48 -0
  9. package/lib/utils/generationRetry.js +81 -0
  10. package/lib/utils/interactivity/componentsGuide.d.ts +50 -0
  11. package/lib/utils/interactivity/componentsGuide.js +193 -0
  12. package/lib/utils/interactivity/interactivityPlan.d.ts +20 -0
  13. package/lib/utils/interactivity/interactivityPlan.js +33 -0
  14. package/lib/utils/interactivity/structuredComponents.d.ts +34 -0
  15. package/lib/utils/interactivity/structuredComponents.js +792 -0
  16. package/lib/utils/rigoActions.d.ts +1 -1
  17. package/lib/utils/rigoActions.js +2 -2
  18. package/package.json +1 -1
  19. package/src/commands/serve.ts +653 -397
  20. package/src/creator/package-lock.json +297 -3
  21. package/src/creator/package.json +1 -0
  22. package/src/creator/src/App.test.tsx +124 -1
  23. package/src/creator/src/App.tsx +52 -36
  24. package/src/creator/src/components/FileCard.tsx +63 -19
  25. package/src/creator/src/components/FileReadListener.test.tsx +128 -0
  26. package/src/creator/src/components/FileReadListener.tsx +75 -0
  27. package/src/creator/src/components/FileReviewModal.test.tsx +193 -0
  28. package/src/creator/src/components/FileReviewModal.tsx +128 -0
  29. package/src/creator/src/components/FileUploader.test.tsx +229 -0
  30. package/src/creator/src/components/FileUploader.tsx +212 -58
  31. package/src/creator/src/components/GenerationErrorCard.tsx +57 -0
  32. package/src/creator/src/components/MarkdownRenderer.tsx +4 -2
  33. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +6 -9
  34. package/src/creator/src/index.css +45 -0
  35. package/src/creator/src/locales/en.json +27 -5
  36. package/src/creator/src/locales/es.json +27 -5
  37. package/src/creator/src/main.tsx +2 -0
  38. package/src/creator/src/utils/completionResult.test.ts +61 -0
  39. package/src/creator/src/utils/completionResult.ts +61 -2
  40. package/src/creator/src/utils/courseLanguage.test.ts +6 -0
  41. package/src/creator/src/utils/creationRequest.test.ts +53 -18
  42. package/src/creator/src/utils/creationRequest.ts +17 -12
  43. package/src/creator/src/utils/fileRead.ts +60 -0
  44. package/src/creator/src/utils/lib.ts +0 -5
  45. package/src/creator/src/utils/store.ts +0 -10
  46. package/src/creatorDist/assets/{index-B7s5Nmq8.css → index-Cco3XhDR.css} +96 -0
  47. package/src/creatorDist/assets/{index-CKg5uuoj.js → index-o3Cw0bsF.js} +69620 -66788
  48. package/src/creatorDist/index.html +2 -2
  49. package/src/models/creator.ts +87 -84
  50. package/src/utils/api.ts +0 -83
  51. package/src/utils/generationRetry.ts +100 -0
  52. package/src/utils/interactivity/componentsGuide.ts +294 -0
  53. package/src/utils/interactivity/interactivityPlan.ts +57 -0
  54. package/src/utils/interactivity/structuredComponents.ts +1004 -0
  55. package/src/utils/rigoActions.ts +3 -2
@@ -49,6 +49,12 @@ import {
49
49
  insertStepInCorrectPosition,
50
50
  } from "../utils/creatorUtilities"
51
51
  import { findFinishedLessons } from "../utils/lessonRecovery"
52
+ import {
53
+ MAX_AUTO_RETRIES,
54
+ isStaleWebhook,
55
+ planGenerationRetry,
56
+ RetryPlan,
57
+ } from "../utils/generationRetry"
52
58
  // import { handleAssetCreation } from "./publish"
53
59
  import axios from "axios"
54
60
  import * as FormData from "form-data"
@@ -129,6 +135,18 @@ import type { RedisClientType } from "redis"
129
135
  import { HistoryManager } from "../managers/historyManager"
130
136
  import { ReadmeHistoryService } from "../managers/readmeHistoryService"
131
137
  import { sanitizeReadmeNewlines } from "../utils/readmeSanitizer"
138
+ import {
139
+ buildInteractivityGuide,
140
+ ComponentsGuideUnavailableError,
141
+ componentsGuideLoader,
142
+ decidedByFromGuide,
143
+ } from "../utils/interactivity/componentsGuide"
144
+ import {
145
+ describeInteractivityFailure,
146
+ isInteractivityV2Enabled,
147
+ planInteractivityRequest,
148
+ } from "../utils/interactivity/interactivityPlan"
149
+ import { processInteractivityResult } from "../utils/interactivity/structuredComponents"
132
150
 
133
151
  // eslint-disable-next-line
134
152
  const frontMatter = require("front-matter");
@@ -159,7 +177,11 @@ function opId(): string {
159
177
  return crypto.randomBytes(4).toString("hex")
160
178
  }
161
179
 
162
- // Serialization per course to prevent syllabus race conditions
180
+ // Serialization per course to prevent syllabus race conditions. The queue lives
181
+ // in memory, so it only orders requests within one process: serve runs on a
182
+ // single Heroku dyno. With more than one, it has to move to Redis.
183
+ // A function running in the queue must not call serializeByCourse for the same
184
+ // course, or it waits for itself.
163
185
  const courseQueues = new Map<string, Promise<unknown>>()
164
186
 
165
187
  async function serializeByCourse<T>(
@@ -167,11 +189,11 @@ async function serializeByCourse<T>(
167
189
  fn: () => Promise<T>
168
190
  ): Promise<T> {
169
191
  const prev = courseQueues.get(courseSlug) ?? Promise.resolve()
192
+ // A failure belongs to its own caller. Without the catch, every function
193
+ // queued after it would be skipped and fail with the same error.
170
194
  const next = prev
195
+ .catch(() => null)
171
196
  .then(() => fn())
172
- .catch(error => {
173
- throw error
174
- })
175
197
  .finally(() => {
176
198
  if (courseQueues.get(courseSlug) === next) {
177
199
  courseQueues.delete(courseSlug)
@@ -696,12 +718,45 @@ async function startInteractivityGeneration(
696
718
  courseSlug: string,
697
719
  purposeSlug: string,
698
720
  bucket: Bucket,
699
- lastLesson = ""
721
+ lastLesson = "",
722
+ options: { forceV1?: boolean } = {}
700
723
  ): Promise<number> {
701
724
  const exSlug = slugify(exercise.id + "-" + exercise.title)
702
725
  console.log("Starting interactivity generation for", exSlug)
703
726
 
704
- const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/interactivity-processor/${exercise.uid}/${rigoToken}`
727
+ const request = planInteractivityRequest({
728
+ v2Enabled: isInteractivityV2Enabled(),
729
+ forceV1: options.forceV1,
730
+ })
731
+
732
+ // The structured template (v2) needs the components guide: without it the
733
+ // model does not know what each component field means. The guide is loaded
734
+ // before anything else so that rigobot is never called without it.
735
+ let componentsYml: string
736
+ if (request.tpl === "v2") {
737
+ try {
738
+ componentsYml = buildInteractivityGuide(
739
+ await componentsGuideLoader.load()
740
+ )
741
+ } catch (error) {
742
+ if (error instanceof ComponentsGuideUnavailableError) {
743
+ console.error("Components guide unavailable:", error.message)
744
+ emitToCourse(courseSlug, "course-creation", {
745
+ lesson: exSlug,
746
+ status: "error",
747
+ log: `❌ Could not load the components guide for lesson ${exercise.title}; retry the generation`,
748
+ })
749
+ }
750
+
751
+ throw error
752
+ }
753
+ } else {
754
+ componentsYml = await fetchComponentsYml()
755
+ }
756
+
757
+ // The template and the attempt travel in the webhook URL: rigobot posts back
758
+ // to this exact URL, and its error payload says neither.
759
+ const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/interactivity-processor/${exercise.uid}/${rigoToken}${request.webhookQuery}`
705
760
 
706
761
  // Emit notification that interactivity generation is starting
707
762
  emitToCourse(courseSlug, "course-creation", {
@@ -710,8 +765,6 @@ async function startInteractivityGeneration(
710
765
  log: `🔄 Starting interactivity generation for lesson: ${exercise.title}`,
711
766
  })
712
767
 
713
- const componentsYml = await fetchComponentsYml()
714
-
715
768
  const currentSyllabus = await getSyllabus(courseSlug, bucket)
716
769
 
717
770
  const fullSyllabus = {
@@ -734,9 +787,16 @@ async function startInteractivityGeneration(
734
787
  current_syllabus: JSON.stringify(fullSyllabus),
735
788
  lesson_info: JSON.stringify(lessonCleaner(exercise)),
736
789
  },
737
- webhookUrl
790
+ webhookUrl,
791
+ request.slug
738
792
  )
739
793
 
794
+ if (!res) {
795
+ throw new Error(
796
+ `Could not start interactivity generation for lesson ${exSlug} (${request.slug})`
797
+ )
798
+ }
799
+
740
800
  return res.id
741
801
  }
742
802
 
@@ -923,6 +983,63 @@ async function updateLessonWithInitialContent(
923
983
  return lesson
924
984
  }
925
985
 
986
+ /**
987
+ * Changes one lesson on a fresh read of the syllabus. Saving a copy read
988
+ * earlier would undo whatever was saved since, like the lesson's
989
+ * `initialContent`.
990
+ * @param courseSlug - The course.
991
+ * @param lessonUID - The lesson to change.
992
+ * @param bucket - The bucket that holds the syllabus.
993
+ * @param patch - Changes the lesson in place. Gets the course language.
994
+ * @returns Once the syllabus is saved, or right away if the lesson is gone.
995
+ */
996
+ async function patchLesson(
997
+ courseSlug: string,
998
+ lessonUID: string,
999
+ bucket: Bucket,
1000
+ patch: (lesson: Lesson, language: string) => void
1001
+ ): Promise<void> {
1002
+ const syllabus = await getSyllabus(courseSlug, bucket)
1003
+ const lesson = syllabus.lessons.find(lesson => lesson.uid === lessonUID)
1004
+
1005
+ if (!lesson) {
1006
+ console.error(`Lesson ${lessonUID} not found in syllabus`)
1007
+ return
1008
+ }
1009
+
1010
+ patch(lesson, syllabus.courseInfo.language || "en")
1011
+ await saveSyllabus(courseSlug, syllabus, bucket)
1012
+ }
1013
+
1014
+ /**
1015
+ * The translation entry of a lesson that waits for a new job.
1016
+ * @param completionId - The Rigobot job.
1017
+ * @param autoRetries - The automatic retries of the current phase so far.
1018
+ * @returns The entry to save under the course language.
1019
+ */
1020
+ const startedJob = (completionId: number, autoRetries = 0) => ({
1021
+ completionId,
1022
+ startedAt: Date.now(),
1023
+ completedAt: 0,
1024
+ autoRetries,
1025
+ })
1026
+
1027
+ /**
1028
+ * The creator log for a failed job that is not retried.
1029
+ * @param reason - Why there is no retry.
1030
+ * @param phase - What the job generated.
1031
+ * @param exSlug - The lesson slug.
1032
+ * @returns The log line.
1033
+ */
1034
+ const noRetryLog = (
1035
+ reason: Extract<RetryPlan, { retry: false }>["reason"],
1036
+ phase: string,
1037
+ exSlug: string
1038
+ ): string =>
1039
+ reason === "permanent" ?
1040
+ `❌ Error generating ${phase} for lesson ${exSlug}. Retrying cannot fix this error, so it was not retried` :
1041
+ `❌ Error generating ${phase} for lesson ${exSlug} after ${MAX_AUTO_RETRIES} retries`
1042
+
926
1043
  async function updateLessonStatusToError(
927
1044
  courseSlug: string,
928
1045
  lessonUID: string,
@@ -1592,8 +1709,6 @@ class ServeCommand extends SessionCommand {
1592
1709
  process.exit(1)
1593
1710
  }
1594
1711
 
1595
- await api.updateTechnologiesPeriodically()
1596
-
1597
1712
  const credentials = JSON.parse(crendsEnv)
1598
1713
  const bucketStorage = new Storage({
1599
1714
  credentials,
@@ -2149,88 +2264,89 @@ class ServeCommand extends SessionCommand {
2149
2264
  )
2150
2265
  }
2151
2266
 
2152
- const syllabus = await getSyllabus(courseSlug, bucket)
2153
-
2154
- const exercise = syllabus.lessons.find(
2155
- lesson => lesson.uid === lessonUid
2156
- )
2157
- const exerciseIndex = syllabus.lessons.findIndex(
2158
- lesson => lesson.uid === lessonUid
2159
- )
2267
+ // In the course queue, so the webhook of this job cannot be handled
2268
+ // before the lesson is saved as generating.
2269
+ await serializeByCourse(courseSlug, async () => {
2270
+ const syllabus = await getSyllabus(courseSlug, bucket)
2160
2271
 
2161
- // Validate that exercise exists
2162
- if (!exercise) {
2163
- throw new NotFoundError(
2164
- `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2272
+ const exercise = syllabus.lessons.find(
2273
+ lesson => lesson.uid === lessonUid
2165
2274
  )
2166
- }
2167
-
2168
- // Validate that exercise index is valid (defensive check)
2169
- if (exerciseIndex === -1) {
2170
- throw new NotFoundError(
2171
- `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2275
+ const exerciseIndex = syllabus.lessons.findIndex(
2276
+ lesson => lesson.uid === lessonUid
2172
2277
  )
2173
- }
2174
2278
 
2175
- // Validate that exercise has required properties
2176
- if (!exercise.id || !exercise.title) {
2177
- throw new ValidationError(
2178
- `Lesson "${lessonUid}" is missing required properties (id or title)`
2179
- )
2180
- }
2279
+ // Validate that exercise exists
2280
+ if (!exercise) {
2281
+ throw new NotFoundError(
2282
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2283
+ )
2284
+ }
2181
2285
 
2182
- // previous exercise
2183
- let previousReadme = "---"
2184
- const previousExercise = syllabus.lessons[exerciseIndex - 1]
2185
- if (previousExercise) {
2186
- // Get the readme of the previous exercise
2187
- const exSlug = slugify(
2188
- previousExercise.id + "-" + previousExercise.title
2189
- )
2190
- // llist the files un
2191
- const [files] = await bucket.getFiles({
2192
- prefix: `courses/${courseSlug}/exercises/${exSlug}/README`,
2193
- })
2194
- // select any README
2195
- const readmeFiles = files.map(f => f.name)
2196
- const readmeFile = readmeFiles.find(f => f.includes("README"))
2197
- if (readmeFile) {
2198
- const [content] = await bucket.file(readmeFile).download()
2199
- previousReadme = content.toString()
2286
+ // Validate that exercise index is valid (defensive check)
2287
+ if (exerciseIndex === -1) {
2288
+ throw new NotFoundError(
2289
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2290
+ )
2200
2291
  }
2201
- }
2202
2292
 
2203
- // Use new two-phase generation workflow
2204
- const completionId = await startInitialContentGeneration(
2205
- rigoToken,
2206
- syllabus.lessons,
2207
- syllabus.courseInfo,
2208
- exercise,
2209
- courseSlug,
2210
- syllabus.courseInfo.purpose,
2211
- previousReadme +
2212
- "\n\nThe user provided the following feedback related to the content of the course so far: \n\n" +
2213
- feedback
2214
- )
2293
+ // Validate that exercise has required properties
2294
+ if (!exercise.id || !exercise.title) {
2295
+ throw new ValidationError(
2296
+ `Lesson "${lessonUid}" is missing required properties (id or title)`
2297
+ )
2298
+ }
2215
2299
 
2216
- syllabus.lessons[exerciseIndex].status = "GENERATING"
2217
- syllabus.lessons[exerciseIndex].translations = {
2218
- [syllabus.courseInfo.language || "en"]: {
2219
- completionId,
2220
- startedAt: Date.now(),
2221
- completedAt: 0,
2222
- },
2223
- }
2224
- console.log("Lesson", syllabus.lessons[exerciseIndex])
2225
- if (syllabus.feedback && typeof syllabus.feedback === "string") {
2226
- syllabus.feedback += "\n\n" + feedback
2227
- } else {
2228
- syllabus.feedback = feedback
2229
- }
2300
+ // previous exercise
2301
+ let previousReadme = "---"
2302
+ const previousExercise = syllabus.lessons[exerciseIndex - 1]
2303
+ if (previousExercise) {
2304
+ // Get the readme of the previous exercise
2305
+ const exSlug = slugify(
2306
+ previousExercise.id + "-" + previousExercise.title
2307
+ )
2308
+ // llist the files un
2309
+ const [files] = await bucket.getFiles({
2310
+ prefix: `courses/${courseSlug}/exercises/${exSlug}/README`,
2311
+ })
2312
+ // select any README
2313
+ const readmeFiles = files.map(f => f.name)
2314
+ const readmeFile = readmeFiles.find(f => f.includes("README"))
2315
+ if (readmeFile) {
2316
+ const [content] = await bucket.file(readmeFile).download()
2317
+ previousReadme = content.toString()
2318
+ }
2319
+ }
2320
+
2321
+ // Use new two-phase generation workflow
2322
+ const completionId = await startInitialContentGeneration(
2323
+ rigoToken,
2324
+ syllabus.lessons,
2325
+ syllabus.courseInfo,
2326
+ exercise,
2327
+ courseSlug,
2328
+ syllabus.courseInfo.purpose,
2329
+ previousReadme +
2330
+ "\n\nThe user provided the following feedback related to the content of the course so far: \n\n" +
2331
+ feedback
2332
+ )
2333
+
2334
+ // A new job by the user, so the automatic retries start over.
2335
+ syllabus.lessons[exerciseIndex].status = "GENERATING"
2336
+ syllabus.lessons[exerciseIndex].translations = {
2337
+ [syllabus.courseInfo.language || "en"]: startedJob(completionId),
2338
+ }
2339
+ console.log("Lesson", syllabus.lessons[exerciseIndex])
2340
+ if (syllabus.feedback && typeof syllabus.feedback === "string") {
2341
+ syllabus.feedback += "\n\n" + feedback
2342
+ } else {
2343
+ syllabus.feedback = feedback
2344
+ }
2230
2345
 
2231
- syllabus.generationMode = mode
2346
+ syllabus.generationMode = mode
2232
2347
 
2233
- await saveSyllabus(courseSlug, syllabus, bucket)
2348
+ await saveSyllabus(courseSlug, syllabus, bucket)
2349
+ })
2234
2350
 
2235
2351
  res.json({ status: "SUCCESS" })
2236
2352
  })
@@ -2548,223 +2664,152 @@ class ServeCommand extends SessionCommand {
2548
2664
 
2549
2665
  console.log("RECEIVING INITIAL CONTENT WEBHOOK", response)
2550
2666
 
2551
- const syllabus = await getSyllabus(courseSlug, bucket)
2552
-
2553
- const exerciseIndex = syllabus.lessons.findIndex(
2554
- lesson => lesson.uid === lessonUID
2555
- )
2556
-
2557
- if (exerciseIndex === -1) {
2558
- console.error("Exercise not found receiving webhook:", lessonUID)
2559
- return res.json({ status: "ERROR", error: "Exercise not found" })
2560
- }
2561
-
2562
- let exercise: Lesson | null = syllabus.lessons[exerciseIndex]
2563
- const exSlug = slugify(exercise.id + "-" + exercise.title)
2564
- // Handle errors
2565
- if (response.status === "ERROR") {
2566
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2567
- emitToCourse(courseSlug, "course-creation", {
2568
- lesson: exSlug,
2569
- status: "error",
2570
- log: `❌ Error generating initial content for lesson ${exSlug}`,
2571
- })
2572
-
2573
- // Retry initial content generation
2574
- try {
2575
- emitToCourse(courseSlug, "course-creation", {
2576
- lesson: exSlug,
2577
- status: "generating",
2578
- log: `🔄 Retrying initial content generation for lesson ${exSlug}`,
2579
- })
2667
+ try {
2668
+ // In the course queue, nothing else saves the syllabus between
2669
+ // reading the lesson and saving it.
2670
+ await serializeByCourse(courseSlug, async () => {
2671
+ const syllabus = await getSyllabus(courseSlug, bucket)
2672
+ const language = syllabus.courseInfo.language || "en"
2580
2673
 
2581
- const retryCompletionId = await startInitialContentGeneration(
2582
- rigoToken,
2583
- syllabus.lessons,
2584
- syllabus.courseInfo,
2585
- exercise,
2586
- courseSlug,
2587
- syllabus.courseInfo.purpose,
2588
- ""
2674
+ const exerciseIndex = syllabus.lessons.findIndex(
2675
+ lesson => lesson.uid === lessonUID
2589
2676
  )
2590
2677
 
2591
- // Update lesson status to show it's retrying
2592
- exercise.status = "GENERATING"
2593
- exercise.translations = {
2594
- [syllabus.courseInfo.language || "en"]: {
2595
- completionId: retryCompletionId,
2596
- startedAt: Date.now(),
2597
- completedAt: 0,
2598
- },
2678
+ if (exerciseIndex === -1) {
2679
+ console.error("Exercise not found receiving webhook:", lessonUID)
2680
+ return res.json({ status: "ERROR", error: "Exercise not found" })
2599
2681
  }
2600
- await saveSyllabus(courseSlug, syllabus, bucket)
2601
- } catch (retryError) {
2602
- console.error(
2603
- "Error retrying initial content generation:",
2604
- retryError
2605
- )
2606
- emitToCourse(courseSlug, "course-creation", {
2607
- lesson: lessonUID,
2608
- status: "error",
2609
- log: `❌ Failed to retry initial content generation for lesson ${lessonUID}`,
2610
- })
2611
- }
2612
2682
 
2613
- return res.json({ status: "ERROR" })
2614
- }
2615
-
2616
- try {
2617
- emitToCourse(courseSlug, "course-creation", {
2618
- lesson: exSlug,
2619
- status: "generating",
2620
- log: `✅ Initial content generated for lesson ${exSlug}`,
2621
- })
2683
+ let exercise: Lesson | null = syllabus.lessons[exerciseIndex]
2684
+ const exSlug = slugify(exercise.id + "-" + exercise.title)
2622
2685
 
2623
- // Update lesson with initial content
2624
- exercise = await updateLessonWithInitialContent(
2625
- courseSlug,
2626
- lessonUID,
2627
- response.parsed,
2628
- bucket
2629
- )
2630
-
2631
- if (!exercise) {
2632
- console.error("Exercise not found after updating initial content")
2633
- return res.json({ status: "ERROR", error: "Exercise not found" })
2634
- }
2635
-
2636
- let lastLesson = ""
2637
-
2638
- const prevLessonIndex = exerciseIndex - 1
2639
- if (prevLessonIndex >= 0) {
2640
- try {
2641
- const prevLesson = syllabus.lessons[prevLessonIndex]
2642
- // search the lesson content in the bucket
2643
- const prevLessonSlug = slugify(
2644
- prevLesson.id + "-" + prevLesson.title
2645
- )
2646
- const file = bucket.file(
2647
- `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2648
- syllabus.courseInfo.language || "en"
2649
- )}`
2686
+ if (isStaleWebhook(exercise, language, response.id)) {
2687
+ console.log(
2688
+ `Ignoring initial content webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
2650
2689
  )
2651
- const [content] = await file.download()
2652
- lastLesson = content.toString()
2653
- } catch (error) {
2654
- console.error("Error searching previous lesson content:", error)
2690
+ return res.json({ status: "IGNORED" })
2655
2691
  }
2656
- }
2657
-
2658
- const completionId = await startInteractivityGeneration(
2659
- rigoToken,
2660
- syllabus.lessons,
2661
- syllabus.courseInfo,
2662
- exercise,
2663
- courseSlug,
2664
- syllabus.courseInfo.purpose,
2665
- bucket,
2666
- lastLesson
2667
- )
2668
2692
 
2669
- // Update lesson status to show it's in Phase 2
2670
- exercise.status = "GENERATING"
2671
- exercise.translations = {
2672
- [syllabus.courseInfo.language || "en"]: {
2673
- completionId,
2674
- startedAt: Date.now(),
2675
- completedAt: 0,
2676
- },
2677
- }
2678
- await saveSyllabus(courseSlug, syllabus, bucket)
2693
+ // Handle errors
2694
+ if (response.status === "ERROR") {
2695
+ console.error(
2696
+ `Initial content job ${response.id} failed for lesson ${exSlug}:`,
2697
+ response.status_text
2698
+ )
2679
2699
 
2680
- emitToCourse(courseSlug, "course-creation", {
2681
- lesson: exSlug,
2682
- status: "generating",
2683
- log: `🔄 Starting interactivity phase for lesson ${exercise.title}`,
2684
- })
2700
+ const retryPlan = planGenerationRetry(
2701
+ exercise,
2702
+ language,
2703
+ response.status_text
2704
+ )
2685
2705
 
2686
- emitToCourse(courseSlug, "course-creation", {
2687
- lesson: exSlug,
2688
- status: "initial-content-complete",
2689
- log: `✅ Initial content generated for lesson ${exSlug}, starting interactivity phase`,
2690
- })
2706
+ if (!retryPlan.retry) {
2707
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2708
+ emitToCourse(courseSlug, "course-creation", {
2709
+ lesson: exSlug,
2710
+ status: "error",
2711
+ log: noRetryLog(retryPlan.reason, "initial content", exSlug),
2712
+ })
2713
+ return res.json({ status: "ERROR" })
2714
+ }
2691
2715
 
2692
- res.json({ status: "SUCCESS" })
2693
- } catch (error) {
2694
- console.error("Error processing initial content webhook:", error)
2695
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2696
- emitToCourse(courseSlug, "course-creation", {
2697
- lesson: exSlug,
2698
- status: "error",
2699
- log: `❌ Error processing initial content for lesson ${exSlug}`,
2700
- })
2701
- res
2702
- .status(500)
2703
- .json({ status: "ERROR", error: (error as Error).message })
2704
- }
2705
- }
2706
- )
2716
+ const attempt = retryPlan.attempt
2707
2717
 
2708
- // Phase 2: Interactivity generation webhook (replaces exercise-processor logic)
2709
- app.post(
2710
- "/webhooks/:courseSlug/interactivity-processor/:lessonUID/:rigoToken",
2711
- async (req, res) => {
2712
- const { courseSlug, lessonUID, rigoToken } = req.params
2713
- const response = req.body
2718
+ try {
2719
+ emitToCourse(courseSlug, "course-creation", {
2720
+ lesson: exSlug,
2721
+ status: "generating",
2722
+ log: `🔄 Retrying initial content generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
2723
+ })
2714
2724
 
2715
- console.log("RECEIVING INTERACTIVITY WEBHOOK")
2716
- // console.log("LESSON UID", lessonUID)
2717
- // console.log("RESPONSE", response)
2725
+ const retryCompletionId = await startInitialContentGeneration(
2726
+ rigoToken,
2727
+ syllabus.lessons,
2728
+ syllabus.courseInfo,
2729
+ exercise,
2730
+ courseSlug,
2731
+ syllabus.courseInfo.purpose,
2732
+ ""
2733
+ )
2718
2734
 
2719
- // Handle errors
2720
- if (response.status === "ERROR") {
2721
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2722
- emitToCourse(courseSlug, "course-creation", {
2723
- lesson: lessonUID,
2724
- status: "error",
2725
- log: `❌ Error adding interactivity to lesson ${lessonUID}`,
2726
- })
2735
+ await patchLesson(
2736
+ courseSlug,
2737
+ lessonUID,
2738
+ bucket,
2739
+ (lesson, lang) => {
2740
+ lesson.status = "GENERATING"
2741
+ lesson.translations = {
2742
+ [lang]: startedJob(retryCompletionId, attempt),
2743
+ }
2744
+ }
2745
+ )
2746
+ } catch (retryError) {
2747
+ console.error(
2748
+ "Error retrying initial content generation:",
2749
+ retryError
2750
+ )
2751
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2752
+ emitToCourse(courseSlug, "course-creation", {
2753
+ lesson: exSlug,
2754
+ status: "error",
2755
+ log: `❌ Failed to retry initial content generation for lesson ${exSlug}`,
2756
+ })
2757
+ }
2727
2758
 
2728
- // Retry interactivity generation
2729
- try {
2730
- const syllabus = await getSyllabus(courseSlug, bucket)
2731
- const lessonIndex = syllabus.lessons.findIndex(
2732
- lesson => lesson.uid === lessonUID
2733
- )
2734
- const exercise = syllabus.lessons[lessonIndex]
2759
+ return res.json({ status: "ERROR" })
2760
+ }
2735
2761
 
2736
- if (exercise && exercise.initialContent) {
2762
+ try {
2737
2763
  emitToCourse(courseSlug, "course-creation", {
2738
- lesson: lessonUID,
2764
+ lesson: exSlug,
2739
2765
  status: "generating",
2740
- log: `🔄 Retrying interactivity generation for lesson ${lessonUID}`,
2766
+ log: `✅ Initial content generated for lesson ${exSlug}`,
2741
2767
  })
2742
2768
 
2743
- // Get previous lesson content for context
2769
+ // Update lesson with initial content
2770
+ exercise = await updateLessonWithInitialContent(
2771
+ courseSlug,
2772
+ lessonUID,
2773
+ response.parsed,
2774
+ bucket
2775
+ )
2776
+
2777
+ if (!exercise) {
2778
+ console.error(
2779
+ "Exercise not found after updating initial content"
2780
+ )
2781
+ return res.json({
2782
+ status: "ERROR",
2783
+ error: "Exercise not found",
2784
+ })
2785
+ }
2786
+
2744
2787
  let lastLesson = ""
2745
- const prevLessonIndex = lessonIndex - 1
2788
+
2789
+ const prevLessonIndex = exerciseIndex - 1
2746
2790
  if (prevLessonIndex >= 0) {
2747
2791
  try {
2748
2792
  const prevLesson = syllabus.lessons[prevLessonIndex]
2793
+ // search the lesson content in the bucket
2749
2794
  const prevLessonSlug = slugify(
2750
2795
  prevLesson.id + "-" + prevLesson.title
2751
2796
  )
2752
2797
  const file = bucket.file(
2753
2798
  `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2754
- syllabus.courseInfo.language || "en"
2799
+ language
2755
2800
  )}`
2756
2801
  )
2757
2802
  const [content] = await file.download()
2758
2803
  lastLesson = content.toString()
2759
2804
  } catch (error) {
2760
2805
  console.error(
2761
- "Error getting previous lesson content for retry:",
2806
+ "Error searching previous lesson content:",
2762
2807
  error
2763
2808
  )
2764
2809
  }
2765
2810
  }
2766
2811
 
2767
- const retryCompletionId = await startInteractivityGeneration(
2812
+ const completionId = await startInteractivityGeneration(
2768
2813
  rigoToken,
2769
2814
  syllabus.lessons,
2770
2815
  syllabus.courseInfo,
@@ -2775,120 +2820,337 @@ class ServeCommand extends SessionCommand {
2775
2820
  lastLesson
2776
2821
  )
2777
2822
 
2778
- // Update lesson status to show it's retrying
2779
- exercise.status = "GENERATING"
2780
- exercise.translations = {
2781
- [syllabus.courseInfo.language || "en"]: {
2782
- completionId: retryCompletionId,
2783
- startedAt: Date.now(),
2784
- completedAt: 0,
2785
- },
2786
- }
2787
- await saveSyllabus(courseSlug, syllabus, bucket)
2823
+ // Saved on a fresh read: `syllabus` was read before the initial
2824
+ // content was saved, and saving it would erase that content.
2825
+ await patchLesson(
2826
+ courseSlug,
2827
+ lessonUID,
2828
+ bucket,
2829
+ (lesson, lang) => {
2830
+ lesson.status = "GENERATING"
2831
+ lesson.translations = { [lang]: startedJob(completionId) }
2832
+ }
2833
+ )
2834
+
2835
+ emitToCourse(courseSlug, "course-creation", {
2836
+ lesson: exSlug,
2837
+ status: "generating",
2838
+ log: `🔄 Starting interactivity phase for lesson ${exercise.title}`,
2839
+ })
2840
+
2841
+ emitToCourse(courseSlug, "course-creation", {
2842
+ lesson: exSlug,
2843
+ status: "initial-content-complete",
2844
+ log: `✅ Initial content generated for lesson ${exSlug}, starting interactivity phase`,
2845
+ })
2846
+
2847
+ res.json({ status: "SUCCESS" })
2848
+ } catch (error) {
2849
+ console.error("Error processing initial content webhook:", error)
2850
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2851
+ emitToCourse(courseSlug, "course-creation", {
2852
+ lesson: exSlug,
2853
+ status: "error",
2854
+ log: `❌ Error processing initial content for lesson ${exSlug}`,
2855
+ })
2856
+ res
2857
+ .status(500)
2858
+ .json({ status: "ERROR", error: (error as Error).message })
2788
2859
  }
2789
- } catch (retryError) {
2790
- console.error(
2791
- "Error retrying interactivity generation:",
2792
- retryError
2793
- )
2794
- emitToCourse(courseSlug, "course-creation", {
2795
- lesson: lessonUID,
2796
- status: "error",
2797
- log: `❌ Failed to retry interactivity generation for lesson ${lessonUID}`,
2798
- })
2860
+ })
2861
+ } catch (error) {
2862
+ console.error("Error handling initial content webhook:", error)
2863
+ if (!res.headersSent) {
2864
+ res
2865
+ .status(500)
2866
+ .json({ status: "ERROR", error: (error as Error).message })
2799
2867
  }
2800
-
2801
- return res.json({ status: "ERROR" })
2802
2868
  }
2869
+ }
2870
+ )
2871
+
2872
+ // Phase 2: Interactivity generation webhook (replaces exercise-processor logic)
2873
+ app.post(
2874
+ "/webhooks/:courseSlug/interactivity-processor/:lessonUID/:rigoToken",
2875
+ async (req, res) => {
2876
+ const { courseSlug, lessonUID, rigoToken } = req.params
2877
+ const response = req.body
2878
+
2879
+ console.log("RECEIVING INTERACTIVITY WEBHOOK")
2880
+ // console.log("LESSON UID", lessonUID)
2881
+ // console.log("RESPONSE", response)
2803
2882
 
2804
2883
  try {
2805
- const syllabus = await getSyllabus(courseSlug, bucket)
2806
- const exerciseIndex = syllabus.lessons.findIndex(
2807
- lesson => lesson.uid === lessonUID
2808
- )
2884
+ // In the course queue, nothing else saves the syllabus between
2885
+ // reading the lesson and saving it.
2886
+ await serializeByCourse(courseSlug, async () => {
2887
+ const syllabus = await getSyllabus(courseSlug, bucket)
2888
+ const language = syllabus.courseInfo.language || "en"
2809
2889
 
2810
- if (exerciseIndex === -1) {
2811
- console.error("Exercise not found receiving webhook:", lessonUID)
2812
- return res.json({ status: "ERROR", error: "Exercise not found" })
2813
- }
2890
+ const exerciseIndex = syllabus.lessons.findIndex(
2891
+ lesson => lesson.uid === lessonUID
2892
+ )
2814
2893
 
2815
- const exercise = syllabus.lessons[exerciseIndex]
2816
- const exSlug = slugify(exercise.id + "-" + exercise.title)
2894
+ if (exerciseIndex === -1) {
2895
+ console.error("Exercise not found receiving webhook:", lessonUID)
2896
+ return res.json({ status: "ERROR", error: "Exercise not found" })
2897
+ }
2817
2898
 
2818
- // Process readability of final content
2819
- const readability = checkReadability(
2820
- response.parsed.final_content,
2821
- PARAMS.max_words,
2822
- 3
2823
- )
2899
+ const exercise = syllabus.lessons[exerciseIndex]
2900
+ const exSlug = slugify(exercise.id + "-" + exercise.title)
2824
2901
 
2825
- emitToCourse(courseSlug, "course-creation", {
2826
- lesson: exSlug,
2827
- status: "generating",
2828
- log: `🔄 The lesson ${exercise.title} has a readability score of ${readability.fkglResult.fkgl}`,
2829
- })
2902
+ if (isStaleWebhook(exercise, language, response.id)) {
2903
+ console.log(
2904
+ `Ignoring interactivity webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
2905
+ )
2906
+ return res.json({ status: "IGNORED" })
2907
+ }
2830
2908
 
2831
- // Upload README with final content
2832
- const exercisesDir = `courses/${courseSlug}/exercises`
2833
- const targetDir = `${exercisesDir}/${exSlug}`
2834
- const readmeFilename = `README${getReadmeExtension(
2835
- response.parsed.output_language ||
2836
- syllabus.courseInfo.language ||
2837
- "en"
2838
- )}`
2909
+ // Handle errors
2910
+ if (response.status === "ERROR") {
2911
+ console.error(
2912
+ `Interactivity job ${response.id} failed for lesson ${exSlug}:`,
2913
+ response.status_text
2914
+ )
2839
2915
 
2840
- await uploadFileToBucket(
2841
- bucket,
2842
- readability.newMarkdown,
2843
- `${targetDir}/${readmeFilename}`
2844
- )
2916
+ // Every retry uses v1. A failed v2 job gets its v1 retry even
2917
+ // when its error would repeat, because the error belongs to the
2918
+ // v2 template.
2919
+ const { fromV2, retryStatusText } = describeInteractivityFailure(
2920
+ req.query,
2921
+ response.status_text
2922
+ )
2845
2923
 
2846
- // Update used components if provided by the AI
2847
- if (
2848
- response.parsed.used_components &&
2849
- Array.isArray(response.parsed.used_components)
2850
- ) {
2851
- await updateUsedComponents(
2852
- courseSlug,
2853
- response.parsed.used_components,
2854
- bucket
2855
- )
2856
- emitToCourse(courseSlug, "course-creation", {
2857
- lesson: exSlug,
2858
- status: "generating",
2859
- log: `📊 Updated component usage tracking: ${response.parsed.used_components.join(
2860
- ", "
2861
- )}`,
2862
- })
2863
- }
2924
+ const retryPlan = planGenerationRetry(
2925
+ exercise,
2926
+ language,
2927
+ retryStatusText
2928
+ )
2864
2929
 
2865
- // Continue with next lesson
2866
- await continueWithNextLesson(
2867
- courseSlug,
2868
- exerciseIndex,
2869
- rigoToken,
2870
- response.parsed.final_content,
2871
- bucket
2872
- )
2930
+ // The retry builds on the initial content, so without it there
2931
+ // is nothing to retry.
2932
+ if (!retryPlan.retry || !exercise.initialContent) {
2933
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2934
+ emitToCourse(courseSlug, "course-creation", {
2935
+ lesson: exSlug,
2936
+ status: "error",
2937
+ log: retryPlan.retry ?
2938
+ `❌ Error adding interactivity to lesson ${exSlug}. Its initial content is missing, so it was not retried` :
2939
+ noRetryLog(retryPlan.reason, "interactivity", exSlug),
2940
+ })
2941
+ return res.json({ status: "ERROR" })
2942
+ }
2873
2943
 
2874
- emitToCourse(courseSlug, "course-creation", {
2875
- lesson: exSlug,
2876
- status: "done",
2877
- log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
2878
- })
2944
+ const attempt = retryPlan.attempt
2879
2945
 
2880
- res.json({ status: "SUCCESS" })
2881
- } catch (error) {
2882
- console.error("Error processing interactivity webhook:", error)
2883
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2884
- emitToCourse(courseSlug, "course-creation", {
2885
- lesson: lessonUID,
2886
- status: "error",
2887
- log: `❌ Error processing interactivity for lesson ${lessonUID}`,
2946
+ try {
2947
+ emitToCourse(courseSlug, "course-creation", {
2948
+ lesson: exSlug,
2949
+ status: "generating",
2950
+ log: fromV2 ?
2951
+ `⚠️ Structured interactivity failed for lesson ${exSlug}, retrying with the previous method (${attempt} of ${MAX_AUTO_RETRIES})` :
2952
+ `🔄 Retrying interactivity generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
2953
+ })
2954
+
2955
+ // Get previous lesson content for context
2956
+ let lastLesson = ""
2957
+ const prevLessonIndex = exerciseIndex - 1
2958
+ if (prevLessonIndex >= 0) {
2959
+ try {
2960
+ const prevLesson = syllabus.lessons[prevLessonIndex]
2961
+ const prevLessonSlug = slugify(
2962
+ prevLesson.id + "-" + prevLesson.title
2963
+ )
2964
+ const file = bucket.file(
2965
+ `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2966
+ language
2967
+ )}`
2968
+ )
2969
+ const [content] = await file.download()
2970
+ lastLesson = content.toString()
2971
+ } catch (error) {
2972
+ console.error(
2973
+ "Error getting previous lesson content for retry:",
2974
+ error
2975
+ )
2976
+ }
2977
+ }
2978
+
2979
+ const retryCompletionId = await startInteractivityGeneration(
2980
+ rigoToken,
2981
+ syllabus.lessons,
2982
+ syllabus.courseInfo,
2983
+ exercise,
2984
+ courseSlug,
2985
+ syllabus.courseInfo.purpose,
2986
+ bucket,
2987
+ lastLesson,
2988
+ { forceV1: true }
2989
+ )
2990
+
2991
+ await patchLesson(
2992
+ courseSlug,
2993
+ lessonUID,
2994
+ bucket,
2995
+ (lesson, lang) => {
2996
+ lesson.status = "GENERATING"
2997
+ lesson.translations = {
2998
+ [lang]: startedJob(retryCompletionId, attempt),
2999
+ }
3000
+ }
3001
+ )
3002
+ } catch (retryError) {
3003
+ console.error(
3004
+ "Error retrying interactivity generation:",
3005
+ retryError
3006
+ )
3007
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
3008
+ emitToCourse(courseSlug, "course-creation", {
3009
+ lesson: exSlug,
3010
+ status: "error",
3011
+ log: `❌ Failed to retry interactivity generation for lesson ${exSlug}`,
3012
+ })
3013
+ }
3014
+
3015
+ return res.json({ status: "ERROR" })
3016
+ }
3017
+
3018
+ try {
3019
+ let finalContent: string = response.parsed?.final_content
3020
+ let usedComponents: unknown = response.parsed?.used_components
3021
+
3022
+ // The structured template (v2) returns component data and
3023
+ // placeholders; the CLI writes the component markdown.
3024
+ if (req.query.tpl === "v2") {
3025
+ if (!Array.isArray(response.parsed?.multiple_choice_question)) {
3026
+ console.warn(
3027
+ `Structured interactivity response for ${exSlug} has no component lists`
3028
+ )
3029
+ }
3030
+
3031
+ const guide = componentsGuideLoader.peek()
3032
+ const result = processInteractivityResult(response.parsed, {
3033
+ stepType: exercise.type,
3034
+ isFirstOrLast:
3035
+ exerciseIndex === 0 ||
3036
+ exerciseIndex === syllabus.lessons.length - 1,
3037
+ lessonId: exercise.id,
3038
+ decidedBy: guide ? decidedByFromGuide(guide) : undefined,
3039
+ })
3040
+ finalContent = result.markdown
3041
+ usedComponents = result.usedComponents
3042
+
3043
+ if (result.warnings.length > 0) {
3044
+ console.warn(
3045
+ `Component warnings for ${exSlug}:\n- ${result.warnings
3046
+ .map(warning => warning.message)
3047
+ .join("\n- ")}`
3048
+ )
3049
+ }
3050
+
3051
+ if (result.dropped.length > 0) {
3052
+ console.warn(
3053
+ `Components removed from ${exSlug}:\n- ${result.dropped
3054
+ .map(issue => issue.message)
3055
+ .join("\n- ")}`
3056
+ )
3057
+ emitToCourse(courseSlug, "course-creation", {
3058
+ lesson: exSlug,
3059
+ status: "generating",
3060
+ log: `⚠️ ${
3061
+ result.dropped.length
3062
+ } component(s) removed from lesson ${
3063
+ exercise.title
3064
+ } because they could not be rendered: ${result.dropped
3065
+ .map(issue => issue.message)
3066
+ .join("; ")}`,
3067
+ })
3068
+ }
3069
+
3070
+ if (exercise.type === "QUIZ" && result.assessmentCount === 0) {
3071
+ emitToCourse(courseSlug, "course-creation", {
3072
+ lesson: exSlug,
3073
+ status: "generating",
3074
+ log: `⚠️ The quiz ${exercise.title} has no questions; review it or regenerate it`,
3075
+ })
3076
+ }
3077
+ }
3078
+
3079
+ // Process readability of final content
3080
+ const readability = checkReadability(
3081
+ finalContent,
3082
+ PARAMS.max_words,
3083
+ 3
3084
+ )
3085
+
3086
+ emitToCourse(courseSlug, "course-creation", {
3087
+ lesson: exSlug,
3088
+ status: "generating",
3089
+ log: `🔄 The lesson ${exercise.title} has a readability score of ${readability.fkglResult.fkgl}`,
3090
+ })
3091
+
3092
+ // Upload README with final content
3093
+ const exercisesDir = `courses/${courseSlug}/exercises`
3094
+ const targetDir = `${exercisesDir}/${exSlug}`
3095
+ const readmeFilename = `README${getReadmeExtension(
3096
+ response.parsed.output_language || language
3097
+ )}`
3098
+
3099
+ await uploadFileToBucket(
3100
+ bucket,
3101
+ readability.newMarkdown,
3102
+ `${targetDir}/${readmeFilename}`
3103
+ )
3104
+
3105
+ // Update used components (v1: listed by the AI; v2: the
3106
+ // components that were actually written)
3107
+ if (Array.isArray(usedComponents)) {
3108
+ await updateUsedComponents(courseSlug, usedComponents, bucket)
3109
+ emitToCourse(courseSlug, "course-creation", {
3110
+ lesson: exSlug,
3111
+ status: "generating",
3112
+ log: `📊 Updated component usage tracking: ${usedComponents.join(
3113
+ ", "
3114
+ )}`,
3115
+ })
3116
+ }
3117
+
3118
+ // Continue with next lesson
3119
+ await continueWithNextLesson(
3120
+ courseSlug,
3121
+ exerciseIndex,
3122
+ rigoToken,
3123
+ finalContent,
3124
+ bucket
3125
+ )
3126
+
3127
+ emitToCourse(courseSlug, "course-creation", {
3128
+ lesson: exSlug,
3129
+ status: "done",
3130
+ log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
3131
+ })
3132
+
3133
+ res.json({ status: "SUCCESS" })
3134
+ } catch (error) {
3135
+ console.error("Error processing interactivity webhook:", error)
3136
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
3137
+ emitToCourse(courseSlug, "course-creation", {
3138
+ lesson: exSlug,
3139
+ status: "error",
3140
+ log: `❌ Error processing interactivity for lesson ${exSlug}`,
3141
+ })
3142
+ res
3143
+ .status(500)
3144
+ .json({ status: "ERROR", error: (error as Error).message })
3145
+ }
2888
3146
  })
2889
- res
2890
- .status(500)
2891
- .json({ status: "ERROR", error: (error as Error).message })
3147
+ } catch (error) {
3148
+ console.error("Error handling interactivity webhook:", error)
3149
+ if (!res.headersSent) {
3150
+ res
3151
+ .status(500)
3152
+ .json({ status: "ERROR", error: (error as Error).message })
3153
+ }
2892
3154
  }
2893
3155
  }
2894
3156
  )
@@ -3857,42 +4119,43 @@ class ServeCommand extends SessionCommand {
3857
4119
  uid: stepSlug,
3858
4120
  }
3859
4121
 
3860
- const newLessons = insertStepInCorrectPosition(
3861
- initialSyllabus.lessons,
3862
- newLesson
3863
- )
3864
- // Use new two-phase generation workflow
3865
- const completionId = await startInitialContentGeneration(
3866
- rigoToken,
3867
- newLessons,
3868
- initialSyllabus.courseInfo,
3869
- newLesson,
3870
- courseSlug,
3871
- initialSyllabus.courseInfo.purpose,
3872
- "lastResult"
3873
- )
3874
- newLesson.translations = {
3875
- [initialSyllabus.courseInfo.language || "en"]: {
3876
- completionId,
3877
- startedAt: Date.now(),
3878
- completedAt: 0,
3879
- },
3880
- }
4122
+ // In the course queue, so the webhook of this job finds the new lesson
4123
+ // saved, and the syllabus saved here is not older than the last save.
4124
+ await serializeByCourse(courseSlug, async () => {
4125
+ const syllabus = await getSyllabus(courseSlug, bucket)
4126
+ const newLessons = insertStepInCorrectPosition(
4127
+ syllabus.lessons,
4128
+ newLesson
4129
+ )
4130
+ // Use new two-phase generation workflow
4131
+ const completionId = await startInitialContentGeneration(
4132
+ rigoToken,
4133
+ newLessons,
4134
+ syllabus.courseInfo,
4135
+ newLesson,
4136
+ courseSlug,
4137
+ syllabus.courseInfo.purpose,
4138
+ "lastResult"
4139
+ )
4140
+ newLesson.translations = {
4141
+ [syllabus.courseInfo.language || "en"]: startedJob(completionId),
4142
+ }
3881
4143
 
3882
- await uploadFileToBucket(
3883
- bucket,
3884
- JSON.stringify({ ...initialSyllabus, lessons: newLessons }),
3885
- `courses/${courseSlug}/.learn/initialSyllabus.json`
3886
- )
4144
+ await uploadFileToBucket(
4145
+ bucket,
4146
+ JSON.stringify({ ...syllabus, lessons: newLessons }),
4147
+ `courses/${courseSlug}/.learn/initialSyllabus.json`
4148
+ )
3887
4149
 
3888
- const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
4150
+ const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
3889
4151
 
3890
- await uploadInitialReadme(
3891
- bucket,
3892
- stepSlug,
3893
- targetDir,
3894
- initialSyllabus.courseInfo
3895
- )
4152
+ await uploadInitialReadme(
4153
+ bucket,
4154
+ stepSlug,
4155
+ targetDir,
4156
+ syllabus.courseInfo
4157
+ )
4158
+ })
3896
4159
 
3897
4160
  res.json({ status: "SUCCESS", message: "Exercise generati on started!" })
3898
4161
  })
@@ -5134,13 +5397,6 @@ class ServeCommand extends SessionCommand {
5134
5397
  }
5135
5398
  })
5136
5399
 
5137
- app.get("/technologies", async (req, res) => {
5138
- console.log("GET /technologies")
5139
-
5140
- const technologies = await api.getCurrentTechnologies()
5141
- res.json(technologies)
5142
- })
5143
-
5144
5400
  app.get("/assets/:file", (req, res) => {
5145
5401
  const file = path.join(localAppPath, req.params.file)
5146
5402
  res.sendFile(file)