@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
@@ -445,6 +445,12 @@ class BuildCommand extends SessionCommand_1.default {
445
445
  this.log(`Build completed: ${zipFilePath} (${archive.pointer()} total bytes)`);
446
446
  // Remove build directory after zip is created
447
447
  console_1.default.debug("Zip file saved in project root");
448
+ // TODO(#2103): this CLI path publishes without the guarantees the
449
+ // creator flow has. It builds no package-manifest.json (pre-existing), it
450
+ // does not run the descriptions stage, and it emits neither
451
+ // package_published nor package_manifest_updated, so breathecode never
452
+ // hears about these publications. Decide between parity, deferring the
453
+ // work to the sweep, or documenting this path as degraded.
448
454
  const formData = new FormData();
449
455
  formData.append("file", fs.createReadStream(zipFilePath));
450
456
  formData.append("config", JSON.stringify(learnJson));
@@ -34,6 +34,11 @@ const sidebarGenerator_1 = require("../utils/sidebarGenerator");
34
34
  const publish_1 = require("./publish");
35
35
  const export_1 = require("../utils/export");
36
36
  const packageManifest_1 = require("../utils/packageManifest");
37
+ const gcsBucketName_1 = require("../utils/gcsBucketName");
38
+ const syllabusSync_1 = require("../utils/syllabusSync");
39
+ const publishJournal_1 = require("../utils/publishJournal");
40
+ const publishStage_1 = require("../utils/descriptions/publishStage");
41
+ const publishEvents_1 = require("../utils/publishEvents");
37
42
  const errorHandler_1 = require("../utils/errorHandler");
38
43
  const jsdom_1 = require("jsdom");
39
44
  const redis_1 = require("redis");
@@ -97,7 +102,7 @@ const createLearnJson = (courseInfo) => {
97
102
  },
98
103
  grading: "isolated",
99
104
  telemetry: {
100
- batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
105
+ batch: api_1.BREATHECODE_TELEMETRY_URL,
101
106
  },
102
107
  preview: expectedPreviewUrl,
103
108
  };
