@learnpack/learnpack 5.0.363 → 5.0.365

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.
@@ -39,6 +39,7 @@ import {
39
39
  addInteractivity,
40
40
  generateCodeChallenge,
41
41
  generateStepSlug,
42
+ getCompletionJob,
42
43
  } from "../utils/rigoActions"
43
44
  import * as dotenv from "dotenv"
44
45
 
@@ -49,6 +50,17 @@ import {
49
50
  insertStepInCorrectPosition,
50
51
  } from "../utils/creatorUtilities"
51
52
  import { findFinishedLessons } from "../utils/lessonRecovery"
53
+ import {
54
+ MAX_AUTO_RETRIES,
55
+ isStaleWebhook,
56
+ planGenerationRetry,
57
+ RetryPlan,
58
+ } from "../utils/generationRetry"
59
+ import {
60
+ currentJobId,
61
+ isGenerationRunning,
62
+ resolveGenerationMode,
63
+ } from "../utils/continueGeneration"
52
64
  // import { handleAssetCreation } from "./publish"
53
65
  import axios from "axios"
54
66
  import * as FormData from "form-data"
@@ -129,6 +141,18 @@ import type { RedisClientType } from "redis"
129
141
  import { HistoryManager } from "../managers/historyManager"
130
142
  import { ReadmeHistoryService } from "../managers/readmeHistoryService"
131
143
  import { sanitizeReadmeNewlines } from "../utils/readmeSanitizer"
144
+ import {
145
+ buildInteractivityGuide,
146
+ ComponentsGuideUnavailableError,
147
+ componentsGuideLoader,
148
+ decidedByFromGuide,
149
+ } from "../utils/interactivity/componentsGuide"
150
+ import {
151
+ describeInteractivityFailure,
152
+ isInteractivityV2Enabled,
153
+ planInteractivityRequest,
154
+ } from "../utils/interactivity/interactivityPlan"
155
+ import { processInteractivityResult } from "../utils/interactivity/structuredComponents"
132
156
 
133
157
  // eslint-disable-next-line
134
158
  const frontMatter = require("front-matter");
@@ -159,7 +183,11 @@ function opId(): string {
159
183
  return crypto.randomBytes(4).toString("hex")
160
184
  }
161
185
 
162
- // Serialization per course to prevent syllabus race conditions
186
+ // Serialization per course to prevent syllabus race conditions. The queue lives
187
+ // in memory, so it only orders requests within one process: serve runs on a
188
+ // single Heroku dyno. With more than one, it has to move to Redis.
189
+ // A function running in the queue must not call serializeByCourse for the same
190
+ // course, or it waits for itself.
163
191
  const courseQueues = new Map<string, Promise<unknown>>()
164
192
 
