@learnpack/learnpack 5.0.352 → 5.0.353

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 (69) hide show
  1. package/lib/commands/publish.js +6 -0
  2. package/lib/commands/serve.js +85 -259
  3. package/lib/models/creator.d.ts +6 -0
  4. package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
  5. package/lib/scripts/descriptionsGcsBackfill.js +141 -0
  6. package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
  7. package/lib/scripts/descriptionsS3Backfill.js +157 -0
  8. package/lib/scripts/descriptionsSweep.d.ts +1 -0
  9. package/lib/scripts/descriptionsSweep.js +142 -0
  10. package/lib/utils/api.d.ts +7 -0
  11. package/lib/utils/api.js +8 -1
  12. package/lib/utils/creatorUtilities.js +2 -1
  13. package/lib/utils/descriptionHash.d.ts +66 -0
  14. package/lib/utils/descriptionHash.js +173 -0
  15. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  16. package/lib/utils/descriptions/gcsStorage.js +60 -0
  17. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
  18. package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
  19. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  20. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  21. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  22. package/lib/utils/descriptions/publishStage.js +234 -0
  23. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  24. package/lib/utils/descriptions/resumePublication.js +113 -0
  25. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  26. package/lib/utils/descriptions/s3Storage.js +134 -0
  27. package/lib/utils/descriptions/workList.d.ts +75 -0
  28. package/lib/utils/descriptions/workList.js +177 -0
  29. package/lib/utils/gcsBucketName.d.ts +10 -0
  30. package/lib/utils/gcsBucketName.js +19 -0
  31. package/lib/utils/packageManifest.d.ts +14 -0
  32. package/lib/utils/packageManifest.js +49 -5
  33. package/lib/utils/publishEvents.d.ts +66 -0
  34. package/lib/utils/publishEvents.js +111 -0
  35. package/lib/utils/publishJournal.d.ts +119 -0
  36. package/lib/utils/publishJournal.js +275 -0
  37. package/lib/utils/rigoActions.d.ts +44 -0
  38. package/lib/utils/rigoActions.js +75 -1
  39. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  40. package/lib/utils/s3/packageManifestBackfill.js +2 -0
  41. package/lib/utils/syllabusSync.d.ts +71 -0
  42. package/lib/utils/syllabusSync.js +273 -0
  43. package/package.json +1 -1
  44. package/src/commands/publish.ts +7 -0
  45. package/src/commands/serve.ts +144 -335
  46. package/src/models/creator.ts +9 -0
  47. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  48. package/src/scripts/descriptionsS3Backfill.ts +208 -0
  49. package/src/scripts/descriptionsSweep.ts +185 -0
  50. package/src/ui/_app/app.css +1 -1
  51. package/src/ui/_app/app.js +142 -140
  52. package/src/ui/app.tar.gz +0 -0
  53. package/src/utils/api.ts +9 -0
  54. package/src/utils/creatorUtilities.ts +2 -1
  55. package/src/utils/descriptionHash.ts +196 -0
  56. package/src/utils/descriptions/gcsStorage.ts +67 -0
  57. package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
  58. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  59. package/src/utils/descriptions/publishStage.ts +382 -0
  60. package/src/utils/descriptions/resumePublication.ts +200 -0
  61. package/src/utils/descriptions/s3Storage.ts +206 -0
  62. package/src/utils/descriptions/workList.ts +283 -0
  63. package/src/utils/gcsBucketName.ts +19 -0
  64. package/src/utils/packageManifest.ts +54 -4
  65. package/src/utils/publishEvents.ts +181 -0
  66. package/src/utils/publishJournal.ts +383 -0
  67. package/src/utils/rigoActions.ts +130 -0
  68. package/src/utils/s3/packageManifestBackfill.ts +2 -2
  69. package/src/utils/syllabusSync.ts +390 -0
