@learnpack/learnpack 5.0.353 → 5.0.355

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 (35) hide show
  1. package/lib/commands/serve.js +1 -1
  2. package/lib/scripts/descriptionsS3Backfill.js +142 -22
  3. package/lib/utils/awsCredentials.d.ts +20 -0
  4. package/lib/utils/awsCredentials.js +43 -0
  5. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  6. package/lib/utils/descriptions/backfillEvents.js +107 -0
  7. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
  8. package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
  9. package/lib/utils/descriptions/publishStage.js +14 -3
  10. package/lib/utils/descriptions/resumePublication.js +17 -2
  11. package/lib/utils/descriptions/s3Storage.js +13 -6
  12. package/lib/utils/packageManifest.d.ts +8 -0
  13. package/lib/utils/packageManifest.js +8 -0
  14. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  15. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  16. package/lib/utils/repair/repairStorage.d.ts +68 -0
  17. package/lib/utils/repair/repairStorage.js +89 -0
  18. package/lib/utils/s3/packageManifestBackfill.js +3 -8
  19. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  20. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  21. package/package.json +3 -1
  22. package/src/commands/serve.ts +1 -1
  23. package/src/scripts/README.md +244 -0
  24. package/src/scripts/descriptionsS3Backfill.ts +188 -20
  25. package/src/utils/awsCredentials.ts +57 -0
  26. package/src/utils/descriptions/backfillEvents.ts +152 -0
  27. package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
  28. package/src/utils/descriptions/publishStage.ts +394 -382
  29. package/src/utils/descriptions/resumePublication.ts +217 -200
  30. package/src/utils/descriptions/s3Storage.ts +214 -206
  31. package/src/utils/packageManifest.ts +8 -1
  32. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  33. package/src/utils/repair/repairStorage.ts +168 -0
  34. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  35. package/src/utils/s3/packageSourcesAudit.ts +311 -0
