@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
@@ -99,6 +99,50 @@ type TInitialContentGeneratorInputs = {
99
99
  topic_description?: string;
100
100
  };
101
101
  export declare const initialContentGenerator: (token: string, inputs: TInitialContentGeneratorInputs, webhookUrl?: string, endpointSlug?: string) => Promise<any>;
102
+ export type TGenerateStepDescriptionsParams = {
103
+ /** README of the step, in `sourceLanguage`. Images are stripped before sending. */
104
+ readmeContent: string;
105
+ sourceLanguage: string;
106
+ /** Every language to produce; may or may not include `sourceLanguage`. */
107
+ outputLanguages: string[];
108
+ lessonTitle: string;
109
+ courseTitle?: string;
110
+ targetWordCount?: number;
111
+ promptVersion?: number;
112
+ };
113
+ export type TGenerateStepDescriptionsResult = {
114
+ /**
115
+ * Descriptions keyed by the `language_code` Rigobot answered with — building
116
+ * this record IS the reassociation, the response order is not contractual.
117
+ * A `null` value is the deliberate fallback for content too thin to describe.
118
+ */
119
+ descriptionsByLanguage: Record<string, string | null>;
120
+ /** Requested languages absent from the response (a protocol problem, not a
121
+ * "no description" answer: callers should retry these rather than storing a
122
+ * null that would look settled). */
123
+ missingLanguages: string[];
124
+ promptVersion: number;
125
+ /** Seconds the completion took; worth logging to watch the 30s Heroku edge. */
126
+ durationSeconds: number | null;
127
+ completionId: number | null;
128
+ };
129
+ /**
130
+ * Generate one short description per language for a single step.
131
+ *
132
+ * One request per step: the eye-catching saving is on the *languages* axis (one
133
+ * README in, N descriptions out, ~67% fewer input tokens for a typical course),
134
+ * while batching several steps together would save nothing and would cost cache
135
+ * granularity and partial results.
136
+ *
137
+ * Synchronous by design (`execute_async: false`): the caller owns the write, so
138
+ * every flow — the post-publish stage, the sweep and the backfill — persists
139
+ * its own results instead of relying on a callback. Callers throttle with a
140
+ * concurrency pool.
141
+ *
142
+ * All inputs travel as strings: Rigobot fills the template with plain string
143
+ * replacement and a non-string raises before reaching the LLM.
144
+ */
145
+ export declare const generateStepDescriptions: (token: string, params: TGenerateStepDescriptionsParams, endpointSlug?: string) => Promise<TGenerateStepDescriptionsResult | null>;
102
146
  type TAddInteractivityInputs = {
103
147
  components: string;
104
148
  prev_lesson: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
3
+ exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.generateStepDescriptions = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
4
4
  exports.downloadImage = downloadImage;
5
5
  exports.createPreviewReadme = createPreviewReadme;
6
6
  exports.makeReadmeReadable = makeReadmeReadable;
@@ -10,6 +10,8 @@ const console_1 = require("../utils/console");
10
10
  const fs = require("fs");
11
11
  const path = require("path");
12
12
  const api_1 = require("./api");
13
+ const descriptionHash_1 = require("./descriptionHash");
14
+ const packageManifest_1 = require("./packageManifest");
13
15
  const createReadme = async (token, inputs, purpose, webhookUrl) => {
14
16
  try {
15
17
  const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/create-sequencial-readme/`, {
@@ -304,6 +306,78 @@ const initialContentGenerator = async (token, inputs, webhookUrl, endpointSlug =
304
306
  }
305
307
  };
306
308
  exports.initialContentGenerator = initialContentGenerator;
309
+ /**
310
+ * Generate one short description per language for a single step.
311
+ *
312
+ * One request per step: the eye-catching saving is on the *languages* axis (one
313
+ * README in, N descriptions out, ~67% fewer input tokens for a typical course),
314
+ * while batching several steps together would save nothing and would cost cache
315
+ * granularity and partial results.
316
+ *
317
+ * Synchronous by design (`execute_async: false`): the caller owns the write, so
318
+ * every flow — the post-publish stage, the sweep and the backfill — persists
319
+ * its own results instead of relying on a callback. Callers throttle with a
320
+ * concurrency pool.
321
+ *
322
+ * All inputs travel as strings: Rigobot fills the template with plain string
323
+ * replacement and a non-string raises before reaching the LLM.
324
+ */
325
+ const generateStepDescriptions = async (token, params, endpointSlug = "generate-step-descriptions") => {
326
+ var _a, _b, _c, _d, _e;
327
+ const promptVersion = (_a = params.promptVersion) !== null && _a !== void 0 ? _a : packageManifest_1.DESCRIPTION_PROMPT_VERSION;
328
+ const targetWordCount = (_b = params.targetWordCount) !== null && _b !== void 0 ? _b : packageManifest_1.DESCRIPTION_TARGET_WORD_COUNT;
329
+ const inputs = {
330
+ // Images never help a description and their alt text may be a whole
331
+ // image-generation prompt; markdown structure is kept, it helps the model.
332
+ readme_content: (0, descriptionHash_1.stripMarkdownImages)(params.readmeContent),
333
+ source_language: params.sourceLanguage,
334
+ output_languages: params.outputLanguages.join(","),
335
+ lesson_title: params.lessonTitle,
336
+ course_title: (_c = params.courseTitle) !== null && _c !== void 0 ? _c : "",
337
+ target_word_count: String(targetWordCount),
338
+ prompt_version: String(promptVersion),
339
+ };
340
+ try {
341
+ const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/${endpointSlug}/`, { inputs, execute_async: false }, {
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ Authorization: "Token " + token.trim(),
345
+ },
346
+ });
347
+ const data = response.data;
348
+ if (!data || data.status === "ERROR") {
349
+ console.error("Error in generateStepDescriptions:", (data === null || data === void 0 ? void 0 : data.status_text) || "completion returned ERROR");
350
+ return null;
351
+ }
352
+ const entries = Array.isArray((_d = data.parsed) === null || _d === void 0 ? void 0 : _d.descriptions) ?
353
+ data.parsed.descriptions :
354
+ [];
355
+ const descriptionsByLanguage = {};
356
+ for (const entry of entries) {
357
+ const code = entry === null || entry === void 0 ? void 0 : entry.language_code;
358
+ if (typeof code !== "string" || !code) {
359
+ continue;
360
+ }
361
+ descriptionsByLanguage[code] =
362
+ typeof entry.description === "string" ? entry.description : null;
363
+ }
364
+ const echoedVersion = Number.parseInt((_e = data.inputs) === null || _e === void 0 ? void 0 : _e.prompt_version, 10);
365
+ return {
366
+ descriptionsByLanguage,
367
+ missingLanguages: params.outputLanguages.filter(lang => !(lang in descriptionsByLanguage)),
368
+ promptVersion: Number.isFinite(echoedVersion) ?
369
+ echoedVersion :
370
+ promptVersion,
371
+ durationSeconds: typeof data.duration === "number" ? data.duration : null,
372
+ completionId: typeof data.id === "number" ? data.id : null,
373
+ };
374
+ }
375
+ catch (error) {
376
+ console.error("Error in generateStepDescriptions:", error);
377
+ return null;
378
+ }
379
+ };
380
+ exports.generateStepDescriptions = generateStepDescriptions;
307
381
  const addInteractivity = async (token, inputs, webhookUrl) => {
308
382
  try {
309
383
  const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/add-lesson-interactivy/`, {
@@ -64,6 +64,8 @@ export declare function withRetry<T>(fn: () => Promise<T>, attempts?: number, at
64
64
  export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
65
65
  export declare function listPublishedSlugs(s3: AwsClient, bucket: string): Promise<string[]>;
66
66
  export declare function fetchJsonObject<T>(s3: AwsClient, bucket: string, key: string): Promise<T | null>;
67
+ export declare function fetchTextObject(s3: AwsClient, bucket: string, key: string): Promise<string | null>;
68
+ export declare function listObjectKeys(s3: AwsClient, bucket: string, prefix: string): Promise<string[]>;
67
69
  export declare function fetchPackageSources(s3: AwsClient, bucket: string, slug: string): Promise<PackageSources>;
68
70
  export declare function buildManifestFromS3Sources(slug: string, sources: PackageSources, options: {
69
71
  publishedAt: string | null;
@@ -5,6 +5,8 @@ exports.withRetry = withRetry;
5
5
  exports.mapWithConcurrency = mapWithConcurrency;
6
6
  exports.listPublishedSlugs = listPublishedSlugs;
7
7
  exports.fetchJsonObject = fetchJsonObject;
8
+ exports.fetchTextObject = fetchTextObject;
9
+ exports.listObjectKeys = listObjectKeys;
8
10
  exports.fetchPackageSources = fetchPackageSources;
9
11
  exports.buildManifestFromS3Sources = buildManifestFromS3Sources;
10
12
  exports.processPackage = processPackage;
@@ -14,6 +16,7 @@ exports.getBatchExitCode = getBatchExitCode;
14
16
  const tslib_1 = require("tslib");
15
17
  const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
16
18
  const client_s3_1 = require("@aws-sdk/client-s3");
19
+ const awsCredentials_1 = require("../awsCredentials");
17
20
  const packageManifest_1 = require("../packageManifest");
18
21
  const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i;
19
22
  const INDEX_HTML_PATTERN = /^[^/]+\/index\.html$/;
@@ -387,18 +390,12 @@ async function runBatch(options, clients) {
387
390
  const s3 = (clients === null || clients === void 0 ? void 0 : clients.s3) ||
388
391
  new client_s3_1.S3Client({
389
392
  region: options.region,
390
- credentials: {
391
- accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
392
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
393
- },
393
+ credentials: (0, awsCredentials_1.requireAwsCredentials)(),
394
394
  });
395
395
  const cf = (clients === null || clients === void 0 ? void 0 : clients.cf) ||
396
396
  new client_cloudfront_1.CloudFrontClient({
397
397
  region: "us-east-1",
398
- credentials: {
399
- accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
400
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
401
- },
398
+ credentials: (0, awsCredentials_1.requireAwsCredentials)(),
402
399
  });
403
400
  let slugs = options.slug ?
404
401
  [options.slug] :
@@ -0,0 +1,75 @@
1
+ import { AwsClient } from "./packageManifestBackfill";
2
+ /**
3
+ * Inventory of the files a published package needs for its
4
+ * `package-manifest.json` to be generated at full fidelity.
5
+ *
6
+ * This reads **key names only** — one paginated `ListObjectsV2` over the bucket,
7
+ * zero `GetObject`. Cheap enough to run over the whole catalogue on demand, and
8
+ * safe at any time: it never writes.
9
+ *
10
+ * What it answers: which packages would *fail* the manifest backfill (no
11
+ * `config.json`, no exercises), and which ones only produce a *degraded*
12
+ * manifest (no `initialSyllabus.json` → every lesson typed `READ`, ids derived
13
+ * from the folder name, and descriptions permanently `null`, because the
14
+ * descriptions pipeline builds its work list from the syllabus keys and cannot
15
+ * see a course that has none).
16
+ *
17
+ * Companion of `legacyPackageRepair.ts`, which writes what this reports missing.
18
+ */
19
+ export type PackageSourcesAuditEntry = {
20
+ slug: string;
21
+ /** A published package is a prefix holding an `index.html` at its root. */
22
+ hasIndexHtml: boolean;
23
+ /** Required: without it the manifest backfill reports `failed`. */
24
+ hasConfigJson: boolean;
25
+ configJsonKey: string | null;
26
+ /**
27
+ * Optional *as a key*: `config.json` may carry the same metadata under
28
+ * `config`. Only a `GetObject` could tell, so `false` here means "resolves
29
+ * through the fallback, or not at all" — not "broken".
30
+ */
31
+ hasLearnJson: boolean;
32
+ hasSyllabus: boolean;
33
+ hasSidebar: boolean;
34
+ sidebarKey: string | null;
35
+ hasManifest: boolean;
36
+ /** Folders directly under `exercises/` holding at least one object. */
37
+ exerciseFolders: number;
38
+ /** `README.md` / `README.xx.md` files found in those folders. */
39
+ readmeCount: number;
40
+ /** Missing inputs, named as in the docs, for quick reporting. */
41
+ missing: string[];
42
+ /** True when the manifest backfill cannot produce a manifest at all. */
43
+ blocking: boolean;
44
+ };
45
+ export type PackageSourcesAuditSummary = {
46
+ missingConfigJson: number;
47
+ missingLearnJson: number;
48
+ missingSyllabus: number;
49
+ missingSidebar: number;
50
+ missingManifest: number;
51
+ noExerciseFolders: number;
52
+ blocking: number;
53
+ };
54
+ export type PackageSourcesAuditReport = {
55
+ generatedAt: string;
56
+ bucket: string;
57
+ scannedKeys: number;
58
+ totalPackages: number;
59
+ summary: PackageSourcesAuditSummary;
60
+ packages: PackageSourcesAuditEntry[];
61
+ };
62
+ export type AuditOptions = {
63
+ /** Restrict the scan to a single package (uses a prefixed listing). */
64
+ slug?: string;
65
+ /** Keep prefixes with no `index.html` (drafts, leftovers). Default false. */
66
+ includeUnpublished?: boolean;
67
+ };
68
+ export declare function summarize(packages: PackageSourcesAuditEntry[]): PackageSourcesAuditSummary;
69
+ /**
70
+ * One paginated pass over the bucket, folding each page into the accumulator.
71
+ *
72
+ * Deliberately not built on `listObjectKeys`: that one materializes every key in
73
+ * the catalogue before the caller sees the first one.
74
+ */
75
+ export declare function auditPackageSources(s3: AwsClient, bucket: string, options?: AuditOptions): Promise<PackageSourcesAuditReport>;
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.summarize = summarize;
4
+ exports.auditPackageSources = auditPackageSources;
5
+ const client_s3_1 = require("@aws-sdk/client-s3");
6
+ const packageManifestBackfill_1 = require("./packageManifestBackfill");
7
+ const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i;
8
+ const ROOT_FILES = new Set([
9
+ "index.html",
10
+ "learn.json",
11
+ "config.json",
12
+ ".learn/config.json",
13
+ ".learn/initialSyllabus.json",
14
+ ".learn/sidebar.json",
15
+ "sidebar.json",
16
+ ".learn/package-manifest.json",
17
+ ]);
18
+ function emptyAccumulator(slug) {
19
+ return {
20
+ slug,
21
+ hasIndexHtml: false,
22
+ configJsonKey: null,
23
+ hasLearnJson: false,
24
+ hasSyllabus: false,
25
+ sidebarKey: null,
26
+ hasManifest: false,
27
+ exerciseFolders: new Set(),
28
+ readmeCount: 0,
29
+ };
30
+ }
31
+ /** Fold one object key into the accumulator map. */
32
+ function classifyKey(entries, key) {
33
+ const separatorIndex = key.indexOf("/");
34
+ if (separatorIndex <= 0) {
35
+ // Object at the bucket root: not part of any package.
36
+ return;
37
+ }
38
+ const slug = key.slice(0, separatorIndex);
39
+ const rest = key.slice(separatorIndex + 1);
40
+ if (!rest) {
41
+ return;
42
+ }
43
+ const exerciseParts = rest.startsWith("exercises/") ? rest.split("/") : [];
44
+ // `exercises/<slug>/<file>`: shorter paths are the folder marker itself.
45
+ const exerciseSlug = exerciseParts.length >= 3 && exerciseParts[1] ? exerciseParts[1] : null;
46
+ if (!ROOT_FILES.has(rest) && !exerciseSlug) {
47
+ return;
48
+ }
49
+ let entry = entries.get(slug);
50
+ if (!entry) {
51
+ entry = emptyAccumulator(slug);
52
+ entries.set(slug, entry);
53
+ }
54
+ if (exerciseSlug) {
55
+ entry.exerciseFolders.add(exerciseSlug);
56
+ const filename = exerciseParts[exerciseParts.length - 1];
57
+ if (exerciseParts.length === 3 && README_FILENAME_PATTERN.test(filename)) {
58
+ entry.readmeCount += 1;
59
+ }
60
+ return;
61
+ }
62
+ switch (rest) {
63
+ case "index.html":
64
+ entry.hasIndexHtml = true;
65
+ break;
66
+ case "learn.json":
67
+ entry.hasLearnJson = true;
68
+ break;
69
+ // The root copy wins over `.learn/config.json`, matching the order
70
+ // `fetchPackageSources` resolves them in. Same for the sidebar, inverted.
71
+ case "config.json":
72
+ entry.configJsonKey = key;
73
+ break;
74
+ case ".learn/config.json":
75
+ entry.configJsonKey = entry.configJsonKey || key;
76
+ break;
77
+ case ".learn/sidebar.json":
78
+ entry.sidebarKey = key;
79
+ break;
80
+ case "sidebar.json":
81
+ entry.sidebarKey = entry.sidebarKey || key;
82
+ break;
83
+ case ".learn/initialSyllabus.json":
84
+ entry.hasSyllabus = true;
85
+ break;
86
+ case ".learn/package-manifest.json":
87
+ entry.hasManifest = true;
88
+ break;
89
+ default:
90
+ break;
91
+ }
92
+ }
93
+ function finalizeEntry(entry) {
94
+ const missing = [];
95
+ if (!entry.configJsonKey) {
96
+ missing.push("config.json");
97
+ }
98
+ if (!entry.hasLearnJson) {
99
+ missing.push("learn.json");
100
+ }
101
+ if (!entry.hasSyllabus) {
102
+ missing.push("initialSyllabus.json");
103
+ }
104
+ if (!entry.sidebarKey) {
105
+ missing.push("sidebar.json");
106
+ }
107
+ if (entry.exerciseFolders.size === 0) {
108
+ missing.push("exercises/");
109
+ }
110
+ if (!entry.hasManifest) {
111
+ missing.push("package-manifest.json");
112
+ }
113
+ return {
114
+ slug: entry.slug,
115
+ hasIndexHtml: entry.hasIndexHtml,
116
+ hasConfigJson: Boolean(entry.configJsonKey),
117
+ configJsonKey: entry.configJsonKey,
118
+ hasLearnJson: entry.hasLearnJson,
119
+ hasSyllabus: entry.hasSyllabus,
120
+ hasSidebar: Boolean(entry.sidebarKey),
121
+ sidebarKey: entry.sidebarKey,
122
+ hasManifest: entry.hasManifest,
123
+ exerciseFolders: entry.exerciseFolders.size,
124
+ readmeCount: entry.readmeCount,
125
+ missing,
126
+ // A missing `learn.json` is not blocking on its own: `config.config` can
127
+ // stand in for it, and only a download would tell.
128
+ blocking: !entry.configJsonKey || entry.exerciseFolders.size === 0,
129
+ };
130
+ }
131
+ function count(packages, predicate) {
132
+ return packages.filter(entry => predicate(entry)).length;
133
+ }
134
+ function summarize(packages) {
135
+ return {
136
+ missingConfigJson: count(packages, entry => !entry.hasConfigJson),
137
+ missingLearnJson: count(packages, entry => !entry.hasLearnJson),
138
+ missingSyllabus: count(packages, entry => !entry.hasSyllabus),
139
+ missingSidebar: count(packages, entry => !entry.hasSidebar),
140
+ missingManifest: count(packages, entry => !entry.hasManifest),
141
+ noExerciseFolders: count(packages, entry => entry.exerciseFolders === 0),
142
+ blocking: count(packages, entry => entry.blocking),
143
+ };
144
+ }
145
+ /**
146
+ * One paginated pass over the bucket, folding each page into the accumulator.
147
+ *
148
+ * Deliberately not built on `listObjectKeys`: that one materializes every key in
149
+ * the catalogue before the caller sees the first one.
150
+ */
151
+ async function auditPackageSources(s3, bucket, options = {}) {
152
+ const entries = new Map();
153
+ const prefix = options.slug ? `${options.slug}/` : undefined;
154
+ let continuationToken;
155
+ let scannedKeys = 0;
156
+ do {
157
+ // eslint-disable-next-line no-await-in-loop -- S3 pagination is sequential
158
+ const response = (await (0, packageManifestBackfill_1.withRetry)(() => s3.send(new client_s3_1.ListObjectsV2Command({
159
+ Bucket: bucket,
160
+ Prefix: prefix,
161
+ ContinuationToken: continuationToken,
162
+ }))));
163
+ for (const item of response.Contents || []) {
164
+ if (!item.Key) {
165
+ continue;
166
+ }
167
+ scannedKeys += 1;
168
+ classifyKey(entries, item.Key);
169
+ }
170
+ continuationToken = response.NextContinuationToken;
171
+ } while (continuationToken);
172
+ const packages = [...entries.values()]
173
+ .filter(entry => options.includeUnpublished || entry.hasIndexHtml)
174
+ .map(entry => finalizeEntry(entry))
175
+ .sort((a, b) => a.slug.localeCompare(b.slug));
176
+ return {
177
+ generatedAt: new Date().toISOString(),
178
+ bucket,
179
+ scannedKeys,
180
+ totalPackages: packages.length,
181
+ summary: summarize(packages),
182
+ packages,
183
+ };
184
+ }
@@ -0,0 +1,71 @@
1
+ import { Bucket } from "@google-cloud/storage";
2
+ import { Syllabus } from "../models/creator";
3
+ /**
4
+ * Provisional syllabus ↔ bucket reconciliation.
5
+ *
6
+ * The `initialSyllabus.json` occasionally drifts from what actually lives in the
7
+ * bucket (lessons/translations present as files but missing from the syllabus,
8
+ * or vice versa). Because step descriptions are stored per (step, lang) in the
9
+ * syllabus, that drift leaves missing slots and causes skipped/spurious
10
+ * generations. This reconciles the two.
11
+ *
12
+ * Extracted from the inline `/actions/synchronize-syllabus` handler so it can be
13
+ * reused by the sweep/backfill, and generalized over a minimal storage adapter
14
+ * so it works for both GCS and S3.
15
+ *
16
+ * NOTE: this is a temporary patch until the root cause of the drift is fixed.
17
+ * Track its removal via the `DESCRIPTIONS_RECONCILE_SYLLABUS` flag on callers.
18
+ */
19
+ export interface SyllabusSyncStorage {
20
+ /**
21
+ * Map of `folderSlug -> fileCount` for every folder directly under
22
+ * `exercises/`. A single authoritative listing: if it throws, callers must
23
+ * abort any destructive action (never delete on an errored/unknown listing).
24
+ */
25
+ listExerciseFolderFileCounts(courseSlug: string): Promise<Map<string, number>>;
26
+ /** README language codes (lowercased) present per exercise folder slug. */
27
+ translationLangsBySlug(courseSlug: string): Promise<Map<string, string[]>>;
28
+ }
29
+ export type SyllabusSyncOptions = {
30
+ /**
31
+ * When false (default, "additive" mode) only non-destructive passes run:
32
+ * add-missing, repair-translations, unstick-status. Removal/dedupe are
33
+ * skipped. When true (manual dev button / root-cause runs) removals apply.
34
+ */
35
+ prune?: boolean;
36
+ };
37
+ export type SyllabusSyncCounters = {
38
+ totalLessons: number;
39
+ keptLessons: number;
40
+ removedLessons: number;
41
+ duplicatesResolved: number;
42
+ addedLessons: number;
43
+ fixedLessons: number;
44
+ repairedTranslationsInLessons: number;
45
+ repairedTranslationEntries: number;
46
+ };
47
+ type LessonRef = {
48
+ id: string;
49
+ title: string;
50
+ slug: string;
51
+ };
52
+ type LessonRefWithCount = LessonRef & {
53
+ fileCount: number;
54
+ };
55
+ export type SyllabusSyncResult = {
56
+ syllabus: Syllabus;
57
+ changed: boolean;
58
+ counters: SyllabusSyncCounters;
59
+ details: {
60
+ removed: LessonRef[];
61
+ duplicates: LessonRefWithCount[];
62
+ kept: Array<LessonRef & {
63
+ fileCount?: number;
64
+ }>;
65
+ added: LessonRef[];
66
+ };
67
+ };
68
+ export declare function synchronizeSyllabusWithBucket(storage: SyllabusSyncStorage, courseSlug: string, syllabus: Syllabus, options?: SyllabusSyncOptions): Promise<SyllabusSyncResult>;
69
+ /** GCS-backed storage adapter (uses the bucket listing + buildConfig). */
70
+ export declare function createGcsSyllabusSyncStorage(bucket: Bucket): SyllabusSyncStorage;
71
+ export {};