@@ -51,6 +51,7 @@ import {
51
51
  import axios from "axios"
52
52
  import * as FormData from "form-data"
53
53
  import api, {
54
+ BREATHECODE_TELEMETRY_URL,
54
55
  RIGOBOT_HOST,
55
56
  RIGOBOT_REALTIME_HOST,
56
57
  listUserAcademies,
@@ -74,6 +75,25 @@ import {
74
75
  } from "../models/creator"
75
76
  import { exportToScorm, exportToEpub, exportToZip } from "../utils/export"
76
77
  import { generateAndPersistPackageManifest } from "../utils/packageManifest"
78
+ import { requireGcsBucketName } from "../utils/gcsBucketName"
79
+ import {
80
+ createGcsSyllabusSyncStorage,
81
+ synchronizeSyllabusWithBucket,
82
+ } from "../utils/syllabusSync"
83
+ import {
84
+ createGcsJournalStorage,
85
+ createJournal,
86
+ markStage,
87
+ } from "../utils/publishJournal"
88
+ import {
89
+ countPendingDescriptions,
90
+ runDescriptionsStageAfterPublish,
91
+ } from "../utils/descriptions/publishStage"
92
+ import {
93
+ buildPackagePublishedEvent,
94
+ fetchPackageInfo,
95
+ sendPublishEvent,
96
+ } from "../utils/publishEvents"
77
97
  import {
78
98
  errorHandler,
79
99
  notFoundHandler,
@@ -160,7 +180,7 @@ export const createLearnJson = (courseInfo: FormState) => {
160
180
  },
161
181
  grading: "isolated",
162
182
  telemetry: {
163
- batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
183
+ batch: BREATHECODE_TELEMETRY_URL,
164
184
  },
165
185
  preview: expectedPreviewUrl,
166
186
  }
@@ -1471,9 +1491,7 @@ class ServeCommand extends SessionCommand {
1471
1491
  credentials,
1472
1492
  })
1473
1493
 
1474
- const bucket = bucketStorage.bucket(
1475
- process.env.GCP_BUCKET_NAME || "learnpack-packages"
1476
- )
1494
+ const bucket = bucketStorage.bucket(requireGcsBucketName())
1477
1495
 
1478
1496
  const rawHost = process.env.HOST || ""
1479
1497
  const host = rawHost.trim().split(/\s+#/)[0].trim()
@@ -4716,320 +4734,22 @@ class ServeCommand extends SessionCommand {
4716
4734
  }
4717
4735
 
4718
4736
  try {
4719
- // Get the current syllabus
4737
+ // Reconciliation logic extracted to src/utils/syllabusSync.ts so the
4738
+ // sweep/backfill can reuse it. The manual dev button keeps the original
4739
+ // destructive behavior via prune: true.
4720
4740
  const syllabus = await getSyllabus(courseSlug, bucket)
4721
- const removedLessons: Array<{
4722
- id: string;
4723
- title: string;
4724
- slug: string;
4725
- }> = []
4726
- const keptLessons: Array<{
4727
- id: string;
4728
- title: string;
4729
- slug: string;
4730
- fileCount?: number;
4731
- }> = []
4732
- const duplicatesRemoved: Array<{
4733
- id: string;
4734
- title: string;
4735
- slug: string;
4736
- fileCount: number;
4737
- }> = []
4738
- const addedLessons: Array<{
4739
- id: string;
4740
- title: string;
4741
- slug: string;
4742
- }> = []
4743
- let repairedTranslationsInLessons = 0
4744
- let repairedTranslationEntries = 0
4745
- let fixedLessons = 0
4746
-
4747
- console.log(
4748
- `📋 Checking ${syllabus.lessons.length} lessons in syllabus...`
4741
+ const storage = createGcsSyllabusSyncStorage(bucket)
4742
+ const result = await synchronizeSyllabusWithBucket(
4743
+ storage,
4744
+ courseSlug,
4745
+ syllabus,
4746
+ { prune: true }
4749
4747
  )
4750
4748
 
4751
- // First pass: Check each lesson to see if it exists in the bucket and count files.
4752
- // We try two possible folder names because they can differ by source:
4753
- // - Initial upload / most flows: folder = slugify(lesson.id + "-" + lesson.title)
4754
- // - create-step flow: folder = lesson.uid (Rigo's stepSlug), which may not match slugify()
4755
- // (e.g. casing or character handling). GCS prefixes are case-sensitive.
4756
- const existingLessons: Array<{
4757
- lesson: any;
4758
- slug: string;
4759
- fileCount: number;
4760
- }> = []
4761
-
4762
- for (const lesson of syllabus.lessons) {
4763
- const lessonSlug = slugify(lesson.id + "-" + lesson.title)
4764
- let filePrefix = `courses/${courseSlug}/exercises/${lessonSlug}/`
4765
-
4766
- // eslint-disable-next-line no-await-in-loop
4767
- let [files] = await bucket.getFiles({
4768
- prefix: filePrefix,
4769
- })
4770
-
4771
- if (files.length === 0 && lesson.uid) {
4772
- filePrefix = `courses/${courseSlug}/exercises/${lesson.uid}/`;
4773
- // eslint-disable-next-line no-await-in-loop
4774
- [files] = await bucket.getFiles({ prefix: filePrefix })
4775
- }
4776
-
4777
- const resolvedSlug =
4778
- files.length > 0 ?
4779
- filePrefix
4780
- .replace(`courses/${courseSlug}/exercises/`, "")
4781
- .replace(/\/$/, "") :
4782
- lessonSlug
4783
-
4784
- if (files.length === 0) {
4785
- removedLessons.push({
4786
- id: lesson.id,
4787
- title: lesson.title,
4788
- slug: lessonSlug,
4789
- })
4790
- console.log(
4791
- `❌ Lesson not found: ${lessonSlug}${
4792
- lesson.uid ? ` (also tried uid: ${lesson.uid})` : ""
4793
- }`
4794
- )
4795
- } else {
4796
- existingLessons.push({
4797
- lesson,
4798
- slug: resolvedSlug,
4799
- fileCount: files.length,
4800
- })
4801
- console.log(
4802
- `✅ Lesson exists: ${resolvedSlug} (${files.length} files)`
4803
- )
4804
- }
4805
- }
4806
-
4807
- // Second pass: Detect and resolve duplicates
4808
- const slugMap = new Map<
4809
- string,
4810
- Array<{ lesson: any; slug: string; fileCount: number }>
4811
- >()
4812
-
4813
- // Group by slug
4814
- for (const item of existingLessons) {
4815
- if (!slugMap.has(item.slug)) {
4816
- slugMap.set(item.slug, [])
4817
- }
4818
-
4819
- slugMap.get(item.slug)!.push(item)
4820
- }
4821
-
4822
- // Process each group
4823
- for (const [slug, items] of slugMap.entries()) {
4824
- if (items.length > 1) {
4825
- // Duplicates found! Sort by file count (descending) and keep the most complete
4826
- items.sort((a, b) => b.fileCount - a.fileCount)
4827
-
4828
- const winner = items[0]
4829
- const losers = items.slice(1)
4830
-
4831
- console.log(`🔍 Duplicate slug detected: ${slug}`)
4832
- console.log(
4833
- ` ✅ Keeping: ${winner.lesson.id} - ${winner.lesson.title} (${winner.fileCount} files)`
4834
- )
4835
-
4836
- // Keep the winner
4837
- keptLessons.push({
4838
- id: winner.lesson.id,
4839
- title: winner.lesson.title,
4840
- slug: winner.slug,
4841
- fileCount: winner.fileCount,
4842
- })
4843
-
4844
- // Mark losers for removal
4845
- for (const loser of losers) {
4846
- console.log(
4847
- ` ❌ Removing duplicate: ${loser.lesson.id} - ${loser.lesson.title} (${loser.fileCount} files)`
4848
- )
4849
- duplicatesRemoved.push({
4850
- id: loser.lesson.id,
4851
- title: loser.lesson.title,
4852
- slug: loser.slug,
4853
- fileCount: loser.fileCount,
4854
- })
4855
- }
4856
- } else {
4857
- // No duplicates, keep as is
4858
- keptLessons.push({
4859
- id: items[0].lesson.id,
4860
- title: items[0].lesson.title,
4861
- slug: items[0].slug,
4862
- fileCount: items[0].fileCount,
4863
- })
4864
- }
4865
- }
4866
-
4867
- // Update syllabus: keep only the winners and remove non-existent + duplicates
4868
- const totalRemoved = removedLessons.length + duplicatesRemoved.length
4869
-
4870
- if (totalRemoved > 0) {
4871
- syllabus.lessons = syllabus.lessons.filter(lesson => {
4872
- const lessonSlug = slugify(lesson.id + "-" + lesson.title)
4873
- const isRemoved = removedLessons.some(
4874
- removed => removed.slug === lessonSlug
4875
- )
4876
- const isDuplicate = duplicatesRemoved.some(
4877
- dup => dup.id === lesson.id && dup.title === lesson.title
4878
- )
4879
- return !isRemoved && !isDuplicate
4880
- })
4881
- }
4882
-
4883
- // Third pass: add lessons that exist in the bucket but are missing from the syllabus
4884
- const existingSlugs = new Set(existingLessons.map(e => e.slug))
4885
- const exercisesPrefix = `courses/${courseSlug}/exercises/`
4886
- const [allExerciseFiles] = await bucket.getFiles({
4887
- prefix: exercisesPrefix,
4888
- })
4889
- const bucketFolderSlugs = new Set<string>()
4890
- for (const file of allExerciseFiles) {
4891
- if (!file.name.startsWith(exercisesPrefix)) continue
4892
- const afterPrefix = file.name.slice(exercisesPrefix.length)
4893
- const segment = afterPrefix.split("/")[0]
4894
- if (segment) bucketFolderSlugs.add(segment)
4895
- }
4896
-
4897
- for (const folderSlug of bucketFolderSlugs) {
4898
- if (existingSlugs.has(folderSlug)) continue
4899
- const idMatch = folderSlug.match(/^(\d+(?:\.\d+)?)(?:-(.*))?$/)
4900
- const id = idMatch ? idMatch[1] : folderSlug
4901
- const titlePart = idMatch && idMatch[2] ? idMatch[2] : folderSlug
4902
- const title = titlePart.replace(/-/g, " ").trim() || folderSlug
4903
- const newLesson: Lesson = {
4904
- id,
4905
- uid: folderSlug,
4906
- title,
4907
- type: "READ",
4908
- description: title,
4909
- duration: 2,
4910
- generated: true,
4911
- status: "DONE",
4912
- }
4913
- syllabus.lessons.push(newLesson)
4914
- addedLessons.push({ id, title, slug: folderSlug })
4915
- console.log(`➕ Added missing lesson from bucket: ${folderSlug}`)
4916
- }
4917
-
4918
- if (addedLessons.length > 0) {
4919
- syllabus.lessons.sort((a, b) => {
4920
- const na = parseFloat(a.id)
4921
- const nb = parseFloat(b.id)
4922
- if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb
4923
- return String(a.id).localeCompare(String(b.id))
4924
- })
4925
- }
4926
-
4927
- // Fourth pass: reconcile lesson.translations from actual README files in bucket.
4928
- // This fixes lessons that exist but lost translations metadata after renames or syncs.
4929
- try {
4930
- const { exercises } = await buildConfig(bucket, courseSlug)
4931
- const translationsBySlug = new Map<string, string[]>()
4932
-
4933
- for (const exercise of exercises) {
4934
- const langs = Object.keys(exercise.translations || {})
4935
- .map(lang => lang.toLowerCase())
4936
- .filter(Boolean)
4937
- if (langs.length > 0) {
4938
- translationsBySlug.set(exercise.slug, [...new Set(langs)])
4939
- }
4940
- }
4941
-
4942
- for (const lesson of syllabus.lessons) {
4943
- const candidateSlugs = [
4944
- slugify(lesson.id + "-" + lesson.title),
4945
- lesson.uid,
4946
- ].filter(Boolean)
4947
- const matchedSlug = candidateSlugs.find(s =>
4948
- translationsBySlug.has(s)
4949
- )
4950
- if (!matchedSlug) continue
4951
-
4952
- const languageCodes = translationsBySlug.get(matchedSlug) || []
4953
- if (languageCodes.length === 0) continue
4954
-
4955
- const currentTranslations = lesson.translations || {}
4956
- let lessonChanged = false
4957
-
4958
- for (const lang of languageCodes) {
4959
- const now = Date.now()
4960
- if (!currentTranslations[lang]) {
4961
- currentTranslations[lang] = {
4962
- completionId: 0,
4963
- startedAt: now,
4964
- completedAt: now,
4965
- }
4966
- repairedTranslationEntries += 1
4967
- lessonChanged = true
4968
- } else {
4969
- // If README exists for this language but metadata is incomplete, mark it as completed.
4970
- if (!currentTranslations[lang].startedAt) {
4971
- currentTranslations[lang].startedAt = now
4972
- lessonChanged = true
4973
- }
4974
-
4975
- if (!currentTranslations[lang].completedAt) {
4976
- currentTranslations[lang].completedAt = now
4977
- repairedTranslationEntries += 1
4978
- lessonChanged = true
4979
- }
4980
- }
4981
- }
4982
-
4983
- if (lessonChanged) {
4984
- lesson.translations = currentTranslations
4985
- repairedTranslationsInLessons += 1
4986
- }
4987
- }
4988
-
4989
- // Fifth pass: fix lessons stuck in GENERATING or ERROR when the file exists in the bucket
4990
- const primaryLanguage = syllabus.courseInfo.language || "en"
4991
- for (const lesson of syllabus.lessons) {
4992
- if (lesson.generated !== false) continue
4993
- if (lesson.status !== "GENERATING" && lesson.status !== "ERROR")
4994
- continue
4995
-
4996
- const candidateSlugs = [
4997
- slugify(lesson.id + "-" + lesson.title),
4998
- lesson.uid,
4999
- ].filter(Boolean) as string[]
5000
-
5001
- const matchedSlug = candidateSlugs.find(s =>
5002
- translationsBySlug.has(s)
5003
- )
5004
- if (!matchedSlug) continue
5005
-
5006
- const langs = translationsBySlug.get(matchedSlug) || []
5007
- if (!langs.includes(primaryLanguage)) continue
5008
-
5009
- const prevStatus = lesson.status
5010
- lesson.generated = true
5011
- lesson.status = "DONE"
5012
- fixedLessons += 1
5013
- console.log(
5014
- `🔧 Fixed lesson: ${lesson.id} - "${lesson.title}" (was generated:false status:${prevStatus}, primary language "${primaryLanguage}" exists in bucket)`
5015
- )
5016
- }
5017
- } catch (error) {
5018
- console.error(
5019
- "⚠️ Could not reconcile lesson translations during syllabus sync:",
5020
- error
5021
- )
5022
- }
5023
-
5024
- if (
5025
- totalRemoved > 0 ||
5026
- addedLessons.length > 0 ||
5027
- repairedTranslationsInLessons > 0 ||
5028
- fixedLessons > 0
5029
- ) {
5030
- await saveSyllabus(courseSlug, syllabus, bucket)
4749
+ if (result.changed) {
4750
+ await saveSyllabus(courseSlug, result.syllabus, bucket)
5031
4751
  console.log(
5032
- `✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket; repaired translations in ${repairedTranslationsInLessons} lesson(s); fixed ${fixedLessons} stuck lesson(s).`
4752
+ `✅ Syllabus synchronized. Removed ${result.counters.removedLessons} non-existent + duplicate(s); added ${result.counters.addedLessons} from bucket; repaired translations in ${result.counters.repairedTranslationsInLessons} lesson(s); fixed ${result.counters.fixedLessons} stuck lesson(s).`
5033
4753
  )
5034
4754
  } else {
5035
4755
  console.log(`✅ Syllabus is already in sync. No changes.`)
@@ -5038,18 +4758,20 @@ class ServeCommand extends SessionCommand {
5038
4758
  res.json({
5039
4759
  status: "SUCCESS",
5040
4760
  message: `Syllabus synchronized successfully`,
5041
- totalLessons: syllabus.lessons.length,
5042
- keptLessons: keptLessons.length,
5043
- removedLessons: removedLessons.length,
5044
- duplicatesResolved: duplicatesRemoved.length,
5045
- addedLessons: addedLessons.length,
5046
- fixedLessons,
5047
- repairedTranslationsInLessons,
5048
- repairedTranslationEntries,
5049
- removed: removedLessons,
5050
- duplicates: duplicatesRemoved,
5051
- kept: keptLessons,
5052
- added: addedLessons,
4761
+ totalLessons: result.counters.totalLessons,
4762
+ keptLessons: result.counters.keptLessons,
4763
+ removedLessons: result.counters.removedLessons,
4764
+ duplicatesResolved: result.counters.duplicatesResolved,
4765
+ addedLessons: result.counters.addedLessons,
4766
+ fixedLessons: result.counters.fixedLessons,
4767
+ repairedTranslationsInLessons:
4768
+ result.counters.repairedTranslationsInLessons,
4769
+ repairedTranslationEntries:
4770
+ result.counters.repairedTranslationEntries,
4771
+ removed: result.details.removed,
4772
+ duplicates: result.details.duplicates,
4773
+ kept: result.details.kept,
4774
+ added: result.details.added,
5053
4775
  })
5054
4776
  } catch (error) {
5055
4777
  console.error("❌ Error synchronizing syllabus:", error)
@@ -5601,6 +5323,11 @@ class ServeCommand extends SessionCommand {
5601
5323
  rimraf.sync(tmpRoot)
5602
5324
  mkdirp.sync(buildRoot)
5603
5325
 
5326
+ // Bookkeeping for the whole publication, so an interrupted run can be
5327
+ // resumed. Best effort: never blocks the publish.
5328
+ const journalStorage = createGcsJournalStorage(bucket)
5329
+ const journal = await createJournal(journalStorage, slug)
5330
+
5604
5331
  // 4) Copiar UI descomprimida (_app)
5605
5332
  const uiSrc = path.resolve(__dirname, "../ui/_app")
5606
5333
  const copyDir = (src: string, dest: string) => {
@@ -5666,10 +5393,24 @@ class ServeCommand extends SessionCommand {
5666
5393
  })
5667
5394
  )
5668
5395
 
5669
- await generateAndPersistPackageManifest(bucket, slug, {
5670
- publishedAt: new Date().toISOString(),
5671
- localDir: buildRoot,
5672
- })
5396
+ const publishedManifest = await generateAndPersistPackageManifest(
5397
+ bucket,
5398
+ slug,
5399
+ {
5400
+ publishedAt: new Date().toISOString(),
5401
+ localDir: buildRoot,
5402
+ }
5403
+ )
5404
+
5405
+ // Decide here, while the build is still on disk, whether this package
5406
+ // will need descriptions: the answer is what the response and the
5407
+ // package_published event promise to breathecode.
5408
+ const pendingDescriptions = countPendingDescriptions(
5409
+ buildRoot,
5410
+ exercises
5411
+ )
5412
+ const descriptionsOutcome =
5413
+ pendingDescriptions > 0 ? "queued" : "up_to_date"
5673
5414
 
5674
5415
  // 8) Crear el config.json en buildRoot con lo que retorna buildConfig
5675
5416
  const fullConfig = { config, exercises }
@@ -5709,6 +5450,9 @@ class ServeCommand extends SessionCommand {
5709
5450
  }
5710
5451
  )
5711
5452
  rigoPublishUrl = rigoRes.data.url
5453
+ await markStage(journalStorage, journal, "deploy", "done", {
5454
+ meta: { url: rigoPublishUrl },
5455
+ })
5712
5456
 
5713
5457
  let errors: AssetSyncError[]
5714
5458
  try {
@@ -5722,6 +5466,15 @@ class ServeCommand extends SessionCommand {
5722
5466
  academyId
5723
5467
  )
5724
5468
  errors = assetResults.errors
5469
+ await markStage(
5470
+ journalStorage,
5471
+ journal,
5472
+ "assetsSync",
5473
+ errors.length > 0 ? "failed" : "done",
5474
+ {
5475
+ error: errors.length > 0 ? JSON.stringify(errors) : undefined,
5476
+ }
5477
+ )
5725
5478
  } catch (error) {
5726
5479
  console.error("Asset sync failed unexpectedly:", error)
5727
5480
  errors = [
@@ -5730,16 +5483,71 @@ class ServeCommand extends SessionCommand {
5730
5483
  error: { detail: "Asset sync failed unexpectedly." },
5731
5484
  },
5732
5485
  ]
5486
+ await markStage(journalStorage, journal, "assetsSync", "failed", {
5487
+ error: (error as Error).message,
5488
+ })
5733
5489
  }
5734
5490
 
5735
- if (res.headersSent) return
5736
- console.log("RigoRes", rigoRes.data)
5737
- res.json({
5738
- url: rigoPublishUrl,
5739
- errors,
5491
+ if (!res.headersSent) {
5492
+ console.log("RigoRes", rigoRes.data)
5493
+ res.json({
5494
+ url: rigoPublishUrl,
5495
+ errors,
5496
+ descriptions: descriptionsOutcome,
5497
+ })
5498
+ }
5499
+
5500
+ // The package is published: tell breathecode, announcing whether a
5501
+ // second event is coming. Emitted after the asset sync so the
5502
+ // assets the payload refers to already exist.
5503
+ const eventContext = {
5504
+ publishId: journal.publishId,
5505
+ courseSlug: slug,
5506
+ packageInfo: await fetchPackageInfo(slug, rigoToken),
5507
+ manifest: publishedManifest,
5508
+ }
5509
+
5510
+ const publishedDelivered = await sendPublishEvent(
5511
+ buildPackagePublishedEvent(eventContext, descriptionsOutcome),
5512
+ bcToken
5513
+ )
5514
+ await markStage(
5515
+ journalStorage,
5516
+ journal,
5517
+ "publishedEvent",
5518
+ publishedDelivered ? "done" : "failed",
5519
+ {
5520
+ error: publishedDelivered ? undefined : "delivery failed",
5521
+ // Recorded so a sweep resuming this publication knows whether a
5522
+ // manifest event was promised to breathecode.
5523
+ meta: { descriptions: descriptionsOutcome },
5524
+ }
5525
+ )
5526
+
5527
+ // Stage B, after responding: a course may need several ~15s
5528
+ // completions, far past any request limit. The package is already
5529
+ // published and durable; this only enriches it.
5530
+ runDescriptionsStageAfterPublish({
5531
+ courseSlug: slug,
5532
+ gcsBucket: bucket,
5533
+ journal,
5534
+ journalStorage,
5535
+ hasWork: pendingDescriptions > 0,
5536
+ eventContext,
5537
+ breathecodeToken: bcToken,
5538
+ }).catch(error => {
5539
+ console.error("[descriptions] Detached stage rejected:", error)
5740
5540
  })
5741
5541
  } catch (error) {
5742
5542
  console.error(error)
5543
+ await markStage(
5544
+ journalStorage,
5545
+ journal,
5546
+ rigoPublishUrl === undefined ? "deploy" : "assetsSync",
5547
+ "failed",
5548
+ { error: (error as Error).message }
5549
+ )
5550
+
5743
5551
  if (res.headersSent) return
5744
5552
  if (rigoPublishUrl !== undefined) {
5745
5553
  res.json({
@@ -5750,6 +5558,7 @@ class ServeCommand extends SessionCommand {
5750
5558
  error: { detail: "Asset sync failed unexpectedly." },
5751
5559
  },
5752
5560
  ],
5561
+ descriptions: descriptionsOutcome,
5753
5562
  })
5754
5563
  } else {
5755
5564
  res.status(500).json({ error: (error as Error).message })
@@ -37,6 +37,15 @@ export interface Lesson {
37
37
  startedAt: number;
38
38
  completedAt?: number;
39
39
  error?: boolean;
40
+ // --- description fields (per language) ---
41
+ // The step description is a projection derived from the README content;
42
+ // it lives here (source of truth) and is projected into package-manifest.json.
43
+ description?: string | null;
44
+ descriptionStatus?: "generated" | "pending" | "error" | "none";
45
+ sourceContentHash?: string; // SHA-256 of the normalized README it was generated from
46
+ sourceSimHash?: string; // SimHash-64 (hex) used for the significance threshold
47
+ descriptionPromptVersion?: number;
48
+ descriptionSource?: "auto" | "human";
40
49
  };
41
50
  };
42
51
  syncNotifications?: SyncNotification[];