165
193
  async function serializeByCourse<T>(
@@ -167,11 +195,11 @@ async function serializeByCourse<T>(
167
195
  fn: () => Promise<T>
168
196
  ): Promise<T> {
169
197
  const prev = courseQueues.get(courseSlug) ?? Promise.resolve()
198
+ // A failure belongs to its own caller. Without the catch, every function
199
+ // queued after it would be skipped and fail with the same error.
170
200
  const next = prev
201
+ .catch(() => null)
171
202
  .then(() => fn())
172
- .catch(error => {
173
- throw error
174
- })
175
203
  .finally(() => {
176
204
  if (courseQueues.get(courseSlug) === next) {
177
205
  courseQueues.delete(courseSlug)
@@ -696,12 +724,45 @@ async function startInteractivityGeneration(
696
724
  courseSlug: string,
697
725
  purposeSlug: string,
698
726
  bucket: Bucket,
699
- lastLesson = ""
727
+ lastLesson = "",
728
+ options: { forceV1?: boolean } = {}
700
729
  ): Promise<number> {
701
730
  const exSlug = slugify(exercise.id + "-" + exercise.title)
702
731
  console.log("Starting interactivity generation for", exSlug)
703
732
 
704
- const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/interactivity-processor/${exercise.uid}/${rigoToken}`
733
+ const request = planInteractivityRequest({
734
+ v2Enabled: isInteractivityV2Enabled(),
735
+ forceV1: options.forceV1,
736
+ })
737
+
738
+ // The structured template (v2) needs the components guide: without it the
739
+ // model does not know what each component field means. The guide is loaded
740
+ // before anything else so that rigobot is never called without it.
741
+ let componentsYml: string
742
+ if (request.tpl === "v2") {
743
+ try {
744
+ componentsYml = buildInteractivityGuide(
745
+ await componentsGuideLoader.load()
746
+ )
747
+ } catch (error) {
748
+ if (error instanceof ComponentsGuideUnavailableError) {
749
+ console.error("Components guide unavailable:", error.message)
750
+ emitToCourse(courseSlug, "course-creation", {
751
+ lesson: exSlug,
752
+ status: "error",
753
+ log: `❌ Could not load the components guide for lesson ${exercise.title}; retry the generation`,
754
+ })
755
+ }
756
+
757
+ throw error
758
+ }
759
+ } else {
760
+ componentsYml = await fetchComponentsYml()
761
+ }
762
+
763
+ // The template and the attempt travel in the webhook URL: rigobot posts back
764
+ // to this exact URL, and its error payload says neither.
765
+ const webhookUrl = `${process.env.HOST}/webhooks/${courseSlug}/interactivity-processor/${exercise.uid}/${rigoToken}${request.webhookQuery}`
705
766
 
706
767
  // Emit notification that interactivity generation is starting
707
768
  emitToCourse(courseSlug, "course-creation", {
@@ -710,8 +771,6 @@ async function startInteractivityGeneration(
710
771
  log: `🔄 Starting interactivity generation for lesson: ${exercise.title}`,
711
772
  })
712
773
 
713
- const componentsYml = await fetchComponentsYml()
714
-
715
774
  const currentSyllabus = await getSyllabus(courseSlug, bucket)
716
775
 
717
776
  const fullSyllabus = {
@@ -734,9 +793,16 @@ async function startInteractivityGeneration(
734
793
  current_syllabus: JSON.stringify(fullSyllabus),
735
794
  lesson_info: JSON.stringify(lessonCleaner(exercise)),
736
795
  },
737
- webhookUrl
796
+ webhookUrl,
797
+ request.slug
738
798
  )
739
799
 
800
+ if (!res) {
801
+ throw new Error(
802
+ `Could not start interactivity generation for lesson ${exSlug} (${request.slug})`
803
+ )
804
+ }
805
+
740
806
  return res.id
741
807
  }
742
808
 
@@ -923,6 +989,63 @@ async function updateLessonWithInitialContent(
923
989
  return lesson
924
990
  }
925
991
 
992
+ /**
993
+ * Changes one lesson on a fresh read of the syllabus. Saving a copy read
994
+ * earlier would undo whatever was saved since, like the lesson's
995
+ * `initialContent`.
996
+ * @param courseSlug - The course.
997
+ * @param lessonUID - The lesson to change.
998
+ * @param bucket - The bucket that holds the syllabus.
999
+ * @param patch - Changes the lesson in place. Gets the course language.
1000
+ * @returns Once the syllabus is saved, or right away if the lesson is gone.
1001
+ */
1002
+ async function patchLesson(
1003
+ courseSlug: string,
1004
+ lessonUID: string,
1005
+ bucket: Bucket,
1006
+ patch: (lesson: Lesson, language: string) => void
1007
+ ): Promise<void> {
1008
+ const syllabus = await getSyllabus(courseSlug, bucket)
1009
+ const lesson = syllabus.lessons.find(lesson => lesson.uid === lessonUID)
1010
+
1011
+ if (!lesson) {
1012
+ console.error(`Lesson ${lessonUID} not found in syllabus`)
1013
+ return
1014
+ }
1015
+
1016
+ patch(lesson, syllabus.courseInfo.language || "en")
1017
+ await saveSyllabus(courseSlug, syllabus, bucket)
1018
+ }
1019
+
1020
+ /**
1021
+ * The translation entry of a lesson that waits for a new job.
1022
+ * @param completionId - The Rigobot job.
1023
+ * @param autoRetries - The automatic retries of the current phase so far.
1024
+ * @returns The entry to save under the course language.
1025
+ */
1026
+ const startedJob = (completionId: number, autoRetries = 0) => ({
1027
+ completionId,
1028
+ startedAt: Date.now(),
1029
+ completedAt: 0,
1030
+ autoRetries,
1031
+ })
1032
+
1033
+ /**
1034
+ * The creator log for a failed job that is not retried.
1035
+ * @param reason - Why there is no retry.
1036
+ * @param phase - What the job generated.
1037
+ * @param exSlug - The lesson slug.
1038
+ * @returns The log line.
1039
+ */
1040
+ const noRetryLog = (
1041
+ reason: Extract<RetryPlan, { retry: false }>["reason"],
1042
+ phase: string,
1043
+ exSlug: string
1044
+ ): string =>
1045
+ reason === "permanent" ?
1046
+ `❌ Error generating ${phase} for lesson ${exSlug}. Retrying cannot fix this error, so it was not retried` :
1047
+ `❌ Error generating ${phase} for lesson ${exSlug} after ${MAX_AUTO_RETRIES} retries`
1048
+
926
1049
  async function updateLessonStatusToError(
927
1050
  courseSlug: string,
928
1051
  lessonUID: string,
@@ -2147,88 +2270,122 @@ class ServeCommand extends SessionCommand {
2147
2270
  )
2148
2271
  }
2149
2272
 
2150
- const syllabus = await getSyllabus(courseSlug, bucket)
2151
-
2152
- const exercise = syllabus.lessons.find(
2153
- lesson => lesson.uid === lessonUid
2154
- )
2155
- const exerciseIndex = syllabus.lessons.findIndex(
2156
- lesson => lesson.uid === lessonUid
2157
- )
2273
+ // In the course queue, so the webhook of this job cannot be handled
2274
+ // before the lesson is saved as generating.
2275
+ await serializeByCourse(courseSlug, async () => {
2276
+ const syllabus = await getSyllabus(courseSlug, bucket)
2158
2277
 
2159
- // Validate that exercise exists
2160
- if (!exercise) {
2161
- throw new NotFoundError(
2162
- `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2278
+ const exercise = syllabus.lessons.find(
2279
+ lesson => lesson.uid === lessonUid
2163
2280
  )
2164
- }
2165
-
2166
- // Validate that exercise index is valid (defensive check)
2167
- if (exerciseIndex === -1) {
2168
- throw new NotFoundError(
2169
- `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2281
+ const exerciseIndex = syllabus.lessons.findIndex(
2282
+ lesson => lesson.uid === lessonUid
2170
2283
  )
2171
- }
2172
2284
 
2173
- // Validate that exercise has required properties
2174
- if (!exercise.id || !exercise.title) {
2175
- throw new ValidationError(
2176
- `Lesson "${lessonUid}" is missing required properties (id or title)`
2177
- )
2178
- }
2285
+ // Validate that exercise exists
2286
+ if (!exercise) {
2287
+ throw new NotFoundError(
2288
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2289
+ )
2290
+ }
2179
2291
 
2180
- // previous exercise
2181
- let previousReadme = "---"
2182
- const previousExercise = syllabus.lessons[exerciseIndex - 1]
2183
- if (previousExercise) {
2184
- // Get the readme of the previous exercise
2185
- const exSlug = slugify(
2186
- previousExercise.id + "-" + previousExercise.title
2187
- )
2188
- // llist the files un
2189
- const [files] = await bucket.getFiles({
2190
- prefix: `courses/${courseSlug}/exercises/${exSlug}/README`,
2191
- })
2192
- // select any README
2193
- const readmeFiles = files.map(f => f.name)
2194
- const readmeFile = readmeFiles.find(f => f.includes("README"))
2195
- if (readmeFile) {
2196
- const [content] = await bucket.file(readmeFile).download()
2197
- previousReadme = content.toString()
2292
+ // Validate that exercise index is valid (defensive check)
2293
+ if (exerciseIndex === -1) {
2294
+ throw new NotFoundError(
2295
+ `Lesson with UID "${lessonUid}" not found in course "${courseSlug}"`
2296
+ )
2198
2297
  }
2199
- }
2200
2298
 
2201
- // Use new two-phase generation workflow
2202
- const completionId = await startInitialContentGeneration(
2203
- rigoToken,
2204
- syllabus.lessons,
2205
- syllabus.courseInfo,
2206
- exercise,
2207
- courseSlug,
2208
- syllabus.courseInfo.purpose,
2209
- previousReadme +
2210
- "\n\nThe user provided the following feedback related to the content of the course so far: \n\n" +
2211
- feedback
2212
- )
2299
+ // Validate that exercise has required properties
2300
+ if (!exercise.id || !exercise.title) {
2301
+ throw new ValidationError(
2302
+ `Lesson "${lessonUid}" is missing required properties (id or title)`
2303
+ )
2304
+ }
2213
2305
 
2214
- syllabus.lessons[exerciseIndex].status = "GENERATING"
2215
- syllabus.lessons[exerciseIndex].translations = {
2216
- [syllabus.courseInfo.language || "en"]: {
2217
- completionId,
2218
- startedAt: Date.now(),
2219
- completedAt: 0,
2220
- },
2221
- }
2222
- console.log("Lesson", syllabus.lessons[exerciseIndex])
2223
- if (syllabus.feedback && typeof syllabus.feedback === "string") {
2224
- syllabus.feedback += "\n\n" + feedback
2225
- } else {
2226
- syllabus.feedback = feedback
2227
- }
2306
+ const language = syllabus.courseInfo.language || "en"
2307
+ const previousStatus = exercise.status
2308
+
2309
+ // A second click, or a retry while the job still runs, would start
2310
+ // another paid job for the same lesson.
2311
+ if (previousStatus === "GENERATING") {
2312
+ const jobId = currentJobId(exercise, language)
2313
+ let job = null
2314
+ if (jobId) {
2315
+ try {
2316
+ job = await getCompletionJob(rigoToken, jobId, {
2317
+ timeoutMs: 5000,
2318
+ })
2319
+ } catch (error) {
2320
+ console.error(`Could not check job ${jobId}:`, error)
2321
+ }
2322
+ }
2228
2323
 
2229
- syllabus.generationMode = mode
2324
+ if (isGenerationRunning(exercise, language, job, Date.now())) {
2325
+ console.log(
2326
+ `Lesson ${lessonUid} is already being generated (job ${jobId}), not starting another one`
2327
+ )
2328
+ throw new ConflictError(
2329
+ `Lesson "${exercise.title}" is already being generated`,
2330
+ { code: "LESSON_ALREADY_GENERATING", completionId: jobId }
2331
+ )
2332
+ }
2333
+ }
2334
+
2335
+ // previous exercise
2336
+ let previousReadme = "---"
2337
+ const previousExercise = syllabus.lessons[exerciseIndex - 1]
2338
+ if (previousExercise) {
2339
+ // Get the readme of the previous exercise
2340
+ const exSlug = slugify(
2341
+ previousExercise.id + "-" + previousExercise.title
2342
+ )
2343
+ // llist the files un
2344
+ const [files] = await bucket.getFiles({
2345
+ prefix: `courses/${courseSlug}/exercises/${exSlug}/README`,
2346
+ })
2347
+ // select any README
2348
+ const readmeFiles = files.map(f => f.name)
2349
+ const readmeFile = readmeFiles.find(f => f.includes("README"))
2350
+ if (readmeFile) {
2351
+ const [content] = await bucket.file(readmeFile).download()
2352
+ previousReadme = content.toString()
2353
+ }
2354
+ }
2230
2355
 
2231
- await saveSyllabus(courseSlug, syllabus, bucket)
2356
+ // Use new two-phase generation workflow
2357
+ const completionId = await startInitialContentGeneration(
2358
+ rigoToken,
2359
+ syllabus.lessons,
2360
+ syllabus.courseInfo,
2361
+ exercise,
2362
+ courseSlug,
2363
+ syllabus.courseInfo.purpose,
2364
+ previousReadme +
2365
+ "\n\nThe user provided the following feedback related to the content of the course so far: \n\n" +
2366
+ feedback
2367
+ )
2368
+
2369
+ // A new job by the user, so the automatic retries start over.
2370
+ syllabus.lessons[exerciseIndex].status = "GENERATING"
2371
+ syllabus.lessons[exerciseIndex].translations = {
2372
+ [language]: startedJob(completionId),
2373
+ }
2374
+ console.log("Lesson", syllabus.lessons[exerciseIndex])
2375
+ if (syllabus.feedback && typeof syllabus.feedback === "string") {
2376
+ syllabus.feedback += "\n\n" + feedback
2377
+ } else {
2378
+ syllabus.feedback = feedback
2379
+ }
2380
+
2381
+ syllabus.generationMode = resolveGenerationMode(
2382
+ previousStatus,
2383
+ syllabus.generationMode,
2384
+ mode
2385
+ )
2386
+
2387
+ await saveSyllabus(courseSlug, syllabus, bucket)
2388
+ })
2232
2389
 
2233
2390
  res.json({ status: "SUCCESS" })
2234
2391
  })
@@ -2546,223 +2703,152 @@ class ServeCommand extends SessionCommand {
2546
2703
 
2547
2704
  console.log("RECEIVING INITIAL CONTENT WEBHOOK", response)
2548
2705
 
2549
- const syllabus = await getSyllabus(courseSlug, bucket)
2550
-
2551
- const exerciseIndex = syllabus.lessons.findIndex(
2552
- lesson => lesson.uid === lessonUID
2553
- )
2554
-
2555
- if (exerciseIndex === -1) {
2556
- console.error("Exercise not found receiving webhook:", lessonUID)
2557
- return res.json({ status: "ERROR", error: "Exercise not found" })
2558
- }
2559
-
2560
- let exercise: Lesson | null = syllabus.lessons[exerciseIndex]
2561
- const exSlug = slugify(exercise.id + "-" + exercise.title)
2562
- // Handle errors
2563
- if (response.status === "ERROR") {
2564
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2565
- emitToCourse(courseSlug, "course-creation", {
2566
- lesson: exSlug,
2567
- status: "error",
2568
- log: `❌ Error generating initial content for lesson ${exSlug}`,
2569
- })
2570
-
2571
- // Retry initial content generation
2572
- try {
2573
- emitToCourse(courseSlug, "course-creation", {
2574
- lesson: exSlug,
2575
- status: "generating",
2576
- log: `🔄 Retrying initial content generation for lesson ${exSlug}`,
2577
- })
2706
+ try {
2707
+ // In the course queue, nothing else saves the syllabus between
2708
+ // reading the lesson and saving it.
2709
+ await serializeByCourse(courseSlug, async () => {
2710
+ const syllabus = await getSyllabus(courseSlug, bucket)
2711
+ const language = syllabus.courseInfo.language || "en"
2578
2712
 
2579
- const retryCompletionId = await startInitialContentGeneration(
2580
- rigoToken,
2581
- syllabus.lessons,
2582
- syllabus.courseInfo,
2583
- exercise,
2584
- courseSlug,
2585
- syllabus.courseInfo.purpose,
2586
- ""
2713
+ const exerciseIndex = syllabus.lessons.findIndex(
2714
+ lesson => lesson.uid === lessonUID
2587
2715
  )
2588
2716
 
2589
- // Update lesson status to show it's retrying
2590
- exercise.status = "GENERATING"
2591
- exercise.translations = {
2592
- [syllabus.courseInfo.language || "en"]: {
2593
- completionId: retryCompletionId,
2594
- startedAt: Date.now(),
2595
- completedAt: 0,
2596
- },
2717
+ if (exerciseIndex === -1) {
2718
+ console.error("Exercise not found receiving webhook:", lessonUID)
2719
+ return res.json({ status: "ERROR", error: "Exercise not found" })
2597
2720
  }
2598
- await saveSyllabus(courseSlug, syllabus, bucket)
2599
- } catch (retryError) {
2600
- console.error(
2601
- "Error retrying initial content generation:",
2602
- retryError
2603
- )
2604
- emitToCourse(courseSlug, "course-creation", {
2605
- lesson: lessonUID,
2606
- status: "error",
2607
- log: `❌ Failed to retry initial content generation for lesson ${lessonUID}`,
2608
- })
2609
- }
2610
-
2611
- return res.json({ status: "ERROR" })
2612
- }
2613
-
2614
- try {
2615
- emitToCourse(courseSlug, "course-creation", {
2616
- lesson: exSlug,
2617
- status: "generating",
2618
- log: `✅ Initial content generated for lesson ${exSlug}`,
2619
- })
2620
-
2621
- // Update lesson with initial content
2622
- exercise = await updateLessonWithInitialContent(
2623
- courseSlug,
2624
- lessonUID,
2625
- response.parsed,
2626
- bucket
2627
- )
2628
-
2629
- if (!exercise) {
2630
- console.error("Exercise not found after updating initial content")
2631
- return res.json({ status: "ERROR", error: "Exercise not found" })
2632
- }
2633
2721
 
2634
- let lastLesson = ""
2722
+ let exercise: Lesson | null = syllabus.lessons[exerciseIndex]
2723
+ const exSlug = slugify(exercise.id + "-" + exercise.title)
2635
2724
 
2636
- const prevLessonIndex = exerciseIndex - 1
2637
- if (prevLessonIndex >= 0) {
2638
- try {
2639
- const prevLesson = syllabus.lessons[prevLessonIndex]
2640
- // search the lesson content in the bucket
2641
- const prevLessonSlug = slugify(
2642
- prevLesson.id + "-" + prevLesson.title
2643
- )
2644
- const file = bucket.file(
2645
- `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2646
- syllabus.courseInfo.language || "en"
2647
- )}`
2725
+ if (isStaleWebhook(exercise, language, response.id)) {
2726
+ console.log(
2727
+ `Ignoring initial content webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
2648
2728
  )
2649
- const [content] = await file.download()
2650
- lastLesson = content.toString()
2651
- } catch (error) {
2652
- console.error("Error searching previous lesson content:", error)
2729
+ return res.json({ status: "IGNORED" })
2653
2730
  }
2654
- }
2655
-
2656
- const completionId = await startInteractivityGeneration(
2657
- rigoToken,
2658
- syllabus.lessons,
2659
- syllabus.courseInfo,
2660
- exercise,
2661
- courseSlug,
2662
- syllabus.courseInfo.purpose,
2663
- bucket,
2664
- lastLesson
2665
- )
2666
2731
 
2667
- // Update lesson status to show it's in Phase 2
2668
- exercise.status = "GENERATING"
2669
- exercise.translations = {
2670
- [syllabus.courseInfo.language || "en"]: {
2671
- completionId,
2672
- startedAt: Date.now(),
2673
- completedAt: 0,
2674
- },
2675
- }
2676
- await saveSyllabus(courseSlug, syllabus, bucket)
2732
+ // Handle errors
2733
+ if (response.status === "ERROR") {
2734
+ console.error(
2735
+ `Initial content job ${response.id} failed for lesson ${exSlug}:`,
2736
+ response.status_text
2737
+ )
2677
2738
 
2678
- emitToCourse(courseSlug, "course-creation", {
2679
- lesson: exSlug,
2680
- status: "generating",
2681
- log: `🔄 Starting interactivity phase for lesson ${exercise.title}`,
2682
- })
2739
+ const retryPlan = planGenerationRetry(
2740
+ exercise,
2741
+ language,
2742
+ response.status_text
2743
+ )
2683
2744
 
2684
- emitToCourse(courseSlug, "course-creation", {
2685
- lesson: exSlug,
2686
- status: "initial-content-complete",
2687
- log: `✅ Initial content generated for lesson ${exSlug}, starting interactivity phase`,
2688
- })
2745
+ if (!retryPlan.retry) {
2746
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2747
+ emitToCourse(courseSlug, "course-creation", {
2748
+ lesson: exSlug,
2749
+ status: "error",
2750
+ log: noRetryLog(retryPlan.reason, "initial content", exSlug),
2751
+ })
2752
+ return res.json({ status: "ERROR" })
2753
+ }
2689
2754
 
2690
- res.json({ status: "SUCCESS" })
2691
- } catch (error) {
2692
- console.error("Error processing initial content webhook:", error)
2693
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2694
- emitToCourse(courseSlug, "course-creation", {
2695
- lesson: exSlug,
2696
- status: "error",
2697
- log: `❌ Error processing initial content for lesson ${exSlug}`,
2698
- })
2699
- res
2700
- .status(500)
2701
- .json({ status: "ERROR", error: (error as Error).message })
2702
- }
2703
- }
2704
- )
2755
+ const attempt = retryPlan.attempt
2705
2756
 