@@ -932,7 +937,7 @@ class ServeCommand extends SessionCommand_1.default {
932
937
  const bucketStorage = new storage_1.Storage({
933
938
  credentials,
934
939
  });
935
- const bucket = bucketStorage.bucket(process.env.GCP_BUCKET_NAME || "learnpack-packages");
940
+ const bucket = bucketStorage.bucket((0, gcsBucketName_1.requireGcsBucketName)());
936
941
  const rawHost = process.env.HOST || "";
937
942
  const host = rawHost.trim().split(/\s+#/)[0].trim();
938
943
  if (!host) {
@@ -3264,245 +3269,15 @@ class ServeCommand extends SessionCommand_1.default {
3264
3269
  return res.status(400).json({ error: "Course slug is required" });
3265
3270
  }
3266
3271
  try {
3267
- // Get the current syllabus
3272
+ // Reconciliation logic extracted to src/utils/syllabusSync.ts so the
3273
+ // sweep/backfill can reuse it. The manual dev button keeps the original
3274
+ // destructive behavior via prune: true.
3268
3275
  const syllabus = await getSyllabus(courseSlug, bucket);
3269
- const removedLessons = [];
3270
- const keptLessons = [];
3271
- const duplicatesRemoved = [];
3272
- const addedLessons = [];
3273
- let repairedTranslationsInLessons = 0;
3274
- let repairedTranslationEntries = 0;
3275
- let fixedLessons = 0;
3276
- console.log(`📋 Checking ${syllabus.lessons.length} lessons in syllabus...`);
3277
- // First pass: Check each lesson to see if it exists in the bucket and count files.
3278
- // We try two possible folder names because they can differ by source:
3279
- // - Initial upload / most flows: folder = slugify(lesson.id + "-" + lesson.title)
3280
- // - create-step flow: folder = lesson.uid (Rigo's stepSlug), which may not match slugify()
3281
- // (e.g. casing or character handling). GCS prefixes are case-sensitive.
3282
- const existingLessons = [];
3283
- for (const lesson of syllabus.lessons) {
3284
- const lessonSlug = (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title);
3285
- let filePrefix = `courses/${courseSlug}/exercises/${lessonSlug}/`;
3286
- // eslint-disable-next-line no-await-in-loop
3287
- let [files] = await bucket.getFiles({
3288
- prefix: filePrefix,
3289
- });
3290
- if (files.length === 0 && lesson.uid) {
3291
- filePrefix = `courses/${courseSlug}/exercises/${lesson.uid}/`;
3292
- // eslint-disable-next-line no-await-in-loop
3293
- [files] = await bucket.getFiles({ prefix: filePrefix });
3294
- }
3295
- const resolvedSlug = files.length > 0 ?
3296
- filePrefix
3297
- .replace(`courses/${courseSlug}/exercises/`, "")
3298
- .replace(/\/$/, "") :
3299
- lessonSlug;
3300
- if (files.length === 0) {
3301
- removedLessons.push({
3302
- id: lesson.id,
3303
- title: lesson.title,
3304
- slug: lessonSlug,
3305
- });
3306
- console.log(`❌ Lesson not found: ${lessonSlug}${lesson.uid ? ` (also tried uid: ${lesson.uid})` : ""}`);
3307
- }
3308
- else {
3309
- existingLessons.push({
3310
- lesson,
3311
- slug: resolvedSlug,
3312
- fileCount: files.length,
3313
- });
3314
- console.log(`✅ Lesson exists: ${resolvedSlug} (${files.length} files)`);
3315
- }
3316
- }
3317
- // Second pass: Detect and resolve duplicates
3318
- const slugMap = new Map();
3319
- // Group by slug
3320
- for (const item of existingLessons) {
3321
- if (!slugMap.has(item.slug)) {
3322
- slugMap.set(item.slug, []);
3323
- }
3324
- slugMap.get(item.slug).push(item);
3325
- }
3326
- // Process each group
3327
- for (const [slug, items] of slugMap.entries()) {
3328
- if (items.length > 1) {
3329
- // Duplicates found! Sort by file count (descending) and keep the most complete
3330
- items.sort((a, b) => b.fileCount - a.fileCount);
3331
- const winner = items[0];
3332
- const losers = items.slice(1);
3333
- console.log(`🔍 Duplicate slug detected: ${slug}`);
3334
- console.log(` ✅ Keeping: ${winner.lesson.id} - ${winner.lesson.title} (${winner.fileCount} files)`);
3335
- // Keep the winner
3336
- keptLessons.push({
3337
- id: winner.lesson.id,
3338
- title: winner.lesson.title,
3339
- slug: winner.slug,
3340
- fileCount: winner.fileCount,
3341
- });
3342
- // Mark losers for removal
3343
- for (const loser of losers) {
3344
- console.log(` ❌ Removing duplicate: ${loser.lesson.id} - ${loser.lesson.title} (${loser.fileCount} files)`);
3345
- duplicatesRemoved.push({
3346
- id: loser.lesson.id,
3347
- title: loser.lesson.title,
3348
- slug: loser.slug,
3349
- fileCount: loser.fileCount,
3350
- });
3351
- }
3352
- }
3353
- else {
3354
- // No duplicates, keep as is
3355
- keptLessons.push({
3356
- id: items[0].lesson.id,
3357
- title: items[0].lesson.title,
3358
- slug: items[0].slug,
3359
- fileCount: items[0].fileCount,
3360
- });
3361
- }
3362
- }
3363
- // Update syllabus: keep only the winners and remove non-existent + duplicates
3364
- const totalRemoved = removedLessons.length + duplicatesRemoved.length;
3365
- if (totalRemoved > 0) {
3366
- syllabus.lessons = syllabus.lessons.filter(lesson => {
3367
- const lessonSlug = (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title);
3368
- const isRemoved = removedLessons.some(removed => removed.slug === lessonSlug);
3369
- const isDuplicate = duplicatesRemoved.some(dup => dup.id === lesson.id && dup.title === lesson.title);
3370
- return !isRemoved && !isDuplicate;
3371
- });
3372
- }
3373
- // Third pass: add lessons that exist in the bucket but are missing from the syllabus
3374
- const existingSlugs = new Set(existingLessons.map(e => e.slug));
3375
- const exercisesPrefix = `courses/${courseSlug}/exercises/`;
3376
- const [allExerciseFiles] = await bucket.getFiles({
3377
- prefix: exercisesPrefix,
3378
- });
3379
- const bucketFolderSlugs = new Set();
3380
- for (const file of allExerciseFiles) {
3381
- if (!file.name.startsWith(exercisesPrefix))
3382
- continue;
3383
- const afterPrefix = file.name.slice(exercisesPrefix.length);
3384
- const segment = afterPrefix.split("/")[0];
3385
- if (segment)
3386
- bucketFolderSlugs.add(segment);
3387
- }
3388
- for (const folderSlug of bucketFolderSlugs) {
3389
- if (existingSlugs.has(folderSlug))
3390
- continue;
3391
- const idMatch = folderSlug.match(/^(\d+(?:\.\d+)?)(?:-(.*))?$/);
3392
- const id = idMatch ? idMatch[1] : folderSlug;
3393
- const titlePart = idMatch && idMatch[2] ? idMatch[2] : folderSlug;
3394
- const title = titlePart.replace(/-/g, " ").trim() || folderSlug;
3395
- const newLesson = {
3396
- id,
3397
- uid: folderSlug,
3398
- title,
3399
- type: "READ",
3400
- description: title,
3401
- duration: 2,
3402
- generated: true,
3403
- status: "DONE",
3404
- };
3405
- syllabus.lessons.push(newLesson);
3406
- addedLessons.push({ id, title, slug: folderSlug });
3407
- console.log(`➕ Added missing lesson from bucket: ${folderSlug}`);
3408
- }
3409
- if (addedLessons.length > 0) {
3410
- syllabus.lessons.sort((a, b) => {
3411
- const na = parseFloat(a.id);
3412
- const nb = parseFloat(b.id);
3413
- if (!Number.isNaN(na) && !Number.isNaN(nb))
3414
- return na - nb;
3415
- return String(a.id).localeCompare(String(b.id));
3416
- });
3417
- }
3418
- // Fourth pass: reconcile lesson.translations from actual README files in bucket.
3419
- // This fixes lessons that exist but lost translations metadata after renames or syncs.
3420
- try {
3421
- const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
3422
- const translationsBySlug = new Map();
3423
- for (const exercise of exercises) {
3424
- const langs = Object.keys(exercise.translations || {})
3425
- .map(lang => lang.toLowerCase())
3426
- .filter(Boolean);
3427
- if (langs.length > 0) {
3428
- translationsBySlug.set(exercise.slug, [...new Set(langs)]);
3429
- }
3430
- }
3431
- for (const lesson of syllabus.lessons) {
3432
- const candidateSlugs = [
3433
- (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title),
3434
- lesson.uid,
3435
- ].filter(Boolean);
3436
- const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s));
3437
- if (!matchedSlug)
3438
- continue;
3439
- const languageCodes = translationsBySlug.get(matchedSlug) || [];
3440
- if (languageCodes.length === 0)
3441
- continue;
3442
- const currentTranslations = lesson.translations || {};
3443
- let lessonChanged = false;
3444
- for (const lang of languageCodes) {
3445
- const now = Date.now();
3446
- if (!currentTranslations[lang]) {
3447
- currentTranslations[lang] = {
3448
- completionId: 0,
3449
- startedAt: now,
3450
- completedAt: now,
3451
- };
3452
- repairedTranslationEntries += 1;
3453
- lessonChanged = true;
3454
- }
3455
- else {
3456
- // If README exists for this language but metadata is incomplete, mark it as completed.
3457
- if (!currentTranslations[lang].startedAt) {
3458
- currentTranslations[lang].startedAt = now;
3459
- lessonChanged = true;
3460
- }
3461
- if (!currentTranslations[lang].completedAt) {
3462
- currentTranslations[lang].completedAt = now;
3463
- repairedTranslationEntries += 1;
3464
- lessonChanged = true;
3465
- }
3466
- }
3467
- }
3468
- if (lessonChanged) {
3469
- lesson.translations = currentTranslations;
3470
- repairedTranslationsInLessons += 1;
3471
- }
3472
- }
3473
- // Fifth pass: fix lessons stuck in GENERATING or ERROR when the file exists in the bucket
3474
- const primaryLanguage = syllabus.courseInfo.language || "en";
3475
- for (const lesson of syllabus.lessons) {
3476
- if (lesson.generated !== false)
3477
- continue;
3478
- if (lesson.status !== "GENERATING" && lesson.status !== "ERROR")
3479
- continue;
3480
- const candidateSlugs = [
3481
- (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title),
3482
- lesson.uid,
3483
- ].filter(Boolean);
3484
- const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s));
3485
- if (!matchedSlug)
3486
- continue;
3487
- const langs = translationsBySlug.get(matchedSlug) || [];
3488
- if (!langs.includes(primaryLanguage))
3489
- continue;
3490
- const prevStatus = lesson.status;
3491
- lesson.generated = true;
3492
- lesson.status = "DONE";
3493
- fixedLessons += 1;
3494
- console.log(`🔧 Fixed lesson: ${lesson.id} - "${lesson.title}" (was generated:false status:${prevStatus}, primary language "${primaryLanguage}" exists in bucket)`);
3495
- }
3496
- }
3497
- catch (error) {
3498
- console.error("⚠️ Could not reconcile lesson translations during syllabus sync:", error);
3499
- }
3500
- if (totalRemoved > 0 ||
3501
- addedLessons.length > 0 ||
3502
- repairedTranslationsInLessons > 0 ||
3503
- fixedLessons > 0) {
3504
- await saveSyllabus(courseSlug, syllabus, bucket);
3505
- console.log(`✅ 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).`);
3276
+ const storage = (0, syllabusSync_1.createGcsSyllabusSyncStorage)(bucket);
3277
+ const result = await (0, syllabusSync_1.synchronizeSyllabusWithBucket)(storage, courseSlug, syllabus, { prune: true });
3278
+ if (result.changed) {
3279
+ await saveSyllabus(courseSlug, result.syllabus, bucket);
3280
+ console.log(`✅ 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).`);
3506
3281
  }
3507
3282
  else {
3508
3283
  console.log(`✅ Syllabus is already in sync. No changes.`);
@@ -3510,18 +3285,18 @@ class ServeCommand extends SessionCommand_1.default {
3510
3285
  res.json({
3511
3286
  status: "SUCCESS",
3512
3287
  message: `Syllabus synchronized successfully`,
3513
- totalLessons: syllabus.lessons.length,
3514
- keptLessons: keptLessons.length,
3515
- removedLessons: removedLessons.length,
3516
- duplicatesResolved: duplicatesRemoved.length,
3517
- addedLessons: addedLessons.length,
3518
- fixedLessons,
3519
- repairedTranslationsInLessons,
3520
- repairedTranslationEntries,
3521
- removed: removedLessons,
3522
- duplicates: duplicatesRemoved,
3523
- kept: keptLessons,
3524
- added: addedLessons,
3288
+ totalLessons: result.counters.totalLessons,
3289
+ keptLessons: result.counters.keptLessons,
3290
+ removedLessons: result.counters.removedLessons,
3291
+ duplicatesResolved: result.counters.duplicatesResolved,
3292
+ addedLessons: result.counters.addedLessons,
3293
+ fixedLessons: result.counters.fixedLessons,
3294
+ repairedTranslationsInLessons: result.counters.repairedTranslationsInLessons,
3295
+ repairedTranslationEntries: result.counters.repairedTranslationEntries,
3296
+ removed: result.details.removed,
3297
+ duplicates: result.details.duplicates,
3298
+ kept: result.details.kept,
3299
+ added: result.details.added,
3525
3300
  });
3526
3301
  }
3527
3302
  catch (error) {
@@ -3906,6 +3681,10 @@ class ServeCommand extends SessionCommand_1.default {
3906
3681
  const buildRoot = path.join(tmpRoot, "build");
3907
3682
  rimraf.sync(tmpRoot);
3908
3683
  mkdirp.sync(buildRoot);
3684
+ // Bookkeeping for the whole publication, so an interrupted run can be
3685
+ // resumed. Best effort: never blocks the publish.
3686
+ const journalStorage = (0, publishJournal_1.createGcsJournalStorage)(bucket);
3687
+ const journal = await (0, publishJournal_1.createJournal)(journalStorage, slug);
3909
3688
  // 4) Copiar UI descomprimida (_app)
3910
3689
  const uiSrc = path.resolve(__dirname, "../ui/_app");
3911
3690
  const copyDir = (src, dest) => {
@@ -3957,10 +3736,15 @@ class ServeCommand extends SessionCommand_1.default {
3957
3736
  const [buf] = await file.download();
3958
3737
  fs.writeFileSync(out, buf);
3959
3738
  }));
3960
- await (0, packageManifest_1.generateAndPersistPackageManifest)(bucket, slug, {
3739
+ const publishedManifest = await (0, packageManifest_1.generateAndPersistPackageManifest)(bucket, slug, {
3961
3740
  publishedAt: new Date().toISOString(),
3962
3741
  localDir: buildRoot,
3963
3742
  });
3743
+ // Decide here, while the build is still on disk, whether this package
3744
+ // will need descriptions: the answer is what the response and the
3745
+ // package_published event promise to breathecode.
3746
+ const pendingDescriptions = (0, publishStage_1.countPendingDescriptions)(buildRoot, exercises);
3747
+ const descriptionsOutcome = pendingDescriptions > 0 ? "queued" : "up_to_date";
3964
3748
  // 8) Crear el config.json en buildRoot con lo que retorna buildConfig
3965
3749
  const fullConfig = { config, exercises };
3966
3750
  fullConfig.config.slug = slug;
@@ -3984,10 +3768,16 @@ class ServeCommand extends SessionCommand_1.default {
3984
3768
  headers: Object.assign(Object.assign({}, form.getHeaders()), { Authorization: "Token " + rigoToken.trim() }),
3985
3769
  });
3986
3770
  rigoPublishUrl = rigoRes.data.url;
3771
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "deploy", "done", {
3772
+ meta: { url: rigoPublishUrl },
3773
+ });
3987
3774
  let errors;
3988
3775
  try {
3989
3776
  const assetResults = await createMultiLangAsset(bucket, rigoToken, bcToken, slug, fullConfig.config, rigoRes.data.url, academyId);
3990
3777
  errors = assetResults.errors;
3778
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "assetsSync", errors.length > 0 ? "failed" : "done", {
3779
+ error: errors.length > 0 ? JSON.stringify(errors) : undefined,
3780
+ });
3991
3781
  }
3992
3782
  catch (error) {
3993
3783
  console.error("Asset sync failed unexpectedly:", error);
@@ -3997,17 +3787,52 @@ class ServeCommand extends SessionCommand_1.default {
3997
3787
  error: { detail: "Asset sync failed unexpectedly." },
3998
3788
  },
3999
3789
  ];
3790
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "assetsSync", "failed", {
3791
+ error: error.message,
3792
+ });
4000
3793
  }
