@learnpack/learnpack 5.0.353 → 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 (33) hide show
  1. package/lib/scripts/descriptionsS3Backfill.js +142 -22
  2. package/lib/utils/awsCredentials.d.ts +20 -0
  3. package/lib/utils/awsCredentials.js +43 -0
  4. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  5. package/lib/utils/descriptions/backfillEvents.js +107 -0
  6. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
  7. package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
  8. package/lib/utils/descriptions/publishStage.js +14 -3
  9. package/lib/utils/descriptions/resumePublication.js +17 -2
  10. package/lib/utils/descriptions/s3Storage.js +13 -6
  11. package/lib/utils/packageManifest.d.ts +8 -0
  12. package/lib/utils/packageManifest.js +8 -0
  13. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  14. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  15. package/lib/utils/repair/repairStorage.d.ts +68 -0
  16. package/lib/utils/repair/repairStorage.js +89 -0
  17. package/lib/utils/s3/packageManifestBackfill.js +3 -8
  18. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  19. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  20. package/package.json +3 -1
  21. package/src/scripts/README.md +244 -0
  22. package/src/scripts/descriptionsS3Backfill.ts +188 -20
  23. package/src/utils/awsCredentials.ts +57 -0
  24. package/src/utils/descriptions/backfillEvents.ts +152 -0
  25. package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
  26. package/src/utils/descriptions/publishStage.ts +394 -382
  27. package/src/utils/descriptions/resumePublication.ts +217 -200
  28. package/src/utils/descriptions/s3Storage.ts +214 -206
  29. package/src/utils/packageManifest.ts +8 -1
  30. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  31. package/src/utils/repair/repairStorage.ts +168 -0
  32. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  33. package/src/utils/s3/packageSourcesAudit.ts +311 -0
