@learnpack/learnpack 5.0.351 → 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.
- package/lib/commands/publish.js +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +157 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +234 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +113 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +134 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +18 -0
- package/lib/utils/packageManifest.js +81 -7
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
- package/lib/utils/s3/packageManifestBackfill.js +487 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +4 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +208 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +143 -141
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +382 -0
- package/src/utils/descriptions/resumePublication.ts +200 -0
- package/src/utils/descriptions/s3Storage.ts +206 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/export/README.md +0 -2
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +101 -10
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +776 -0
- package/src/utils/syllabusSync.ts +390 -0
package/lib/commands/publish.js
CHANGED
|
@@ -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));
|
package/lib/commands/serve.js
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
3270
|
-
const
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
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:
|
|
3514
|
-
keptLessons: keptLessons
|
|
3515
|
-
removedLessons: removedLessons
|
|
3516
|
-
duplicatesResolved:
|
|
3517
|
-
addedLessons: addedLessons
|
|
3518
|
-
fixedLessons,
|
|
3519
|
-
repairedTranslationsInLessons,
|
|
3520
|
-
repairedTranslationEntries,
|
|
3521
|
-
removed:
|
|
3522
|
-
duplicates:
|
|
3523
|
-
kept:
|
|
3524
|
-
added:
|
|
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
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
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 {
|
package/lib/models/creator.d.ts
CHANGED
|
@@ -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 {};
|