4001
- if (res.headersSent)
4002
- return;
4003
- console.log("RigoRes", rigoRes.data);
4004
- res.json({
4005
- url: rigoPublishUrl,
4006
- errors,
3794
+ if (!res.headersSent) {
3795
+ console.log("RigoRes", rigoRes.data);
3796
+ res.json({
3797
+ url: rigoPublishUrl,
3798
+ errors,
3799
+ descriptions: descriptionsOutcome,
3800
+ });
3801
+ }
3802
+ // The package is published: tell breathecode, announcing whether a
3803
+ // second event is coming. Emitted after the asset sync so the
3804
+ // assets the payload refers to already exist.
3805
+ const eventContext = {
3806
+ publishId: journal.publishId,
3807
+ courseSlug: slug,
3808
+ packageInfo: await (0, publishEvents_1.fetchPackageInfo)(slug, rigoToken),
3809
+ manifest: publishedManifest,
3810
+ };
3811
+ const publishedDelivered = await (0, publishEvents_1.sendPublishEvent)((0, publishEvents_1.buildPackagePublishedEvent)(eventContext, descriptionsOutcome), bcToken);
3812
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "publishedEvent", publishedDelivered ? "done" : "failed", {
3813
+ error: publishedDelivered ? undefined : "delivery failed",
3814
+ // Recorded so a sweep resuming this publication knows whether a
3815
+ // manifest event was promised to breathecode.
3816
+ meta: { descriptions: descriptionsOutcome },
3817
+ });
3818
+ // Stage B, after responding: a course may need several ~15s
3819
+ // completions, far past any request limit. The package is already
3820
+ // published and durable; this only enriches it.
3821
+ (0, publishStage_1.runDescriptionsStageAfterPublish)({
3822
+ courseSlug: slug,
3823
+ gcsBucket: bucket,
3824
+ journal,
3825
+ journalStorage,
3826
+ hasWork: pendingDescriptions > 0,
3827
+ eventContext,
3828
+ breathecodeToken: bcToken,
3829
+ }).catch(error => {
3830
+ console.error("[descriptions] Detached stage rejected:", error);
4007
3831
  });
4008
3832
  }
