@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,277 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable no-await-in-loop */
4
+ /**
5
+ * Fase 0a — S3 descriptions backfill (step 2 of the S3 pipeline).
6
+ *
7
+ * One-off fill of the published catalogue (~400+ courses). The per-course work
8
+ * is the shared description service (`generateCourseDescriptions`), the same one
9
+ * the post-publish stage and the sweep use; this script only decides WHICH
10
+ * courses to run it on and in what batches.
11
+ *
12
+ * - Processes at most --limit courses per run (default 50), ordered by the
13
+ * initialSyllabus.json modification time (most recently edited first),
14
+ * skipping courses already settled at the current prompt version. Re-run to
15
+ * advance through the catalogue.
16
+ * - Or target a single course with --slug (remediation / on-demand).
17
+ *
18
+ * Writes descriptions into the published initialSyllabus.json. By default the
19
+ * manifest projection is a separate pass: run the package-manifest backfill
20
+ * (runBatch) afterwards, which over the whole catalogue is cheaper than
21
+ * re-projecting course by course.
22
+ *
23
+ * That default inverts for small, targeted runs, where three flags reproduce per
24
+ * course what a publication does, in one process instead of three:
25
+ *
26
+ * --reproject-manifest projects each course as it is described (the same
27
+ * `processPackage` the separate pass runs, plus a
28
+ * CloudFront invalidation);
29
+ * --mirror-draft copies the new descriptions back into the GCS draft,
30
+ * so the next publication does not regenerate them;
31
+ * --emit-events announces the result to breathecode.
32
+ *
33
+ * Secrets/infra via env: RIGOBOT_SYSTEM_TOKEN (required), BREATHECODE_SYSTEM_TOKEN
34
+ * (required by --emit-events), GCP_CREDENTIALS_JSON and GCP_BUCKET_NAME (required
35
+ * by --mirror-draft), plus S3_PACKAGES_BUCKET, AWS_REGION and
36
+ * CLOUDFRONT_DISTRIBUTION_ID as fallbacks/optionals.
37
+ *
38
+ * Per-run flags:
39
+ * --s3-bucket <name> S3 bucket (or env S3_PACKAGES_BUCKET, default learnpack-paquetes)
40
+ * --gcs-bucket <name> GCS draft bucket for --mirror-draft (or env GCP_BUCKET_NAME)
41
+ * --region <region> AWS region (default us-east-1, or env AWS_REGION)
42
+ * --slug <slug> process a single course (remediation / on-demand)
43
+ * --limit <n> courses per run (default 50)
44
+ * --target-words <n> words per description (default 25)
45
+ * --concurrency <n> completions in flight per course (default 5)
46
+ * --dry-run preview without writing/generating
47
+ * --no-reconcile skip the additive syllabus reconciliation
48
+ * --force regenerate even courses already settled at this prompt version
49
+ * --reproject-manifest re-project package-manifest.json per course, and invalidate it
50
+ * --mirror-draft mirror the descriptions back into the GCS draft
51
+ * --emit-events send package_manifest_updated per course (implies --reproject-manifest)
52
+ *
53
+ * Examples:
54
+ * node lib/scripts/descriptionsS3Backfill.js --limit 50
55
+ * node lib/scripts/descriptionsS3Backfill.js --slug my-course
56
+ * node lib/scripts/descriptionsS3Backfill.js --dry-run --limit 10
57
+ * node lib/scripts/descriptionsS3Backfill.js --slug my-course \
58
+ * --reproject-manifest --mirror-draft --emit-events
59
+ */
60
+ const node_util_1 = require("node:util");
61
+ const client_s3_1 = require("@aws-sdk/client-s3");
62
+ const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
63
+ const storage_1 = require("@google-cloud/storage");
64
+ const packageManifest_1 = require("../utils/packageManifest");
65
+ const generateCourseDescriptions_1 = require("../utils/descriptions/generateCourseDescriptions");
66
+ const s3Storage_1 = require("../utils/descriptions/s3Storage");
67
+ const backfillEvents_1 = require("../utils/descriptions/backfillEvents");
68
+ const gcsStorage_1 = require("../utils/descriptions/gcsStorage");
69
+ const mirrorDescriptions_1 = require("../utils/descriptions/mirrorDescriptions");
70
+ const gcsBucketName_1 = require("../utils/gcsBucketName");
71
+ const workList_1 = require("../utils/descriptions/workList");
72
+ const SYLLABUS_KEY_PATTERN = /^([^/]+)\/\.learn\/initialSyllabus\.json$/;
73
+ /** Courses (by slug) ordered by initialSyllabus.json LastModified, newest first. */
74
+ async function listCoursesByRecency(s3, bucket) {
75
+ var _a, _b;
76
+ const mtimes = new Map();
77
+ let continuationToken;
78
+ do {
79
+ const response = (await s3.send(new client_s3_1.ListObjectsV2Command({
80
+ Bucket: bucket,
81
+ ContinuationToken: continuationToken,
82
+ })));
83
+ for (const object of response.Contents || []) {
84
+ const match = (_a = object.Key) === null || _a === void 0 ? void 0 : _a.match(SYLLABUS_KEY_PATTERN);
85
+ if (match) {
86
+ mtimes.set(match[1], ((_b = object.LastModified) === null || _b === void 0 ? void 0 : _b.getTime()) || 0);
87
+ }
88
+ }
89
+ continuationToken = response.NextContinuationToken;
90
+ } while (continuationToken);
91
+ return [...mtimes.entries()]
92
+ .sort((a, b) => b[1] - a[1])
93
+ .map(([slug]) => slug);
94
+ }
95
+ /**
96
+ * CDN invalidation for the re-projected manifests. Optional: a stale edge cache
97
+ * expires on its own, so a missing distribution id degrades the run instead of
98
+ * stopping it.
99
+ */
100
+ function cloudFrontFor(region) {
101
+ const distributionId = (process.env.CLOUDFRONT_DISTRIBUTION_ID || "").trim();
102
+ if (!distributionId) {
103
+ console.warn("[s3-backfill] CLOUDFRONT_DISTRIBUTION_ID is not set: manifests will be re-projected but not invalidated");
104
+ return undefined;
105
+ }
106
+ return {
107
+ client: new client_cloudfront_1.CloudFrontClient({ region }),
108
+ distributionId,
109
+ };
110
+ }
111
+ async function main() {
112
+ var _a, _b, _c, _d, _e, _f;
113
+ const { values } = (0, node_util_1.parseArgs)({
114
+ options: {
115
+ "s3-bucket": { type: "string" },
116
+ "gcs-bucket": { type: "string" },
117
+ region: { type: "string" },
118
+ slug: { type: "string" },
119
+ limit: { type: "string" },
120
+ "target-words": { type: "string" },
121
+ concurrency: { type: "string" },
122
+ "dry-run": { type: "boolean", default: false },
123
+ "no-reconcile": { type: "boolean", default: false },
124
+ force: { type: "boolean", default: false },
125
+ "reproject-manifest": { type: "boolean", default: false },
126
+ "mirror-draft": { type: "boolean", default: false },
127
+ "emit-events": { type: "boolean", default: false },
128
+ },
129
+ });
130
+ const token = process.env.RIGOBOT_SYSTEM_TOKEN;
131
+ if (!token) {
132
+ console.error("[s3-backfill] RIGOBOT_SYSTEM_TOKEN (env) is required");
133
+ process.exit(1);
134
+ }
135
+ const bucket = values["s3-bucket"] ||
136
+ process.env.S3_PACKAGES_BUCKET ||
137
+ "learnpack-paquetes";
138
+ const region = values.region || process.env.AWS_REGION || "us-east-1";
139
+ const limit = Number.parseInt(values.limit || "50", 10) || 50;
140
+ const dryRun = (_a = values["dry-run"]) !== null && _a !== void 0 ? _a : false;
141
+ const force = (_b = values.force) !== null && _b !== void 0 ? _b : false;
142
+ const emitEvents = (_c = values["emit-events"]) !== null && _c !== void 0 ? _c : false;
143
+ const reprojectManifest = (_d = values["reproject-manifest"]) !== null && _d !== void 0 ? _d : false;
144
+ const mirrorDraft = (_e = values["mirror-draft"]) !== null && _e !== void 0 ? _e : false;
145
+ // The event announces that the manifest changed, so emitting it without
146
+ // projecting the manifest would state something untrue. Required rather than
147
+ // implied: turning on a write to S3 and the CDN as a side effect of another
148
+ // flag is exactly the kind of surprise a backfill should not have.
149
+ if (emitEvents && !reprojectManifest) {
150
+ console.error("[s3-backfill] --emit-events requires --reproject-manifest: the event " +
151
+ "reports a manifest update that would not have happened");
152
+ process.exit(1);
153
+ }
154
+ const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN;
155
+ if (emitEvents && !breathecodeToken) {
156
+ console.error("[s3-backfill] BREATHECODE_SYSTEM_TOKEN (env) is required by --emit-events");
157
+ process.exit(1);
158
+ }
159
+ if (emitEvents && dryRun) {
160
+ console.error("[s3-backfill] --emit-events cannot be combined with --dry-run: there is " +
161
+ "no dry run of an event that breathecode already received");
162
+ process.exit(1);
163
+ }
164
+ // Built up front, not on first use: a misconfigured draft bucket must fail at
165
+ // boot, not after the first course has already been billed to Rigobot.
166
+ let draftStorage;
167
+ if (mirrorDraft) {
168
+ const credentialsEnv = process.env.GCP_CREDENTIALS_JSON;
169
+ if (!credentialsEnv) {
170
+ console.error("[s3-backfill] GCP_CREDENTIALS_JSON (env) is required by --mirror-draft");
171
+ process.exit(1);
172
+ }
173
+ draftStorage = (0, gcsStorage_1.createGcsDescriptionsStorage)(new storage_1.Storage({ credentials: JSON.parse(credentialsEnv) }).bucket(values["gcs-bucket"] || (0, gcsBucketName_1.requireGcsBucketName)()));
174
+ }
175
+ const s3 = new client_s3_1.S3Client({ region });
176
+ const storage = (0, s3Storage_1.createS3DescriptionsStorage)(s3, bucket,
177
+ // Off by default: over the whole catalogue manifests are cheaper to project
178
+ // in a single later pass than course by course.
179
+ reprojectManifest ?
180
+ { cloudFront: cloudFrontFor(region) } :
181
+ { reprojectManifest: false });
182
+ const runId = (0, backfillEvents_1.newBackfillRunId)();
183
+ const singleSlug = values.slug;
184
+ const slugs = singleSlug ?
185
+ [singleSlug] :
186
+ await listCoursesByRecency(s3, bucket);
187
+ console.log(`[s3-backfill] Starting${singleSlug ? ` (slug=${singleSlug})` : ` (limit=${limit})`}${dryRun ? " (dry-run)" : ""} over ${slugs.length} candidate course(s)${reprojectManifest ? ", re-projecting manifests" : ""}${mirrorDraft ? ", mirroring into the draft" : ""}${emitEvents ? `, run ${runId}` : ""}`);
188
+ let processed = 0;
189
+ let generated = 0;
190
+ let failed = 0;
191
+ let mirrored = 0;
192
+ const events = {
193
+ skipped: 0,
194
+ delivered: 0,
195
+ failed: 0,
196
+ };
197
+ for (const slug of slugs) {
198
+ if (!singleSlug && processed >= limit) {
199
+ break;
200
+ }
201
+ try {
202
+ // Cheap pre-filter: skip finished courses without reading their READMEs.
203
+ if (!force) {
204
+ const syllabus = await storage.readSyllabus(slug);
205
+ if (!syllabus) {
206
+ continue;
207
+ }
208
+ const exercises = await (0, s3Storage_1.fetchExercises)(s3, bucket, slug);
209
+ if (exercises.length === 0 || (0, workList_1.isCourseSettled)(syllabus, exercises)) {
210
+ continue;
211
+ }
212
+ }
213
+ const result = await (0, generateCourseDescriptions_1.generateCourseDescriptions)(storage, slug, {
214
+ token,
215
+ dryRun,
216
+ force,
217
+ reconcile: !((_f = values["no-reconcile"]) !== null && _f !== void 0 ? _f : false),
218
+ concurrency: Number.parseInt(values.concurrency || "", 10) ||
219
+ generateCourseDescriptions_1.DEFAULT_DESCRIPTIONS_CONCURRENCY,
220
+ targetWordCount: Number.parseInt(values["target-words"] || "", 10) ||
221
+ packageManifest_1.DESCRIPTION_TARGET_WORD_COUNT,
222
+ });
223
+ if (result.status === "skipped") {
224
+ continue;
225
+ }
226
+ processed += 1;
227
+ generated += result.generated;
228
+ failed += result.failed;
229
+ console.log(`[s3-backfill] "${slug}": ${result.steps} step(s), ${result.generated} description(s)${dryRun ? " (dry-run)" : ""}${result.failed > 0 ? `, ${result.failed} failed` : ""}${result.missing > 0 ? `, ${result.missing} unanswered` : ""}`);
230
+ if (draftStorage) {
231
+ // Before the event, so a course is fully settled by the time it is
232
+ // announced. A failure here does NOT change the event status: the
233
+ // published package and its manifest are already correct, and only the
234
+ // draft lags behind — the next publication regenerates what is missing.
235
+ try {
236
+ const published = await storage.readSyllabus(slug);
237
+ const mirror = await (0, mirrorDescriptions_1.mirrorDescriptionsToDraft)(draftStorage, slug, published, { dryRun });
238
+ mirrored += mirror.copied;
239
+ console.log(`[s3-backfill] "${slug}": ${mirror.copied} mirrored, ${mirror.missed} missed, ${mirror.fresh} already fresh`);
240
+ }
241
+ catch (error) {
242
+ console.error(`[s3-backfill] Could not mirror "${slug}" into the draft:`, error.message);
243
+ }
244
+ }
245
+ if (emitEvents) {
246
+ const outcome = await (0, backfillEvents_1.emitBackfillManifestEvent)({
247
+ courseSlug: slug,
248
+ runId,
249
+ result,
250
+ storage,
251
+ rigobotToken: token,
252
+ breathecodeToken: breathecodeToken,
253
+ });
254
+ events[outcome] += 1;
255
+ console.log(`[s3-backfill] "${slug}": manifest event ${outcome}`);
256
+ }
257
+ }
258
+ catch (error) {
259
+ processed += 1;
260
+ failed += 1;
261
+ // No event here on purpose: unlike a publication, a backfill never
262
+ // announced one, so nothing is owed. The course is simply re-run.
263
+ console.error(`[s3-backfill] Failed processing "${slug}":`, error.message);
264
+ }
265
+ }
266
+ console.log(`[s3-backfill] Done. ${processed} course(s), ${generated} description(s) written, ${failed} failure(s).` +
267
+ (mirrorDraft ? ` ${mirrored} mirrored into the draft.` : "") +
268
+ (emitEvents ?
269
+ ` Events: ${events.delivered} delivered, ${events.failed} failed (run ${runId}).` :
270
+ ""));
271
+ }
272
+ main()
273
+ .then(() => process.exit(0))
274
+ .catch(error => {
275
+ console.error("[s3-backfill] Fatal error:", error);
276
+ process.exit(1);
277
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable no-await-in-loop */
4
+ /**
5
+ * Descriptions sweep — safety net for the post-publish stage.
6
+ *
7
+ * The stage that generates descriptions runs inside the web process, so a dyno
8
+ * restart mid-flight leaves a package published but not described and, worse,
9
+ * breathecode waiting for a manifest event that was promised and never arrived.
10
+ * This job finishes those publications.
11
+ *
12
+ * Runs as a one-off dyno via Heroku Scheduler (hourly is plenty):
13
+ * node lib/scripts/descriptionsSweep.js
14
+ *
15
+ * Two sources of work:
16
+ * 1. Publish journals (`publish-journal/` in GCS) — the primary one. Each
17
+ * journal that stalled is resumed: missing descriptions are generated, the
18
+ * draft is mirrored, and the owed event is emitted. Journals are dropped
19
+ * once complete and abandoned after a few attempts.
20
+ * 2. A deep scan of published courses (DESCRIPTIONS_SWEEP_FULL=1) — catches
21
+ * packages that never got a journal at all, at the cost of reading every
22
+ * course. Off by default.
23
+ *
24
+ * Required env: GCP_CREDENTIALS_JSON, RIGOBOT_SYSTEM_TOKEN.
25
+ * Optional env: GCP_BUCKET_NAME, S3_PACKAGES_BUCKET, AWS_REGION,
26
+ * CLOUDFRONT_DISTRIBUTION_ID, BREATHECODE_SYSTEM_TOKEN (needed to emit the
27
+ * pending events), DESCRIPTIONS_SWEEP_FULL, DESCRIPTIONS_SWEEP_LIMIT,
28
+ * DESCRIPTION_STALE_AFTER_MS.
29
+ */
30
+ const storage_1 = require("@google-cloud/storage");
31
+ const client_s3_1 = require("@aws-sdk/client-s3");
32
+ const publishJournal_1 = require("../utils/publishJournal");
33
+ const packageManifestBackfill_1 = require("../utils/s3/packageManifestBackfill");
34
+ const gcsBucketName_1 = require("../utils/gcsBucketName");
35
+ const gcsStorage_1 = require("../utils/descriptions/gcsStorage");
36
+ const s3Storage_1 = require("../utils/descriptions/s3Storage");
37
+ const generateCourseDescriptions_1 = require("../utils/descriptions/generateCourseDescriptions");
38
+ const resumePublication_1 = require("../utils/descriptions/resumePublication");
39
+ async function main() {
40
+ const credentialsEnv = process.env.GCP_CREDENTIALS_JSON;
41
+ if (!credentialsEnv) {
42
+ console.error("[sweep] GCP_CREDENTIALS_JSON (env) is required");
43
+ process.exit(1);
44
+ }
45
+ const rigobotToken = process.env.RIGOBOT_SYSTEM_TOKEN;
46
+ if (!rigobotToken) {
47
+ console.error("[sweep] RIGOBOT_SYSTEM_TOKEN (env) is required");
48
+ process.exit(1);
49
+ }
50
+ const breathecodeToken = process.env.BREATHECODE_SYSTEM_TOKEN;
51
+ if (!breathecodeToken) {
52
+ console.warn("[sweep] BREATHECODE_SYSTEM_TOKEN is not set: pending events cannot be emitted");
53
+ }
54
+ const bucket = new storage_1.Storage({
55
+ credentials: JSON.parse(credentialsEnv),
56
+ }).bucket((0, gcsBucketName_1.requireGcsBucketName)());
57
+ const journalStorage = (0, publishJournal_1.createGcsJournalStorage)(bucket);
58
+ const publishedStorage = (0, s3Storage_1.createS3DescriptionsStorageFromEnv)();
59
+ const draftStorage = (0, gcsStorage_1.createGcsDescriptionsStorage)(bucket);
60
+ const staleAfterMs = Number.parseInt(process.env.DESCRIPTION_STALE_AFTER_MS || "", 10) ||
61
+ resumePublication_1.DEFAULT_STALE_AFTER_MS;
62
+ const refs = await (0, publishJournal_1.listJournalRefs)(journalStorage);
63
+ console.log(`[sweep] ${refs.length} unfinished publication(s)`);
64
+ const tally = {
65
+ "skipped-recent": 0,
66
+ "skipped-abandoned": 0,
67
+ abandoned: 0,
68
+ resumed: 0,
69
+ "nothing-to-do": 0,
70
+ };
71
+ for (const ref of refs) {
72
+ try {
73
+ const journal = await (0, publishJournal_1.readJournalByKey)(journalStorage, ref.key);
74
+ if (!journal) {
75
+ continue;
76
+ }
77
+ const outcome = await (0, resumePublication_1.resumePublication)(journal, {
78
+ journalStorage,
79
+ publishedStorage,
80
+ draftStorage,
81
+ rigobotToken,
82
+ breathecodeToken,
83
+ staleAfterMs,
84
+ });
85
+ tally[outcome] += 1;
86
+ if (outcome === "resumed") {
87
+ console.log(`[sweep] Resumed "${ref.courseSlug}" (${ref.publishId})`);
88
+ }
89
+ }
90
+ catch (error) {
91
+ console.error(`[sweep] Failed resuming "${ref.courseSlug}":`, error.message);
92
+ }
93
+ }
94
+ console.log(`[sweep] Journals: ${tally.resumed} resumed, ${tally["skipped-recent"]} still in flight, ` +
95
+ `${tally["nothing-to-do"]} nothing to do, ${tally.abandoned} abandoned, ` +
96
+ `${tally["skipped-abandoned"]} already abandoned`);
97
+ if (process.env.DESCRIPTIONS_SWEEP_FULL === "1") {
98
+ await deepScan(rigobotToken, publishedStorage);
99
+ }
100
+ }
101
+ /**
102
+ * Fallback for packages with no journal at all (published before this existed,
103
+ * or whose journal write failed). Expensive: it reads every published course,
104
+ * so it is opt-in rather than the default path.
105
+ */
106
+ async function deepScan(rigobotToken, publishedStorage) {
107
+ const s3 = new client_s3_1.S3Client({
108
+ region: process.env.AWS_REGION || "us-east-1",
109
+ });
110
+ const bucket = process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes";
111
+ const limit = Number.parseInt(process.env.DESCRIPTIONS_SWEEP_LIMIT || "", 10) || 25;
112
+ const slugs = await (0, packageManifestBackfill_1.listPublishedSlugs)(s3, bucket);
113
+ console.log(`[sweep] Deep scan over ${slugs.length} published course(s), limit ${limit}`);
114
+ let processed = 0;
115
+ for (const slug of slugs) {
116
+ if (processed >= limit) {
117
+ break;
118
+ }
119
+ try {
120
+ const result = await (0, generateCourseDescriptions_1.generateCourseDescriptions)(publishedStorage, slug, {
121
+ token: rigobotToken,
122
+ reconcile: true,
123
+ });
124
+ if (result.status === "skipped") {
125
+ continue;
126
+ }
127
+ processed += 1;
128
+ console.log(`[sweep] "${slug}": ${result.generated} description(s), ${result.failed} failed`);
129
+ }
130
+ catch (error) {
131
+ processed += 1;
132
+ console.error(`[sweep] Failed on "${slug}":`, error.message);
133
+ }
134
+ }
135
+ console.log(`[sweep] Deep scan done. ${processed} course(s) touched.`);
136
+ }
137
+ main()
138
+ .then(() => process.exit(0))
139
+ .catch(error => {
140
+ console.error("[sweep] Fatal error:", error);
141
+ process.exit(1);
142
+ });
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Student telemetry, and publication events. Both share the endpoint: a body
3
+ * carrying a top-level `event` key is dispatched as an event, anything else
4
+ * keeps being processed as telemetry.
5
+ */
6
+ export declare const BREATHECODE_TELEMETRY_URL = "https://breathecode.herokuapp.com/v1/assignment/me/telemetry";
7
+ export declare const BREATHECODE_EVENTS_URL = "https://breathecode.herokuapp.com/v1/assignment/me/telemetry";
1
8
  export declare const RIGOBOT_HOST: string;
2
9
  export declare const RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com";
3
10
  type TConsumableSlug = "ai-conversation-message" | "ai-compilation" | "ai-tutorial-generation" | "ai-generation" | "learnpack-publish";
package/lib/utils/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getCurrentTechnologies = exports.fetchTechnologies = exports.doesAssetExists = exports.createAsset = exports.validateToken = exports.listUserAcademies = exports.getConsumable = exports.countConsumables = exports.RIGOBOT_REALTIME_HOST = exports.RIGOBOT_HOST = void 0;
3
+ exports.getCurrentTechnologies = exports.fetchTechnologies = exports.doesAssetExists = exports.createAsset = exports.validateToken = exports.listUserAcademies = exports.getConsumable = exports.countConsumables = exports.RIGOBOT_REALTIME_HOST = exports.RIGOBOT_HOST = exports.BREATHECODE_EVENTS_URL = exports.BREATHECODE_TELEMETRY_URL = void 0;
4
4
  exports.resolveLearnpackPackageId = resolveLearnpackPackageId;
5
5
  const console_1 = require("../utils/console");
6
6
  const storage = require("node-persist");
@@ -9,6 +9,13 @@ const axios_1 = require("axios");
9
9
  const dotenv = require("dotenv");
10
10
  dotenv.config();
11
11
  const HOST = "https://breathecode.herokuapp.com";
12
+ /**
13
+ * Student telemetry, and publication events. Both share the endpoint: a body
14
+ * carrying a top-level `event` key is dispatched as an event, anything else
15
+ * keeps being processed as telemetry.
16
+ */
17
+ exports.BREATHECODE_TELEMETRY_URL = `${HOST}/v1/assignment/me/telemetry`;
18
+ exports.BREATHECODE_EVENTS_URL = exports.BREATHECODE_TELEMETRY_URL;
12
19
  exports.RIGOBOT_HOST = process.env.RIGOBOT_HOST || "https://rigobot.herokuapp.com";
13
20
  exports.RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com";
14
21
  // export const RIGOBOT_REALTIME_HOST = "http://127.0.0.1:8003"
@@ -0,0 +1,20 @@
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
+ export type AwsCredentials = {
15
+ accessKeyId: string;
16
+ secretAccessKey: string;
17
+ };
18
+ export declare function requireAwsCredentials(): AwsCredentials;
19
+ export declare function requireS3PackagesBucket(): string;
20
+ export declare function awsRegion(): string;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ /**
3
+ * AWS credentials for the published bucket, required rather than implicit.
4
+ *
5
+ * Building an `S3Client` with no credentials succeeds: the SDK defers to its
6
+ * provider chain and only fails at the first request, with
7
+ * "Could not load credentials from any providers". Inside a background job that
8
+ * surfaces minutes later, detached from the cause. Validating up front turns it
9
+ * into an obvious misconfiguration.
10
+ *
11
+ * Note this deliberately rejects the empty-string fallback pattern
12
+ * (`process.env.X || ""`): passing empty credentials defeats the provider chain
13
+ * instead of failing, which is how the opaque error appeared in the first place.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.requireAwsCredentials = requireAwsCredentials;
17
+ exports.requireS3PackagesBucket = requireS3PackagesBucket;
18
+ exports.awsRegion = awsRegion;
19
+ function requireAwsCredentials() {
20
+ const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim();
21
+ const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim();
22
+ const missing = [];
23
+ if (!accessKeyId) {
24
+ missing.push("AWS_ACCESS_KEY_ID");
25
+ }
26
+ if (!secretAccessKey) {
27
+ missing.push("AWS_SECRET_ACCESS_KEY");
28
+ }
29
+ if (missing.length > 0) {
30
+ throw new Error(`${missing.join(" and ")} (env) ${missing.length > 1 ? "are" : "is"} required to reach the published package bucket`);
31
+ }
32
+ return { accessKeyId, secretAccessKey };
33
+ }
34
+ function requireS3PackagesBucket() {
35
+ const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim();
36
+ if (!bucket) {
37
+ throw new Error("S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages");
38
+ }
39
+ return bucket;
40
+ }
41
+ function awsRegion() {
42
+ return (process.env.AWS_REGION || "").trim() || "us-east-1";
43
+ }
@@ -17,6 +17,7 @@ exports.countSentences = countSentences;
17
17
  exports.howManyDifficultParagraphs = howManyDifficultParagraphs;
18
18
  exports.insertStepInCorrectPosition = insertStepInCorrectPosition;
19
19
  const console_1 = require("./console");
20
+ const api_1 = require("./api");
20
21
  const frontMatter = require("front-matter");
21
22
  const MarkdownIt = require("markdown-it");
22
23
  const syllable_1 = require("syllable");
@@ -146,7 +147,7 @@ const makePackageInfo = (choices) => {
146
147
  .replace(/ /g, "-")
147
148
  .replace(/[^\w-]+/g, ""),
148
149
  telemetry: {
149
- batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
150
+ batch: api_1.BREATHECODE_TELEMETRY_URL,
150
151
  },
151
152
  };
152
153
  return packageInfo;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Utilities to detect whether a step description is stale relative to its
3
+ * README content.
4
+ *
5
+ * Two signals are stored per (step, lang) at generation time:
6
+ * - sha256(normalizeReadme(content)) -> exact fast-path + content-addressing
7
+ * - simhash64(normalizeReadme(content)) -> near-duplicate significance filter
8
+ *
9
+ * The sweep re-hashes the current README and compares against the stored
10
+ * values: exact SHA-256 match => no-op; otherwise if the SimHash Hamming
11
+ * distance to the *generation baseline* is <= k the change is cosmetic and is
12
+ * ignored, else the description is regenerated. The H1 title is part of the
13
+ * normalized content (a deliberate product decision).
14
+ */
15
+ /**
16
+ * Remove every image from markdown: inline `![alt](url)`, reference-style
17
+ * `![alt][ref]` and raw `<img>` tags. Regular links `[text](url)` are kept.
18
+ *
19
+ * Images are dropped from BOTH sides of the description pipeline — here for
20
+ * fingerprinting, and again before sending the README to the LLM. Adding,
21
+ * replacing or removing an illustration does not change what the lesson
22
+ * teaches, so it must not trigger a regeneration; and the alt text of generated
23
+ * illustrations is a full image-generation prompt (hundreds of tokens) that
24
+ * only invites the model to describe the picture instead of the lesson.
25
+ *
26
+ * Note the alt text may legitimately contain parentheses, so the alt is matched
27
+ * as "anything up to the closing bracket" rather than as balanced text.
28
+ */
29
+ export declare function stripMarkdownImages(content: string): string;
30
+ /**
31
+ * Canonicalize a README for hashing. Strips the frontmatter (metadata such as
32
+ * tutorial/intro video URLs, which do not describe the lesson), removes images
33
+ * (see `stripMarkdownImages`) and collapses all whitespace runs so that
34
+ * reformatting, blank lines and trailing spaces do not register as content
35
+ * changes. The H1 and the prose are preserved.
36
+ */
37
+ export declare function normalizeReadme(content: string): string;
38
+ export declare function sha256(input: string): string;
39
+ /**
40
+ * 64-bit SimHash (Charikar) over whitespace-delimited tokens, returned as a
41
+ * zero-padded 16-char hex string.
42
+ */
43
+ export declare function simhash64(input: string): string;
44
+ /** Hamming distance between two 64-bit hex fingerprints. */
45
+ export declare function hammingHex(a: string, b: string): number;
46
+ export type ContentFingerprint = {
47
+ sha256: string;
48
+ simhash: string;
49
+ };
50
+ /** Convenience: normalize + both fingerprints in one call. */
51
+ export declare function fingerprintReadme(content: string): ContentFingerprint;
52
+ /**
53
+ * Default significance threshold (Hamming distance over the 64-bit SimHash).
54
+ * k=3 is the classic near-duplicate threshold for 64-bit SimHash
55
+ * (Charikar 2002; Manku, Jain & Das Sarma, WWW 2007). Overridable via env.
56
+ */
57
+ export declare function getSimhashThreshold(): number;
58
+ /**
59
+ * Decide whether a description generated from `previous` is stale given the
60
+ * `current` README content. Exact match => not stale; else stale only if the
61
+ * SimHash Hamming distance exceeds the threshold (cosmetic edits are ignored).
62
+ */
63
+ export declare function isDescriptionStale(previous: {
64
+ sha256?: string;
65
+ simhash?: string;
66
+ } | undefined, current: ContentFingerprint, threshold?: number): boolean;