@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.
Files changed (85) hide show
  1. package/lib/commands/publish.js +6 -0
  2. package/lib/commands/serve.js +85 -259
  3. package/lib/models/creator.d.ts +6 -0
  4. package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
  5. package/lib/scripts/descriptionsGcsBackfill.js +141 -0
  6. package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
  7. package/lib/scripts/descriptionsS3Backfill.js +277 -0
  8. package/lib/scripts/descriptionsSweep.d.ts +1 -0
  9. package/lib/scripts/descriptionsSweep.js +142 -0
  10. package/lib/utils/api.d.ts +7 -0
  11. package/lib/utils/api.js +8 -1
  12. package/lib/utils/awsCredentials.d.ts +20 -0
  13. package/lib/utils/awsCredentials.js +43 -0
  14. package/lib/utils/creatorUtilities.js +2 -1
  15. package/lib/utils/descriptionHash.d.ts +66 -0
  16. package/lib/utils/descriptionHash.js +173 -0
  17. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  18. package/lib/utils/descriptions/backfillEvents.js +107 -0
  19. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  20. package/lib/utils/descriptions/gcsStorage.js +60 -0
  21. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +66 -0
  22. package/lib/utils/descriptions/generateCourseDescriptions.js +176 -0
  23. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  24. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  25. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  26. package/lib/utils/descriptions/publishStage.js +245 -0
  27. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  28. package/lib/utils/descriptions/resumePublication.js +128 -0
  29. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  30. package/lib/utils/descriptions/s3Storage.js +141 -0
  31. package/lib/utils/descriptions/workList.d.ts +75 -0
  32. package/lib/utils/descriptions/workList.js +177 -0
  33. package/lib/utils/gcsBucketName.d.ts +10 -0
  34. package/lib/utils/gcsBucketName.js +19 -0
  35. package/lib/utils/packageManifest.d.ts +22 -0
  36. package/lib/utils/packageManifest.js +57 -5
  37. package/lib/utils/publishEvents.d.ts +66 -0
  38. package/lib/utils/publishEvents.js +111 -0
  39. package/lib/utils/publishJournal.d.ts +119 -0
  40. package/lib/utils/publishJournal.js +275 -0
  41. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  42. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  43. package/lib/utils/repair/repairStorage.d.ts +68 -0
  44. package/lib/utils/repair/repairStorage.js +89 -0
  45. package/lib/utils/rigoActions.d.ts +44 -0
  46. package/lib/utils/rigoActions.js +75 -1
  47. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  48. package/lib/utils/s3/packageManifestBackfill.js +5 -8
  49. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  50. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  51. package/lib/utils/syllabusSync.d.ts +71 -0
  52. package/lib/utils/syllabusSync.js +273 -0
  53. package/package.json +3 -1
  54. package/src/commands/publish.ts +7 -0
  55. package/src/commands/serve.ts +144 -335
  56. package/src/models/creator.ts +9 -0
  57. package/src/scripts/README.md +244 -0
  58. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  59. package/src/scripts/descriptionsS3Backfill.ts +376 -0
  60. package/src/scripts/descriptionsSweep.ts +185 -0
  61. package/src/ui/_app/app.css +1 -1
  62. package/src/ui/_app/app.js +142 -140
  63. package/src/ui/app.tar.gz +0 -0
  64. package/src/utils/api.ts +9 -0
  65. package/src/utils/awsCredentials.ts +57 -0
  66. package/src/utils/creatorUtilities.ts +2 -1
  67. package/src/utils/descriptionHash.ts +196 -0
  68. package/src/utils/descriptions/backfillEvents.ts +152 -0
  69. package/src/utils/descriptions/gcsStorage.ts +67 -0
  70. package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
  71. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  72. package/src/utils/descriptions/publishStage.ts +394 -0
  73. package/src/utils/descriptions/resumePublication.ts +217 -0
  74. package/src/utils/descriptions/s3Storage.ts +214 -0
  75. package/src/utils/descriptions/workList.ts +283 -0
  76. package/src/utils/gcsBucketName.ts +19 -0
  77. package/src/utils/packageManifest.ts +62 -5
  78. package/src/utils/publishEvents.ts +181 -0
  79. package/src/utils/publishJournal.ts +383 -0
  80. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  81. package/src/utils/repair/repairStorage.ts +168 -0
  82. package/src/utils/rigoActions.ts +130 -0
  83. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  84. package/src/utils/s3/packageSourcesAudit.ts +311 -0
  85. 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
+ })