@learnpack/learnpack 5.0.352 → 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 (69) 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 +14 -0
  32. package/lib/utils/packageManifest.js +49 -5
  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 +2 -0
  40. package/lib/utils/s3/packageManifestBackfill.js +2 -0
  41. package/lib/utils/syllabusSync.d.ts +71 -0
  42. package/lib/utils/syllabusSync.js +273 -0
  43. package/package.json +1 -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 +142 -140
  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/gcsBucketName.ts +19 -0
  64. package/src/utils/packageManifest.ts +54 -4
  65. package/src/utils/publishEvents.ts +181 -0
  66. package/src/utils/publishJournal.ts +383 -0
  67. package/src/utils/rigoActions.ts +130 -0
  68. package/src/utils/s3/packageManifestBackfill.ts +2 -2
  69. package/src/utils/syllabusSync.ts +390 -0
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createBuildFileReader = createBuildFileReader;
4
+ exports.countPendingDescriptions = countPendingDescriptions;
5
+ exports.runPublishDescriptionsStage = runPublishDescriptionsStage;
6
+ exports.runDescriptionsStageAfterPublish = runDescriptionsStageAfterPublish;
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const publishJournal_1 = require("../publishJournal");
10
+ const generateCourseDescriptions_1 = require("./generateCourseDescriptions");
11
+ const gcsStorage_1 = require("./gcsStorage");
12
+ const mirrorDescriptions_1 = require("./mirrorDescriptions");
13
+ const s3Storage_1 = require("./s3Storage");
14
+ const publishEvents_1 = require("../publishEvents");
15
+ const workList_1 = require("./workList");
16
+ function createBuildFileReader(buildRoot) {
17
+ return relativePath => {
18
+ try {
19
+ return fs.readFileSync(path.join(buildRoot, ...relativePath.split("/")), "utf8");
20
+ }
21
+ catch (_a) {
22
+ return null;
23
+ }
24
+ };
25
+ }
26
+ function countPendingDescriptions(buildRoot, exercises, readFile = createBuildFileReader(buildRoot)) {
27
+ const rawSyllabus = readFile(".learn/initialSyllabus.json");
28
+ if (!rawSyllabus) {
29
+ return 0;
30
+ }
31
+ let syllabus;
32
+ try {
33
+ syllabus = JSON.parse(rawSyllabus);
34
+ }
35
+ catch (_a) {
36
+ return 0;
37
+ }
38
+ const readmes = {};
39
+ for (const exercise of exercises) {
40
+ for (const [lang, fileName] of Object.entries(exercise.translations || {})) {
41
+ const content = readFile(`exercises/${exercise.slug}/${fileName}`);
42
+ if (content === null) {
43
+ // Missing translation file: nothing to fingerprint, nothing to ask for.
44
+ continue;
45
+ }
46
+ if (!readmes[exercise.slug]) {
47
+ readmes[exercise.slug] = {};
48
+ }
49
+ readmes[exercise.slug][lang] = content;
50
+ }
51
+ }
52
+ return (0, workList_1.countWorkItems)((0, workList_1.buildDescriptionWorkList)({ syllabus, exercises, readmes }));
53
+ }
54
+ /**
55
+ * Generate against the published package, then mirror what still matches back
56
+ * into the draft. Stages are recorded as they go so an interrupted run can be
57
+ * resumed by the sweep.
58
+ */
59
+ async function runPublishDescriptionsStage(params) {
60
+ var _a, _b;
61
+ const { courseSlug, journal, journalStorage } = params;
62
+ const publishedStorage = (_a = params.publishedStorage) !== null && _a !== void 0 ? _a : (0, s3Storage_1.createS3DescriptionsStorageFromEnv)();
63
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "running");
64
+ let generation = null;
65
+ try {
66
+ generation = await (0, generateCourseDescriptions_1.generateCourseDescriptions)(publishedStorage, courseSlug, {
67
+ token: params.rigobotToken,
68
+ reconcile: true,
69
+ generate: params.generate,
70
+ });
71
+ }
72
+ catch (error) {
73
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "failed", {
74
+ error: error.message,
75
+ });
76
+ return { generation: null, mirror: null, succeeded: false };
77
+ }
78
+ const succeeded = generation.status !== "failed";
79
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", succeeded ? "done" : "failed", {
80
+ error: succeeded ? undefined : generation.errors.join("; "),
81
+ meta: {
82
+ steps: generation.steps,
83
+ generated: generation.generated,
84
+ failed: generation.failed,
85
+ missing: generation.missing,
86
+ durationsSeconds: generation.durationsSeconds,
87
+ },
88
+ });
89
+ if (!succeeded) {
90
+ return { generation, mirror: null, succeeded: false };
91
+ }
92
+ // Mirror back so the draft keeps the fingerprints: without it the next
93
+ // publication would regenerate the whole course.
94
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "running");
95
+ try {
96
+ const draftStorage = (_b = params.draftStorage) !== null && _b !== void 0 ? _b : (0, gcsStorage_1.createGcsDescriptionsStorage)(params.gcsBucket);
97
+ const publishedSyllabus = await publishedStorage.readSyllabus(courseSlug);
98
+ const mirror = await (0, mirrorDescriptions_1.mirrorDescriptionsToDraft)(draftStorage, courseSlug, publishedSyllabus);
99
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "done", {
100
+ meta: mirror,
101
+ });
102
+ return { generation, mirror, succeeded: true };
103
+ }
104
+ catch (error) {
105
+ // The published package is already correct; only the draft lags behind.
106
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "failed", {
107
+ error: error.message,
108
+ });
109
+ return { generation, mirror: null, succeeded: true };
110
+ }
111
+ }
112
+ /**
113
+ * Entry point for the detached run after publishing. Never throws: it is not
114
+ * awaited by anyone, so an escaping rejection would only surface as an
115
+ * unhandled promise.
116
+ */
117
+ async function runDescriptionsStageAfterPublish(params) {
118
+ var _a, _b, _c, _d, _e, _f;
119
+ const { courseSlug, journal, journalStorage } = params;
120
+ let outcome = {
121
+ generation: null,
122
+ mirror: null,
123
+ succeeded: true,
124
+ };
125
+ // Resolved on demand: the common "nothing to describe" path must not build
126
+ // AWS clients it will never use.
127
+ let resolved = params.publishedStorage;
128
+ const publishedStorage = () => {
129
+ if (!resolved) {
130
+ resolved = (0, s3Storage_1.createS3DescriptionsStorageFromEnv)();
131
+ }
132
+ return resolved;
133
+ };
134
+ try {
135
+ if (!params.hasWork) {
136
+ // Nothing changed enough to describe; both stages are settled, and no
137
+ // second event was promised, so none is sent.
138
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "done", {
139
+ meta: { reason: "up_to_date" },
140
+ });
141
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "gcsMirror", "done", {
142
+ meta: { reason: "up_to_date" },
143
+ });
144
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", "done", {
145
+ meta: { reason: "not_promised" },
146
+ });
147
+ return outcome;
148
+ }
149
+ // Descriptions are platform enrichment, not something the publisher is
150
+ // billed for, so they always run under the service account.
151
+ const token = process.env.RIGOBOT_SYSTEM_TOKEN;
152
+ if (!token) {
153
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "descriptions", "failed", {
154
+ error: "RIGOBOT_SYSTEM_TOKEN is not configured",
155
+ });
156
+ console.error(`[descriptions] Skipping stage for "${courseSlug}": RIGOBOT_SYSTEM_TOKEN is not configured`);
157
+ outcome = { generation: null, mirror: null, succeeded: false };
158
+ return outcome;
159
+ }
160
+ outcome = await runPublishDescriptionsStage({
161
+ courseSlug,
162
+ rigobotToken: token,
163
+ gcsBucket: params.gcsBucket,
164
+ journal,
165
+ journalStorage,
166
+ publishedStorage: publishedStorage(),
167
+ draftStorage: params.draftStorage,
168
+ generate: params.generate,
169
+ });
170
+ if (outcome.succeeded) {
171
+ console.log(`[descriptions] "${courseSlug}": ${(_b = (_a = outcome.generation) === null || _a === void 0 ? void 0 : _a.generated) !== null && _b !== void 0 ? _b : 0} description(s), ${(_d = (_c = outcome.mirror) === null || _c === void 0 ? void 0 : _c.copied) !== null && _d !== void 0 ? _d : 0} mirrored`);
172
+ }
173
+ else {
174
+ console.error(`[descriptions] "${courseSlug}": stage failed${((_e = outcome.generation) === null || _e === void 0 ? void 0 : _e.errors.length) ?
175
+ ` (${outcome.generation.errors.join("; ")})` :
176
+ ""}`);
177
+ }
178
+ return outcome;
179
+ }
180
+ catch (error) {
181
+ console.error(`[descriptions] Stage failed for "${courseSlug}":`, error.message);
182
+ outcome = { generation: null, mirror: null, succeeded: false };
183
+ return outcome;
184
+ }
185
+ finally {
186
+ // The contract: a publication that announced `descriptions: "queued"` is
187
+ // always followed by exactly one manifest event, success or failure.
188
+ // Emitting from `finally` is what makes "exactly one, on every exit path"
189
+ // true by construction rather than by remembering to call it.
190
+ if (params.hasWork) {
191
+ await emitManifestUpdated({
192
+ courseSlug,
193
+ journal,
194
+ journalStorage,
195
+ outcome,
196
+ eventContext: params.eventContext,
197
+ breathecodeToken: params.breathecodeToken,
198
+ publishedStorage: publishedStorage(),
199
+ emit: (_f = params.emit) !== null && _f !== void 0 ? _f : publishEvents_1.sendPublishEvent,
200
+ });
201
+ }
202
+ await (0, publishJournal_1.finalizeJournal)(journalStorage, journal);
203
+ }
204
+ }
205
+ async function emitManifestUpdated(params) {
206
+ var _a, _b, _c;
207
+ const { journal, journalStorage } = params;
208
+ if (!params.eventContext || !params.breathecodeToken) {
209
+ // Nothing was announced to breathecode, so nothing is owed.
210
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", "done", {
211
+ meta: { reason: "no_event_context" },
212
+ });
213
+ return;
214
+ }
215
+ try {
216
+ // Report the manifest as it is NOW: the descriptions are the whole point of
217
+ // this event, so the one captured at publish time would be stale.
218
+ const updatedManifest = (_c = (await ((_b = (_a = params.publishedStorage).readManifest) === null || _b === void 0 ? void 0 : _b.call(_a, params.courseSlug)))) !== null && _c !== void 0 ? _c : params.eventContext.manifest;
219
+ const generation = params.outcome.generation;
220
+ const delivered = await params.emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)(Object.assign(Object.assign({}, params.eventContext), { manifest: updatedManifest }), params.outcome.succeeded ? "success" : "failed", generation ?
221
+ {
222
+ generated: generation.generated,
223
+ failed: generation.failed,
224
+ missing: generation.missing,
225
+ } :
226
+ undefined), params.breathecodeToken);
227
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", delivered ? "done" : "failed", { error: delivered ? undefined : "delivery failed" });
228
+ }
229
+ catch (error) {
230
+ await (0, publishJournal_1.markStage)(journalStorage, journal, "manifestEvent", "failed", {
231
+ error: error.message,
232
+ });
233
+ }
234
+ }
@@ -0,0 +1,36 @@
1
+ import { JournalStorage, PublishJournal } from "../publishJournal";
2
+ import { fetchPackageInfo, sendPublishEvent } from "../publishEvents";
3
+ import { CourseDescriptionsStorage, GenerateCourseDescriptionsOptions } from "./generateCourseDescriptions";
4
+ /**
5
+ * Recovery of a publication whose background stage never finished.
6
+ *
7
+ * The stage runs inside the web process, so a dyno restart mid-flight leaves a
8
+ * package published but not described, and — worse — breathecode waiting for a
9
+ * manifest event that was promised and will never arrive. Content-wise the
10
+ * missing descriptions would eventually be picked up by the next publication,
11
+ * but the promised event has no such second chance: emitting it is the reason
12
+ * this exists.
13
+ */
14
+ export declare const MAX_SWEEP_ATTEMPTS = 3;
15
+ /** Only resume publications nobody is working on any more. */
16
+ export declare const DEFAULT_STALE_AFTER_MS: number;
17
+ export type ResumeOutcome = "skipped-recent" | "skipped-abandoned" | "abandoned" | "resumed" | "nothing-to-do";
18
+ export type ResumeDeps = {
19
+ journalStorage: JournalStorage;
20
+ publishedStorage: CourseDescriptionsStorage;
21
+ draftStorage: CourseDescriptionsStorage;
22
+ rigobotToken: string;
23
+ breathecodeToken?: string;
24
+ staleAfterMs?: number;
25
+ now?: number;
26
+ /** Injection points for tests. */
27
+ emit?: typeof sendPublishEvent;
28
+ readPackageInfo?: typeof fetchPackageInfo;
29
+ generate?: GenerateCourseDescriptionsOptions["generate"];
30
+ };
31
+ /**
32
+ * Whether breathecode is still owed a manifest event: one was promised (the
33
+ * publish event announced `queued`) and it never went out.
34
+ */
35
+ export declare function owesManifestEvent(journal: PublishJournal): boolean;
36
+ export declare function resumePublication(journal: PublishJournal, deps: ResumeDeps): Promise<ResumeOutcome>;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_STALE_AFTER_MS = exports.MAX_SWEEP_ATTEMPTS = void 0;
4
+ exports.owesManifestEvent = owesManifestEvent;
5
+ exports.resumePublication = resumePublication;
6
+ const publishJournal_1 = require("../publishJournal");
7
+ const publishEvents_1 = require("../publishEvents");
8
+ const publishStage_1 = require("./publishStage");
9
+ /**
10
+ * Recovery of a publication whose background stage never finished.
11
+ *
12
+ * The stage runs inside the web process, so a dyno restart mid-flight leaves a
13
+ * package published but not described, and — worse — breathecode waiting for a
14
+ * manifest event that was promised and will never arrive. Content-wise the
15
+ * missing descriptions would eventually be picked up by the next publication,
16
+ * but the promised event has no such second chance: emitting it is the reason
17
+ * this exists.
18
+ */
19
+ exports.MAX_SWEEP_ATTEMPTS = 3;
20
+ /** Only resume publications nobody is working on any more. */
21
+ exports.DEFAULT_STALE_AFTER_MS = 15 * 60 * 1000;
22
+ function isDone(journal, stage) {
23
+ return journal.stages[stage].status === "done";
24
+ }
25
+ /**
26
+ * Whether breathecode is still owed a manifest event: one was promised (the
27
+ * publish event announced `queued`) and it never went out.
28
+ */
29
+ function owesManifestEvent(journal) {
30
+ var _a;
31
+ const promised = ((_a = journal.stages.publishedEvent.meta) === null || _a === void 0 ? void 0 : _a.descriptions) === "queued";
32
+ return promised && !isDone(journal, "manifestEvent");
33
+ }
34
+ async function resumePublication(journal, deps) {
35
+ var _a, _b;
36
+ const { journalStorage } = deps;
37
+ const now = (_a = deps.now) !== null && _a !== void 0 ? _a : Date.now();
38
+ if (journal.abandoned) {
39
+ return "skipped-abandoned";
40
+ }
41
+ // A publication still in flight would race with its own stage.
42
+ if (!(0, publishJournal_1.isJournalStale)(journal, (_b = deps.staleAfterMs) !== null && _b !== void 0 ? _b : exports.DEFAULT_STALE_AFTER_MS, now)) {
43
+ return "skipped-recent";
44
+ }
45
+ const needsDescriptions = !isDone(journal, "descriptions") || !isDone(journal, "gcsMirror");
46
+ const needsEvent = owesManifestEvent(journal);
47
+ if (!needsDescriptions && !needsEvent) {
48
+ // Nothing left that the sweep can do; stage A failures are not its job.
49
+ await (0, publishJournal_1.finalizeJournal)(journalStorage, journal);
50
+ return "nothing-to-do";
51
+ }
52
+ if (journal.attempts >= exports.MAX_SWEEP_ATTEMPTS) {
53
+ await (0, publishJournal_1.abandonJournal)(journalStorage, journal);
54
+ console.error(`[sweep] Giving up on "${journal.courseSlug}" (${journal.publishId}) after ${journal.attempts} attempts`);
55
+ return "abandoned";
56
+ }
57
+ await (0, publishJournal_1.incrementAttempts)(journalStorage, journal);
58
+ let succeeded = true;
59
+ let stats;
60
+ if (needsDescriptions) {
61
+ const outcome = await (0, publishStage_1.runPublishDescriptionsStage)({
62
+ courseSlug: journal.courseSlug,
63
+ rigobotToken: deps.rigobotToken,
64
+ journal,
65
+ journalStorage,
66
+ publishedStorage: deps.publishedStorage,
67
+ draftStorage: deps.draftStorage,
68
+ generate: deps.generate,
69
+ });
70
+ succeeded = outcome.succeeded;
71
+ if (outcome.generation) {
72
+ stats = {
73
+ generated: outcome.generation.generated,
74
+ failed: outcome.generation.failed,
75
+ missing: outcome.generation.missing,
76
+ };
77
+ }
78
+ }
79
+ if (needsEvent) {
80
+ await emitPendingManifestEvent(journal, deps, succeeded, stats);
81
+ }
82
+ await (0, publishJournal_1.finalizeJournal)(journalStorage, journal);
83
+ return "resumed";
84
+ }
85
+ async function emitPendingManifestEvent(journal, deps, succeeded, stats) {
86
+ var _a, _b;
87
+ const emit = (_a = deps.emit) !== null && _a !== void 0 ? _a : publishEvents_1.sendPublishEvent;
88
+ const readPackageInfo = (_b = deps.readPackageInfo) !== null && _b !== void 0 ? _b : publishEvents_1.fetchPackageInfo;
89
+ if (!deps.breathecodeToken) {
90
+ await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", "failed", {
91
+ error: "BREATHECODE_SYSTEM_TOKEN is not configured",
92
+ });
93
+ return;
94
+ }
95
+ try {
96
+ let manifest = null;
97
+ if (deps.publishedStorage.readManifest) {
98
+ manifest = await deps.publishedStorage.readManifest(journal.courseSlug);
99
+ }
100
+ const delivered = await emit((0, publishEvents_1.buildPackageManifestUpdatedEvent)({
101
+ publishId: journal.publishId,
102
+ courseSlug: journal.courseSlug,
103
+ packageInfo: await readPackageInfo(journal.courseSlug, deps.rigobotToken),
104
+ manifest,
105
+ }, succeeded ? "success" : "failed", stats), deps.breathecodeToken);
106
+ await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", delivered ? "done" : "failed", { error: delivered ? undefined : "delivery failed" });
107
+ }
108
+ catch (error) {
109
+ await (0, publishJournal_1.markStage)(deps.journalStorage, journal, "manifestEvent", "failed", {
110
+ error: error.message,
111
+ });
112
+ }
113
+ }
@@ -0,0 +1,30 @@
1
+ import { AwsClient } from "../s3/packageManifestBackfill";
2
+ import { SyllabusSyncStorage } from "../syllabusSync";
3
+ import { CourseDescriptionsStorage } from "./generateCourseDescriptions";
4
+ import { CourseExercise } from "./workList";
5
+ /**
6
+ * Published-bucket (S3) adapter for the description flows.
7
+ *
8
+ * The published package is the snapshot descriptions are generated from: it is
9
+ * immutable between publications, unlike the draft in GCS, which keeps moving
10
+ * while the teacher edits.
11
+ */
12
+ export declare const syllabusKey: (slug: string) => string;
13
+ export declare const manifestKey: (slug: string) => string;
14
+ export declare function fetchExercises(s3: AwsClient, bucket: string, slug: string): Promise<CourseExercise[]>;
15
+ export declare function createS3SyllabusSyncStorage(s3: AwsClient, bucket: string): SyllabusSyncStorage;
16
+ export type S3DescriptionsStorageOptions = {
17
+ /** CloudFront distribution to invalidate after re-projecting the manifest. */
18
+ cloudFront?: {
19
+ client: AwsClient;
20
+ distributionId: string;
21
+ };
22
+ /** Set false in the backfill, where manifests are projected in a later pass. */
23
+ reprojectManifest?: boolean;
24
+ };
25
+ /**
26
+ * Storage for the published bucket, wired from the environment. Used by the
27
+ * post-publish stage and the sweep, which have no CLI flags to read.
28
+ */
29
+ export declare function createS3DescriptionsStorageFromEnv(): CourseDescriptionsStorage;
30
+ export declare function createS3DescriptionsStorage(s3: AwsClient, bucket: string, options?: S3DescriptionsStorageOptions): CourseDescriptionsStorage;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.manifestKey = exports.syllabusKey = void 0;
4
+ exports.fetchExercises = fetchExercises;
5
+ exports.createS3SyllabusSyncStorage = createS3SyllabusSyncStorage;
6
+ exports.createS3DescriptionsStorageFromEnv = createS3DescriptionsStorageFromEnv;
7
+ exports.createS3DescriptionsStorage = createS3DescriptionsStorage;
8
+ const client_s3_1 = require("@aws-sdk/client-s3");
9
+ const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
10
+ const packageManifest_1 = require("../packageManifest");
11
+ const packageManifestBackfill_1 = require("../s3/packageManifestBackfill");
12
+ /**
13
+ * Published-bucket (S3) adapter for the description flows.
14
+ *
15
+ * The published package is the snapshot descriptions are generated from: it is
16
+ * immutable between publications, unlike the draft in GCS, which keeps moving
17
+ * while the teacher edits.
18
+ */
19
+ const syllabusKey = (slug) => `${slug}/.learn/initialSyllabus.json`;
20
+ exports.syllabusKey = syllabusKey;
21
+ const manifestKey = (slug) => `${slug}/${packageManifest_1.PACKAGE_MANIFEST_REL_PATH}`;
22
+ exports.manifestKey = manifestKey;
23
+ const configKeys = (slug) => [
24
+ `${slug}/.learn/config.json`,
25
+ `${slug}/config.json`,
26
+ ];
27
+ async function fetchExercises(s3, bucket, slug) {
28
+ for (const key of configKeys(slug)) {
29
+ // eslint-disable-next-line no-await-in-loop -- the second key is a fallback
30
+ const config = await (0, packageManifestBackfill_1.fetchJsonObject)(s3, bucket, key);
31
+ if ((config === null || config === void 0 ? void 0 : config.exercises) && Array.isArray(config.exercises)) {
32
+ return config.exercises;
33
+ }
34
+ }
35
+ return [];
36
+ }
37
+ function createS3SyllabusSyncStorage(s3, bucket) {
38
+ return {
39
+ async listExerciseFolderFileCounts(courseSlug) {
40
+ const prefix = `${courseSlug}/exercises/`;
41
+ const keys = await (0, packageManifestBackfill_1.listObjectKeys)(s3, bucket, prefix);
42
+ const counts = new Map();
43
+ for (const key of keys) {
44
+ const segment = key.slice(prefix.length).split("/")[0];
45
+ if (segment) {
46
+ counts.set(segment, (counts.get(segment) || 0) + 1);
47
+ }
48
+ }
49
+ return counts;
50
+ },
51
+ async translationLangsBySlug(courseSlug) {
52
+ const exercises = await fetchExercises(s3, bucket, courseSlug);
53
+ const map = new Map();
54
+ for (const exercise of exercises) {
55
+ const langs = Object.keys(exercise.translations || {})
56
+ .map(lang => lang.toLowerCase())
57
+ .filter(Boolean);
58
+ if (langs.length > 0) {
59
+ map.set(exercise.slug, [...new Set(langs)]);
60
+ }
61
+ }
62
+ return map;
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * Storage for the published bucket, wired from the environment. Used by the
68
+ * post-publish stage and the sweep, which have no CLI flags to read.
69
+ */
70
+ function createS3DescriptionsStorageFromEnv() {
71
+ const s3 = new client_s3_1.S3Client({
72
+ region: process.env.AWS_REGION || "us-east-1",
73
+ });
74
+ const distributionId = process.env.CLOUDFRONT_DISTRIBUTION_ID;
75
+ const cloudFront = distributionId ?
76
+ {
77
+ client: new client_cloudfront_1.CloudFrontClient({
78
+ region: process.env.AWS_REGION || "us-east-1",
79
+ }),
80
+ distributionId,
81
+ } :
82
+ undefined;
83
+ if (!cloudFront) {
84
+ console.warn("[descriptions] CLOUDFRONT_DISTRIBUTION_ID is not set: the manifest will be re-projected but not invalidated");
85
+ }
86
+ return createS3DescriptionsStorage(s3, process.env.S3_PACKAGES_BUCKET || "learnpack-paquetes", { cloudFront });
87
+ }
88
+ function createS3DescriptionsStorage(s3, bucket, options = {}) {
89
+ const storage = {
90
+ async readSyllabus(courseSlug) {
91
+ return (0, packageManifestBackfill_1.fetchJsonObject)(s3, bucket, (0, exports.syllabusKey)(courseSlug));
92
+ },
93
+ async writeSyllabus(courseSlug, syllabus) {
94
+ await (0, packageManifestBackfill_1.withRetry)(() => s3.send(new client_s3_1.PutObjectCommand({
95
+ Bucket: bucket,
96
+ Key: (0, exports.syllabusKey)(courseSlug),
97
+ Body: JSON.stringify(syllabus, null, 2),
98
+ ContentType: "application/json",
99
+ })));
100
+ },
101
+ async listExercises(courseSlug) {
102
+ return fetchExercises(s3, bucket, courseSlug);
103
+ },
104
+ async readReadme(courseSlug, exerciseSlug, fileName) {
105
+ return (0, packageManifestBackfill_1.fetchTextObject)(s3, bucket, `${courseSlug}/exercises/${exerciseSlug}/${fileName}`);
106
+ },
107
+ async readManifest(courseSlug) {
108
+ return (0, packageManifestBackfill_1.fetchJsonObject)(s3, bucket, (0, exports.manifestKey)(courseSlug));
109
+ },
110
+ syllabusSyncStorage: createS3SyllabusSyncStorage(s3, bucket),
111
+ };
112
+ if (options.reprojectManifest !== false) {
113
+ storage.reprojectManifest = async (courseSlug) => {
114
+ var _a;
115
+ // Preserve the publication timestamp: this is a re-projection of an
116
+ // already published package, not a new publication.
117
+ const existing = await (0, packageManifestBackfill_1.fetchJsonObject)(s3, bucket, (0, exports.manifestKey)(courseSlug));
118
+ const result = await (0, packageManifestBackfill_1.processPackage)(s3, bucket, courseSlug, {
119
+ dryRun: false,
120
+ force: false,
121
+ skipExisting: false,
122
+ publishedAt: (_a = existing === null || existing === void 0 ? void 0 : existing.publishedAt) !== null && _a !== void 0 ? _a : null,
123
+ });
124
+ if (result.status === "failed") {
125
+ throw new Error(result.error || "manifest projection failed");
126
+ }
127
+ if (result.status === "skipped" || !options.cloudFront) {
128
+ return;
129
+ }
130
+ await (0, packageManifestBackfill_1.invalidatePackageManifestPaths)(options.cloudFront.client, options.cloudFront.distributionId, [`/${(0, exports.manifestKey)(courseSlug)}`]);
131
+ };
132
+ }
133
+ return storage;
134
+ }
@@ -0,0 +1,75 @@
1
+ import { Lesson, Syllabus } from "../../models/creator";
2
+ import { ContentFingerprint } from "../descriptionHash";
3
+ /**
4
+ * Deciding WHAT needs a description, separated from generating it.
5
+ *
6
+ * The publish route needs the answer before doing any work (to tell breathecode
7
+ * whether a second event is coming), and the generation service needs the very
8
+ * same list right after. Keeping the decision here, pure and content-only,
9
+ * means both answer identically — the alternative, two implementations of
10
+ * "is this stale?", is exactly how the backfill and the sweep drifted apart.
11
+ */
12
+ export type CourseExercise = {
13
+ slug: string;
14
+ /** language code -> README file name */
15
+ translations: Record<string, string>;
16
+ };
17
+ /** README contents by exercise slug and language. */
18
+ export type CourseReadmes = Record<string, Record<string, string>>;
19
+ export type LanguageWork = {
20
+ lang: string;
21
+ fingerprint: ContentFingerprint;
22
+ };
23
+ export type StepWork = {
24
+ exerciseSlug: string;
25
+ /** Reference into the syllabus; the generation service writes through it. */
26
+ lesson: Lesson;
27
+ /** Language the description is written from and translated out of. */
28
+ baseLanguage: string;
29
+ baseContent: string;
30
+ languages: LanguageWork[];
31
+ };
32
+ export type BuildWorkListInput = {
33
+ syllabus: Syllabus;
34
+ exercises: CourseExercise[];
35
+ readmes: CourseReadmes;
36
+ promptVersion?: number;
37
+ simhashThreshold?: number;
38
+ /** Regenerate everything except human-edited descriptions. */
39
+ force?: boolean;
40
+ };
41
+ export declare function findSyllabusLesson(syllabus: Syllabus, exerciseSlug: string): Lesson | undefined;
42
+ /**
43
+ * Language a course is actually written in.
44
+ *
45
+ * `courseInfo.language` is trusted only when some exercise really has a README
46
+ * in it; otherwise the language is inferred from the content itself (the most
47
+ * frequent translation across exercises). Defaulting to "en" when the field is
48
+ * missing would label Spanish courses as English and generate their
49
+ * descriptions from a translation instead of the original.
50
+ */
51
+ export declare function inferCourseBaseLanguage(syllabus: Syllabus, exercises: CourseExercise[]): string | null;
52
+ /**
53
+ * Whether a (step, language) needs generating. Human-edited descriptions are
54
+ * never touched; an explicit "not enough content" answer (a stored null with a
55
+ * fingerprint) is respected until the content itself changes.
56
+ */
57
+ export declare function needsGeneration(slot: NonNullable<Lesson["translations"]>[string] | undefined, fingerprint: ContentFingerprint, options: {
58
+ promptVersion: number;
59
+ simhashThreshold: number;
60
+ force: boolean;
61
+ }): boolean;
62
+ /** The (step, language) pairs whose description is missing or out of date. */
63
+ export declare function buildDescriptionWorkList(input: BuildWorkListInput): StepWork[];
64
+ /** Total (step, language) pairs in a work list. */
65
+ export declare function countWorkItems(work: StepWork[]): number;
66
+ /**
67
+ * Cheap "has this course ever been fully processed at this prompt version?"
68
+ * check, from the syllabus alone.
69
+ *
70
+ * Deliberately weaker than the work list: it cannot see content changes because
71
+ * it never reads a README. That is the point — it lets a batched run skip
72
+ * finished courses without paying thousands of object reads. Use the work list
73
+ * whenever correctness matters; use this only as a pre-filter.
74
+ */
75
+ export declare function isCourseSettled(syllabus: Syllabus, exercises: CourseExercise[], promptVersion?: number): boolean;