4009
3833
  catch (error) {
4010
3834
  console.error(error);
3835
+ await (0, publishJournal_1.markStage)(journalStorage, journal, rigoPublishUrl === undefined ? "deploy" : "assetsSync", "failed", { error: error.message });
4011
3836
  if (res.headersSent)
4012
3837
  return;
4013
3838
  if (rigoPublishUrl !== undefined) {
@@ -4019,6 +3844,7 @@ class ServeCommand extends SessionCommand_1.default {
4019
3844
  error: { detail: "Asset sync failed unexpectedly." },
4020
3845
  },
4021
3846
  ],
3847
+ descriptions: descriptionsOutcome,
4022
3848
  });
4023
3849
  }
4024
3850
  else {
@@ -36,6 +36,12 @@ export interface Lesson {
36
36
  startedAt: number;
37
37
  completedAt?: number;
38
38
  error?: boolean;
39
+ description?: string | null;
40
+ descriptionStatus?: "generated" | "pending" | "error" | "none";
41
+ sourceContentHash?: string;
42
+ sourceSimHash?: string;
43
+ descriptionPromptVersion?: number;
44
+ descriptionSource?: "auto" | "human";
39
45
  };
40
46
  };
41
47
  syncNotifications?: SyncNotification[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable no-await-in-loop */
4
+ /**
5
+ * Fase 0b — GCS descriptions backfill (reuse from S3, no LLM).
6
+ *
7
+ * Seeds the GCS (draft) syllabus with descriptions already generated for the
8
+ * published S3 syllabus (Fase 0a) — a pure COPY, guarded by the content hash,
9
+ * performed by the shared mirror (`mirrorDescriptionsToDraft`). It NEVER calls
10
+ * the LLM: the ~800 GCS drafts are mostly unused, so anything not reusable from
11
+ * S3 is left for the next publication of that course (or a single-slug 0a run).
12
+ * Run AFTER 0a has completed.
13
+ *
14
+ * Batched like 0a: --limit courses per run (default 50), ordered by
15
+ * initialSyllabus modification time (newest first).
16
+ *
17
+ * Secrets/infra via env: GCP_CREDENTIALS_JSON (required), plus
18
+ * S3_PACKAGES_BUCKET / GCP_BUCKET_NAME / AWS_REGION as fallbacks for the
19
+ * corresponding flags.
20
+ *
21
+ * Per-run flags:
22
+ * --s3-bucket <name> published S3 bucket to reuse from (or env S3_PACKAGES_BUCKET)
23
+ * --gcs-bucket <name> GCS bucket (or env GCP_BUCKET_NAME, which is required)
24
+ * --region <region> AWS region (default us-east-1, or env AWS_REGION)
25
+ * --slug <slug> process a single course
26
+ * --limit <n> courses per run (default 50)
27
+ * --dry-run preview without writing
28
+ * --no-reconcile skip the additive syllabus reconciliation
29
+ *
30
+ * Examples:
31
+ * node lib/scripts/descriptionsGcsBackfill.js --limit 200
32
+ * node lib/scripts/descriptionsGcsBackfill.js --slug my-course
33
+ */
34
+ const node_util_1 = require("node:util");
35
+ const storage_1 = require("@google-cloud/storage");
36
+ const client_s3_1 = require("@aws-sdk/client-s3");
37
+ const syllabusSync_1 = require("../utils/syllabusSync");
38
+ const packageManifestBackfill_1 = require("../utils/s3/packageManifestBackfill");
39
+ const gcsBucketName_1 = require("../utils/gcsBucketName");
40
+ const gcsStorage_1 = require("../utils/descriptions/gcsStorage");
41
+ const mirrorDescriptions_1 = require("../utils/descriptions/mirrorDescriptions");
42
+ const s3Storage_1 = require("../utils/descriptions/s3Storage");
43
+ const GCS_SYLLABUS_PATTERN = /^courses\/([^/]+)\/\.learn\/initialSyllabus\.json$/;
44
+ /** Courses (by slug) ordered by initialSyllabus.json update time, newest first. */
45
+ async function listCoursesByRecency(bucket) {
46
+ var _a;
47
+ const [files] = await bucket.getFiles({ prefix: "courses/" });
48
+ const mtimes = new Map();
49
+ for (const file of files) {
50
+ const match = file.name.match(GCS_SYLLABUS_PATTERN);
51
+ if (match) {
52
+ const updated = (_a = file.metadata) === null || _a === void 0 ? void 0 : _a.updated;
53
+ mtimes.set(match[1], updated ? new Date(updated).getTime() : 0);
54
+ }
55
+ }
56
+ return [...mtimes.entries()]
57
+ .sort((a, b) => b[1] - a[1])
58
+ .map(entry => entry[0]);
59
+ }
60
+ async function main() {
61
+ var _a, _b;
62
+ const { values } = (0, node_util_1.parseArgs)({
63
+ options: {
64
+ "s3-bucket": { type: "string" },
65
+ "gcs-bucket": { type: "string" },
66
+ region: { type: "string" },
67
+ slug: { type: "string" },
68
+ limit: { type: "string" },
69
+ "dry-run": { type: "boolean", default: false },
70
+ "no-reconcile": { type: "boolean", default: false },
71
+ },
72
+ });
73
+ const s3Bucket = values["s3-bucket"] ||
74
+ process.env.S3_PACKAGES_BUCKET ||
75
+ "learnpack-paquetes";
76
+ const credentialsEnv = process.env.GCP_CREDENTIALS_JSON;
77
+ if (!credentialsEnv) {
78
+ console.error("[gcs-backfill] GCP_CREDENTIALS_JSON (env) is required");
79
+ process.exit(1);
80
+ }
81
+ const bucket = new storage_1.Storage({
82
+ credentials: JSON.parse(credentialsEnv),
83
+ }).bucket(values["gcs-bucket"] || (0, gcsBucketName_1.requireGcsBucketName)());
84
+ const s3 = new client_s3_1.S3Client({
85
+ region: values.region || process.env.AWS_REGION || "us-east-1",
86
+ });
87
+ const dryRun = (_a = values["dry-run"]) !== null && _a !== void 0 ? _a : false;
88
+ const reconcile = !((_b = values["no-reconcile"]) !== null && _b !== void 0 ? _b : false);
89
+ const limit = Number.parseInt(values.limit || "50", 10) || 50;
90
+ const storage = (0, gcsStorage_1.createGcsDescriptionsStorage)(bucket);
91
+ const singleSlug = values.slug;
92
+ const slugs = singleSlug ? [singleSlug] : await listCoursesByRecency(bucket);
93
+ console.log(`[gcs-backfill] Starting${singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`);
94
+ let processed = 0;
95
+ let copied = 0;
96
+ let missed = 0;
97
+ for (const slug of slugs) {
98
+ if (!singleSlug && processed >= limit) {
99
+ break;
100
+ }
101
+ try {
102
+ const publishedSyllabus = await (0, packageManifestBackfill_1.fetchJsonObject)(s3, s3Bucket, (0, s3Storage_1.syllabusKey)(slug));
103
+ if (!publishedSyllabus) {
104
+ continue;
105
+ }
106
+ if (reconcile && storage.syllabusSyncStorage) {
107
+ const draft = await storage.readSyllabus(slug);
108
+ if (draft) {
109
+ try {
110
+ const sync = await (0, syllabusSync_1.synchronizeSyllabusWithBucket)(storage.syllabusSyncStorage, slug, draft, { prune: false });
111
+ if (sync.changed && !dryRun) {
112
+ await storage.writeSyllabus(slug, sync.syllabus);
113
+ }
114
+ }
115
+ catch (error) {
116
+ console.error(`[gcs-backfill] Reconciliation failed for "${slug}":`, error.message);
117
+ }
118
+ }
119
+ }
120
+ const result = await (0, mirrorDescriptions_1.mirrorDescriptionsToDraft)(storage, slug, publishedSyllabus, { dryRun });
121
+ if (result.copied === 0 && result.missed === 0) {
122
+ continue;
123
+ }
124
+ processed += 1;
125
+ copied += result.copied;
126
+ missed += result.missed;
127
+ console.log(`[gcs-backfill] "${slug}": copied ${result.copied}, ${result.missed} miss(es)${dryRun ? " (dry-run)" : ""}`);
128
+ }
129
+ catch (error) {
130
+ processed += 1;
131
+ console.error(`[gcs-backfill] Failed processing "${slug}":`, error.message);
132
+ }
133
+ }
134
+ console.log(`[gcs-backfill] Done. ${processed} course(s), ${copied} description(s) copied, ${missed} miss(es).`);
135
+ }
136
+ main()
137
+ .then(() => process.exit(0))
138
+ .catch(error => {
139
+ console.error("[gcs-backfill] Fatal error:", error);
140
+ process.exit(1);
141
+ });
@@ -0,0 +1 @@
1
+ export {};