@@ -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
+ }
@@ -0,0 +1,152 @@
1
+ import { v4 as uuidv4 } from "uuid"
2
+ import { PackageManifest } from "../packageManifest"
3
+ import {
4
+ buildPackageManifestUpdatedEvent,
5
+ fetchPackageInfo,
6
+ sendPublishEvent,
7
+ } from "../publishEvents"
8
+ import {
9
+ CourseDescriptionsStorage,
10
+ GenerateCourseDescriptionsResult,
11
+ } from "./generateCourseDescriptions"
12
+
13
+ /**
14
+ * `package_manifest_updated` for courses reconciled by the backfill.
15
+ *
16
+ * The backfill regenerates descriptions of packages that are already published,
17
+ * so breathecode needs the same notification a publication sends — but there is
18
+ * no publication here: no `package_published` precedes this event, and no
19
+ * journal tracks it (the sweep resumes publications, and a backfill is simply
20
+ * re-run instead).
21
+ *
22
+ * That is what makes `publish_id` awkward. The field exists to correlate the two
23
+ * events of one publication, a pair this event has no half of. Since it is
24
+ * required, the value has to be something that cannot be mistaken for a real
25
+ * publication: a bare uuid — the obvious choice — would be exactly that mistake,
26
+ * and if breathecode ever starts pairing events it would search forever for a
27
+ * `package_published` that was never sent.
28
+ *
29
+ * Hence `backfill-{runId}-{slug}`:
30
+ *
31
+ * - the `backfill-` prefix makes the origin readable in the webhook log and
32
+ * discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
33
+ * `createJournal`, so the two spaces cannot collide;
34
+ * - `{slug}` keeps it unique per event, which a per-run id alone would not be;
35
+ * - a `{runId}` shared by every course of one invocation makes "every event from
36
+ * the run I fired at 15:40" a single substring query, which is the question
37
+ * you actually ask when the catalogue is backfilled in small batches.
38
+ *
39
+ * Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
40
+ * event contract untouched: breathecode ignores `publish_id` today, so this
41
+ * costs them nothing and needs no coordination. If they ever start consuming the
42
+ * provenance, it can be promoted to a field of its own.
43
+ */
44
+
45
+ export const BACKFILL_PUBLISH_ID_PREFIX = "backfill-"
46
+
47
+ /** One per script invocation, shared by every course it touches. */
48
+ export function newBackfillRunId(): string {
49
+ return uuidv4()
50
+ }
51
+
52
+ export function backfillPublishId(runId: string, courseSlug: string): string {
53
+ return `${BACKFILL_PUBLISH_ID_PREFIX}${runId}-${courseSlug}`
54
+ }
55
+
56
+ export function isBackfillPublishId(publishId: string): boolean {
57
+ return publishId.startsWith(BACKFILL_PUBLISH_ID_PREFIX)
58
+ }
59
+
60
+ /**
61
+ * `skipped` means the event was never attempted, which is not a failure: a
62
+ * course the backfill did not change has nothing to announce.
63
+ */
64
+ export type BackfillEventOutcome = "skipped" | "delivered" | "failed";
65
+
66
+ export type EmitBackfillManifestEventParams = {
67
+ courseSlug: string;
68
+ runId: string;
69
+ result: GenerateCourseDescriptionsResult;
70
+ storage: CourseDescriptionsStorage;
71
+ rigobotToken: string;
72
+ breathecodeToken: string;
73
+ /** Injection points for tests. */
74
+ emit?: typeof sendPublishEvent;
75
+ readPackageInfo?: typeof fetchPackageInfo;
76
+ };
77
+
78
+ /**
79
+ * Announce one reconciled course. Never throws: the descriptions and the
80
+ * manifest are already written by the time this runs, and losing the
81
+ * notification must not turn a successful course into a failed one.
82
+ */
83
+ export async function emitBackfillManifestEvent(
84
+ params: EmitBackfillManifestEventParams
85
+ ): Promise<BackfillEventOutcome> {
86
+ const { courseSlug, result } = params
87
+
88
+ if (result.status === "skipped") {
89
+ return "skipped"
90
+ }
91
+
92
+ const emit = params.emit ?? sendPublishEvent
93
+ const readPackageInfo = params.readPackageInfo ?? fetchPackageInfo
94
+
95
+ // Enriching the payload must never prevent the delivery: both the manifest and
96
+ // the package info are optional in the contract, and an event with less
97
+ // context beats an event that never arrives.
98
+ let manifest: PackageManifest | null = null
99
+ try {
100
+ if (params.storage.readManifest) {
101
+ manifest = await params.storage.readManifest(courseSlug)
102
+ }
103
+ } catch (error) {
104
+ console.error(
105
+ `[backfill-events] Could not read the manifest of "${courseSlug}", reporting without it:`,
106
+ (error as Error).message
107
+ )
108
+ }
109
+
110
+ let packageInfo: Record<string, unknown> | null = null
111
+ try {
112
+ packageInfo = await readPackageInfo(courseSlug, params.rigobotToken)
113
+ } catch (error) {
114
+ console.error(
115
+ `[backfill-events] Could not read the package "${courseSlug}", reporting without it:`,
116
+ (error as Error).message
117
+ )
118
+ }
119
+
120
+ try {
121
+ const delivered = await emit(
122
+ buildPackageManifestUpdatedEvent(
123
+ {
124
+ publishId: backfillPublishId(params.runId, courseSlug),
125
+ courseSlug,
126
+ packageInfo,
127
+ manifest,
128
+ },
129
+ // A failed projection does not fail the run — the syllabus is saved
130
+ // either way — but this event reports the manifest, so it cannot claim
131
+ // success over one that was never rewritten.
132
+ result.status === "failed" || result.manifestProjected === false ?
133
+ "failed" :
134
+ "success",
135
+ {
136
+ generated: result.generated,
137
+ failed: result.failed,
138
+ missing: result.missing,
139
+ }
140
+ ),
141
+ params.breathecodeToken
142
+ )
143
+
144
+ return delivered ? "delivered" : "failed"
145
+ } catch (error) {
146
+ console.error(
147
+ `[backfill-events] Could not deliver the manifest event for "${courseSlug}":`,
148
+ (error as Error).message
149
+ )
150
+ return "failed"
151
+ }
152
+ }
@@ -79,6 +79,13 @@ export type GenerateCourseDescriptionsResult = {
79
79
  errors: string[];
80
80
  /** Completion durations, to watch how close we run to the request limits. */
81
81
  durationsSeconds: number[];
82
+ /**
83
+ * Whether `package-manifest.json` was re-projected; null when the storage does
84
+ * not project at all. A failed projection is not a failed run — the syllabus,
85
+ * which is the source of truth, is already saved — but callers that announce
86
+ * the manifest downstream must not report success on it.
87
+ */
88
+ manifestProjected: boolean | null;
82
89
  };
83
90
 
84
91
  function emptyResult(
@@ -92,6 +99,7 @@ function emptyResult(
92
99
  missing: 0,
93
100
  errors: [],
94
101
  durationsSeconds: [],
102
+ manifestProjected: null,
95
103
  }
96
104
  }
97
105
 
@@ -288,9 +296,11 @@ export async function generateCourseDescriptions(
288
296
  if (storage.reprojectManifest) {
289
297
  try {
290
298
  await storage.reprojectManifest(courseSlug)
299
+ result.manifestProjected = true
291
300
  } catch (error) {
292
301
  // The syllabus (the source of truth) is already saved; a failed
293
302
  // projection is recoverable and must not lose the generated text.
303
+ result.manifestProjected = false
294
304
  result.errors.push(
295
305
  `manifest projection failed: ${(error as Error).message}`
296
306
  )