@learnpack/learnpack 5.0.351 → 5.0.353

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) 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 +157 -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/creatorUtilities.js +2 -1
  13. package/lib/utils/descriptionHash.d.ts +66 -0
  14. package/lib/utils/descriptionHash.js +173 -0
  15. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  16. package/lib/utils/descriptions/gcsStorage.js +60 -0
  17. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
  18. package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
  19. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  20. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  21. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  22. package/lib/utils/descriptions/publishStage.js +234 -0
  23. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  24. package/lib/utils/descriptions/resumePublication.js +113 -0
  25. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  26. package/lib/utils/descriptions/s3Storage.js +134 -0
  27. package/lib/utils/descriptions/workList.d.ts +75 -0
  28. package/lib/utils/descriptions/workList.js +177 -0
  29. package/lib/utils/gcsBucketName.d.ts +10 -0
  30. package/lib/utils/gcsBucketName.js +19 -0
  31. package/lib/utils/packageManifest.d.ts +18 -0
  32. package/lib/utils/packageManifest.js +81 -7
  33. package/lib/utils/publishEvents.d.ts +66 -0
  34. package/lib/utils/publishEvents.js +111 -0
  35. package/lib/utils/publishJournal.d.ts +119 -0
  36. package/lib/utils/publishJournal.js +275 -0
  37. package/lib/utils/rigoActions.d.ts +44 -0
  38. package/lib/utils/rigoActions.js +75 -1
  39. package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
  40. package/lib/utils/s3/packageManifestBackfill.js +487 -0
  41. package/lib/utils/syllabusSync.d.ts +71 -0
  42. package/lib/utils/syllabusSync.js +273 -0
  43. package/package.json +4 -1
  44. package/src/commands/publish.ts +7 -0
  45. package/src/commands/serve.ts +144 -335
  46. package/src/models/creator.ts +9 -0
  47. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  48. package/src/scripts/descriptionsS3Backfill.ts +208 -0
  49. package/src/scripts/descriptionsSweep.ts +185 -0
  50. package/src/ui/_app/app.css +1 -1
  51. package/src/ui/_app/app.js +143 -141
  52. package/src/ui/app.tar.gz +0 -0
  53. package/src/utils/api.ts +9 -0
  54. package/src/utils/creatorUtilities.ts +2 -1
  55. package/src/utils/descriptionHash.ts +196 -0
  56. package/src/utils/descriptions/gcsStorage.ts +67 -0
  57. package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
  58. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  59. package/src/utils/descriptions/publishStage.ts +382 -0
  60. package/src/utils/descriptions/resumePublication.ts +200 -0
  61. package/src/utils/descriptions/s3Storage.ts +206 -0
  62. package/src/utils/descriptions/workList.ts +283 -0
  63. package/src/utils/export/README.md +0 -2
  64. package/src/utils/gcsBucketName.ts +19 -0
  65. package/src/utils/packageManifest.ts +101 -10
  66. package/src/utils/publishEvents.ts +181 -0
  67. package/src/utils/publishJournal.ts +383 -0
  68. package/src/utils/rigoActions.ts +130 -0
  69. package/src/utils/s3/packageManifestBackfill.ts +776 -0
  70. package/src/utils/syllabusSync.ts +390 -0
