@learnpack/learnpack 5.0.352 → 5.0.354
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 +277 -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/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- 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/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -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 +66 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +176 -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 +245 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +128 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +141 -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 +22 -0
- package/lib/utils/packageManifest.js +57 -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/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -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 +5 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +3 -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/README.md +244 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +376 -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/awsCredentials.ts +57 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +394 -0
- package/src/utils/descriptions/resumePublication.ts +217 -0
- package/src/utils/descriptions/s3Storage.ts +214 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +62 -5
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
/**
|
|
3
|
+
* Fase 0a — S3 descriptions backfill (step 2 of the S3 pipeline).
|
|
4
|
+
*
|
|
5
|
+
* One-off fill of the published catalogue (~400+ courses). The per-course work
|
|
6
|
+
* is the shared description service (`generateCourseDescriptions`), the same one
|
|
7
|
+
* the post-publish stage and the sweep use; this script only decides WHICH
|
|
8
|
+
* courses to run it on and in what batches.
|
|
9
|
+
*
|
|
10
|
+
* - Processes at most --limit courses per run (default 50), ordered by the
|
|
11
|
+
* initialSyllabus.json modification time (most recently edited first),
|
|
12
|
+
* skipping courses already settled at the current prompt version. Re-run to
|
|
13
|
+
* advance through the catalogue.
|
|
14
|
+
* - Or target a single course with --slug (remediation / on-demand).
|
|
15
|
+
*
|
|
16
|
+
* Writes descriptions into the published initialSyllabus.json. By default the
|
|
17
|
+
* manifest projection is a separate pass: run the package-manifest backfill
|
|
18
|
+
* (runBatch) afterwards, which over the whole catalogue is cheaper than
|
|
19
|
+
* re-projecting course by course.
|
|
20
|
+
*
|
|
21
|
+
* That default inverts for small, targeted runs, where three flags reproduce per
|
|
22
|
+
* course what a publication does, in one process instead of three:
|
|
23
|
+
*
|
|
24
|
+
* --reproject-manifest projects each course as it is described (the same
|
|
25
|
+
* `processPackage` the separate pass runs, plus a
|
|
26
|
+
* CloudFront invalidation);
|
|
27
|
+
* --mirror-draft copies the new descriptions back into the GCS draft,
|
|
28
|
+
* so the next publication does not regenerate them;
|
|
29
|
+
* --emit-events announces the result to breathecode.
|
|
30
|
+
*
|
|
31
|
+
* Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), BREATHECODE_SYSTEM_TOKEN
|
|
32
|
+
* (required by --emit-events), GCP_CREDENTIALS_JSON and GCP_BUCKET_NAME (required
|
|
33
|
+
* by --mirror-draft), plus S3_PACKAGES_BUCKET, AWS_REGION and
|
|
34
|
+
* CLOUDFRONT_DISTRIBUTION_ID as fallbacks/optionals.
|
|
35
|
+
*
|
|
36
|
+
* Per-run flags:
|
|
37
|
+
* --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
|
|
38
|
+
* --gcs-bucket <name> GCS draft bucket for --mirror-draft (or env GCP_BUCKET_NAME)
|
|
39
|
+
* --region <region> AWS region (default us-east-1, or env AWS_REGION)
|
|
40
|
+
* --slug <slug> process a single course (remediation / on-demand)
|
|
41
|
+
* --limit <n> courses per run (default 50)
|
|
42
|
+
* --target-words <n> words per description (default 25)
|
|
43
|
+
* --concurrency <n> completions in flight per course (default 5)
|
|
44
|
+
* --dry-run preview without writing/generating
|
|
45
|
+
* --no-reconcile skip the additive syllabus reconciliation
|
|
46
|
+
* --force regenerate even courses already settled at this prompt version
|
|
47
|
+
* --reproject-manifest re-project package-manifest.json per course, and invalidate it
|
|
48
|
+
* --mirror-draft mirror the descriptions back into the GCS draft
|
|
49
|
+
* --emit-events send package_manifest_updated per course (implies --reproject-manifest)
|
|
50
|
+
*
|
|
51
|
+
* Examples:
|
|
52
|
+
* node lib/scripts/descriptionsS3Backfill.js --limit 50
|
|
53
|
+
* node lib/scripts/descriptionsS3Backfill.js --slug my-course
|
|
54
|
+
* node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
|
|
55
|
+
* node lib/scripts/descriptionsS3Backfill.js --slug my-course \
|
|
56
|
+
* --reproject-manifest --mirror-draft --emit-events
|
|
57
|
+
*/
|
|
58
|
+
import { parseArgs } from "node:util"
|
|
59
|
+
import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"
|
|
60
|
+
import { CloudFrontClient } from "@aws-sdk/client-cloudfront"
|
|
61
|
+
import { Storage } from "@google-cloud/storage"
|
|
62
|
+
import { DESCRIPTION_TARGET_WORD_COUNT } from "../utils/packageManifest"
|
|
63
|
+
import { AwsClient } from "../utils/s3/packageManifestBackfill"
|
|
64
|
+
import {
|
|
65
|
+
DEFAULT_DESCRIPTIONS_CONCURRENCY,
|
|
66
|
+
generateCourseDescriptions,
|
|
67
|
+
} from "../utils/descriptions/generateCourseDescriptions"
|
|
68
|
+
import {
|
|
69
|
+
createS3DescriptionsStorage,
|
|
70
|
+
fetchExercises,
|
|
71
|
+
S3DescriptionsStorageOptions,
|
|
72
|
+
} from "../utils/descriptions/s3Storage"
|
|
73
|
+
import {
|
|
74
|
+
BackfillEventOutcome,
|
|
75
|
+
emitBackfillManifestEvent,
|
|
76
|
+
newBackfillRunId,
|
|
77
|
+
} from "../utils/descriptions/backfillEvents"
|
|
78
|
+
import { createGcsDescriptionsStorage } from "../utils/descriptions/gcsStorage"
|
|
79
|
+
import { mirrorDescriptionsToDraft } from "../utils/descriptions/mirrorDescriptions"
|
|
80
|
+
import { requireGcsBucketName } from "../utils/gcsBucketName"
|
|
81
|
+
import { isCourseSettled } from "../utils/descriptions/workList"
|
|
82
|
+
|
|
83
|
+
const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/
|
|
84
|
+
|
|
85
|
+
/** Courses (by slug) ordered by initialSyllabus.json LastModified, newest first. */
|
|
86
|
+
async function listCoursesByRecency(
|
|
87
|
+
s3: AwsClient,
|
|
88
|
+
bucket: string
|
|
89
|
+
): Promise<string[]> {
|
|
90
|
+
const mtimes = new Map<string, number>()
|
|
91
|
+
let continuationToken: string | undefined
|
|
92
|
+
|
|
93
|
+
do {
|
|
94
|
+
const response = (await s3.send(
|
|
95
|
+
new ListObjectsV2Command({
|
|
96
|
+
Bucket: bucket,
|
|
97
|
+
ContinuationToken: continuationToken,
|
|
98
|
+
})
|
|
99
|
+
)) as {
|
|
100
|
+
Contents?: Array<{ Key?: string; LastModified?: Date }>;
|
|
101
|
+
NextContinuationToken?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const object of response.Contents || []) {
|
|
105
|
+
const match = object.Key?.match(SYLLABUS_KEY_PATTERN)
|
|
106
|
+
if (match) {
|
|
107
|
+
mtimes.set(match[1], object.LastModified?.getTime() || 0)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
continuationToken = response.NextContinuationToken
|
|
112
|
+
} while (continuationToken)
|
|
113
|
+
|
|
114
|
+
return [...mtimes.entries()]
|
|
115
|
+
.sort((a, b) => b[1] - a[1])
|
|
116
|
+
.map(([slug]) => slug)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* CDN invalidation for the re-projected manifests. Optional: a stale edge cache
|
|
121
|
+
* expires on its own, so a missing distribution id degrades the run instead of
|
|
122
|
+
* stopping it.
|
|
123
|
+
*/
|
|
124
|
+
function cloudFrontFor(
|
|
125
|
+
region: string
|
|
126
|
+
): S3DescriptionsStorageOptions["cloudFront"] {
|
|
127
|
+
const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim()
|
|
128
|
+
if (!distributionId) {
|
|
129
|
+
console.warn(
|
|
130
|
+
"[s3-backfill] CLOUDFRONT_DISTRIBUTION_ID is not set: manifests will be re-projected but not invalidated"
|
|
131
|
+
)
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
client: new CloudFrontClient({ region }) as unknown as AwsClient,
|
|
137
|
+
distributionId,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function main(): Promise<void> {
|
|
142
|
+
const { values } = parseArgs({
|
|
143
|
+
options: {
|
|
144
|
+
"s3-bucket": { type: "string" },
|
|
145
|
+
"gcs-bucket": { type: "string" },
|
|
146
|
+
region: { type: "string" },
|
|
147
|
+
slug: { type: "string" },
|
|
148
|
+
limit: { type: "string" },
|
|
149
|
+
"target-words": { type: "string" },
|
|
150
|
+
concurrency: { type: "string" },
|
|
151
|
+
"dry-run": { type: "boolean", default: false },
|
|
152
|
+
"no-reconcile": { type: "boolean", default: false },
|
|
153
|
+
force: { type: "boolean", default: false },
|
|
154
|
+
"reproject-manifest": { type: "boolean", default: false },
|
|
155
|
+
"mirror-draft": { type: "boolean", default: false },
|
|
156
|
+
"emit-events": { type: "boolean", default: false },
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const token = process.env.RIGOBOT_SYSTEM_TOKEN
|
|
161
|
+
if (!token) {
|
|
162
|
+
console.error("[s3-backfill] RIGOBOT_SYSTEM_TOKEN (env) is required")
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const bucket =
|
|
167
|
+
values["s3-bucket"] ||
|
|
168
|
+
process.env.S3_PACKAGES_BUCKET ||
|
|
169
|
+
"learnpack-paquetes"
|
|
170
|
+
|
|
171
|
+
const region = values.region || process.env.AWS_REGION || "us-east-1"
|
|
172
|
+
const limit = Number.parseInt(values.limit || "50", 10) || 50
|
|
173
|
+
const dryRun = values["dry-run"] ?? false
|
|
174
|
+
const force = values.force ?? false
|
|
175
|
+
const emitEvents = values["emit-events"] ?? false
|
|
176
|
+
const reprojectManifest = values["reproject-manifest"] ?? false
|
|
177
|
+
const mirrorDraft = values["mirror-draft"] ?? false
|
|
178
|
+
|
|
179
|
+
// The event announces that the manifest changed, so emitting it without
|
|
180
|
+
// projecting the manifest would state something untrue. Required rather than
|
|
181
|
+
// implied: turning on a write to S3 and the CDN as a side effect of another
|
|
182
|
+
// flag is exactly the kind of surprise a backfill should not have.
|
|
183
|
+
if (emitEvents && !reprojectManifest) {
|
|
184
|
+
console.error(
|
|
185
|
+
"[s3-backfill] --emit-events requires --reproject-manifest: the event " +
|
|
186
|
+
"reports a manifest update that would not have happened"
|
|
187
|
+
)
|
|
188
|
+
process.exit(1)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN
|
|
192
|
+
if (emitEvents && !breathecodeToken) {
|
|
193
|
+
console.error(
|
|
194
|
+
"[s3-backfill] BREATHECODE_SYSTEM_TOKEN (env) is required by --emit-events"
|
|
195
|
+
)
|
|
196
|
+
process.exit(1)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (emitEvents && dryRun) {
|
|
200
|
+
console.error(
|
|
201
|
+
"[s3-backfill] --emit-events cannot be combined with --dry-run: there is " +
|
|
202
|
+
"no dry run of an event that breathecode already received"
|
|
203
|
+
)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Built up front, not on first use: a misconfigured draft bucket must fail at
|
|
208
|
+
// boot, not after the first course has already been billed to Rigobot.
|
|
209
|
+
let draftStorage
|
|
210
|
+
if (mirrorDraft) {
|
|
211
|
+
const credentialsEnv = process.env.GCP_CREDENTIALS_JSON
|
|
212
|
+
if (!credentialsEnv) {
|
|
213
|
+
console.error(
|
|
214
|
+
"[s3-backfill] GCP_CREDENTIALS_JSON (env) is required by --mirror-draft"
|
|
215
|
+
)
|
|
216
|
+
process.exit(1)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
draftStorage = createGcsDescriptionsStorage(
|
|
220
|
+
new Storage({ credentials: JSON.parse(credentialsEnv) }).bucket(
|
|
221
|
+
values["gcs-bucket"] || requireGcsBucketName()
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const s3 = new S3Client({ region }) as unknown as AwsClient
|
|
227
|
+
const storage = createS3DescriptionsStorage(
|
|
228
|
+
s3,
|
|
229
|
+
bucket,
|
|
230
|
+
// Off by default: over the whole catalogue manifests are cheaper to project
|
|
231
|
+
// in a single later pass than course by course.
|
|
232
|
+
reprojectManifest ?
|
|
233
|
+
{ cloudFront: cloudFrontFor(region) } :
|
|
234
|
+
{ reprojectManifest: false }
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const runId = newBackfillRunId()
|
|
238
|
+
|
|
239
|
+
const singleSlug = values.slug
|
|
240
|
+
const slugs = singleSlug ?
|
|
241
|
+
[singleSlug] :
|
|
242
|
+
await listCoursesByRecency(s3, bucket)
|
|
243
|
+
|
|
244
|
+
console.log(
|
|
245
|
+
`[s3-backfill] Starting${
|
|
246
|
+
singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`
|
|
247
|
+
}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)${
|
|
248
|
+
reprojectManifest ? ", re-projecting manifests" : ""
|
|
249
|
+
}${mirrorDraft ? ", mirroring into the draft" : ""}${
|
|
250
|
+
emitEvents ? `, run ${runId}` : ""
|
|
251
|
+
}`
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
let processed = 0
|
|
255
|
+
let generated = 0
|
|
256
|
+
let failed = 0
|
|
257
|
+
let mirrored = 0
|
|
258
|
+
const events: Record<BackfillEventOutcome, number> = {
|
|
259
|
+
skipped: 0,
|
|
260
|
+
delivered: 0,
|
|
261
|
+
failed: 0,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const slug of slugs) {
|
|
265
|
+
if (!singleSlug && processed >= limit) {
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
// Cheap pre-filter: skip finished courses without reading their READMEs.
|
|
271
|
+
if (!force) {
|
|
272
|
+
const syllabus = await storage.readSyllabus(slug)
|
|
273
|
+
if (!syllabus) {
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const exercises = await fetchExercises(s3, bucket, slug)
|
|
278
|
+
if (exercises.length === 0 || isCourseSettled(syllabus, exercises)) {
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const result = await generateCourseDescriptions(storage, slug, {
|
|
284
|
+
token,
|
|
285
|
+
dryRun,
|
|
286
|
+
force,
|
|
287
|
+
reconcile: !(values["no-reconcile"] ?? false),
|
|
288
|
+
concurrency:
|
|
289
|
+
Number.parseInt(values.concurrency || "", 10) ||
|
|
290
|
+
DEFAULT_DESCRIPTIONS_CONCURRENCY,
|
|
291
|
+
targetWordCount:
|
|
292
|
+
Number.parseInt(values["target-words"] || "", 10) ||
|
|
293
|
+
DESCRIPTION_TARGET_WORD_COUNT,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
if (result.status === "skipped") {
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
processed += 1
|
|
301
|
+
generated += result.generated
|
|
302
|
+
failed += result.failed
|
|
303
|
+
|
|
304
|
+
console.log(
|
|
305
|
+
`[s3-backfill] "${slug}": ${result.steps} step(s), ${
|
|
306
|
+
result.generated
|
|
307
|
+
} description(s)${dryRun ? " (dry-run)" : ""}${
|
|
308
|
+
result.failed > 0 ? `, ${result.failed} failed` : ""
|
|
309
|
+
}${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if (draftStorage) {
|
|
313
|
+
// Before the event, so a course is fully settled by the time it is
|
|
314
|
+
// announced. A failure here does NOT change the event status: the
|
|
315
|
+
// published package and its manifest are already correct, and only the
|
|
316
|
+
// draft lags behind — the next publication regenerates what is missing.
|
|
317
|
+
try {
|
|
318
|
+
const published = await storage.readSyllabus(slug)
|
|
319
|
+
const mirror = await mirrorDescriptionsToDraft(
|
|
320
|
+
draftStorage,
|
|
321
|
+
slug,
|
|
322
|
+
published,
|
|
323
|
+
{ dryRun }
|
|
324
|
+
)
|
|
325
|
+
mirrored += mirror.copied
|
|
326
|
+
console.log(
|
|
327
|
+
`[s3-backfill] "${slug}": ${mirror.copied} mirrored, ${mirror.missed} missed, ${mirror.fresh} already fresh`
|
|
328
|
+
)
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(
|
|
331
|
+
`[s3-backfill] Could not mirror "${slug}" into the draft:`,
|
|
332
|
+
(error as Error).message
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (emitEvents) {
|
|
338
|
+
const outcome = await emitBackfillManifestEvent({
|
|
339
|
+
courseSlug: slug,
|
|
340
|
+
runId,
|
|
341
|
+
result,
|
|
342
|
+
storage,
|
|
343
|
+
rigobotToken: token,
|
|
344
|
+
breathecodeToken: breathecodeToken as string,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
events[outcome] += 1
|
|
348
|
+
console.log(`[s3-backfill] "${slug}": manifest event ${outcome}`)
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
processed += 1
|
|
352
|
+
failed += 1
|
|
353
|
+
// No event here on purpose: unlike a publication, a backfill never
|
|
354
|
+
// announced one, so nothing is owed. The course is simply re-run.
|
|
355
|
+
console.error(
|
|
356
|
+
`[s3-backfill] Failed processing "${slug}":`,
|
|
357
|
+
(error as Error).message
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
console.log(
|
|
363
|
+
`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).` +
|
|
364
|
+
(mirrorDraft ? ` ${mirrored} mirrored into the draft.` : "") +
|
|
365
|
+
(emitEvents ?
|
|
366
|
+
` Events: ${events.delivered} delivered, ${events.failed} failed (run ${runId}).` :
|
|
367
|
+
"")
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
main()
|
|
372
|
+
.then(() => process.exit(0))
|
|
373
|
+
.catch(error => {
|
|
374
|
+
console.error("[s3-backfill] Fatal error:", error)
|
|
375
|
+
process.exit(1)
|
|
376
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
/**
|
|
3
|
+
* Descriptions sweep — safety net for the post-publish stage.
|
|
4
|
+
*
|
|
5
|
+
* The stage that generates descriptions runs inside the web process, so a dyno
|
|
6
|
+
* restart mid-flight leaves a package published but not described and, worse,
|
|
7
|
+
* breathecode waiting for a manifest event that was promised and never arrived.
|
|
8
|
+
* This job finishes those publications.
|
|
9
|
+
*
|
|
10
|
+
* Runs as a one-off dyno via Heroku Scheduler (hourly is plenty):
|
|
11
|
+
* node lib/scripts/descriptionsSweep.js
|
|
12
|
+
*
|
|
13
|
+
* Two sources of work:
|
|
14
|
+
* 1. Publish journals (`publish-journal/` in GCS) — the primary one. Each
|
|
15
|
+
* journal that stalled is resumed: missing descriptions are generated, the
|
|
16
|
+
* draft is mirrored, and the owed event is emitted. Journals are dropped
|
|
17
|
+
* once complete and abandoned after a few attempts.
|
|
18
|
+
* 2. A deep scan of published courses (DESCRIPTIONS_SWEEP_FULL=1) — catches
|
|
19
|
+
* packages that never got a journal at all, at the cost of reading every
|
|
20
|
+
* course. Off by default.
|
|
21
|
+
*
|
|
22
|
+
* Required env: GCP_CREDENTIALS_JSON, RIGOBOT_SYSTEM_TOKEN.
|
|
23
|
+
* Optional env: GCP_BUCKET_NAME, S3_PACKAGES_BUCKET, AWS_REGION,
|
|
24
|
+
* CLOUDFRONT_DISTRIBUTION_ID, BREATHECODE_SYSTEM_TOKEN (needed to emit the
|
|
25
|
+
* pending events), DESCRIPTIONS_SWEEP_FULL, DESCRIPTIONS_SWEEP_LIMIT,
|
|
26
|
+
* DESCRIPTION_STALE_AFTER_MS.
|
|
27
|
+
*/
|
|
28
|
+
import { Storage } from "@google-cloud/storage"
|
|
29
|
+
import { S3Client } from "@aws-sdk/client-s3"
|
|
30
|
+
import {
|
|
31
|
+
createGcsJournalStorage,
|
|
32
|
+
listJournalRefs,
|
|
33
|
+
readJournalByKey,
|
|
34
|
+
} from "../utils/publishJournal"
|
|
35
|
+
import {
|
|
36
|
+
AwsClient,
|
|
37
|
+
listPublishedSlugs,
|
|
38
|
+
} from "../utils/s3/packageManifestBackfill"
|
|
39
|
+
import { requireGcsBucketName } from "../utils/gcsBucketName"
|
|
40
|
+
import { createGcsDescriptionsStorage } from "../utils/descriptions/gcsStorage"
|
|
41
|
+
import { createS3DescriptionsStorageFromEnv } from "../utils/descriptions/s3Storage"
|
|
42
|
+
import { generateCourseDescriptions } from "../utils/descriptions/generateCourseDescriptions"
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_STALE_AFTER_MS,
|
|
45
|
+
resumePublication,
|
|
46
|
+
ResumeOutcome,
|
|
47
|
+
} from "../utils/descriptions/resumePublication"
|
|
48
|
+
|
|
49
|
+
async function main(): Promise<void> {
|
|
50
|
+
const credentialsEnv = process.env.GCP_CREDENTIALS_JSON
|
|
51
|
+
if (!credentialsEnv) {
|
|
52
|
+
console.error("[sweep] GCP_CREDENTIALS_JSON (env) is required")
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rigobotToken = process.env.RIGOBOT_SYSTEM_TOKEN
|
|
57
|
+
if (!rigobotToken) {
|
|
58
|
+
console.error("[sweep] RIGOBOT_SYSTEM_TOKEN (env) is required")
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN
|
|
63
|
+
if (!breathecodeToken) {
|
|
64
|
+
console.warn(
|
|
65
|
+
"[sweep] BREATHECODE_SYSTEM_TOKEN is not set: pending events cannot be emitted"
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const bucket = new Storage({
|
|
70
|
+
credentials: JSON.parse(credentialsEnv),
|
|
71
|
+
}).bucket(requireGcsBucketName())
|
|
72
|
+
|
|
73
|
+
const journalStorage = createGcsJournalStorage(bucket)
|
|
74
|
+
const publishedStorage = createS3DescriptionsStorageFromEnv()
|
|
75
|
+
const draftStorage = createGcsDescriptionsStorage(bucket)
|
|
76
|
+
const staleAfterMs =
|
|
77
|
+
Number.parseInt(process.env.DESCRIPTION_STALE_AFTER_MS || "", 10) ||
|
|
78
|
+
DEFAULT_STALE_AFTER_MS
|
|
79
|
+
|
|
80
|
+
const refs = await listJournalRefs(journalStorage)
|
|
81
|
+
console.log(`[sweep] ${refs.length} unfinished publication(s)`)
|
|
82
|
+
|
|
83
|
+
const tally: Record<ResumeOutcome, number> = {
|
|
84
|
+
"skipped-recent": 0,
|
|
85
|
+
"skipped-abandoned": 0,
|
|
86
|
+
abandoned: 0,
|
|
87
|
+
resumed: 0,
|
|
88
|
+
"nothing-to-do": 0,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const ref of refs) {
|
|
92
|
+
try {
|
|
93
|
+
const journal = await readJournalByKey(journalStorage, ref.key)
|
|
94
|
+
if (!journal) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const outcome = await resumePublication(journal, {
|
|
99
|
+
journalStorage,
|
|
100
|
+
publishedStorage,
|
|
101
|
+
draftStorage,
|
|
102
|
+
rigobotToken,
|
|
103
|
+
breathecodeToken,
|
|
104
|
+
staleAfterMs,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
tally[outcome] += 1
|
|
108
|
+
if (outcome === "resumed") {
|
|
109
|
+
console.log(`[sweep] Resumed "${ref.courseSlug}" (${ref.publishId})`)
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(
|
|
113
|
+
`[sweep] Failed resuming "${ref.courseSlug}":`,
|
|
114
|
+
(error as Error).message
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(
|
|
120
|
+
`[sweep] Journals: ${tally.resumed} resumed, ${tally["skipped-recent"]} still in flight, ` +
|
|
121
|
+
`${tally["nothing-to-do"]} nothing to do, ${tally.abandoned} abandoned, ` +
|
|
122
|
+
`${tally["skipped-abandoned"]} already abandoned`
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if (process.env.DESCRIPTIONS_SWEEP_FULL === "1") {
|
|
126
|
+
await deepScan(rigobotToken, publishedStorage)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fallback for packages with no journal at all (published before this existed,
|
|
132
|
+
* or whose journal write failed). Expensive: it reads every published course,
|
|
133
|
+
* so it is opt-in rather than the default path.
|
|
134
|
+
*/
|
|
135
|
+
async function deepScan(
|
|
136
|
+
rigobotToken: string,
|
|
137
|
+
publishedStorage: ReturnType<typeof createS3DescriptionsStorageFromEnv>
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const s3 = new S3Client({
|
|
140
|
+
region: process.env.AWS_REGION || "us-east-1",
|
|
141
|
+
}) as unknown as AwsClient
|
|
142
|
+
const bucket = process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes"
|
|
143
|
+
const limit =
|
|
144
|
+
Number.parseInt(process.env.DESCRIPTIONS_SWEEP_LIMIT || "", 10) || 25
|
|
145
|
+
|
|
146
|
+
const slugs = await listPublishedSlugs(s3, bucket)
|
|
147
|
+
console.log(
|
|
148
|
+
`[sweep] Deep scan over ${slugs.length} published course(s), limit ${limit}`
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
let processed = 0
|
|
152
|
+
for (const slug of slugs) {
|
|
153
|
+
if (processed >= limit) {
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const result = await generateCourseDescriptions(publishedStorage, slug, {
|
|
159
|
+
token: rigobotToken,
|
|
160
|
+
reconcile: true,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (result.status === "skipped") {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
processed += 1
|
|
168
|
+
console.log(
|
|
169
|
+
`[sweep] "${slug}": ${result.generated} description(s), ${result.failed} failed`
|
|
170
|
+
)
|
|
171
|
+
} catch (error) {
|
|
172
|
+
processed += 1
|
|
173
|
+
console.error(`[sweep] Failed on "${slug}":`, (error as Error).message)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`[sweep] Deep scan done. ${processed} course(s) touched.`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main()
|
|
181
|
+
.then(() => process.exit(0))
|
|
182
|
+
.catch(error => {
|
|
183
|
+
console.error("[sweep] Fatal error:", error)
|
|
184
|
+
process.exit(1)
|
|
185
|
+
})
|