@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
abandonJournal,
|
|
3
|
+
finalizeJournal,
|
|
4
|
+
incrementAttempts,
|
|
5
|
+
isJournalStale,
|
|
6
|
+
JournalStorage,
|
|
7
|
+
markStage,
|
|
8
|
+
PublishJournal,
|
|
9
|
+
} from "../publishJournal"
|
|
10
|
+
import { PackageManifest } from "../packageManifest"
|
|
11
|
+
import {
|
|
12
|
+
buildPackageManifestUpdatedEvent,
|
|
13
|
+
fetchPackageInfo,
|
|
14
|
+
sendPublishEvent,
|
|
15
|
+
} from "../publishEvents"
|
|
16
|
+
import {
|
|
17
|
+
CourseDescriptionsStorage,
|
|
18
|
+
GenerateCourseDescriptionsOptions,
|
|
19
|
+
} from "./generateCourseDescriptions"
|
|
20
|
+
import { runPublishDescriptionsStage } from "./publishStage"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recovery of a publication whose background stage never finished.
|
|
24
|
+
*
|
|
25
|
+
* The stage runs inside the web process, so a dyno restart mid-flight leaves a
|
|
26
|
+
* package published but not described, and — worse — breathecode waiting for a
|
|
27
|
+
* manifest event that was promised and will never arrive. Content-wise the
|
|
28
|
+
* missing descriptions would eventually be picked up by the next publication,
|
|
29
|
+
* but the promised event has no such second chance: emitting it is the reason
|
|
30
|
+
* this exists.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const MAX_SWEEP_ATTEMPTS = 3
|
|
34
|
+
|
|
35
|
+
/** Only resume publications nobody is working on any more. */
|
|
36
|
+
export const DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000
|
|
37
|
+
|
|
38
|
+
export type ResumeOutcome =
|
|
39
|
+
| "skipped-recent"
|
|
40
|
+
| "skipped-abandoned"
|
|
41
|
+
| "abandoned"
|
|
42
|
+
| "resumed"
|
|
43
|
+
| "nothing-to-do";
|
|
44
|
+
|
|
45
|
+
export type ResumeDeps = {
|
|
46
|
+
journalStorage: JournalStorage;
|
|
47
|
+
publishedStorage: CourseDescriptionsStorage;
|
|
48
|
+
draftStorage: CourseDescriptionsStorage;
|
|
49
|
+
rigobotToken: string;
|
|
50
|
+
breathecodeToken?: string;
|
|
51
|
+
staleAfterMs?: number;
|
|
52
|
+
now?: number;
|
|
53
|
+
/** Injection points for tests. */
|
|
54
|
+
emit?: typeof sendPublishEvent;
|
|
55
|
+
readPackageInfo?: typeof fetchPackageInfo;
|
|
56
|
+
generate?: GenerateCourseDescriptionsOptions["generate"];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function isDone(
|
|
60
|
+
journal: PublishJournal,
|
|
61
|
+
stage: "descriptions" | "gcsMirror" | "manifestEvent"
|
|
62
|
+
): boolean {
|
|
63
|
+
return journal.stages[stage].status === "done"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether breathecode is still owed a manifest event: one was promised (the
|
|
68
|
+
* publish event announced `queued`) and it never went out.
|
|
69
|
+
*/
|
|
70
|
+
export function owesManifestEvent(journal: PublishJournal): boolean {
|
|
71
|
+
const promised =
|
|
72
|
+
(
|
|
73
|
+
journal.stages.publishedEvent.meta as
|
|
74
|
+
| { descriptions?: string }
|
|
75
|
+
| undefined
|
|
76
|
+
)?.descriptions === "queued"
|
|
77
|
+
return promised && !isDone(journal, "manifestEvent")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function resumePublication(
|
|
81
|
+
journal: PublishJournal,
|
|
82
|
+
deps: ResumeDeps
|
|
83
|
+
): Promise<ResumeOutcome> {
|
|
84
|
+
const { journalStorage } = deps
|
|
85
|
+
const now = deps.now ?? Date.now()
|
|
86
|
+
|
|
87
|
+
if (journal.abandoned) {
|
|
88
|
+
return "skipped-abandoned"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A publication still in flight would race with its own stage.
|
|
92
|
+
if (
|
|
93
|
+
!isJournalStale(journal, deps.staleAfterMs ?? DEFAULT_STALE_AFTER_MS, now)
|
|
94
|
+
) {
|
|
95
|
+
return "skipped-recent"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const needsDescriptions =
|
|
99
|
+
!isDone(journal, "descriptions") || !isDone(journal, "gcsMirror")
|
|
100
|
+
const needsEvent = owesManifestEvent(journal)
|
|
101
|
+
|
|
102
|
+
if (!needsDescriptions && !needsEvent) {
|
|
103
|
+
// Nothing left that the sweep can do; stage A failures are not its job.
|
|
104
|
+
await finalizeJournal(journalStorage, journal)
|
|
105
|
+
return "nothing-to-do"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (journal.attempts >= MAX_SWEEP_ATTEMPTS) {
|
|
109
|
+
await abandonJournal(journalStorage, journal)
|
|
110
|
+
console.error(
|
|
111
|
+
`[sweep] Giving up on "${journal.courseSlug}" (${journal.publishId}) after ${journal.attempts} attempts`
|
|
112
|
+
)
|
|
113
|
+
return "abandoned"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await incrementAttempts(journalStorage, journal)
|
|
117
|
+
|
|
118
|
+
let succeeded = true
|
|
119
|
+
let stats: { generated: number; failed: number; missing: number } | undefined
|
|
120
|
+
|
|
121
|
+
if (needsDescriptions) {
|
|
122
|
+
const outcome = await runPublishDescriptionsStage({
|
|
123
|
+
courseSlug: journal.courseSlug,
|
|
124
|
+
rigobotToken: deps.rigobotToken,
|
|
125
|
+
journal,
|
|
126
|
+
journalStorage,
|
|
127
|
+
publishedStorage: deps.publishedStorage,
|
|
128
|
+
draftStorage: deps.draftStorage,
|
|
129
|
+
generate: deps.generate,
|
|
130
|
+
})
|
|
131
|
+
succeeded = outcome.succeeded
|
|
132
|
+
if (outcome.generation) {
|
|
133
|
+
stats = {
|
|
134
|
+
generated: outcome.generation.generated,
|
|
135
|
+
failed: outcome.generation.failed,
|
|
136
|
+
missing: outcome.generation.missing,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (needsEvent) {
|
|
142
|
+
await emitPendingManifestEvent(journal, deps, succeeded, stats)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await finalizeJournal(journalStorage, journal)
|
|
146
|
+
return "resumed"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function emitPendingManifestEvent(
|
|
150
|
+
journal: PublishJournal,
|
|
151
|
+
deps: ResumeDeps,
|
|
152
|
+
succeeded: boolean,
|
|
153
|
+
stats?: { generated: number; failed: number; missing: number }
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const emit = deps.emit ?? sendPublishEvent
|
|
156
|
+
const readPackageInfo = deps.readPackageInfo ?? fetchPackageInfo
|
|
157
|
+
|
|
158
|
+
if (!deps.breathecodeToken) {
|
|
159
|
+
await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
|
|
160
|
+
error: "BREATHECODE_SYSTEM_TOKEN is not configured",
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
let manifest: PackageManifest | null = null
|
|
167
|
+
if (deps.publishedStorage.readManifest) {
|
|
168
|
+
manifest = await deps.publishedStorage.readManifest(journal.courseSlug)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const delivered = await emit(
|
|
172
|
+
buildPackageManifestUpdatedEvent(
|
|
173
|
+
{
|
|
174
|
+
publishId: journal.publishId,
|
|
175
|
+
courseSlug: journal.courseSlug,
|
|
176
|
+
packageInfo: await readPackageInfo(
|
|
177
|
+
journal.courseSlug,
|
|
178
|
+
deps.rigobotToken
|
|
179
|
+
),
|
|
180
|
+
manifest,
|
|
181
|
+
},
|
|
182
|
+
succeeded ? "success" : "failed",
|
|
183
|
+
stats
|
|
184
|
+
),
|
|
185
|
+
deps.breathecodeToken
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
await markStage(
|
|
189
|
+
deps.journalStorage,
|
|
190
|
+
journal,
|
|
191
|
+
"manifestEvent",
|
|
192
|
+
delivered ? "done" : "failed",
|
|
193
|
+
{ error: delivered ? undefined : "delivery failed" }
|
|
194
|
+
)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
await markStage(deps.journalStorage, journal, "manifestEvent", "failed", {
|
|
197
|
+
error: (error as Error).message,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
|
|
2
|
+
import { CloudFrontClient } from "@aws-sdk/client-cloudfront"
|
|
3
|
+
import { Syllabus } from "../../models/creator"
|
|
4
|
+
import { PACKAGE_MANIFEST_REL_PATH, PackageManifest } from "../packageManifest"
|
|
5
|
+
import {
|
|
6
|
+
AwsClient,
|
|
7
|
+
fetchJsonObject,
|
|
8
|
+
fetchTextObject,
|
|
9
|
+
invalidatePackageManifestPaths,
|
|
10
|
+
listObjectKeys,
|
|
11
|
+
processPackage,
|
|
12
|
+
withRetry,
|
|
13
|
+
} from "../s3/packageManifestBackfill"
|
|
14
|
+
import { SyllabusSyncStorage } from "../syllabusSync"
|
|
15
|
+
import { CourseDescriptionsStorage } from "./generateCourseDescriptions"
|
|
16
|
+
import { CourseExercise } from "./workList"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Published-bucket (S3) adapter for the description flows.
|
|
20
|
+
*
|
|
21
|
+
* The published package is the snapshot descriptions are generated from: it is
|
|
22
|
+
* immutable between publications, unlike the draft in GCS, which keeps moving
|
|
23
|
+
* while the teacher edits.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const syllabusKey = (slug: string) =>
|
|
27
|
+
`${slug}/.learn/initialSyllabus.json`
|
|
28
|
+
|
|
29
|
+
export const manifestKey = (slug: string) =>
|
|
30
|
+
`${slug}/${PACKAGE_MANIFEST_REL_PATH}`
|
|
31
|
+
|
|
32
|
+
const configKeys = (slug: string) => [
|
|
33
|
+
`${slug}/.learn/config.json`,
|
|
34
|
+
`${slug}/config.json`,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
type ConfigShape = {
|
|
38
|
+
exercises?: CourseExercise[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export async function fetchExercises(
|
|
42
|
+
s3: AwsClient,
|
|
43
|
+
bucket: string,
|
|
44
|
+
slug: string
|
|
45
|
+
): Promise<CourseExercise[]> {
|
|
46
|
+
for (const key of configKeys(slug)) {
|
|
47
|
+
// eslint-disable-next-line no-await-in-loop -- the second key is a fallback
|
|
48
|
+
const config = await fetchJsonObject<ConfigShape>(s3, bucket, key)
|
|
49
|
+
if (config?.exercises && Array.isArray(config.exercises)) {
|
|
50
|
+
return config.exercises
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createS3SyllabusSyncStorage(
|
|
58
|
+
s3: AwsClient,
|
|
59
|
+
bucket: string
|
|
60
|
+
): SyllabusSyncStorage {
|
|
61
|
+
return {
|
|
62
|
+
async listExerciseFolderFileCounts(courseSlug) {
|
|
63
|
+
const prefix = `${courseSlug}/exercises/`
|
|
64
|
+
const keys = await listObjectKeys(s3, bucket, prefix)
|
|
65
|
+
const counts = new Map<string, number>()
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
const segment = key.slice(prefix.length).split("/")[0]
|
|
68
|
+
if (segment) {
|
|
69
|
+
counts.set(segment, (counts.get(segment) || 0) + 1)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return counts
|
|
74
|
+
},
|
|
75
|
+
async translationLangsBySlug(courseSlug) {
|
|
76
|
+
const exercises = await fetchExercises(s3, bucket, courseSlug)
|
|
77
|
+
const map = new Map<string, string[]>()
|
|
78
|
+
for (const exercise of exercises) {
|
|
79
|
+
const langs = Object.keys(exercise.translations || {})
|
|
80
|
+
.map(lang => lang.toLowerCase())
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
if (langs.length > 0) {
|
|
83
|
+
map.set(exercise.slug, [...new Set(langs)])
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return map
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type S3DescriptionsStorageOptions = {
|
|
93
|
+
/** CloudFront distribution to invalidate after re-projecting the manifest. */
|
|
94
|
+
cloudFront?: { client: AwsClient; distributionId: string };
|
|
95
|
+
/** Set false in the backfill, where manifests are projected in a later pass. */
|
|
96
|
+
reprojectManifest?: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Storage for the published bucket, wired from the environment. Used by the
|
|
101
|
+
* post-publish stage and the sweep, which have no CLI flags to read.
|
|
102
|
+
*/
|
|
103
|
+
export function createS3DescriptionsStorageFromEnv(): CourseDescriptionsStorage {
|
|
104
|
+
const s3 = new S3Client({
|
|
105
|
+
region: process.env.AWS_REGION || "us-east-1",
|
|
106
|
+
}) as unknown as AwsClient
|
|
107
|
+
|
|
108
|
+
const distributionId = process.env.CLOUDFRONT_DISTRIBUTION_ID
|
|
109
|
+
const cloudFront = distributionId ?
|
|
110
|
+
{
|
|
111
|
+
client: new CloudFrontClient({
|
|
112
|
+
region: process.env.AWS_REGION || "us-east-1",
|
|
113
|
+
}) as unknown as AwsClient,
|
|
114
|
+
distributionId,
|
|
115
|
+
} :
|
|
116
|
+
undefined
|
|
117
|
+
|
|
118
|
+
if (!cloudFront) {
|
|
119
|
+
console.warn(
|
|
120
|
+
"[descriptions] CLOUDFRONT_DISTRIBUTION_ID is not set: the manifest will be re-projected but not invalidated"
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return createS3DescriptionsStorage(
|
|
125
|
+
s3,
|
|
126
|
+
process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes",
|
|
127
|
+
{ cloudFront }
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createS3DescriptionsStorage(
|
|
132
|
+
s3: AwsClient,
|
|
133
|
+
bucket: string,
|
|
134
|
+
options: S3DescriptionsStorageOptions = {}
|
|
135
|
+
): CourseDescriptionsStorage {
|
|
136
|
+
const storage: CourseDescriptionsStorage = {
|
|
137
|
+
async readSyllabus(courseSlug) {
|
|
138
|
+
return fetchJsonObject<Syllabus>(s3, bucket, syllabusKey(courseSlug))
|
|
139
|
+
},
|
|
140
|
+
async writeSyllabus(courseSlug, syllabus) {
|
|
141
|
+
await withRetry(() =>
|
|
142
|
+
s3.send(
|
|
143
|
+
new PutObjectCommand({
|
|
144
|
+
Bucket: bucket,
|
|
145
|
+
Key: syllabusKey(courseSlug),
|
|
146
|
+
Body: JSON.stringify(syllabus, null, 2),
|
|
147
|
+
ContentType: "application/json",
|
|
148
|
+
})
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
async listExercises(courseSlug) {
|
|
153
|
+
return fetchExercises(s3, bucket, courseSlug)
|
|
154
|
+
},
|
|
155
|
+
async readReadme(courseSlug, exerciseSlug, fileName) {
|
|
156
|
+
return fetchTextObject(
|
|
157
|
+
s3,
|
|
158
|
+
bucket,
|
|
159
|
+
`${courseSlug}/exercises/${exerciseSlug}/${fileName}`
|
|
160
|
+
)
|
|
161
|
+
},
|
|
162
|
+
async readManifest(courseSlug) {
|
|
163
|
+
return fetchJsonObject<PackageManifest>(
|
|
164
|
+
s3,
|
|
165
|
+
bucket,
|
|
166
|
+
manifestKey(courseSlug)
|
|
167
|
+
)
|
|
168
|
+
},
|
|
169
|
+
syllabusSyncStorage: createS3SyllabusSyncStorage(s3, bucket),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (options.reprojectManifest !== false) {
|
|
173
|
+
storage.reprojectManifest = async courseSlug => {
|
|
174
|
+
// Preserve the publication timestamp: this is a re-projection of an
|
|
175
|
+
// already published package, not a new publication.
|
|
176
|
+
const existing = await fetchJsonObject<PackageManifest>(
|
|
177
|
+
s3,
|
|
178
|
+
bucket,
|
|
179
|
+
manifestKey(courseSlug)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const result = await processPackage(s3, bucket, courseSlug, {
|
|
183
|
+
dryRun: false,
|
|
184
|
+
force: false,
|
|
185
|
+
skipExisting: false,
|
|
186
|
+
publishedAt: existing?.publishedAt ?? null,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
if (result.status === "failed") {
|
|
190
|
+
throw new Error(result.error || "manifest projection failed")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (result.status === "skipped" || !options.cloudFront) {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await invalidatePackageManifestPaths(
|
|
198
|
+
options.cloudFront.client,
|
|
199
|
+
options.cloudFront.distributionId,
|
|
200
|
+
[`/${manifestKey(courseSlug)}`]
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return storage
|
|
206
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { Lesson, Syllabus } from "../../models/creator"
|
|
2
|
+
import { slugify } from "../creatorUtilities"
|
|
3
|
+
import {
|
|
4
|
+
ContentFingerprint,
|
|
5
|
+
fingerprintReadme,
|
|
6
|
+
getSimhashThreshold,
|
|
7
|
+
isDescriptionStale,
|
|
8
|
+
} from "../descriptionHash"
|
|
9
|
+
import { DESCRIPTION_PROMPT_VERSION } from "../packageManifest"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Deciding WHAT needs a description, separated from generating it.
|
|
13
|
+
*
|
|
14
|
+
* The publish route needs the answer before doing any work (to tell breathecode
|
|
15
|
+
* whether a second event is coming), and the generation service needs the very
|
|
16
|
+
* same list right after. Keeping the decision here, pure and content-only,
|
|
17
|
+
* means both answer identically — the alternative, two implementations of
|
|
18
|
+
* "is this stale?", is exactly how the backfill and the sweep drifted apart.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type CourseExercise = {
|
|
22
|
+
slug: string;
|
|
23
|
+
/** language code -> README file name */
|
|
24
|
+
translations: Record<string, string>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** README contents by exercise slug and language. */
|
|
28
|
+
export type CourseReadmes = Record<string, Record<string, string>>;
|
|
29
|
+
|
|
30
|
+
export type LanguageWork = {
|
|
31
|
+
lang: string;
|
|
32
|
+
fingerprint: ContentFingerprint;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type StepWork = {
|
|
36
|
+
exerciseSlug: string;
|
|
37
|
+
/** Reference into the syllabus; the generation service writes through it. */
|
|
38
|
+
lesson: Lesson;
|
|
39
|
+
/** Language the description is written from and translated out of. */
|
|
40
|
+
baseLanguage: string;
|
|
41
|
+
baseContent: string;
|
|
42
|
+
languages: LanguageWork[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type BuildWorkListInput = {
|
|
46
|
+
syllabus: Syllabus;
|
|
47
|
+
exercises: CourseExercise[];
|
|
48
|
+
readmes: CourseReadmes;
|
|
49
|
+
promptVersion?: number;
|
|
50
|
+
simhashThreshold?: number;
|
|
51
|
+
/** Regenerate everything except human-edited descriptions. */
|
|
52
|
+
force?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function findSyllabusLesson(
|
|
56
|
+
syllabus: Syllabus,
|
|
57
|
+
exerciseSlug: string
|
|
58
|
+
): Lesson | undefined {
|
|
59
|
+
return syllabus.lessons.find(
|
|
60
|
+
lesson =>
|
|
61
|
+
lesson.uid === exerciseSlug ||
|
|
62
|
+
slugify(lesson.id + "-" + lesson.title) === exerciseSlug
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Language a course is actually written in.
|
|
68
|
+
*
|
|
69
|
+
* `courseInfo.language` is trusted only when some exercise really has a README
|
|
70
|
+
* in it; otherwise the language is inferred from the content itself (the most
|
|
71
|
+
* frequent translation across exercises). Defaulting to "en" when the field is
|
|
72
|
+
* missing would label Spanish courses as English and generate their
|
|
73
|
+
* descriptions from a translation instead of the original.
|
|
74
|
+
*/
|
|
75
|
+
export function inferCourseBaseLanguage(
|
|
76
|
+
syllabus: Syllabus,
|
|
77
|
+
exercises: CourseExercise[]
|
|
78
|
+
): string | null {
|
|
79
|
+
const counts = new Map<string, number>()
|
|
80
|
+
for (const exercise of exercises) {
|
|
81
|
+
for (const lang of Object.keys(exercise.translations || {})) {
|
|
82
|
+
counts.set(lang, (counts.get(lang) || 0) + 1)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (counts.size === 0) {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const declared = syllabus.courseInfo?.language?.toLowerCase()
|
|
91
|
+
if (declared) {
|
|
92
|
+
for (const lang of counts.keys()) {
|
|
93
|
+
if (lang.toLowerCase() === declared) {
|
|
94
|
+
return lang
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let best: string | null = null
|
|
100
|
+
let bestCount = 0
|
|
101
|
+
for (const [lang, count] of counts) {
|
|
102
|
+
if (count > bestCount) {
|
|
103
|
+
best = lang
|
|
104
|
+
bestCount = count
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return best
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Language to generate this step from: the course's, when it has it. */
|
|
112
|
+
function resolveStepBaseLanguage(
|
|
113
|
+
exercise: CourseExercise,
|
|
114
|
+
courseBaseLanguage: string | null
|
|
115
|
+
): string | null {
|
|
116
|
+
const langs = Object.keys(exercise.translations || {})
|
|
117
|
+
if (langs.length === 0) {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (courseBaseLanguage && langs.includes(courseBaseLanguage)) {
|
|
122
|
+
return courseBaseLanguage
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return langs[0]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a (step, language) needs generating. Human-edited descriptions are
|
|
130
|
+
* never touched; an explicit "not enough content" answer (a stored null with a
|
|
131
|
+
* fingerprint) is respected until the content itself changes.
|
|
132
|
+
*/
|
|
133
|
+
export function needsGeneration(
|
|
134
|
+
slot: NonNullable<Lesson["translations"]>[string] | undefined,
|
|
135
|
+
fingerprint: ContentFingerprint,
|
|
136
|
+
options: {
|
|
137
|
+
promptVersion: number;
|
|
138
|
+
simhashThreshold: number;
|
|
139
|
+
force: boolean;
|
|
140
|
+
}
|
|
141
|
+
): boolean {
|
|
142
|
+
if (slot?.descriptionSource === "human") {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!slot) {
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (options.force) {
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (slot.descriptionStatus === "error") {
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if ((slot.descriptionPromptVersion ?? 0) < options.promptVersion) {
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return isDescriptionStale(
|
|
163
|
+
{ sha256: slot.sourceContentHash, simhash: slot.sourceSimHash },
|
|
164
|
+
fingerprint,
|
|
165
|
+
options.simhashThreshold
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The (step, language) pairs whose description is missing or out of date. */
|
|
170
|
+
export function buildDescriptionWorkList(
|
|
171
|
+
input: BuildWorkListInput
|
|
172
|
+
): StepWork[] {
|
|
173
|
+
const promptVersion = input.promptVersion ?? DESCRIPTION_PROMPT_VERSION
|
|
174
|
+
const simhashThreshold = input.simhashThreshold ?? getSimhashThreshold()
|
|
175
|
+
const force = input.force ?? false
|
|
176
|
+
const courseBaseLanguage = inferCourseBaseLanguage(
|
|
177
|
+
input.syllabus,
|
|
178
|
+
input.exercises
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const work: StepWork[] = []
|
|
182
|
+
|
|
183
|
+
for (const exercise of input.exercises) {
|
|
184
|
+
const lesson = findSyllabusLesson(input.syllabus, exercise.slug)
|
|
185
|
+
if (!lesson) {
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const baseLanguage = resolveStepBaseLanguage(exercise, courseBaseLanguage)
|
|
190
|
+
if (!baseLanguage) {
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const exerciseReadmes = input.readmes[exercise.slug] || {}
|
|
195
|
+
const baseContent = exerciseReadmes[baseLanguage]
|
|
196
|
+
if (typeof baseContent !== "string") {
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const languages: LanguageWork[] = []
|
|
201
|
+
for (const lang of Object.keys(exercise.translations || {})) {
|
|
202
|
+
const content = exerciseReadmes[lang]
|
|
203
|
+
if (typeof content !== "string") {
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Each language's staleness is anchored to its OWN content, even though
|
|
208
|
+
// the description is generated from the base language: that is what makes
|
|
209
|
+
// "the Spanish README changed" regenerate only Spanish.
|
|
210
|
+
const fingerprint = fingerprintReadme(content)
|
|
211
|
+
if (
|
|
212
|
+
!needsGeneration(lesson.translations?.[lang], fingerprint, {
|
|
213
|
+
promptVersion,
|
|
214
|
+
simhashThreshold,
|
|
215
|
+
force,
|
|
216
|
+
})
|
|
217
|
+
) {
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
languages.push({ lang, fingerprint })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (languages.length === 0) {
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
work.push({
|
|
229
|
+
exerciseSlug: exercise.slug,
|
|
230
|
+
lesson,
|
|
231
|
+
baseLanguage,
|
|
232
|
+
baseContent,
|
|
233
|
+
languages,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return work
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Total (step, language) pairs in a work list. */
|
|
241
|
+
export function countWorkItems(work: StepWork[]): number {
|
|
242
|
+
return work.reduce((total, step) => total + step.languages.length, 0)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Cheap "has this course ever been fully processed at this prompt version?"
|
|
247
|
+
* check, from the syllabus alone.
|
|
248
|
+
*
|
|
249
|
+
* Deliberately weaker than the work list: it cannot see content changes because
|
|
250
|
+
* it never reads a README. That is the point — it lets a batched run skip
|
|
251
|
+
* finished courses without paying thousands of object reads. Use the work list
|
|
252
|
+
* whenever correctness matters; use this only as a pre-filter.
|
|
253
|
+
*/
|
|
254
|
+
export function isCourseSettled(
|
|
255
|
+
syllabus: Syllabus,
|
|
256
|
+
exercises: CourseExercise[],
|
|
257
|
+
promptVersion: number = DESCRIPTION_PROMPT_VERSION
|
|
258
|
+
): boolean {
|
|
259
|
+
for (const exercise of exercises) {
|
|
260
|
+
const lesson = findSyllabusLesson(syllabus, exercise.slug)
|
|
261
|
+
if (!lesson) {
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const lang of Object.keys(exercise.translations || {})) {
|
|
266
|
+
const slot = lesson.translations?.[lang]
|
|
267
|
+
if (slot?.descriptionSource === "human") {
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (
|
|
272
|
+
!slot ||
|
|
273
|
+
slot.descriptionStatus === "error" ||
|
|
274
|
+
!slot.sourceContentHash ||
|
|
275
|
+
(slot.descriptionPromptVersion ?? 0) < promptVersion
|
|
276
|
+
) {
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return true
|
|
283
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The draft bucket name, required rather than defaulted.
|
|
3
|
+
*
|
|
4
|
+
* The code used to fall back to "learnpack-packages", a name the service
|
|
5
|
+
* account cannot reach: with the variable missing, the process would start
|
|
6
|
+
* cleanly and then fail with a 403 on every single object operation. Failing at
|
|
7
|
+
* boot with the reason turns a puzzling runtime outage into an obvious
|
|
8
|
+
* misconfiguration.
|
|
9
|
+
*/
|
|
10
|
+
export function requireGcsBucketName(): string {
|
|
11
|
+
const name = (process.env.GCP_BUCKET_NAME || "").trim()
|
|
12
|
+
if (!name) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"GCP_BUCKET_NAME (env) is required: it names the bucket holding course drafts"
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return name
|
|
19
|
+
}
|