@@ -0,0 +1,193 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ /**
3
+ * Fase 0b — GCS descriptions backfill (reuse from S3, no LLM).
4
+ *
5
+ * Seeds the GCS (draft) syllabus with descriptions already generated for the
6
+ * published S3 syllabus (Fase 0a) — a pure COPY, guarded by the content hash,
7
+ * performed by the shared mirror (`mirrorDescriptionsToDraft`). It NEVER calls
8
+ * the LLM: the ~800 GCS drafts are mostly unused, so anything not reusable from
9
+ * S3 is left for the next publication of that course (or a single-slug 0a run).
10
+ * Run AFTER 0a has completed.
11
+ *
12
+ * Batched like 0a: --limit courses per run (default 50), ordered by
13
+ * initialSyllabus modification time (newest first).
14
+ *
15
+ * Secrets/infra via env: GCP_CREDENTIALS_JSON (required), plus
16
+ * S3_PACKAGES_BUCKET / GCP_BUCKET_NAME / AWS_REGION as fallbacks for the
17
+ * corresponding flags.
18
+ *
19
+ * Per-run flags:
20
+ * --s3-bucket <name> published S3 bucket to reuse from (or env S3_PACKAGES_BUCKET)
21
+ * --gcs-bucket <name> GCS bucket (or env GCP_BUCKET_NAME, which is required)
22
+ * --region <region> AWS region (default us-east-1, or env AWS_REGION)
23
+ * --slug <slug> process a single course
24
+ * --limit <n> courses per run (default 50)
25
+ * --dry-run preview without writing
26
+ * --no-reconcile skip the additive syllabus reconciliation
27
+ *
28
+ * Examples:
29
+ * node lib/scripts/descriptionsGcsBackfill.js --limit 200
30
+ * node lib/scripts/descriptionsGcsBackfill.js --slug my-course
31
+ */
32
+ import { parseArgs } from "node:util"
33
+ import { Bucket, Storage } from "@google-cloud/storage"
34
+ import { S3Client } from "@aws-sdk/client-s3"
35
+ import { Syllabus } from "../models/creator"
36
+ import { synchronizeSyllabusWithBucket } from "../utils/syllabusSync"
37
+ import {
38
+ AwsClient,
39
+ fetchJsonObject,
40
+ } from "../utils/s3/packageManifestBackfill"
41
+ import { requireGcsBucketName } from "../utils/gcsBucketName"
42
+ import { createGcsDescriptionsStorage } from "../utils/descriptions/gcsStorage"
43
+ import { mirrorDescriptionsToDraft } from "../utils/descriptions/mirrorDescriptions"
44
+ import { syllabusKey as s3SyllabusKey } from "../utils/descriptions/s3Storage"
45
+
46
+ const GCS_SYLLABUS_PATTERN =
47
+ /^courses\/([^/]+)\/\.learn\/initialSyllabus\.json$/
48
+
49
+ /** Courses (by slug) ordered by initialSyllabus.json update time, newest first. */
50
+ async function listCoursesByRecency(bucket: Bucket): Promise<string[]> {
51
+ const [files] = await bucket.getFiles({ prefix: "courses/" })
52
+ const mtimes = new Map<string, number>()
53
+ for (const file of files) {
54
+ const match = file.name.match(GCS_SYLLABUS_PATTERN)
55
+ if (match) {
56
+ const updated = (file.metadata as { updated?: string } | undefined)
57
+ ?.updated
58
+ mtimes.set(match[1], updated ? new Date(updated).getTime() : 0)
59
+ }
60
+ }
61
+
62
+ return [...mtimes.entries()]
63
+ .sort((a, b) => b[1] - a[1])
64
+ .map(entry => entry[0])
65
+ }
66
+
67
+ async function main(): Promise<void> {
68
+ const { values } = parseArgs({
69
+ options: {
70
+ "s3-bucket": { type: "string" },
71
+ "gcs-bucket": { type: "string" },
72
+ region: { type: "string" },
73
+ slug: { type: "string" },
74
+ limit: { type: "string" },
75
+ "dry-run": { type: "boolean", default: false },
76
+ "no-reconcile": { type: "boolean", default: false },
77
+ },
78
+ })
79
+
80
+ const s3Bucket =
81
+ values["s3-bucket"] ||
82
+ process.env.S3_PACKAGES_BUCKET ||
83
+ "learnpack-paquetes"
84
+
85
+ const credentialsEnv = process.env.GCP_CREDENTIALS_JSON
86
+ if (!credentialsEnv) {
87
+ console.error("[gcs-backfill] GCP_CREDENTIALS_JSON (env) is required")
88
+ process.exit(1)
89
+ }
90
+
91
+ const bucket = new Storage({
92
+ credentials: JSON.parse(credentialsEnv),
93
+ }).bucket(values["gcs-bucket"] || requireGcsBucketName())
94
+
95
+ const s3 = new S3Client({
96
+ region: values.region || process.env.AWS_REGION || "us-east-1",
97
+ }) as unknown as AwsClient
98
+
99
+ const dryRun = values["dry-run"] ?? false
100
+ const reconcile = !(values["no-reconcile"] ?? false)
101
+ const limit = Number.parseInt(values.limit || "50", 10) || 50
102
+ const storage = createGcsDescriptionsStorage(bucket)
103
+
104
+ const singleSlug = values.slug
105
+ const slugs = singleSlug ? [singleSlug] : await listCoursesByRecency(bucket)
106
+
107
+ console.log(
108
+ `[gcs-backfill] Starting${
109
+ singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`
110
+ }${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`
111
+ )
112
+
113
+ let processed = 0
114
+ let copied = 0
115
+ let missed = 0
116
+
117
+ for (const slug of slugs) {
118
+ if (!singleSlug && processed >= limit) {
119
+ break
120
+ }
121
+
122
+ try {
123
+ const publishedSyllabus = await fetchJsonObject<Syllabus>(
124
+ s3,
125
+ s3Bucket,
126
+ s3SyllabusKey(slug)
127
+ )
128
+ if (!publishedSyllabus) {
129
+ continue
130
+ }
131
+
132
+ if (reconcile && storage.syllabusSyncStorage) {
133
+ const draft = await storage.readSyllabus(slug)
134
+ if (draft) {
135
+ try {
136
+ const sync = await synchronizeSyllabusWithBucket(
137
+ storage.syllabusSyncStorage,
138
+ slug,
139
+ draft,
140
+ { prune: false }
141
+ )
142
+ if (sync.changed && !dryRun) {
143
+ await storage.writeSyllabus(slug, sync.syllabus)
144
+ }
145
+ } catch (error) {
146
+ console.error(
147
+ `[gcs-backfill] Reconciliation failed for "${slug}":`,
148
+ (error as Error).message
149
+ )
150
+ }
151
+ }
152
+ }
153
+
154
+ const result = await mirrorDescriptionsToDraft(
155
+ storage,
156
+ slug,
157
+ publishedSyllabus,
158
+ { dryRun }
159
+ )
160
+
161
+ if (result.copied === 0 && result.missed === 0) {
162
+ continue
163
+ }
164
+
165
+ processed += 1
166
+ copied += result.copied
167
+ missed += result.missed
168
+
169
+ console.log(
170
+ `[gcs-backfill] "${slug}": copied ${result.copied}, ${
171
+ result.missed
172
+ } miss(es)${dryRun ? " (dry-run)" : ""}`
173
+ )
174
+ } catch (error) {
175
+ processed += 1
176
+ console.error(
177
+ `[gcs-backfill] Failed processing "${slug}":`,
178
+ (error as Error).message
179
+ )
180
+ }
181
+ }
182
+
183
+ console.log(
184
+ `[gcs-backfill] Done. ${processed} course(s), ${copied} description(s) copied, ${missed} miss(es).`
185
+ )
186
+ }
187
+
188
+ main()
189
+ .then(() => process.exit(0))
190
+ .catch(error => {
191
+ console.error("[gcs-backfill] Fatal error:", error)
192
+ process.exit(1)
193
+ })
@@ -0,0 +1,208 @@
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. Manifest
17
+ * projection is a separate pass here: run the package-manifest backfill
18
+ * (runBatch) afterwards, which is cheaper than re-projecting course by course.
19
+ *
20
+ * Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), plus S3_PACKAGES_BUCKET
21
+ * and AWS_REGION as fallbacks for --s3-bucket / --region.
22
+ *
23
+ * Per-run flags:
24
+ * --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
25
+ * --region <region> AWS region (default us-east-1, or env AWS_REGION)
26
+ * --slug <slug> process a single course (remediation / on-demand)
27
+ * --limit <n> courses per run (default 50)
28
+ * --target-words <n> words per description (default 25)
29
+ * --concurrency <n> completions in flight per course (default 5)
30
+ * --dry-run preview without writing/generating
31
+ * --no-reconcile skip the additive syllabus reconciliation
32
+ * --force regenerate even courses already settled at this prompt version
33
+ *
34
+ * Examples:
35
+ * node lib/scripts/descriptionsS3Backfill.js --limit 50
36
+ * node lib/scripts/descriptionsS3Backfill.js --slug my-course
37
+ * node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
38
+ */
39
+ import { parseArgs } from "node:util"
40
+ import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"
41
+ import { DESCRIPTION_TARGET_WORD_COUNT } from "../utils/packageManifest"
42
+ import { AwsClient } from "../utils/s3/packageManifestBackfill"
43
+ import {
44
+ DEFAULT_DESCRIPTIONS_CONCURRENCY,
45
+ generateCourseDescriptions,
46
+ } from "../utils/descriptions/generateCourseDescriptions"
47
+ import {
48
+ createS3DescriptionsStorage,
49
+ fetchExercises,
50
+ } from "../utils/descriptions/s3Storage"
51
+ import { isCourseSettled } from "../utils/descriptions/workList"
52
+
53
+ const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/
54
+
55
+ /** Courses (by slug) ordered by initialSyllabus.json LastModified, newest first. */
56
+ async function listCoursesByRecency(
57
+ s3: AwsClient,
58
+ bucket: string
59
+ ): Promise<string[]> {
60
+ const mtimes = new Map<string, number>()
61
+ let continuationToken: string | undefined
62
+
63
+ do {
64
+ const response = (await s3.send(
65
+ new ListObjectsV2Command({
66
+ Bucket: bucket,
67
+ ContinuationToken: continuationToken,
68
+ })
69
+ )) as {
70
+ Contents?: Array<{ Key?: string; LastModified?: Date }>;
71
+ NextContinuationToken?: string;
72
+ }
73
+
74
+ for (const object of response.Contents || []) {
75
+ const match = object.Key?.match(SYLLABUS_KEY_PATTERN)
76
+ if (match) {
77
+ mtimes.set(match[1], object.LastModified?.getTime() || 0)
78
+ }
79
+ }
80
+
81
+ continuationToken = response.NextContinuationToken
82
+ } while (continuationToken)
83
+
84
+ return [...mtimes.entries()]
85
+ .sort((a, b) => b[1] - a[1])
86
+ .map(([slug]) => slug)
87
+ }
88
+
89
+ async function main(): Promise<void> {
90
+ const { values } = parseArgs({
91
+ options: {
92
+ "s3-bucket": { type: "string" },
93
+ region: { type: "string" },
94
+ slug: { type: "string" },
95
+ limit: { type: "string" },
96
+ "target-words": { type: "string" },
97
+ concurrency: { type: "string" },
98
+ "dry-run": { type: "boolean", default: false },
99
+ "no-reconcile": { type: "boolean", default: false },
100
+ force: { type: "boolean", default: false },
101
+ },
102
+ })
103
+
104
+ const token = process.env.RIGOBOT_SYSTEM_TOKEN
105
+ if (!token) {
106
+ console.error("[s3-backfill] RIGOBOT_SYSTEM_TOKEN (env) is required")
107
+ process.exit(1)
108
+ }
109
+
110
+ const bucket =
111
+ values["s3-bucket"] ||
112
+ process.env.S3_PACKAGES_BUCKET ||
113
+ "learnpack-paquetes"
114
+
115
+ const region = values.region || process.env.AWS_REGION || "us-east-1"
116
+ const limit = Number.parseInt(values.limit || "50", 10) || 50
117
+ const dryRun = values["dry-run"] ?? false
118
+ const force = values.force ?? false
119
+
120
+ const s3 = new S3Client({ region }) as unknown as AwsClient
121
+ // Manifests are projected in a separate pass after this backfill.
122
+ const storage = createS3DescriptionsStorage(s3, bucket, {
123
+ reprojectManifest: false,
124
+ })
125
+
126
+ const singleSlug = values.slug
127
+ const slugs = singleSlug ?
128
+ [singleSlug] :
129
+ await listCoursesByRecency(s3, bucket)
130
+
131
+ console.log(
132
+ `[s3-backfill] Starting${
133
+ singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`
134
+ }${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`
135
+ )
136
+
137
+ let processed = 0
138
+ let generated = 0
139
+ let failed = 0
140
+
141
+ for (const slug of slugs) {
142
+ if (!singleSlug && processed >= limit) {
143
+ break
144
+ }
145
+
146
+ try {
147
+ // Cheap pre-filter: skip finished courses without reading their READMEs.
148
+ if (!force) {
149
+ const syllabus = await storage.readSyllabus(slug)
150
+ if (!syllabus) {
151
+ continue
152
+ }
153
+
154
+ const exercises = await fetchExercises(s3, bucket, slug)
155
+ if (exercises.length === 0 || isCourseSettled(syllabus, exercises)) {
156
+ continue
157
+ }
158
+ }
159
+
160
+ const result = await generateCourseDescriptions(storage, slug, {
161
+ token,
162
+ dryRun,
163
+ force,
164
+ reconcile: !(values["no-reconcile"] ?? false),
165
+ concurrency:
166
+ Number.parseInt(values.concurrency || "", 10) ||
167
+ DEFAULT_DESCRIPTIONS_CONCURRENCY,
168
+ targetWordCount:
169
+ Number.parseInt(values["target-words"] || "", 10) ||
170
+ DESCRIPTION_TARGET_WORD_COUNT,
171
+ })
172
+
173
+ if (result.status === "skipped") {
174
+ continue
175
+ }
176
+
177
+ processed += 1
178
+ generated += result.generated
179
+ failed += result.failed
180
+
181
+ console.log(
182
+ `[s3-backfill] "${slug}": ${result.steps} step(s), ${
183
+ result.generated
184
+ } description(s)${dryRun ? " (dry-run)" : ""}${
185
+ result.failed > 0 ? `, ${result.failed} failed` : ""
186
+ }${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`
187
+ )
188
+ } catch (error) {
189
+ processed += 1
190
+ failed += 1
191
+ console.error(
192
+ `[s3-backfill] Failed processing "${slug}":`,
193
+ (error as Error).message
194
+ )
195
+ }
196
+ }
197
+
198
+ console.log(
199
+ `[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).`
200
+ )
201
+ }
202
+
203
+ main()
204
+ .then(() => process.exit(0))
205
+ .catch(error => {
206
+ console.error("[s3-backfill] Fatal error:", error)
207
+ process.exit(1)
208
+ })
@@ -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
+ })