@@ -0,0 +1,244 @@
1
+ # Descriptions scripts (`src/scripts/`)
2
+
3
+ Ops scripts for the **step descriptions** of published courses. They are dev/ops
4
+ entry points, not oclif commands: they never appear under `learnpack <something>`
5
+ and are run with `node` against the compiled output.
6
+
7
+ | Script | What it does | Calls the LLM |
8
+ | ------------------------- | ----------------------------------------------------------------------- | ------------- |
9
+ | `descriptionsS3Backfill` | Generates descriptions for **published** courses (S3) | Yes |
10
+ | `descriptionsGcsBackfill` | Copies existing descriptions from S3 into the **draft** (GCS) | No |
11
+ | `descriptionsSweep` | Finishes publications that stalled mid-flight; runs on Heroku Scheduler | Yes |
12
+
13
+ All three delegate the per-course work to the same service,
14
+ [`generateCourseDescriptions`](../utils/descriptions/generateCourseDescriptions.ts),
15
+ which is also what the post-publish stage uses. They differ only in **which**
16
+ courses they run it on and **what else** they do around it.
17
+
18
+ ## Build and run
19
+
20
+ ```bash
21
+ npm run tsc
22
+ node lib/scripts/<script>.js [flags]
23
+ ```
24
+
25
+ They run against `lib/`, not `src/`, so recompile after editing. There is no
26
+ `--help`: the flags are documented here and in each script's header.
27
+
28
+ ## Environment
29
+
30
+ Copy from [`.env.example`](../../.env.example) into `learnpack-cli/.env`. These
31
+ scripts read `process.env` directly — they do **not** load the `.env` themselves,
32
+ so export it (`set -a; . ./.env; set +a`) or pass the variables inline.
33
+
34
+ | Variable | Needed by |
35
+ | ------------------------------------------ | ------------------------------------------------------------------------------- |
36
+ | `RIGOBOT_SYSTEM_TOKEN` | S3 backfill, sweep (generation) |
37
+ | `BREATHECODE_SYSTEM_TOKEN` | `--emit-events`, sweep (pending events) |
38
+ | `AWS_ACCESS_KEY_ID` / `_SECRET_ACCESS_KEY` | anything reading the published bucket |
39
+ | `AWS_REGION` | optional, default `us-east-1` |
40
+ | `S3_PACKAGES_BUCKET` | optional in the backfills (default `learnpack-paquetes`), required by the sweep |
41
+ | `CLOUDFRONT_DISTRIBUTION_ID` | optional; without it manifests are re-projected but not invalidated |
42
+ | `GCP_CREDENTIALS_JSON` | GCS backfill, sweep, `--mirror-draft` |
43
+ | `GCP_BUCKET_NAME` | same; no default on purpose |
44
+
45
+ Descriptions are always generated with the Rigobot **service** account, never
46
+ with a user token: they are platform enrichment, not something a creator is
47
+ billed for.
48
+
49
+ ---
50
+
51
+ ## Mode 1 — targeted run (a few slugs, on demand)
52
+
53
+ The everyday case: a course was published before descriptions existed, or one
54
+ needs remediation. Three flags make the S3 backfill reproduce per course
55
+ everything a publication does, in one process:
56
+
57
+ ```bash
58
+ node lib/scripts/descriptionsS3Backfill.js --slug my-course \
59
+ --reproject-manifest --mirror-draft --emit-events --concurrency 2
60
+ ```
61
+
62
+ | Flag | Without it |
63
+ | ---------------------- | --------------------------------------------------------------------------------------- |
64
+ | `--reproject-manifest` | `package-manifest.json` keeps the old descriptions until a later pass |
65
+ | `--mirror-draft` | the draft has no fingerprints, so the **next publication regenerates the whole course** |
66
+ | `--emit-events` | breathecode is never told the manifest changed |
67
+
68
+ All three are off by default, so the catalogue-wide run below is unaffected.
69
+
70
+ Run it once per slug. Do a `--dry-run` first if you want to see the scope —
71
+ it reports what it would generate and mirror without writing anything.
72
+
73
+ **During high-traffic hours**, lower `--concurrency` (default 5 completions in
74
+ flight per course). A single completion takes ~15s.
75
+
76
+ Notes:
77
+
78
+ - `--emit-events` **requires** `--reproject-manifest`. The event reports a
79
+ manifest update, so emitting it without projecting would state something
80
+ untrue. It is required rather than implied on purpose: writing to S3 and the
81
+ CDN as a side effect of another flag is the kind of surprise a backfill should
82
+ not have.
83
+ - `--emit-events` is rejected together with `--dry-run`: there is no dry run of
84
+ an event breathecode already received.
85
+ - `--mirror-draft` is independent of the other two — it touches the draft, not
86
+ the manifest.
87
+ - Prefer `--slug` over `--limit 4` when you care about _which_ courses are
88
+ touched: `--limit` counts **processed** courses (already-settled ones are
89
+ skipped without counting) and walks the catalogue by recency.
90
+
91
+ ### What each course does, in order
92
+
93
+ 1. Generate the missing descriptions and write them into the published
94
+ `initialSyllabus.json` (S3).
95
+ 2. `--reproject-manifest`: rebuild `package-manifest.json` from that syllabus
96
+ (the same `processPackage` the manifest backfill runs) and invalidate its
97
+ CloudFront path.
98
+ 3. `--mirror-draft`: copy the descriptions back into the GCS draft, guarded by
99
+ the README content hash — anything the teacher edited since publishing is
100
+ counted as `missed` and waits for the next publication.
101
+ 4. `--emit-events`: send `package_manifest_updated` to breathecode.
102
+
103
+ A failure in step 3 does not change the event status: the published package and
104
+ its manifest are already correct, only the draft lags behind. A failure in step 2
105
+ **does** — see below.
106
+
107
+ ---
108
+
109
+ ## Mode 2 — catalogue backfill (fases 0a / 0b)
110
+
111
+ The one-off sweep over the ~400 published courses and ~800 drafts. Here the
112
+ defaults are the right ones: manifests are cheaper to project in a single later
113
+ pass than course by course, and the draft is seeded by copy rather than by
114
+ re-generating.
115
+
116
+ ```bash
117
+ # Fase 0a — descriptions for the published catalogue, 50 courses per run.
118
+ # Re-run until it stops reporting processed courses.
119
+ node lib/scripts/descriptionsS3Backfill.js --limit 50
120
+
121
+ # Then project every manifest in one pass (cheaper than per course).
122
+ npm run backfill:package-manifest
123
+
124
+ # Fase 0b — seed the drafts by copying from S3. Never calls the LLM.
125
+ node lib/scripts/descriptionsGcsBackfill.js --limit 200
126
+ ```
127
+
128
+ Order matters: 0b reads the **published** syllabus, so 0a must have written it
129
+ first. Whatever 0b cannot reuse (drafts edited since their last publication) is
130
+ left for the next publication of that course, or for a single-slug 0a run.
131
+
132
+ No events are emitted in this mode. If breathecode needs to be told about the
133
+ catalogue, that is a separate decision — adding `--emit-events` to a 400-course
134
+ run would deliver 400 events.
135
+
136
+ ---
137
+
138
+ ## Flag reference
139
+
140
+ ### `descriptionsS3Backfill`
141
+
142
+ | Flag | Default | Description |
143
+ | ---------------------- | -------------------- | ----------------------------------------------------------------- |
144
+ | `--slug <slug>` | — | single course (remediation / on-demand) |
145
+ | `--limit <n>` | 50 | courses **processed** per run, ordered by syllabus recency |
146
+ | `--s3-bucket <name>` | `learnpack-paquetes` | published bucket |
147
+ | `--gcs-bucket <name>` | `GCP_BUCKET_NAME` | draft bucket, for `--mirror-draft` |
148
+ | `--region <region>` | `us-east-1` | AWS region |
149
+ | `--concurrency <n>` | 5 | completions in flight per course |
150
+ | `--target-words <n>` | 25 | words per description |
151
+ | `--dry-run` | off | preview; no writes, no completions |
152
+ | `--force` | off | regenerate courses already settled at the current prompt version |
153
+ | `--no-reconcile` | off | skip the additive syllabus reconciliation |
154
+ | `--reproject-manifest` | off | rebuild + invalidate `package-manifest.json` per course |
155
+ | `--mirror-draft` | off | copy the descriptions into the GCS draft |
156
+ | `--emit-events` | off | send `package_manifest_updated` (requires `--reproject-manifest`) |
157
+
158
+ ### `descriptionsGcsBackfill`
159
+
160
+ | Flag | Default | Description |
161
+ | --------------------- | -------------------- | -------------------------------- |
162
+ | `--slug <slug>` | — | single course |
163
+ | `--limit <n>` | 50 | courses per run |
164
+ | `--s3-bucket <name>` | `learnpack-paquetes` | source (published) bucket |
165
+ | `--gcs-bucket <name>` | `GCP_BUCKET_NAME` | target (draft) bucket |
166
+ | `--region <region>` | `us-east-1` | AWS region |
167
+ | `--dry-run` | off | preview without writing |
168
+ | `--no-reconcile` | off | skip the syllabus reconciliation |
169
+
170
+ ### `descriptionsSweep`
171
+
172
+ No flags — it is a scheduled job, configured by env:
173
+
174
+ ```bash
175
+ node lib/scripts/descriptionsSweep.js
176
+ ```
177
+
178
+ Two sources of work:
179
+
180
+ 1. **Publish journals** (`publish-journal/` in GCS), the primary one. Every
181
+ publication that stalled is resumed: missing descriptions generated, draft
182
+ mirrored, and the owed `package_manifest_updated` emitted. Journals are
183
+ dropped once complete and abandoned after a few attempts.
184
+ 2. **A deep scan** of published courses (`DESCRIPTIONS_SWEEP_FULL=1`), for
185
+ packages that never got a journal at all. Expensive — it reads every course —
186
+ and off by default. It re-projects manifests but does **not** emit events.
187
+
188
+ Tuning: `DESCRIPTIONS_SWEEP_LIMIT` (courses per deep scan, default 25),
189
+ `DESCRIPTION_STALE_AFTER_MS` (how long a publication may look stalled before it
190
+ is resumed, default 15 min).
191
+
192
+ ---
193
+
194
+ ## The `package_manifest_updated` event
195
+
196
+ Emitted by `--emit-events`, and by the sweep for the publications it finishes.
197
+ Built in [`publishEvents.ts`](../utils/publishEvents.ts), sent to the breathecode
198
+ telemetry endpoint with `BREATHECODE_SYSTEM_TOKEN`.
199
+
200
+ Breathecode processes it **independently of `package_published`**, so a backfill
201
+ event with no preceding publication is fine.
202
+
203
+ **`publish_id` in a backfill** is `backfill-{runId}-{slug}`:
204
+
205
+ - the `backfill-` prefix makes the origin readable in the webhook log and
206
+ discriminable in code (`isBackfillPublishId`); real publish ids are bare
207
+ uuids, so the two spaces cannot collide;
208
+ - `{slug}` keeps it unique per event;
209
+ - `{runId}` is one uuid per **invocation**, shared by every course it touches, so
210
+ "every event from the run I fired at 15:40" is a single substring query. It is
211
+ printed at start and in the closing summary.
212
+
213
+ Note that running four separate `--slug` commands produces four run ids. Use
214
+ `--limit` if you want one id for the batch — at the cost of not choosing the
215
+ slugs.
216
+
217
+ **Status is tied to the real outcome**, not to the script finishing:
218
+
219
+ | Outcome | `status` |
220
+ | ---------------------------------------- | --------- |
221
+ | Descriptions written, manifest projected | `success` |
222
+ | Generation failed outright | `failed` |
223
+ | Manifest projection failed | `failed` |
224
+ | Course unchanged (`skipped`) | no event |
225
+ | Course threw | no event |
226
+
227
+ A failed manifest projection is not a failed run — the syllabus, which is the
228
+ source of truth, is already saved — but this event reports the manifest, so it
229
+ cannot claim success over one that was never rewritten. That is what
230
+ `GenerateCourseDescriptionsResult.manifestProjected` exists for.
231
+
232
+ The last row is deliberate: unlike a publication, a backfill never _announced_ an
233
+ event, so it owes none. The course is simply re-run.
234
+
235
+ ---
236
+
237
+ ## Related
238
+
239
+ - [`../utils/descriptions/`](../utils/descriptions/) — the shared service, the
240
+ mirror, the post-publish stage and the resume logic
241
+ - [`../../scripts/README.md`](../../scripts/README.md) — the package-manifest S3
242
+ backfill (`npm run backfill:package-manifest`)
243
+ - [`../utils/publishJournal.ts`](../utils/publishJournal.ts) — why the sweep can
244
+ know an event is owed
@@ -13,31 +13,52 @@
13
13
  * advance through the catalogue.
