@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.
- 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 +14 -0
- package/lib/utils/packageManifest.js +49 -5
- 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 +2 -0
- package/lib/utils/s3/packageManifestBackfill.js +2 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +1 -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 +142 -140
- 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/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +54 -4
- 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 +2 -2
- package/src/utils/syllabusSync.ts +390 -0
package/src/commands/serve.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
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
|
-
|
|
4752
|
-
|
|
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
|
|
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:
|
|
5042
|
-
keptLessons: keptLessons
|
|
5043
|
-
removedLessons: removedLessons
|
|
5044
|
-
duplicatesResolved:
|
|
5045
|
-
addedLessons: addedLessons
|
|
5046
|
-
fixedLessons,
|
|
5047
|
-
repairedTranslationsInLessons
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
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(
|
|
5670
|
-
|
|
5671
|
-
|
|
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)
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
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 })
|
package/src/models/creator.ts
CHANGED
|
@@ -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[];
|