2706
- // Phase 2: Interactivity generation webhook (replaces exercise-processor logic)
2707
- app.post(
2708
- "/webhooks/:courseSlug/interactivity-processor/:lessonUID/:rigoToken",
2709
- async (req, res) => {
2710
- const { courseSlug, lessonUID, rigoToken } = req.params
2711
- const response = req.body
2757
+ try {
2758
+ emitToCourse(courseSlug, "course-creation", {
2759
+ lesson: exSlug,
2760
+ status: "generating",
2761
+ log: `🔄 Retrying initial content generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
2762
+ })
2712
2763
 
2713
- console.log("RECEIVING INTERACTIVITY WEBHOOK")
2714
- // console.log("LESSON UID", lessonUID)
2715
- // console.log("RESPONSE", response)
2764
+ const retryCompletionId = await startInitialContentGeneration(
2765
+ rigoToken,
2766
+ syllabus.lessons,
2767
+ syllabus.courseInfo,
2768
+ exercise,
2769
+ courseSlug,
2770
+ syllabus.courseInfo.purpose,
2771
+ ""
2772
+ )
2716
2773
 
2717
- // Handle errors
2718
- if (response.status === "ERROR") {
2719
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2720
- emitToCourse(courseSlug, "course-creation", {
2721
- lesson: lessonUID,
2722
- status: "error",
2723
- log: `❌ Error adding interactivity to lesson ${lessonUID}`,
2724
- })
2774
+ await patchLesson(
2775
+ courseSlug,
2776
+ lessonUID,
2777
+ bucket,
2778
+ (lesson, lang) => {
2779
+ lesson.status = "GENERATING"
2780
+ lesson.translations = {
2781
+ [lang]: startedJob(retryCompletionId, attempt),
2782
+ }
2783
+ }
2784
+ )
2785
+ } catch (retryError) {
2786
+ console.error(
2787
+ "Error retrying initial content generation:",
2788
+ retryError
2789
+ )
2790
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2791
+ emitToCourse(courseSlug, "course-creation", {
2792
+ lesson: exSlug,
2793
+ status: "error",
2794
+ log: `❌ Failed to retry initial content generation for lesson ${exSlug}`,
2795
+ })
2796
+ }
2725
2797
 
2726
- // Retry interactivity generation
2727
- try {
2728
- const syllabus = await getSyllabus(courseSlug, bucket)
2729
- const lessonIndex = syllabus.lessons.findIndex(
2730
- lesson => lesson.uid === lessonUID
2731
- )
2732
- const exercise = syllabus.lessons[lessonIndex]
2798
+ return res.json({ status: "ERROR" })
2799
+ }
2733
2800
 
2734
- if (exercise && exercise.initialContent) {
2801
+ try {
2735
2802
  emitToCourse(courseSlug, "course-creation", {
2736
- lesson: lessonUID,
2803
+ lesson: exSlug,
2737
2804
  status: "generating",
2738
- log: `🔄 Retrying interactivity generation for lesson ${lessonUID}`,
2805
+ log: `✅ Initial content generated for lesson ${exSlug}`,
2739
2806
  })
2740
2807
 
2741
- // Get previous lesson content for context
2808
+ // Update lesson with initial content
2809
+ exercise = await updateLessonWithInitialContent(
2810
+ courseSlug,
2811
+ lessonUID,
2812
+ response.parsed,
2813
+ bucket
2814
+ )
2815
+
2816
+ if (!exercise) {
2817
+ console.error(
2818
+ "Exercise not found after updating initial content"
2819
+ )
2820
+ return res.json({
2821
+ status: "ERROR",
2822
+ error: "Exercise not found",
2823
+ })
2824
+ }
2825
+
2742
2826
  let lastLesson = ""
2743
- const prevLessonIndex = lessonIndex - 1
2827
+
2828
+ const prevLessonIndex = exerciseIndex - 1
2744
2829
  if (prevLessonIndex >= 0) {
2745
2830
  try {
2746
2831
  const prevLesson = syllabus.lessons[prevLessonIndex]
2832
+ // search the lesson content in the bucket
2747
2833
  const prevLessonSlug = slugify(
2748
2834
  prevLesson.id + "-" + prevLesson.title
2749
2835
  )
2750
2836
  const file = bucket.file(
2751
2837
  `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
2752
- syllabus.courseInfo.language || "en"
2838
+ language
2753
2839
  )}`
2754
2840
  )
2755
2841
  const [content] = await file.download()
2756
2842
  lastLesson = content.toString()
2757
2843
  } catch (error) {
2758
2844
  console.error(
2759
- "Error getting previous lesson content for retry:",
2845
+ "Error searching previous lesson content:",
2760
2846
  error
2761
2847
  )
2762
2848
  }
2763
2849
  }
2764
2850
 
2765
- const retryCompletionId = await startInteractivityGeneration(
2851
+ const completionId = await startInteractivityGeneration(
2766
2852
  rigoToken,
2767
2853
  syllabus.lessons,
2768
2854
  syllabus.courseInfo,
@@ -2773,120 +2859,337 @@ class ServeCommand extends SessionCommand {
2773
2859
  lastLesson
2774
2860
  )
2775
2861
 
2776
- // Update lesson status to show it's retrying
2777
- exercise.status = "GENERATING"
2778
- exercise.translations = {
2779
- [syllabus.courseInfo.language || "en"]: {
2780
- completionId: retryCompletionId,
2781
- startedAt: Date.now(),
2782
- completedAt: 0,
2783
- },
2784
- }
2785
- await saveSyllabus(courseSlug, syllabus, bucket)
2862
+ // Saved on a fresh read: `syllabus` was read before the initial
2863
+ // content was saved, and saving it would erase that content.
2864
+ await patchLesson(
2865
+ courseSlug,
2866
+ lessonUID,
2867
+ bucket,
2868
+ (lesson, lang) => {
2869
+ lesson.status = "GENERATING"
2870
+ lesson.translations = { [lang]: startedJob(completionId) }
2871
+ }
2872
+ )
2873
+
2874
+ emitToCourse(courseSlug, "course-creation", {
2875
+ lesson: exSlug,
2876
+ status: "generating",
2877
+ log: `🔄 Starting interactivity phase for lesson ${exercise.title}`,
2878
+ })
2879
+
2880
+ emitToCourse(courseSlug, "course-creation", {
2881
+ lesson: exSlug,
2882
+ status: "initial-content-complete",
2883
+ log: `✅ Initial content generated for lesson ${exSlug}, starting interactivity phase`,
2884
+ })
2885
+
2886
+ res.json({ status: "SUCCESS" })
2887
+ } catch (error) {
2888
+ console.error("Error processing initial content webhook:", error)
2889
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2890
+ emitToCourse(courseSlug, "course-creation", {
2891
+ lesson: exSlug,
2892
+ status: "error",
2893
+ log: `❌ Error processing initial content for lesson ${exSlug}`,
2894
+ })
2895
+ res
2896
+ .status(500)
2897
+ .json({ status: "ERROR", error: (error as Error).message })
2786
2898
  }
2787
- } catch (retryError) {
2788
- console.error(
2789
- "Error retrying interactivity generation:",
2790
- retryError
2791
- )
2792
- emitToCourse(courseSlug, "course-creation", {
2793
- lesson: lessonUID,
2794
- status: "error",
2795
- log: `❌ Failed to retry interactivity generation for lesson ${lessonUID}`,
2796
- })
2899
+ })
2900
+ } catch (error) {
2901
+ console.error("Error handling initial content webhook:", error)
2902
+ if (!res.headersSent) {
2903
+ res
2904
+ .status(500)
2905
+ .json({ status: "ERROR", error: (error as Error).message })
2797
2906
  }
2798
-
2799
- return res.json({ status: "ERROR" })
2800
2907
  }
2908
+ }
2909
+ )
2910
+
2911
+ // Phase 2: Interactivity generation webhook (replaces exercise-processor logic)
2912
+ app.post(
2913
+ "/webhooks/:courseSlug/interactivity-processor/:lessonUID/:rigoToken",
2914
+ async (req, res) => {
2915
+ const { courseSlug, lessonUID, rigoToken } = req.params
2916
+ const response = req.body
2917
+
2918
+ console.log("RECEIVING INTERACTIVITY WEBHOOK")
2919
+ // console.log("LESSON UID", lessonUID)
2920
+ // console.log("RESPONSE", response)
2801
2921
 
2802
2922
  try {
2803
- const syllabus = await getSyllabus(courseSlug, bucket)
2804
- const exerciseIndex = syllabus.lessons.findIndex(
2805
- lesson => lesson.uid === lessonUID
2806
- )
2923
+ // In the course queue, nothing else saves the syllabus between
2924
+ // reading the lesson and saving it.
2925
+ await serializeByCourse(courseSlug, async () => {
2926
+ const syllabus = await getSyllabus(courseSlug, bucket)
2927
+ const language = syllabus.courseInfo.language || "en"
2807
2928
 
2808
- if (exerciseIndex === -1) {
2809
- console.error("Exercise not found receiving webhook:", lessonUID)
2810
- return res.json({ status: "ERROR", error: "Exercise not found" })
2811
- }
2929
+ const exerciseIndex = syllabus.lessons.findIndex(
2930
+ lesson => lesson.uid === lessonUID
2931
+ )
2812
2932
 
2813
- const exercise = syllabus.lessons[exerciseIndex]
2814
- const exSlug = slugify(exercise.id + "-" + exercise.title)
2933
+ if (exerciseIndex === -1) {
2934
+ console.error("Exercise not found receiving webhook:", lessonUID)
2935
+ return res.json({ status: "ERROR", error: "Exercise not found" })
2936
+ }
2815
2937
 
2816
- // Process readability of final content
2817
- const readability = checkReadability(
2818
- response.parsed.final_content,
2819
- PARAMS.max_words,
2820
- 3
2821
- )
2938
+ const exercise = syllabus.lessons[exerciseIndex]
2939
+ const exSlug = slugify(exercise.id + "-" + exercise.title)
2822
2940
 
2823
- emitToCourse(courseSlug, "course-creation", {
2824
- lesson: exSlug,
2825
- status: "generating",
2826
- log: `🔄 The lesson ${exercise.title} has a readability score of ${readability.fkglResult.fkgl}`,
2827
- })
2941
+ if (isStaleWebhook(exercise, language, response.id)) {
2942
+ console.log(
2943
+ `Ignoring interactivity webhook of job ${response.id} for lesson ${exSlug}: the lesson no longer waits for it`
2944
+ )
2945
+ return res.json({ status: "IGNORED" })
2946
+ }
2828
2947
 
2829
- // Upload README with final content
2830
- const exercisesDir = `courses/${courseSlug}/exercises`
2831
- const targetDir = `${exercisesDir}/${exSlug}`
2832
- const readmeFilename = `README${getReadmeExtension(
2833
- response.parsed.output_language ||
2834
- syllabus.courseInfo.language ||
2835
- "en"
2836
- )}`
2948
+ // Handle errors
2949
+ if (response.status === "ERROR") {
2950
+ console.error(
2951
+ `Interactivity job ${response.id} failed for lesson ${exSlug}:`,
2952
+ response.status_text
2953
+ )
2837
2954
 
2838
- await uploadFileToBucket(
2839
- bucket,
2840
- readability.newMarkdown,
2841
- `${targetDir}/${readmeFilename}`
2842
- )
2955
+ // Every retry uses v1. A failed v2 job gets its v1 retry even
2956
+ // when its error would repeat, because the error belongs to the
2957
+ // v2 template.
2958
+ const { fromV2, retryStatusText } = describeInteractivityFailure(
2959
+ req.query,
2960
+ response.status_text
2961
+ )
2843
2962
 
2844
- // Update used components if provided by the AI
2845
- if (
2846
- response.parsed.used_components &&
2847
- Array.isArray(response.parsed.used_components)
2848
- ) {
2849
- await updateUsedComponents(
2850
- courseSlug,
2851
- response.parsed.used_components,
2852
- bucket
2853
- )
2854
- emitToCourse(courseSlug, "course-creation", {
2855
- lesson: exSlug,
2856
- status: "generating",
2857
- log: `📊 Updated component usage tracking: ${response.parsed.used_components.join(
2858
- ", "
2859
- )}`,
2860
- })
2861
- }
2963
+ const retryPlan = planGenerationRetry(
2964
+ exercise,
2965
+ language,
2966
+ retryStatusText
2967
+ )
2862
2968
 
2863
- // Continue with next lesson
2864
- await continueWithNextLesson(
2865
- courseSlug,
2866
- exerciseIndex,
2867
- rigoToken,
2868
- response.parsed.final_content,
2869
- bucket
2870
- )
2969
+ // The retry builds on the initial content, so without it there
2970
+ // is nothing to retry.
2971
+ if (!retryPlan.retry || !exercise.initialContent) {
2972
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2973
+ emitToCourse(courseSlug, "course-creation", {
2974
+ lesson: exSlug,
2975
+ status: "error",
2976
+ log: retryPlan.retry ?
2977
+ `❌ Error adding interactivity to lesson ${exSlug}. Its initial content is missing, so it was not retried` :
2978
+ noRetryLog(retryPlan.reason, "interactivity", exSlug),
2979
+ })
2980
+ return res.json({ status: "ERROR" })
2981
+ }
2871
2982
 
2872
- emitToCourse(courseSlug, "course-creation", {
2873
- lesson: exSlug,
2874
- status: "done",
2875
- log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
2876
- })
2983
+ const attempt = retryPlan.attempt
2877
2984
 
2878
- res.json({ status: "SUCCESS" })
2879
- } catch (error) {
2880
- console.error("Error processing interactivity webhook:", error)
2881
- await updateLessonStatusToError(courseSlug, lessonUID, bucket)
2882
- emitToCourse(courseSlug, "course-creation", {
2883
- lesson: lessonUID,
2884
- status: "error",
2885
- log: `❌ Error processing interactivity for lesson ${lessonUID}`,
2985
+ try {
2986
+ emitToCourse(courseSlug, "course-creation", {
2987
+ lesson: exSlug,
2988
+ status: "generating",
2989
+ log: fromV2 ?
2990
+ `⚠️ Structured interactivity failed for lesson ${exSlug}, retrying with the previous method (${attempt} of ${MAX_AUTO_RETRIES})` :
2991
+ `🔄 Retrying interactivity generation for lesson ${exSlug} (${attempt} of ${MAX_AUTO_RETRIES})`,
2992
+ })
2993
+
2994
+ // Get previous lesson content for context
2995
+ let lastLesson = ""
2996
+ const prevLessonIndex = exerciseIndex - 1
2997
+ if (prevLessonIndex >= 0) {
2998
+ try {
2999
+ const prevLesson = syllabus.lessons[prevLessonIndex]
3000
+ const prevLessonSlug = slugify(
3001
+ prevLesson.id + "-" + prevLesson.title
3002
+ )
3003
+ const file = bucket.file(
3004
+ `courses/${courseSlug}/exercises/${prevLessonSlug}/README${getReadmeExtension(
3005
+ language
3006
+ )}`
3007
+ )
3008
+ const [content] = await file.download()
3009
+ lastLesson = content.toString()
3010
+ } catch (error) {
3011
+ console.error(
3012
+ "Error getting previous lesson content for retry:",
3013
+ error
3014
+ )
3015
+ }
3016
+ }
3017
+
3018
+ const retryCompletionId = await startInteractivityGeneration(
3019
+ rigoToken,
3020
+ syllabus.lessons,
3021
+ syllabus.courseInfo,
3022
+ exercise,
3023
+ courseSlug,
3024
+ syllabus.courseInfo.purpose,
3025
+ bucket,
3026
+ lastLesson,
3027
+ { forceV1: true }
3028
+ )
3029
+
3030
+ await patchLesson(
3031
+ courseSlug,
3032
+ lessonUID,
3033
+ bucket,
3034
+ (lesson, lang) => {
3035
+ lesson.status = "GENERATING"
3036
+ lesson.translations = {
3037
+ [lang]: startedJob(retryCompletionId, attempt),
3038
+ }
3039
+ }
3040
+ )
3041
+ } catch (retryError) {
3042
+ console.error(
3043
+ "Error retrying interactivity generation:",
3044
+ retryError
3045
+ )
3046
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
3047
+ emitToCourse(courseSlug, "course-creation", {
3048
+ lesson: exSlug,
3049
+ status: "error",
3050
+ log: `❌ Failed to retry interactivity generation for lesson ${exSlug}`,
3051
+ })
3052
+ }
3053
+
3054
+ return res.json({ status: "ERROR" })
3055
+ }
3056
+
3057
+ try {
3058
+ let finalContent: string = response.parsed?.final_content
3059
+ let usedComponents: unknown = response.parsed?.used_components
3060
+
3061
+ // The structured template (v2) returns component data and
3062
+ // placeholders; the CLI writes the component markdown.
3063
+ if (req.query.tpl === "v2") {
3064
+ if (!Array.isArray(response.parsed?.multiple_choice_question)) {
3065
+ console.warn(
3066
+ `Structured interactivity response for ${exSlug} has no component lists`
3067
+ )
3068
+ }
3069
+
3070
+ const guide = componentsGuideLoader.peek()
3071
+ const result = processInteractivityResult(response.parsed, {
3072
+ stepType: exercise.type,
3073
+ isFirstOrLast:
3074
+ exerciseIndex === 0 ||
3075
+ exerciseIndex === syllabus.lessons.length - 1,
3076
+ lessonId: exercise.id,
3077
+ decidedBy: guide ? decidedByFromGuide(guide) : undefined,
3078
+ })
3079
+ finalContent = result.markdown
3080
+ usedComponents = result.usedComponents
3081
+
3082
+ if (result.warnings.length > 0) {
3083
+ console.warn(
3084
+ `Component warnings for ${exSlug}:\n- ${result.warnings
3085
+ .map(warning => warning.message)
3086
+ .join("\n- ")}`
3087
+ )
3088
+ }
3089
+
3090
+ if (result.dropped.length > 0) {
3091
+ console.warn(
3092
+ `Components removed from ${exSlug}:\n- ${result.dropped
3093
+ .map(issue => issue.message)
3094
+ .join("\n- ")}`
3095
+ )
3096
+ emitToCourse(courseSlug, "course-creation", {
3097
+ lesson: exSlug,
3098
+ status: "generating",
3099
+ log: `⚠️ ${
3100
+ result.dropped.length
3101
+ } component(s) removed from lesson ${
3102
+ exercise.title
3103
+ } because they could not be rendered: ${result.dropped
3104
+ .map(issue => issue.message)
3105
+ .join("; ")}`,
3106
+ })
3107
+ }
3108
+
3109
+ if (exercise.type === "QUIZ" && result.assessmentCount === 0) {
3110
+ emitToCourse(courseSlug, "course-creation", {
3111
+ lesson: exSlug,
3112
+ status: "generating",
3113
+ log: `⚠️ The quiz ${exercise.title} has no questions; review it or regenerate it`,
3114
+ })
3115
+ }
3116
+ }
3117
+
3118
+ // Process readability of final content
3119
+ const readability = checkReadability(
3120
+ finalContent,
3121
+ PARAMS.max_words,
3122
+ 3
3123
+ )
3124
+
3125
+ emitToCourse(courseSlug, "course-creation", {
3126
+ lesson: exSlug,
3127
+ status: "generating",
3128
+ log: `🔄 The lesson ${exercise.title} has a readability score of ${readability.fkglResult.fkgl}`,
3129
+ })
3130
+
3131
+ // Upload README with final content
3132
+ const exercisesDir = `courses/${courseSlug}/exercises`
3133
+ const targetDir = `${exercisesDir}/${exSlug}`
3134
+ const readmeFilename = `README${getReadmeExtension(
3135
+ response.parsed.output_language || language
3136
+ )}`
3137
+
3138
+ await uploadFileToBucket(
3139
+ bucket,
3140
+ readability.newMarkdown,
3141
+ `${targetDir}/${readmeFilename}`
3142
+ )
3143
+
3144
+ // Update used components (v1: listed by the AI; v2: the
3145
+ // components that were actually written)
3146
+ if (Array.isArray(usedComponents)) {
3147
+ await updateUsedComponents(courseSlug, usedComponents, bucket)
3148
+ emitToCourse(courseSlug, "course-creation", {
3149
+ lesson: exSlug,
3150
+ status: "generating",
3151
+ log: `📊 Updated component usage tracking: ${usedComponents.join(
3152
+ ", "
3153
+ )}`,
3154
+ })
3155
+ }
3156
+
3157
+ // Continue with next lesson
3158
+ await continueWithNextLesson(
3159
+ courseSlug,
3160
+ exerciseIndex,
3161
+ rigoToken,
3162
+ finalContent,
3163
+ bucket
3164
+ )
3165
+
3166
+ emitToCourse(courseSlug, "course-creation", {
3167
+ lesson: exSlug,
3168
+ status: "done",
3169
+ log: `✅ The lesson ${exercise.id} - ${exercise.title} has been generated successfully with interactivity!`,
3170
+ })
3171
+
3172
+ res.json({ status: "SUCCESS" })
3173
+ } catch (error) {
3174
+ console.error("Error processing interactivity webhook:", error)
3175
+ await updateLessonStatusToError(courseSlug, lessonUID, bucket)
3176
+ emitToCourse(courseSlug, "course-creation", {
3177
+ lesson: exSlug,
3178
+ status: "error",
3179
+ log: `❌ Error processing interactivity for lesson ${exSlug}`,
3180
+ })
3181
+ res
3182
+ .status(500)
3183
+ .json({ status: "ERROR", error: (error as Error).message })
3184
+ }
2886
3185
  })
2887
- res
2888
- .status(500)
2889
- .json({ status: "ERROR", error: (error as Error).message })
3186
+ } catch (error) {
3187
+ console.error("Error handling interactivity webhook:", error)
3188
+ if (!res.headersSent) {
3189
+ res
3190
+ .status(500)
3191
+ .json({ status: "ERROR", error: (error as Error).message })
3192
+ }
2890
3193
  }
2891
3194
  }
2892
3195
  )
@@ -3855,42 +4158,43 @@ class ServeCommand extends SessionCommand {
3855
4158
  uid: stepSlug,
3856
4159
  }
3857
4160
 
3858
- const newLessons = insertStepInCorrectPosition(
3859
- initialSyllabus.lessons,
3860
- newLesson
3861
- )
3862
- // Use new two-phase generation workflow
3863
- const completionId = await startInitialContentGeneration(
3864
- rigoToken,
3865
- newLessons,
3866
- initialSyllabus.courseInfo,
3867
- newLesson,
3868
- courseSlug,
3869
- initialSyllabus.courseInfo.purpose,
3870
- "lastResult"
3871
- )
3872
- newLesson.translations = {
3873
- [initialSyllabus.courseInfo.language || "en"]: {
3874
- completionId,
3875
- startedAt: Date.now(),
3876
- completedAt: 0,
3877
- },
3878
- }
4161
+ // In the course queue, so the webhook of this job finds the new lesson
4162
+ // saved, and the syllabus saved here is not older than the last save.
4163
+ await serializeByCourse(courseSlug, async () => {
4164
+ const syllabus = await getSyllabus(courseSlug, bucket)
4165
+ const newLessons = insertStepInCorrectPosition(
4166
+ syllabus.lessons,
4167
+ newLesson
4168
+ )
4169
+ // Use new two-phase generation workflow
4170
+ const completionId = await startInitialContentGeneration(
4171
+ rigoToken,
4172
+ newLessons,
4173
+ syllabus.courseInfo,
4174
+ newLesson,
4175
+ courseSlug,
4176
+ syllabus.courseInfo.purpose,
4177
+ "lastResult"
4178
+ )
4179
+ newLesson.translations = {
4180
+ [syllabus.courseInfo.language || "en"]: startedJob(completionId),
4181
+ }
3879
4182
 
3880
- await uploadFileToBucket(
3881
- bucket,
3882
- JSON.stringify({ ...initialSyllabus, lessons: newLessons }),
3883
- `courses/${courseSlug}/.learn/initialSyllabus.json`
3884
- )
4183
+ await uploadFileToBucket(
4184
+ bucket,
4185
+ JSON.stringify({ ...syllabus, lessons: newLessons }),
4186
+ `courses/${courseSlug}/.learn/initialSyllabus.json`
4187
+ )
3885
4188
 
3886
- const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
4189
+ const targetDir = `courses/${courseSlug}/exercises/${stepSlug}`
3887
4190
 
3888
- await uploadInitialReadme(
3889
- bucket,
3890
- stepSlug,
3891
- targetDir,
3892
- initialSyllabus.courseInfo
3893
- )
4191
+ await uploadInitialReadme(
4192
+ bucket,
4193
+ stepSlug,
4194
+ targetDir,
4195
+ syllabus.courseInfo
4196
+ )
4197
+ })
3894
4198
 
3895
4199
  res.json({ status: "SUCCESS", message: "Exercise generati on started!" })
3896
4200
  })
@@ -5214,11 +5518,19 @@ class ServeCommand extends SessionCommand {
5214
5518
  syllabus.courseInfo.language
5215
5519
  )
5216
5520
 
5521
+ const language = syllabus.courseInfo.language || "en"
5217
5522
  const initialSyllabus = {
5218
5523
  ...syllabus,
5219
5524
  lessons: syllabus.lessons.map((lesson, index) => {
5220
5525
  if (index < 1) {
5221
- return { ...lesson, generated: false, status: "GENERATING" }
5526
+ // Its start time, so a retry right away is refused even before
5527
+ // the job id is saved below.
5528
+ return {
5529
+ ...lesson,
5530
+ generated: false,
5531
+ status: "GENERATING",
5532
+ translations: { [language]: startedJob(0) },
5533
+ }
5222
5534
  }
5223
5535
 
5224
5536
  return {
@@ -5243,25 +5555,25 @@ class ServeCommand extends SessionCommand {
5243
5555
 
5244
5556
  const lastResult = "---"
5245
5557
 
5246
- // Use new two-phase generation workflow
5247
- const completionId = await startInitialContentGeneration(
5248
- rigoToken,
5249
- syllabus.lessons,
5250
- syllabus.courseInfo,
5251
- firstLesson,
5252
- courseSlug,
5253
- syllabus.courseInfo.purpose,
5254
- lastResult
5255
- )
5256
- if (firstLesson) {
5257
- firstLesson.translations = {
5258
- [syllabus.courseInfo.language || "en"]: {
5259
- completionId,
5260
- startedAt: Date.now(),
5261
- completedAt: 0,
5262
- },
5263
- }
5264
- }
5558
+ // In the course queue, so the webhook of this job waits until its id
5559
+ // is saved. The id was never saved before, so the first lesson had no
5560
+ // protection against a second job.
5561
+ await serializeByCourse(courseSlug, async () => {
5562
+ // Use new two-phase generation workflow
5563
+ const completionId = await startInitialContentGeneration(
5564
+ rigoToken,
5565
+ syllabus.lessons,
5566
+ syllabus.courseInfo,
5567
+ firstLesson,
5568
+ courseSlug,
5569
+ syllabus.courseInfo.purpose,
5570
+ lastResult
5571
+ )
5572
+
5573
+ await patchLesson(courseSlug, firstLesson.uid, bucket, lesson => {
5574
+ lesson.translations = { [language]: startedJob(completionId) }
5575
+ })
5576
+ })
5265
5577
 
5266
5578
  await createInitialReadme(
5267
5579
  JSON.stringify(syllabus.courseInfo),