14
14
  * - Or target a single course with --slug (remediation / on-demand).
15
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.
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.
19
20
  *
20
- * Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), plus S3_PACKAGES_BUCKET
21
- * and AWS_REGION as fallbacks for --s3-bucket / --region.
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.
22
35
  *
23
36
  * 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
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)
33
50
  *
34
51
  * Examples:
35
52
  * node lib/scripts/descriptionsS3Backfill.js --limit 50
36
53
  * node lib/scripts/descriptionsS3Backfill.js --slug my-course
37
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
38
57
  */
39
58
  import { parseArgs } from "node:util"
40
59
  import { ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"
60
+ import { CloudFrontClient } from "@aws-sdk/client-cloudfront"
61
+ import { Storage } from "@google-cloud/storage"
41
62
  import { DESCRIPTION_TARGET_WORD_COUNT } from "../utils/packageManifest"
42
63
  import { AwsClient } from "../utils/s3/packageManifestBackfill"
43
64
  import {
@@ -47,7 +68,16 @@ import {
47
68
  import {
48
69
  createS3DescriptionsStorage,
49
70
  fetchExercises,
71
+ S3DescriptionsStorageOptions,
50
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"
51
81
  import { isCourseSettled } from "../utils/descriptions/workList"
52
82
 
53
83
  const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/
@@ -86,10 +116,33 @@ async function listCoursesByRecency(
86
116
  .map(([slug]) => slug)
87
117
  }
88
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
+
89
141
  async function main(): Promise<void> {
90
142
  const { values } = parseArgs({
91
143
  options: {
92
144
  "s3-bucket": { type: "string" },
145
+ "gcs-bucket": { type: "string" },
93
146
  region: { type: "string" },
94
147
  slug: { type: "string" },
95
148
  limit: { type: "string" },
@@ -98,6 +151,9 @@ async function main(): Promise<void> {
98
151
  "dry-run": { type: "boolean", default: false },
99
152
  "no-reconcile": { type: "boolean", default: false },
100
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 },
101
157
  },
102
158
  })
103
159
 
@@ -116,12 +172,69 @@ async function main(): Promise<void> {
116
172
  const limit = Number.parseInt(values.limit || "50", 10) || 50
117
173
  const dryRun = values["dry-run"] ?? false
118
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
+ }
119
225
 
120
226
  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
- })
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()
125
238
 
126
239
  const singleSlug = values.slug
127
240
  const slugs = singleSlug ?
@@ -131,12 +244,22 @@ async function main(): Promise<void> {
131
244
  console.log(
132
245
  `[s3-backfill] Starting${
133
246
  singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`
134
- }${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)`
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
+ }`
135
252
  )
136
253
 
137
254
  let processed = 0
138
255
  let generated = 0
139
256
  let failed = 0
257
+ let mirrored = 0
258
+ const events: Record<BackfillEventOutcome, number> = {
259
+ skipped: 0,
260
+ delivered: 0,
261
+ failed: 0,
262
+ }
140
263
 
141
264
  for (const slug of slugs) {
142
265
  if (!singleSlug && processed >= limit) {
@@ -185,9 +308,50 @@ async function main(): Promise<void> {
185
308
  result.failed > 0 ? `, ${result.failed} failed` : ""
186
309
  }${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`
187
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
+ }
188
350
  } catch (error) {
189
351
  processed += 1
190
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.
191
355
  console.error(
192
356
  `[s3-backfill] Failed processing "${slug}":`,
193
357
  (error as Error).message
@@ -196,7 +360,11 @@ async function main(): Promise<void> {
196
360
  }
197
361
 
198
362
  console.log(
199
- `[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).`
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
+ "")
200
368
  )
201
369
  }
202
370
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * AWS credentials for the published bucket, required rather than implicit.
3
+ *
4
+ * Building an `S3Client` with no credentials succeeds: the SDK defers to its
5
+ * provider chain and only fails at the first request, with
6
+ * "Could not load credentials from any providers". Inside a background job that
7
+ * surfaces minutes later, detached from the cause. Validating up front turns it
8
+ * into an obvious misconfiguration.
9
+ *
10
+ * Note this deliberately rejects the empty-string fallback pattern
11
+ * (`process.env.X || ""`): passing empty credentials defeats the provider chain
12
+ * instead of failing, which is how the opaque error appeared in the first place.
13
+ */
14
+
15
+ export type AwsCredentials = {
16
+ accessKeyId: string;
17
+ secretAccessKey: string;
18
+ };
19
+
20
+ export function requireAwsCredentials(): AwsCredentials {
21
+ const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim()
22
+ const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim()
23
+
24
+ const missing: string[] = []
25
+ if (!accessKeyId) {
26
+ missing.push("AWS_ACCESS_KEY_ID")
27
+ }
28
+
29
+ if (!secretAccessKey) {
30
+ missing.push("AWS_SECRET_ACCESS_KEY")
31
+ }
32
+
33
+ if (missing.length > 0) {
34
+ throw new Error(
35
+ `${missing.join(" and ")} (env) ${
36
+ missing.length > 1 ? "are" : "is"
37
+ } required to reach the published package bucket`
38
+ )
39
+ }
40
+
41
+ return { accessKeyId, secretAccessKey }
42
+ }
43
+
44
+ export function requireS3PackagesBucket(): string {
45
+ const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim()
46
+ if (!bucket) {
47
+ throw new Error(
48
+ "S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages"
49
+ )
50
+ }
51
+
52
+ return bucket
53
+ }
54
+
55
+ export function awsRegion(): string {
56
+ return (process.env.AWS_REGION || "").trim() || "us-east-1"
57
+ }